Forecast from the right end of the window, and from the level shown
Four defects stacked into one nonsense report: cartridges at 20% claiming four days, cartridges at 1% claiming weeks. The root cause is a Zabbix API detail. `limit` caps the whole result set rather than each item, and the query sorted ascending, so the cap kept the OLDEST rows in the window. A four-cartridge printer polled every five minutes writes over 100k readings in 90 days; the forecast was fitted to the first few days of that and nothing since. Every rate was real and every rate described a cartridge thrown away three months ago. Nothing in the output looks wrong, which is why it needed pinning in a test rather than a comment. A 90-day burn rate does not need every individual poll, so a long window now reads hourly trends - the table meant for this, a tenth of the rows, and kept longer. Raw history serves short windows and any item a site keeps no trends for. Both are fetched newest-first with the budget scaled per item. Second, the countdown was computed from the last stored reading while the level displayed was the live one, so the two could disagree by a whole cartridge. The live level is now what the countdown divides. A live level far above the stored run means it was swapped since the last reading, and that is reported as a replacement rather than as a collapse in the burn rate. Third, at or below 5% a cartridge reads as empty rather than as a slow drain. At 1% losing a tenth of a point a day the arithmetic says ten days. The printer is out of toner, and it is the first thing to order. Fourth, the days-left column spanned the printer's rows, so the printer's soonest figure was printed beside every supply it had. That alone accounts for the shape of both complaints: a healthy cartridge wearing its neighbour's deadline, and an empty one wearing a number that belonged to nothing on its row. Also fixes float-typed supplies vanishing from any printer that also had an integer-typed one - they live in different history tables and the fetch stopped at whichever answered first. Not verified against live data: Zabbix is not reachable from the dev box.
This commit is contained in:
@@ -99,6 +99,58 @@ def test_days_left_never_goes_negative():
|
||||
assert result['daysleft'] == 0
|
||||
|
||||
|
||||
def test_live_level_drives_the_countdown_not_the_stored_one():
|
||||
"""The report shows the live level, so it must count down from that one.
|
||||
|
||||
History lags a poll, and trends lag an hour. Displaying 20% beside a
|
||||
countdown computed from a stored 4% is how a report loses its reader.
|
||||
"""
|
||||
result = analyse(series([100, 80, 60, 40]), currentlevel=20)
|
||||
|
||||
assert result['currentlevel'] == 20
|
||||
assert result['burnrateperday'] == 20.0 # 60 points over 3 days
|
||||
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)
|
||||
|
||||
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)
|
||||
healthy = analyse(series([100, 80, 60, 40]))
|
||||
|
||||
assert empty['daysleft'] < healthy['daysleft']
|
||||
|
||||
|
||||
def test_a_low_live_level_is_actionable_without_any_history():
|
||||
"""A printer new to Zabbix still reports it is out of toner."""
|
||||
result = analyse([], currentlevel=1)
|
||||
|
||||
assert result['daysleft'] == 0
|
||||
assert result['reason'] is None
|
||||
|
||||
|
||||
def test_no_history_and_a_healthy_level_still_says_no_history():
|
||||
result = analyse([], currentlevel=80)
|
||||
|
||||
assert result['daysleft'] is None
|
||||
assert result['reason'] == 'no history'
|
||||
|
||||
|
||||
def test_a_live_level_above_the_stored_run_means_it_was_just_swapped():
|
||||
"""Trends lag an hour; a cartridge changed in that hour must not inherit
|
||||
the rate of the one that came out."""
|
||||
result = analyse(series([40, 30, 20, 10]), currentlevel=100)
|
||||
|
||||
assert result['daysleft'] is None
|
||||
assert result['reason'] == 'replaced recently'
|
||||
assert result['replacements'] == 1
|
||||
|
||||
|
||||
def test_printer_sorts_by_its_soonest_supply():
|
||||
"""A colour MFP is as urgent as its most pressing cartridge."""
|
||||
supplies = [{'daysleft': 40}, {'daysleft': 6}, {'daysleft': None}]
|
||||
|
||||
145
tests/test_plugins/test_zabbix_history.py
Normal file
145
tests/test_plugins/test_zabbix_history.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""Tests for how level history is fetched out of Zabbix.
|
||||
|
||||
These are about the shape of the API call, not about the arithmetic done
|
||||
afterwards, because that is where the forecast went wrong: the numbers were
|
||||
computed correctly from the wrong rows.
|
||||
|
||||
Zabbix applies `limit` to the whole result set rather than to each item, so a
|
||||
capped query sorted ascending returns the OLDEST rows in the window. A
|
||||
four-cartridge printer polled every five minutes writes over 100k rows in 90
|
||||
days; the report was reading the first few days of that and calling it current.
|
||||
No arithmetic recovers from it, and nothing about the output looks wrong -
|
||||
which is why it is pinned here.
|
||||
"""
|
||||
|
||||
from plugins.printers.services.zabbix_service import ZabbixService
|
||||
|
||||
|
||||
class RecordingService(ZabbixService):
|
||||
"""A service whose API calls are recorded and answered from a script."""
|
||||
|
||||
def __init__(self, answers=None):
|
||||
super().__init__()
|
||||
self.calls = []
|
||||
self.answers = answers or {}
|
||||
|
||||
def _apicall(self, method, params):
|
||||
self.calls.append((method, params))
|
||||
answer = self.answers.get(method)
|
||||
if callable(answer):
|
||||
return answer(params)
|
||||
return answer or []
|
||||
|
||||
def paramsfor(self, method):
|
||||
return [params for name, params in self.calls if name == method]
|
||||
|
||||
|
||||
def rows(itemid, values, start=1780000000, step=3600):
|
||||
"""history.get rows, newest first, as a DESC query returns them."""
|
||||
return [{'itemid': str(itemid), 'clock': str(start + i * step),
|
||||
'value': str(value)} for i, value in enumerate(values)][::-1]
|
||||
|
||||
|
||||
def test_history_asks_for_the_newest_rows_not_the_oldest():
|
||||
service = RecordingService({'history.get': rows(1, [90, 80, 70])})
|
||||
|
||||
service.gethistory(['1'], days=90)
|
||||
|
||||
params = service.paramsfor('history.get')[0]
|
||||
assert params['sortorder'] == 'DESC'
|
||||
assert params['sortfield'] == 'clock'
|
||||
|
||||
|
||||
def test_history_budget_scales_with_the_number_of_items():
|
||||
"""One cap shared by four cartridges is a quarter of a cartridge each."""
|
||||
service = RecordingService({'history.get': rows(1, [90, 80])})
|
||||
|
||||
service.gethistory(['1', '2', '3', '4'], days=90)
|
||||
|
||||
params = service.paramsfor('history.get')[0]
|
||||
assert params['limit'] == ZabbixService.HISTORY_PER_ITEM * 4
|
||||
|
||||
|
||||
def test_history_comes_back_oldest_first():
|
||||
"""The analysis reads a series forwards; DESC is a fetch detail."""
|
||||
service = RecordingService({'history.get': rows(1, [90, 80, 70])})
|
||||
|
||||
result = service.gethistory(['1'], days=90)
|
||||
|
||||
clocks = [int(clock) for clock, _ in result['1']]
|
||||
assert clocks == sorted(clocks)
|
||||
assert [value for _, value in result['1']] == ['90', '80', '70']
|
||||
|
||||
|
||||
def test_items_split_across_both_history_tables_are_all_returned():
|
||||
"""A printer can carry an integer-typed supply and a float-typed one.
|
||||
|
||||
Stopping at the first table that answered dropped every item in the other,
|
||||
and a missing cartridge looks exactly like a cartridge with no history.
|
||||
"""
|
||||
def answer(params):
|
||||
if params['history'] == 3:
|
||||
return rows(1, [90, 80])
|
||||
return rows(2, [70, 60])
|
||||
|
||||
service = RecordingService({'history.get': answer})
|
||||
|
||||
result = service.gethistory(['1', '2'], days=90)
|
||||
|
||||
assert set(result) == {'1', '2'}
|
||||
|
||||
|
||||
def test_the_second_table_is_only_asked_about_what_is_still_missing():
|
||||
def answer(params):
|
||||
return rows(1, [90, 80]) if params['history'] == 3 else []
|
||||
|
||||
service = RecordingService({'history.get': answer})
|
||||
|
||||
service.gethistory(['1', '2'], days=90)
|
||||
|
||||
assert service.paramsfor('history.get')[1]['itemids'] == ['2']
|
||||
|
||||
|
||||
def test_a_long_window_reads_trends_rather_than_every_poll():
|
||||
"""90 days of raw readings is 26k rows an item and hours of transfer for a
|
||||
number that is a slope."""
|
||||
service = RecordingService({'trend.get': [
|
||||
{'itemid': '1', 'clock': '1780000000', 'value_avg': '80'},
|
||||
{'itemid': '1', 'clock': '1779996400', 'value_avg': '90'},
|
||||
]})
|
||||
|
||||
result = service.getlevelhistory(['1'], days=90)
|
||||
|
||||
assert service.paramsfor('trend.get')
|
||||
assert not service.paramsfor('history.get')
|
||||
assert [value for _, value in result['1']] == ['90', '80']
|
||||
|
||||
|
||||
def test_a_short_window_reads_raw_history():
|
||||
"""Over a few days the hourly average has too few points to fit."""
|
||||
service = RecordingService({'history.get': rows(1, [90, 80])})
|
||||
|
||||
service.getlevelhistory(['1'], days=2)
|
||||
|
||||
assert service.paramsfor('history.get')
|
||||
assert not service.paramsfor('trend.get')
|
||||
|
||||
|
||||
def test_an_item_with_no_trends_falls_back_to_its_raw_history():
|
||||
"""A site can keep trends for 0 days. That is a setting, not a fault."""
|
||||
service = RecordingService({
|
||||
'trend.get': [{'itemid': '1', 'clock': '1780000000', 'value_avg': '80'}],
|
||||
'history.get': rows(2, [70, 60]),
|
||||
})
|
||||
|
||||
result = service.getlevelhistory(['1', '2'], days=90)
|
||||
|
||||
assert service.paramsfor('history.get')[0]['itemids'] == ['2']
|
||||
assert set(result) == {'1', '2'}
|
||||
|
||||
|
||||
def test_no_items_asks_nothing():
|
||||
service = RecordingService()
|
||||
|
||||
assert service.getlevelhistory([], days=90) == {}
|
||||
assert service.calls == []
|
||||
Reference in New Issue
Block a user