A review ofd60ed60and8b9b936found five things. Three were introduced by those commits. REFUSING A NAMED DEVICE MADE A MARKER PC CLAIM ITS OPERATION. _sync_partmarker returned [] both for "not a marker PC" and for "a marker PC that linked nothing", and the caller reads [] as the first - so a bay whose asset-id.txt named something unresolvable fell through to the ordinary machine link, and for a marker PC the machine number IS the operation. It took an active link to a record that can only have one holder while several markers share it, and the warning said "not linked". The previous behaviour minted a twin; this traded that for a contested operation. None now means "not a marker PC" and is the only answer that lets the machine link run; the response normalises it away so the API shape is unchanged. A DORMANT CHALLENGER WAS PROMOTED BY DELETING A FILE. Recording a challenger dormant leaves a row that the next cycle finds as `reuse` and reactivated with no incumbent check - so a second PC took a live device by its enrollment file becoming unreadable. Both reuse branches re-check incumbency now, which is what _sync_machine_link always did. AN INCUMBENT UNDER ANOTHER COLLECTOR LABEL WAS INVISIBLE. Incumbency was queried on our own label, but on a CMM the instrument IS the reported bay, so the incumbent's link is the machine sync's row. A second PC naming that instrument found no incumbent and linked actively: two live holders of one instrument, each invisible to the other. Incumbency now counts any collector-owned label. A row made BY HAND carries none of them and is still excluded - a person's link is not the collector's to archive. THE NETWORK FORM LOCKED OUT THE ROWS IT NEEDED TO FIX. Asset number is disabled while editing, correctly, but the payload is built in script so the blank was still sent - and the new server-side guard rejects it. A device with no asset number could not be saved at all, and the field could not be typed into. It now unlocks only for a record that loaded without one, with a hint saying why. "2 in 4.0d" WAS THE LABEL LYING. Replacements are counted across the whole history window; basisdays is only how long the current cartridge has been in. Joining them with "in" claimed two changes inside four days - the exact shape reported as unbelievable, except here the data was right. Now "2, this one 4.0d". Two toner dips the same review found: A MULTI-POLL OUTAGE STILL MINTED A PHANTOM SWAP. Only single readings were dropped, so 90, 0, 0, 90 survived and 0 -> 90 scored as a change. Dips of any length are handled now. One bad sample stays a candidate whatever the polling cadence, because a reading is an instant; several consecutive low ones only count as one outage when they are close together, since days at zero is a real empty period. That time bound also separates an outage from a swap, ordinary consumption, and a second swap, which have the same shape in levels alone. THE DIP FILTER ATE REAL SWAPS OF NEARLY-FULL CARTRIDGES. Recovery was tested with an absolute difference, so 95 then 5 then 100 read as a recovery because 100 and 95 are close, and the swap evidence was deleted. Toner only falls: a recovery comes back at or BELOW where it left, a new cartridge comes back higher. The review also proved by reverting each feature that the previous tests did not pin the median burn rate or the near-full rule - both passed with the bug restored. Verified by the same method that all four toner behaviours now fail when reverted, and the burst assertion is tight enough to tell 0.2 from 0.88.
327 lines
14 KiB
Python
327 lines
14 KiB
Python
"""asset-id.txt names the device, and that name survives a PC swap.
|
|
|
|
Every other identity the collector has for a subordinate device is derived from
|
|
the PC: reuse looks for a prior link from THIS PC asset, and adoption looks up
|
|
`<PC number>-<SUFFIX>`. Both survive a re-image and neither survives a swap - a
|
|
new hostname is a new PC asset with no prior link and a predicted number that
|
|
has never existed, so the same physical device gets a second record while the
|
|
first keeps its history under a dead PC's name.
|
|
|
|
That is the failure that left 43 measuring tools shadowed by minted twins. These
|
|
tests pin the fix for BOTH device families, because the part-marker path was
|
|
modelled on the metrology path as it stood before it was fixed.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from shopdb.core.models import Asset, AssetType
|
|
from shopdb.core.models.relationship import RelationshipType, AssetRelationship
|
|
|
|
KEY = 'deviceid-key'
|
|
|
|
|
|
@pytest.fixture
|
|
def collector_key(app):
|
|
old = app.config.get('COLLECTOR_API_KEY')
|
|
app.config['COLLECTOR_API_KEY'] = KEY
|
|
yield KEY
|
|
app.config['COLLECTOR_API_KEY'] = old
|
|
|
|
|
|
@pytest.fixture
|
|
def rig(db):
|
|
for name in ('computer', 'machine', 'measuring_tool'):
|
|
if not AssetType.query.filter_by(assettype=name).first():
|
|
db.session.add(AssetType(assettype=name))
|
|
for name in ('controls', 'partof'):
|
|
if not RelationshipType.query.filter_by(relationshiptype=name).first():
|
|
db.session.add(RelationshipType(relationshiptype=name))
|
|
db.session.commit()
|
|
|
|
|
|
def _report(client, key, hostname, pctype, **extra):
|
|
payload = {'hostname': hostname, 'pctype': pctype}
|
|
payload.update(extra)
|
|
return client.post('/api/collector/computers', json=payload,
|
|
headers={'X-API-Key': key})
|
|
|
|
|
|
def _asset(db, assetnumber, assettype='machine'):
|
|
"""A bare asset: an operation, or something that is NOT a device."""
|
|
at = AssetType.query.filter_by(assettype=assettype).first()
|
|
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
|
|
db.session.add(asset)
|
|
db.session.commit()
|
|
return asset
|
|
|
|
|
|
def _marker(db, assetnumber):
|
|
"""A Part Marker the collector did NOT create - the real unit on the floor.
|
|
|
|
The extension row and machine type are what make it a marker; a bare asset
|
|
of the right number is deliberately refused, which the wrong-type test pins.
|
|
"""
|
|
from plugins.machines.models import Machine, MachineType
|
|
at = AssetType.query.filter_by(assettype='machine').first()
|
|
mtype = MachineType.query.filter_by(machinetype='Part Marker').first()
|
|
if mtype is None:
|
|
mtype = MachineType(machinetype='Part Marker')
|
|
db.session.add(mtype)
|
|
db.session.flush()
|
|
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
|
|
db.session.add(asset)
|
|
db.session.flush()
|
|
db.session.add(Machine(assetid=asset.assetid,
|
|
machinetypeid=mtype.machinetypeid))
|
|
db.session.commit()
|
|
return asset
|
|
|
|
|
|
def _tool(db, assetnumber):
|
|
"""A measuring tool the collector did NOT create."""
|
|
from plugins.measuringtools.models import MeasuringTool
|
|
at = AssetType.query.filter_by(assettype='measuring_tool').first()
|
|
asset = Asset(assetnumber=assetnumber, assettypeid=at.assettypeid)
|
|
db.session.add(asset)
|
|
db.session.flush()
|
|
db.session.add(MeasuringTool(assetid=asset.assetid))
|
|
db.session.commit()
|
|
return asset
|
|
|
|
|
|
def _controlled(pcname, label):
|
|
"""Asset numbers this PC controls under a collector label."""
|
|
pc = Asset.query.filter(Asset.assetnumber.ilike(pcname)).first()
|
|
if pc is None:
|
|
return []
|
|
rels = AssetRelationship.query.filter_by(
|
|
sourceassetid=pc.assetid, label=label, isactive=True).all()
|
|
return sorted(Asset.query.filter_by(assetid=r.targetassetid).first().assetnumber
|
|
for r in rels)
|
|
|
|
|
|
# --------------------------------------------------------------- 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.
|
|
|
|
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')
|
|
|
|
first = _report(client, collector_key, 'FMARK100',
|
|
pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0613', deviceid='PM-0613-A')
|
|
assert first.status_code in (200, 201), first.get_data(as_text=True)[:300]
|
|
|
|
# The bay's PC is replaced. New hostname, same marker named in asset-id.txt.
|
|
second = _report(client, collector_key, 'FMARK200',
|
|
pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0613', deviceid='PM-0613-A')
|
|
assert second.status_code in (200, 201), second.get_data(as_text=True)[:300]
|
|
|
|
assert Asset.query.filter_by(assetnumber='FMARK100-PARTMARKER').first() is None
|
|
assert Asset.query.filter_by(assetnumber='FMARK200-PARTMARKER').first() is None
|
|
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."""
|
|
_asset(db, '0614')
|
|
assert _report(client, collector_key, 'FMARK300',
|
|
pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0614').status_code in (200, 201)
|
|
assert _report(client, collector_key, 'FMARK400',
|
|
pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0614').status_code in (200, 201)
|
|
assert Asset.query.filter_by(assetnumber='FMARK300-PARTMARKER').first()
|
|
assert Asset.query.filter_by(assetnumber='FMARK400-PARTMARKER').first()
|
|
|
|
|
|
def test_an_unknown_device_warns_and_links_nothing(client, db, rig,
|
|
collector_key):
|
|
_asset(db, '0616')
|
|
resp = _report(client, collector_key, 'FMARK500',
|
|
pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0616', deviceid='PM-TYPO')
|
|
assert resp.status_code in (200, 201)
|
|
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') == []
|
|
# AND it must not fall through to claiming the OPERATION directly. A marker
|
|
# PC reports the operation as its machine number, so "link nothing" that
|
|
# let the ordinary machine link run traded a phantom marker for a contested
|
|
# operation - several marker PCs share one, and it holds a single link.
|
|
assert _controlled('FMARK500', 'collector:machine') == []
|
|
|
|
|
|
def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key):
|
|
"""A machine number pasted into asset-id.txt must not become a marker."""
|
|
_asset(db, '0617')
|
|
_asset(db, 'PLAIN-MACHINE')
|
|
resp = _report(client, collector_key, 'FMARK600',
|
|
pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0617', deviceid='PLAIN-MACHINE')
|
|
assert resp.status_code in (200, 201)
|
|
assert _controlled('FMARK600', 'collector:partmarker') == []
|
|
assert _controlled('FMARK600', 'collector:machine') == []
|
|
assert Asset.query.filter_by(assetnumber='FMARK600-PARTMARKER').first() is None
|
|
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
|
|
assert 'PLAIN-MACHINE' in warnings
|
|
|
|
|
|
def test_repeat_cycles_with_the_file_are_stable(client, db, rig, collector_key):
|
|
_asset(db, '0618')
|
|
_marker(db, 'PM-0618-A')
|
|
codes = [_report(client, collector_key, 'FMARK700',
|
|
pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0618', deviceid='PM-0618-A').status_code
|
|
for _ in range(3)]
|
|
assert codes == [codes[0]] * 3, codes
|
|
assert _controlled('FMARK700', 'collector:partmarker') == ['PM-0618-A']
|
|
|
|
|
|
# ------------------------------------------------------------ measuring tools
|
|
|
|
def test_the_same_file_serves_a_metrology_bay(client, db, rig, collector_key):
|
|
"""One file, no device type in it: the pc-type decides which sync uses it."""
|
|
_tool(db, 'MT-9001')
|
|
resp = _report(client, collector_key, 'KEYENCE100',
|
|
pctype='gea-shopfloor-keyence', deviceid='MT-9001')
|
|
assert resp.status_code in (200, 201), resp.get_data(as_text=True)[:300]
|
|
assert _controlled('KEYENCE100', 'collector:measuringtool') == ['MT-9001']
|
|
assert Asset.query.filter_by(assetnumber='KEYENCE100-KEYENCE').first() is None
|
|
|
|
|
|
def test_the_0120_field_name_still_works(client, db, rig, collector_key):
|
|
"""measuringtool-id.txt shipped in 0.12.0; a staged bay keeps reporting."""
|
|
_tool(db, 'MT-9002')
|
|
resp = _report(client, collector_key, 'KEYENCE200',
|
|
pctype='gea-shopfloor-keyence', measuringtoolid='MT-9002')
|
|
assert resp.status_code in (200, 201)
|
|
assert _controlled('KEYENCE200', 'collector:measuringtool') == ['MT-9002']
|
|
|
|
|
|
def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key):
|
|
_tool(db, 'MT-9003')
|
|
_tool(db, 'MT-9004')
|
|
resp = _report(client, collector_key, 'KEYENCE300',
|
|
pctype='gea-shopfloor-keyence',
|
|
deviceid='MT-9003', measuringtoolid='MT-9004')
|
|
assert resp.status_code in (200, 201)
|
|
assert _controlled('KEYENCE300', 'collector:measuringtool') == ['MT-9003']
|
|
|
|
|
|
def test_a_dormant_challenger_is_not_promoted_when_its_file_disappears(
|
|
client, db, rig, collector_key):
|
|
"""Deleting asset-id.txt must not hand a device to the challenger.
|
|
|
|
The dormant row this feature creates is found as `reuse` on the next cycle.
|
|
Reactivating it unconditionally meant a second PC took a live device by
|
|
losing a file - no warning, two active holders.
|
|
"""
|
|
_asset(db, '0613')
|
|
_marker(db, 'PM-0613-A')
|
|
_report(client, collector_key, 'FMARK100', pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0613', deviceid='PM-0613-A')
|
|
_report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0613', deviceid='PM-0613-A')
|
|
assert _controlled('FMARK200', 'collector:partmarker') == []
|
|
|
|
# Next cycle: the file is gone, so no deviceid is sent at all.
|
|
_report(client, collector_key, 'FMARK200', pctype='gea-shopfloor-partmarker',
|
|
machinenumber='0613')
|
|
|
|
assert _controlled('FMARK100', 'collector:partmarker') == ['PM-0613-A']
|
|
assert _controlled('FMARK200', 'collector:partmarker') == []
|
|
|
|
|
|
def test_an_incumbent_under_another_collector_label_is_still_seen(
|
|
client, db, rig, collector_key):
|
|
"""On a CMM the instrument IS the bay, so the incumbent's link is the
|
|
machine sync's row under collector:machine. Counting only our own label
|
|
left two live holders of one instrument, each invisible to the other.
|
|
"""
|
|
_tool(db, 'CMM4')
|
|
# A LIVE incumbent: it reported through the collector, so it has a computer
|
|
# row and a recent check-in. A bare asset would correctly be treated as
|
|
# yielded, since something that cannot report cannot be holding anything.
|
|
_report(client, collector_key, 'WCMM100',
|
|
pctype='gea-shopfloor-keyence', deviceid='CMM4')
|
|
# Relabel its link to the machine sync's origin, which is the shape a CMM
|
|
# really has: there the instrument IS the reported bay.
|
|
pc = Asset.query.filter(Asset.assetnumber.ilike('WCMM100')).first()
|
|
held = AssetRelationship.query.filter_by(sourceassetid=pc.assetid).first()
|
|
held.label = 'collector:machine'
|
|
db.session.commit()
|
|
|
|
resp = _report(client, collector_key, 'WCMM200',
|
|
pctype='gea-shopfloor-keyence', deviceid='CMM4')
|
|
|
|
assert _controlled('WCMM200', 'collector:measuringtool') == []
|
|
warnings = ' '.join(resp.get_json()['data'].get('warnings', []))
|
|
assert 'CMM4' in warnings
|