Only zero is empty, and the last day is worth reading
Some checks failed
CI / backend (push) Failing after 7m18s
CI / frontend (push) Has been cancelled
CI / migrations-mysql (push) Has been cancelled
CI / naming (push) Has been cancelled

Two separate floors were collapsing a live cartridge into a spent one, and
removing either alone changes nothing.

EMPTY_LEVEL was 5, so everything at or below 5% was assigned daysleft 0
outright. The reasoning was that a printer at 1% is out of toner as far as
anyone standing at it is concerned. But the row exists to say how long is
left, and 2% draining a point a day has two days in it. Flooring put that
cartridge beside ones that genuinely are empty with no way back to the
difference. It is 0 now: empty means empty.

int(level / rate) then truncated the division, so anything under a full day
arrived as 0 whatever the floor did - a cartridge with six hours in it was
indistinguishable from one with nothing, and the band read it as empty.
daysleft is fractional now, rounded to two places, which is about a quarter of
an hour: finer than the estimate deserves, but it costs nothing and keeps the
ordering of two nearly-spent cartridges meaningful.

daysText reads in whatever unit carries meaning: "6 hours", "1 hour", "29 min",
"4 days". Below an hour it goes to minutes with a floor of one, because
rounding hours would land back on "empty" - the same bug one rung down.

BEHAVIOUR CHANGE worth knowing: a cartridge at 1-5% with NO history used to get
daysleft 0 from the floor and land on the order list. A rate needs two
readings; with none there is nothing to divide, and the old answer was right by
accident - it said "empty" about a level nobody had watched move. It now
reports 'no history' and shows under "No estimate yet". This reaches only
printers newly added to Zabbix; anything that got to 3% the ordinary way has
the history to forecast from.

Five existing tests pinned the old rule. They recorded a real decision, so they
are rewritten to the new one rather than deleted. One of them was passing for
the wrong reason: its series (2.0, 1.7, 1.4, 1.1) drops 0.9, under
MIN_DROP_FOR_ESTIMATE, so it never had a rate at all and only passed because
the floor short-circuited ahead of the rate check. It now uses a real 20-day
drop at a tenth of a point a day and asserts the ten days its docstring always
described.

The forecast fixture's black cartridge moves from 1% to 0% so the empty band
keeps its API-level coverage, and a magenta at 3% covers hours-left end to end.
This commit is contained in:
cproudlock
2026-08-21 11:57:02 -04:00
parent e9fcd1e598
commit 3703412baf
5 changed files with 176 additions and 23 deletions

View File

@@ -8,6 +8,8 @@ purchasing decision.
from datetime import datetime, timedelta, timezone
import pytest
from plugins.printers.services.supply_history import (
analyse, band, burn_rate, current_run, find_replacements, normalise,
orderlist, soonest,
@@ -113,28 +115,69 @@ def test_live_level_drives_the_countdown_not_the_stored_one():
assert result['daysleft'] == 1 # 20 / 20, not 40 / 20
def test_a_nearly_empty_cartridge_reads_as_empty_not_as_weeks_away():
"""1% draining a tenth of a point a day computes to ten days. It is out."""
result = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=1)
def test_a_nearly_empty_cartridge_keeps_its_real_countdown():
"""Only zero is empty.
This used to floor everything at or below 5% to daysleft 0, so 1% draining
a tenth of a point a day was reported as gone. It has ten days in it, and
ten days is the only thing the row exists to say. Flooring it put the
cartridge beside ones that genuinely are empty with no way back to the
difference.
"""
# 3.0 down to 1.0 at a tenth of a point a day: a real, slow, measured drop.
slow = [round(3.0 - 0.1 * i, 2) for i in range(21)]
result = analyse(series(slow), currentlevel=1)
assert result['daysleft'] == pytest.approx(10, abs=0.1)
def test_a_cartridge_with_hours_left_is_not_reported_as_empty():
"""The other half of the floor: int() truncated the division, so anything
under a full day became 0 and read as empty. A cartridge with six hours in
it still prints."""
result = analyse(series([100, 75, 50, 25]), currentlevel=6)
assert 0 < result['daysleft'] < 1
assert round(result['daysleft'] * 24) == 6
def test_an_actual_zero_is_still_empty():
"""The half that must not regress. Zero means spent, and spent is ordered
first."""
result = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=0)
assert result['daysleft'] == 0
def test_empty_sorts_ahead_of_a_fast_healthy_cartridge():
empty = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=1)
empty = analyse(series([2.0, 1.7, 1.4, 1.1]), currentlevel=0)
healthy = analyse(series([100, 80, 60, 40]))
assert empty['daysleft'] < healthy['daysleft']
def test_a_low_live_level_is_actionable_without_any_history():
def test_an_empty_live_level_is_actionable_without_any_history():
"""A printer new to Zabbix still reports it is out of toner."""
result = analyse([], currentlevel=1)
result = analyse([], currentlevel=0)
assert result['daysleft'] == 0
assert result['reason'] is None
def test_a_low_live_level_with_no_history_cannot_be_counted_down():
"""A rate needs two readings. At 1% with none, there is nothing to divide.
Previously the 5% floor answered this by asserting daysleft 0, which was
right by accident and wrong in general - it said "empty" about a level
nobody had watched move. It reports no estimate and says why; the report
lists it under "No estimate yet" rather than on the order list.
"""
result = analyse([], currentlevel=1)
assert result['daysleft'] is None
assert result['reason'] == 'no history'
def test_no_history_and_a_healthy_level_still_says_no_history():
result = analyse([], currentlevel=80)

