Blank asset numbers, contested device claims, and honest toner forecasting

This commit is contained in:
2026-08-20 17:09:02 -04:00
parent fa88ef0afe
commit ebb86ccf3b
12 changed files with 473 additions and 34 deletions

View File

@@ -102,8 +102,25 @@ def _controlled(pcname, label):
# --------------------------------------------------------------- part markers
def _retire(db, hostname):
"""Move a PC off In Use, which is the one-step way to yield its device."""
from shopdb.core.models import AssetStatus
retired = AssetStatus.query.filter_by(status='Retired').first()
if retired is None:
retired = AssetStatus(status='Retired')
db.session.add(retired)
db.session.flush()
pc = Asset.query.filter(Asset.assetnumber.ilike(hostname)).first()
pc.statusid = retired.statusid
db.session.commit()
def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key):
"""THE case this exists for. Same physical marker, two different PCs."""
"""THE case this exists for. Same physical marker, two different PCs.
Neither PC mints a twin. Who HOLDS the marker is settled separately, by the
two tests below - this one pins only that the physical unit stays one row.
"""
_asset(db, '0613')
marker = _marker(db, 'PM-0613-A')
@@ -120,12 +137,51 @@ def test_a_pc_swap_does_not_mint_a_second_marker(client, db, rig, collector_key)
assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None
assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None
assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A']
assert Asset.query.filter_by(assetnumber='PM-0613-A').count() == 1
assert marker.assetid == Asset.query.filter_by(
assetnumber='PM-0613-A').first().assetid
def test_a_live_incumbent_keeps_the_marker_and_the_challenger_is_dormant(
client, db, rig, collector_key):
"""Two PCs naming one device must not both hold it actively.
Before this, neither device path looked at who else held the target, so a
replaced PC kept its link forever and a copied asset-id.txt claimed the same
marker from every bay, silently.
"""
_asset(db, '0613')
_marker(db, 'PM-0613-A')
_report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
resp = _report(client, collector_key, 'FMARK200',
pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
assert _controlled('FMARK200', 'collector:partmarker') == []
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'FMARK100' in warnings and 'PM-0613-A' in warnings
def test_handover_completes_once_the_incumbent_yields(client, db, rig,
collector_key):
"""The swap case as it actually happens: the old PC is retired or goes quiet."""
_asset(db, '0613')
_marker(db, 'PM-0613-A')
_report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
_retire(db, 'FMARK100')
_report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
machinenumber='0613', deviceid='PM-0613-A')
assert _controlled('FMARK200', 'collector:partmarker') == ['PM-0613-A']
# Archived, never deleted: "which PC drove this in June" stays answerable.
assert _controlled('FMARK100', 'collector:partmarker') == []
assert AssetRelationship.query.filter_by(label='collector:partmarker').count() >= 2
def test_without_the_file_a_swap_still_mints_the_old_way(client, db, rig,
collector_key):
"""The unfixed behaviour, pinned so the file's value stays visible."""
@@ -150,6 +206,11 @@ def test_an_unknown_device_warns_and_links_nothing(client, db, rig,
assert Asset.query.filter_by(assetnumber='PM-TYPO').first() is None
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'PM-TYPO' in warnings
# LINKS NOTHING, which is what the name claims. This used to warn and then
# mint FMARK500-PARTMARKER anyway - the twin the whole path exists to stop -
# and the test passed because it only checked that PM-TYPO was not created.
assert Asset.query.filter_by(assetnumber='FMARK500-PARTMARKER').first() is None
assert _controlled('FMARK500', 'collector:partmarker') == []
def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
@@ -160,7 +221,8 @@ def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
pctype='gea-shopfloor-partmarker',
machinenumber='0617', deviceid='PLAIN-MACHINE')
assert resp.status_code in (200, 201)
assert _controlled('FMARK600', 'collector:partmarker') != ['PLAIN-MACHINE']
assert _controlled('FMARK600', 'collector:partmarker') == []
assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
assert 'PLAIN-MACHINE' in warnings

View File

@@ -267,3 +267,75 @@ def test_forecast_endpoint_says_so_when_zabbix_is_absent(client, db):
assert 'not configured' in body['reason']
assert body['cartridges'] == []
assert body['orderlist'] == []
# --------------------------------------------------------------------------
# Noise that used to read as cartridge changes, and bursts that used to bias
# the rate for the life of the cartridge. Both were reported from the floor:
# "5 changes in 90 days, that's hard to believe", and a cartridge that dropped
# 20 percent in two days then barely moved.
# --------------------------------------------------------------------------
def test_a_poll_returning_zero_is_not_a_cartridge_change():
"""0 then a real level is an SNMP error or a calibrating printer.
The rise is +60, which cleared the old threshold on its own. It does not
land near full, so it is not a swap.
"""
points = normalise(series([70, 60, 0, 60, 55, 50]))
assert find_replacements(points) == []
def test_a_gauge_ticking_back_up_mid_range_is_not_a_change():
"""A coarse gauge after a reseat or a power cycle. Lands at 55, not full."""
points = normalise(series([70, 60, 45, 55, 50, 45]))
assert find_replacements(points) == []
def test_a_real_swap_is_still_counted():
"""Near-empty to near-full. The shape a cartridge change actually makes."""
points = normalise(series([30, 15, 5, 100, 95, 90]))
assert len(find_replacements(points)) == 1
def test_the_run_starts_at_the_real_swap_not_at_the_noise():
"""current_run and find_replacements must agree on what a change is.
They read the same predicate now; when they did not, a phantom rise reset
the run and threw away the history the estimate needed.
"""
points = normalise(series([90, 0, 85, 80, 75, 70]))
assert find_replacements(points) == []
assert len(current_run(points)) == len(points)
def test_an_early_burst_does_not_dominate_the_rate_forever():
"""20 percent in two days, then a month of almost nothing.
The endpoint slope reads the burst forever: (100-75)/30 = 0.83 %/day, so
the report keeps promising the cartridge runs out long after printing
stopped. The median sees one fast interval among many quiet ones.
"""
levels = [100, 90, 80] + [80 - i * 0.2 for i in range(1, 28)]
rate = burn_rate(normalise(series(levels)))
assert rate is not None
assert rate < 1.0, rate
detail = analyse(series(levels))
assert detail['rateunstable'] is True
def test_a_steady_cartridge_is_not_flagged_unstable():
detail = analyse(series([100, 95, 90, 85, 80, 75, 70]))
assert detail['burnrateperday'] == 5.0
assert detail['rateunstable'] is False
def test_a_real_near_empty_reading_before_a_swap_is_kept():
"""30, 5, 100 is a cartridge run to the end and changed - not a spike.
The dip filter must not eat it: the 5 does not RECOVER to 30, it jumps to
full, which is the shape of a swap rather than of a bad poll.
"""
points = normalise(series([40, 30, 5, 100, 95, 90]))
assert 5.0 in [level for _, level in points]
assert len(find_replacements(points)) == 1