View File

@@ -16,9 +16,13 @@ from plugins.printers.models import ModelSupply, Printer
LEVELS = {
# itemid: (name, live level, history of daily readings)
'10': ('Black Cartridge', 1, [6, 5, 4, 3]),
# Black is a REAL zero, which is the only thing that means empty. Magenta is
# low but not zero and keeps its own countdown - under a day here, which the
# old int() truncation collapsed to 0 and reported as empty alongside black.
'10': ('Black Cartridge', 0, [6, 5, 4, 3]),
'11': ('Cyan Cartridge', 40, [100, 90, 80, 70, 60, 50, 40]),
'12': ('Yellow Cartridge', 92, [95, 94, 93, 92]),
'13': ('Magenta Cartridge', 3, [100, 76, 52, 28]),
}
START = 1780000000
@@ -111,7 +115,8 @@ def test_a_row_is_a_cartridge_not_a_printer(client, printer_with_parts):
data = forecast(client)
names = sorted(c['name'] for c in data['cartridges'] + data['unestimated'])
assert names == ['Black Cartridge', 'Cyan Cartridge', 'Yellow Cartridge']
assert names == ['Black Cartridge', 'Cyan Cartridge', 'Magenta Cartridge',
'Yellow Cartridge']
def test_each_cartridge_carries_its_own_countdown(client, printer_with_parts):
@@ -120,18 +125,30 @@ def test_each_cartridge_carries_its_own_countdown(client, printer_with_parts):
data = forecast(client)
bylevel = {c['name']: c for c in data['cartridges']}
assert bylevel['Black Cartridge']['daysleft'] == 0 # live level 1%
assert bylevel['Black Cartridge']['daysleft'] == 0 # live level 0%
assert bylevel['Cyan Cartridge']['daysleft'] == 4 # 40% at 10%/day
assert len({c['daysleft'] for c in data['cartridges']}) > 1
def test_a_low_cartridge_keeps_a_countdown_measured_in_hours(client,
printer_with_parts):
"""3% at 24%/day is about three hours. It used to arrive as 0 - int()
truncated the division - so it was indistinguishable from black, which is
genuinely spent."""
data = forecast(client)
magenta = {c['name']: c for c in data['cartridges']}['Magenta Cartridge']
assert 0 < magenta['daysleft'] < 1
assert round(magenta['daysleft'] * 24) == 3
def test_cartridges_are_banded_by_urgency(client, printer_with_parts):
data = forecast(client)
byname = {c['name']: c['band'] for c in data['cartridges']}
assert byname['Black Cartridge'] == 'empty'
assert byname['Cyan Cartridge'] == 'soon'
assert data['summary']['bands']['empty'] == 1
assert data['summary']['bands']['empty'] == 1, 'only the real zero'
def test_the_order_list_names_the_part_and_the_quantity(client, printer_with_parts):