diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index e826c42..96f1e6b 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -415,11 +415,13 @@ class ComputersPlugin(BasePlugin): partmarkers = self._sync_partmarker(comp, pctype, machinenumber, warnings, deviceid=deviceid) - # PC -> machine link from the reported machine number. - if partmarkers: - machinelinks = [] - else: + # PC -> machine link from the reported machine number. Only a PC that + # is NOT a marker PC (None above) takes it: a marker PC reaches its + # operation through `partof` on the marker, never by claiming it. + if partmarkers is None: machinelinks = self._sync_machine_link(comp, machinenumber, warnings) + else: + machinelinks = [] # Printer relationship sync (only when the payload carried printer data). printerlinks = self._sync_printer_links(comp.asset, payload, warnings) @@ -444,7 +446,10 @@ class ComputersPlugin(BasePlugin): 'measuringtoollinkcount': len(measuringtoollinks), 'accessprotocols': accessprotocols, 'machinelinks': machinelinks, - 'partmarkers': partmarkers, + # `or []`: None is the internal "not a marker PC" signal and + # has no business in the response, where it would read as a + # different state from "a marker PC that linked nothing". + 'partmarkers': partmarkers or [], }, } @@ -856,10 +861,21 @@ class ComputersPlugin(BasePlugin): if not controls or not deviceasset or not pcasset: return True + # EVERY collector-owned holder, not just ones under this label. On a + # CMM the instrument IS the reported bay, so the incumbent's link is the + # machine sync's row under MACHINE_LINK_ORIGIN. Filtering on our own + # label found no incumbent there, handed the challenger an active link, + # and left two live holders of one instrument - each invisible to the + # other because they were labelled differently. + # + # A row made by hand carries neither label and is deliberately NOT + # counted: a person's link is not the collector's to archive. + ourlabels = (label, MACHINE_LINK_ORIGIN, MEASURINGTOOL_LINK_ORIGIN, + PARTMARKER_LINK_ORIGIN) others = AssetRelationship.query.filter( AssetRelationship.targetassetid == deviceasset.assetid, AssetRelationship.relationshiptypeid == controls.relationshiptypeid, - AssetRelationship.label == label, + AssetRelationship.label.in_(ourlabels), AssetRelationship.isactive.is_(True), AssetRelationship.sourceassetid != pcasset.assetid, ).all() @@ -923,7 +939,16 @@ class ComputersPlugin(BasePlugin): # Only a device that FILES UNDER an operation goes through here. # A measuring tool is a subordinate device too, but it does not # share a machine number, so it keeps the simpler path. - return [] + # + # NONE, not []. None is the ONLY answer that means "not a marker PC, + # run the ordinary machine link". Every other exit below means "this + # IS a marker PC" - possibly one that linked nothing - and returning + # [] there let the caller fall through to the machine link, which + # for a marker PC points at the OPERATION. That is the direct claim + # this whole path exists to prevent: several markers serve one + # operation, so two marker PCs would contest a link that can only + # have one holder, while the warning said "not linked". + return None pcasset = comp.asset controls = RelationshipType.query.filter_by( @@ -998,8 +1023,15 @@ class ComputersPlugin(BasePlugin): self._ensure_device_rows(markerasset, spec, pcasset, controls, label, warnings, active=mayhold) elif reuse: - reuse.isactive = True + # Re-check incumbency HERE too, not only on the named path. A + # challenger recorded dormant keeps its row, so the cycle after its + # asset-id.txt goes missing found that row as `reuse` and flipped it + # active with no incumbent check and no warning - two live holders, + # arrived at by deleting a file. _sync_machine_link never had this + # hole because it re-checks unconditionally. markerasset = db.session.get(Asset, reuse.targetassetid) + reuse.isactive = self._device_incumbents_yield( + markerasset, pcasset, label, warnings) else: coretype = AssetType.query.filter_by( assettype=spec['assettype']).first() @@ -1419,8 +1451,11 @@ class ComputersPlugin(BasePlugin): tooltype.measuringtooltypeid targetid = adopted.assetid elif reuse: - reuse.isactive = True + # Same re-check as the marker path: a dormant challenger must not be + # promoted just because its enrollment file stopped being readable. toolasset = db.session.get(Asset, reuse.targetassetid) + reuse.isactive = self._device_incumbents_yield( + toolasset, pcasset, MEASURINGTOOL_LINK_ORIGIN, warnings) if toolasset and toolasset.measuringtool and tooltype: toolasset.measuringtool.measuringtooltypeid = \ tooltype.measuringtooltypeid diff --git a/plugins/network/frontend/views/NetworkDeviceForm.vue b/plugins/network/frontend/views/NetworkDeviceForm.vue index 526e9d9..3559b7e 100644 --- a/plugins/network/frontend/views/NetworkDeviceForm.vue +++ b/plugins/network/frontend/views/NetworkDeviceForm.vue @@ -24,9 +24,18 @@ type="text" class="form-control" :required="isEdit" - :disabled="isEdit" + :disabled="isEdit && !assetNumberMissing" :placeholder="generatedAssetNumber || 'leave blank to generate from the name'" /> + + + This device has no asset number. Give it one to save. + Will be created as {{ generatedAssetNumber }} @@ -393,6 +402,10 @@ const generatedAssetNumber = computed(() => { if (name.toUpperCase().startsWith(prefix + '-')) return name return `${prefix}-${name}` }) +// True when the record loaded WITHOUT an asset number, which unlocks the field +// so it can be repaired. Captured at load: once typed, the field must stay open. +const assetNumberMissing = ref(false) + const vendors = ref([]) const models = ref([]) @@ -480,6 +493,9 @@ async function loadDevice() { // Populate form with existing data form.value.assetnumber = data.assetnumber || '' + // Remember that it arrived empty, so the field stays editable while the + // user types the number this record should have had. + assetNumberMissing.value = !(data.assetnumber || '').trim() form.value.name = data.name || '' form.value.serialnumber = data.serialnumber || '' form.value.gaugelabreference = data.gaugelabreference || '' diff --git a/plugins/printers/frontend/views/TonerForecast.vue b/plugins/printers/frontend/views/TonerForecast.vue index 9884125..6d254b8 100644 --- a/plugins/printers/frontend/views/TonerForecast.vue +++ b/plugins/printers/frontend/views/TonerForecast.vue @@ -90,7 +90,7 @@ Level Runs out Rate - Replacements + Replacements @@ -131,10 +131,15 @@ - + diff --git a/plugins/printers/services/supply_history.py b/plugins/printers/services/supply_history.py index 9f3b16f..f9e2e70 100644 --- a/plugins/printers/services/supply_history.py +++ b/plugins/printers/services/supply_history.py @@ -33,6 +33,20 @@ REPLACEMENT_RISE = 10 # while a phantom swap resets the run and throws the estimate away entirely. NEW_CARTRIDGE_LEVEL = 80 +# How far ABOVE its previous level a reading may come back and still count as a +# recovery rather than a new cartridge. Small, because it exists for gauge +# noise: a real swap returns near full, well past this. +RECOVERY_TOLERANCE = 2 + +# How long a dip may last and still be a bad reading rather than real use. +# Levels alone cannot separate the two: a swap to full, ordinary consumption, +# then another swap has the same SHAPE as an outage that recovers. What differs +# is elapsed time. A supply out of the machine, a door open or a bad poll spans +# minutes to an hour or two at the few-minute polling these items use; a level +# that stays down for days is genuinely down, and deleting those readings would +# hide a real empty period. +MAX_DIP_HOURS = 6 + # Below this many readings a slope is arithmetic, not evidence. Two points # through a coarse gauge can "prove" any rate at all. MIN_POINTS_FOR_ESTIMATE = 4 @@ -92,8 +106,23 @@ def normalise(points): return drop_spikes(out) +def _recovered(previous, following, tolerance=RECOVERY_TOLERANCE): + """Did the level come BACK to where it was, rather than up to a new one? + + Toner only falls, so a dip that recovers returns to at or below the level it + left - consumption carried on while the reading was junk. A cartridge that + was CHANGED comes back HIGHER than the level before the dip. + + That asymmetry is the whole test. Comparing the absolute difference instead + treated a genuine swap of a nearly-full cartridge (95, then 5, then 100) as + a recovery and deleted the evidence, because 100 and 95 are close. The small + upward tolerance is for gauge noise, not for swaps. + """ + return following <= previous + tolerance + + def drop_spikes(points, rise=REPLACEMENT_RISE): - """Remove one-reading dips that RECOVER to where they came from. + """Remove dips that RECOVER to where they came from, however long they run. A single reading far below both neighbours, then a recovery, is a big upward step that scores as a cartridge change. That is how a cartridge @@ -108,22 +137,45 @@ def drop_spikes(points, rise=REPLACEMENT_RISE): The filter keys on SHAPE rather than cause, which is why it holds for all of them: a level that comes back to where it was did not get a new cartridge. - Only a dip that comes BACK to roughly its previous level is removed. A - genuine near-empty reading before a swap (30, 5, 100) does not recover - it - jumps to full - so it is kept, and the swap after it still counts. + ANY LENGTH, not just one reading. At the few-minute polling these items + often use, a door left open or a supply out of the machine spans several + polls, and a filter that only removed single readings left the original + failure in place for every cartridge above NEW_CARTRIDGE_LEVEL. + + A genuine near-empty reading before a swap (30, 5, 100) does not recover - + it comes back HIGHER than 30 - so it is kept and the swap still counts. """ if len(points) < 3: return points out = [points[0]] - for index in range(1, len(points) - 1): + index = 1 + while index < len(points) - 1: previous = points[index - 1][1] - current = points[index][1] - following = points[index + 1][1] - dipped = (previous - current) >= rise and (following - current) >= rise - recovered = abs(following - previous) <= rise - if dipped and recovered: + if (previous - points[index][1]) < rise: + out.append(points[index]) + index += 1 continue - out.append(points[index]) + # A dip starts here. Take every consecutive reading that stays down. + end = index + while end < len(points) - 1 and (previous - points[end][1]) >= rise: + end += 1 + following = points[end][1] + # ONE bad sample is a candidate whatever the cadence: the reading is an + # instant, and the gap to its neighbours says nothing about how long the + # supply was actually out. SEVERAL consecutive low readings only count + # as one outage if they are close together - spread over days they are a + # real absence, and they also have the same shape as a swap, ordinary + # consumption, then another swap. + spanhours = (points[end][0] - points[index - 1][0]).total_seconds() / 3600 + brief = (end - index) == 1 or spanhours <= MAX_DIP_HOURS + if (brief + and (following - points[end - 1][1]) >= rise + and _recovered(previous, following)): + index = end # drop the whole stretch + continue + for keep in range(index, end): + out.append(points[keep]) + index = end out.append(points[-1]) return out diff --git a/plugins/tools/frontend/labelPages.js b/plugins/tools/frontend/labelPages.js new file mode 100644 index 0000000..7b6e454 --- /dev/null +++ b/plugins/tools/frontend/labelPages.js @@ -0,0 +1,150 @@ +// Rows in, printed pages out. +// +// This is the half of the generator a preview cannot check. Two things live +// here for that reason: the CSV columns, which are a contract with whoever +// builds the file, and the PAGE ORDER of a two-sided card, which decides +// whether card 3's back lands on card 3 or on card 4. Both are the kind of +// wrong you only find once the stock is used up. +// +// Nothing here knows how a code is drawn or how big a label is. That is +// utils/codes.js and the component's CSS respectively. + +// Rendering thousands of codes locks the tab, so stop at a number a person +// would actually feed a label printer in one run. The cap counts CARDS, not +// pages - a two-sided run of 500 is 1000 pages and that is fine. +export const MAX_LABELS = 500 +export const MAX_COPIES = 1000 + +const CONTENT_HEADERS = ['content', 'text', 'data', 'value', 'url', 'qr'] +const LABEL_HEADERS = ['label', 'name', 'caption', 'description'] +const COPIES_HEADERS = ['copies', 'qty', 'quantity', 'count'] +const BACK_HEADERS = ['back', 'backcontent', 'reverse'] +const BACKLABEL_HEADERS = ['backlabel', 'backcaption', 'backname'] + +/** Split one CSV line, honoring quoted fields and "" escapes. */ +export function splitCsvLine(line) { + const fields = [] + let current = '' + let inQuotes = false + for (let i = 0; i < line.length; i++) { + const char = line[i] + if (inQuotes) { + if (char === '"' && line[i + 1] === '"') { current += '"'; i++ } + else if (char === '"') { inQuotes = false } + else { current += char } + } else if (char === '"') { + inQuotes = true + } else if (char === ',') { + fields.push(current); current = '' + } else { + current += char + } + } + fields.push(current) + return fields.map(field => field.trim()) +} + +/** + * Parse the label CSV into rows. + * + * Columns: content (required), label, copies, back, backlabel. A header row is + * optional; without one the order is the order above. back/backlabel are only + * read when the back side is set to take its own content per row. + */ +export function parseCsv(text) { + const lines = String(text || '').split(/\r?\n/).filter(line => line.trim() !== '') + if (!lines.length) return [] + + let columns = { content: 0, label: 1, copies: 2, back: 3, backlabel: 4 } + let start = 0 + const first = splitCsvLine(lines[0]).map(field => field.toLowerCase()) + if (first.some(field => CONTENT_HEADERS.includes(field))) { + // Named header: map by name so column order does not matter. + const indexOf = names => first.findIndex(field => names.includes(field)) + columns = { + content: indexOf(CONTENT_HEADERS), + label: indexOf(LABEL_HEADERS), + copies: indexOf(COPIES_HEADERS), + back: indexOf(BACK_HEADERS), + backlabel: indexOf(BACKLABEL_HEADERS), + } + start = 1 + } + + const at = (fields, index) => (index >= 0 ? (fields[index] || '') : '') + + const rows = [] + for (let i = start; i < lines.length; i++) { + const fields = splitCsvLine(lines[i]) + const content = at(fields, columns.content) + if (!content) continue + const copies = columns.copies >= 0 ? parseInt(fields[columns.copies], 10) : 1 + rows.push({ + content, + label: at(fields, columns.label), + copies: Number.isFinite(copies) && copies > 0 ? Math.min(copies, MAX_COPIES) : 1, + back: at(fields, columns.back), + backlabel: at(fields, columns.backlabel), + }) + } + return rows +} + +/** The back page belonging to one row, per the chosen back source. */ +function backPageFor(row, back) { + const fallback = back.text || '' + switch (back.source) { + // The same payload again, so either face of a card scans. Worth it on a + // badge that can end up in a holder either way round. + case 'code': + return { content: row.content, label: row.label || '', side: 'back' } + // Per-row back: the CSV carries it. A row that left it empty still gets a + // page, because a card printer counts pages and a missing one shifts every + // back after it onto the wrong front. + case 'column': + return { content: row.back || '', label: row.backlabel || fallback, side: 'back' } + case 'blank': + return { content: '', label: '', side: 'back' } + case 'text': + default: + return { content: '', label: fallback, side: 'back' } + } +} + +/** + * Expand rows by their copies count and, when a back side is enabled, pair + * each card with its back page. + * + * `back.order` is the one that matters on the floor: + * interleave - front, back, front, back. What a duplex card printer's driver + * expects: it takes pages two at a time. + * grouped - every front, then every back. For a single-sided printer, + * where the stack comes out, gets flipped and goes back in. + * Getting this backwards prints readable cards with the wrong names on the + * back, which is worse than a jam because nothing looks broken. + */ +export function buildPages(rows, options = {}) { + const { back = {}, max = MAX_LABELS } = options + const fronts = [] + const backs = [] + let dropped = 0 + + for (const row of rows || []) { + const wanted = Number.isFinite(row.copies) && row.copies > 0 + ? Math.min(row.copies, MAX_COPIES) : 1 + for (let i = 0; i < wanted; i++) { + if (fronts.length >= max) { dropped += 1; continue } + fronts.push({ content: row.content, label: row.label || '', side: 'front' }) + if (back.enabled) backs.push(backPageFor(row, back)) + } + } + + let pages = fronts + if (back.enabled) { + pages = back.order === 'grouped' + ? [...fronts, ...backs] + : fronts.flatMap((front, index) => [front, backs[index]]) + } + + return { pages, fronts, backs, dropped } +} diff --git a/plugins/tools/frontend/labelPages.spec.js b/plugins/tools/frontend/labelPages.spec.js new file mode 100644 index 0000000..31d33fb --- /dev/null +++ b/plugins/tools/frontend/labelPages.spec.js @@ -0,0 +1,113 @@ +// Page order is the one thing here that costs money to get wrong: a run of +// badge cards with the backs one card out looks perfect until someone reads +// one. These tests pin the order and the CSV contract. + +import { describe, it, expect } from 'vitest' + +import { parseCsv, splitCsvLine, buildPages, MAX_LABELS } from './labelPages' + +const sides = pages => pages.map(page => page.side).join(' ') +const contents = pages => pages.map(page => page.content || '-').join(' ') + +describe('splitCsvLine', () => { + it('keeps a comma that is inside quotes', () => { + expect(splitCsvLine('WJ-0001,"Bay 12, press",2')).toEqual(['WJ-0001', 'Bay 12, press', '2']) + }) + + it('unescapes a doubled quote', () => { + expect(splitCsvLine('x,"the ""big"" one"')).toEqual(['x', 'the "big" one']) + }) +}) + +describe('parseCsv', () => { + it('reads a named header in any column order', () => { + const rows = parseCsv('copies,label,content\n3,Line 1,WJ-0001') + expect(rows).toEqual([ + { content: 'WJ-0001', label: 'Line 1', copies: 3, back: '', backlabel: '' }, + ]) + }) + + it('falls back to positional columns with no header', () => { + const rows = parseCsv('WJ-0001,Line 1,2,WJ-0001-B,Reverse') + expect(rows[0]).toEqual({ + content: 'WJ-0001', label: 'Line 1', copies: 2, + back: 'WJ-0001-B', backlabel: 'Reverse', + }) + }) + + it('skips a row with no content rather than printing a blank sticker', () => { + expect(parseCsv('content,label\n,orphan\nWJ-0002,real')).toHaveLength(1) + }) + + it('defaults copies to 1 when it is missing or junk', () => { + expect(parseCsv('content,copies\nA,\nB,zero')[0].copies).toBe(1) + expect(parseCsv('content,copies\nA,\nB,zero')[1].copies).toBe(1) + }) +}) + +describe('buildPages without a back side', () => { + it('expands copies and emits fronts only', () => { + const { pages, backs } = buildPages([{ content: 'A', label: 'a', copies: 3 }]) + expect(sides(pages)).toBe('front front front') + expect(backs).toHaveLength(0) + }) + + it('caps cards, not pages, and says how many it dropped', () => { + const { fronts, dropped } = buildPages([{ content: 'A', copies: MAX_LABELS + 5 }]) + expect(fronts).toHaveLength(MAX_LABELS) + expect(dropped).toBe(5) + }) +}) + +describe('buildPages with a back side', () => { + const rows = [ + { content: 'A', label: 'a', copies: 2, back: 'A2', backlabel: 'a2' }, + { content: 'B', label: 'b', copies: 1, back: 'B2', backlabel: 'b2' }, + ] + + it('interleaves front and back, which is what a duplex driver takes', () => { + const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'interleave' } }) + expect(sides(pages)).toBe('front back front back front back') + expect(contents(pages)).toBe('A A2 A A2 B B2') + }) + + it('groups every front then every back, for a flip-and-reload printer', () => { + const { pages } = buildPages(rows, { back: { enabled: true, source: 'column', order: 'grouped' } }) + expect(sides(pages)).toBe('front front front back back back') + // Same card order in both halves, or the flipped stack pairs up wrong. + expect(contents(pages)).toBe('A A B A2 A2 B2') + }) + + it('still emits a back for a row that left the back column empty', () => { + const { pages } = buildPages( + [{ content: 'A', copies: 1 }, { content: 'B', copies: 1, back: 'B2' }], + { back: { enabled: true, source: 'column', order: 'interleave' } }) + // A page count that changes per row would shift every later back onto the + // wrong front. + expect(sides(pages)).toBe('front back front back') + expect(contents(pages)).toBe('A - B B2') + }) + + it('repeats the front payload when the back is the same code', () => { + const { backs } = buildPages(rows, { back: { enabled: true, source: 'code' } }) + expect(contents(backs)).toBe('A A B') + }) + + it('puts the same fixed text on every back and no code', () => { + const { backs } = buildPages(rows, { back: { enabled: true, source: 'text', text: 'Property of GE' } }) + expect(backs.every(page => page.content === '')).toBe(true) + expect(backs.every(page => page.label === 'Property of GE')).toBe(true) + }) + + it('leaves a blank back entirely empty but still counted', () => { + const { pages, backs } = buildPages(rows, { back: { enabled: true, source: 'blank' } }) + expect(pages).toHaveLength(6) + expect(backs.every(page => !page.content && !page.label)).toBe(true) + }) + + it('falls back to the fixed text when a row has no back label', () => { + const { backs } = buildPages([{ content: 'A', copies: 1, back: 'A2' }], + { back: { enabled: true, source: 'column', text: 'Return to IT' } }) + expect(backs[0].label).toBe('Return to IT') + }) +}) diff --git a/tests/test_plugins/test_collector_deviceid.py b/tests/test_plugins/test_collector_deviceid.py index 0f2f84c..708dbde 100644 --- a/tests/test_plugins/test_collector_deviceid.py +++ b/tests/test_plugins/test_collector_deviceid.py @@ -211,6 +211,11 @@ def test_an_unknown_device_warns_and_links_nothing(client, db, rig, # 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): @@ -222,6 +227,7 @@ def test_a_device_of_the_wrong_type_is_refused(client, db, rig, collector_key): 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 @@ -267,3 +273,54 @@ def test_deviceid_wins_when_both_arrive(client, db, rig, collector_key): 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 diff --git a/tests/test_plugins/test_supply_history.py b/tests/test_plugins/test_supply_history.py index 0d8e1ba..7c34c74 100644 --- a/tests/test_plugins/test_supply_history.py +++ b/tests/test_plugins/test_supply_history.py @@ -319,7 +319,9 @@ def test_an_early_burst_does_not_dominate_the_rate_forever(): 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 + # The old endpoint slope gives ~0.88 for this series, so a threshold of 1.0 + # passed with the bug still in. The median gives ~0.2. + assert rate < 0.5, rate detail = analyse(series(levels)) assert detail['rateunstable'] is True @@ -362,3 +364,64 @@ def test_a_run_with_enough_days_still_estimates(): detail = analyse(series([84, 83, 82, 81, 80, 79])) assert detail['burnrateperday'] == 1.0 assert detail['daysleft'] == 79 + + +def test_a_multi_poll_outage_is_one_dip_not_a_cartridge_change(): + """A door open or a supply out of the machine spans several polls. + + These items are often polled every few minutes, so an outage covers more + than one reading. Removing only SINGLE readings left the original failure + in place for any cartridge above NEW_CARTRIDGE_LEVEL: 0 -> 90 clears the + rise and lands near full, so it scored as a swap. + """ + points = normalise(series([90, 0, 0, 90, 88, 86], hours=5 / 60)) + assert [level for _, level in points] == [90.0, 90.0, 88.0, 86.0] + assert find_replacements(points) == [] + + +def test_a_long_absence_is_not_deleted_as_noise(): + """Days at zero is a real empty period, not a bad poll. + + Only a BRIEF multi-reading dip is removed. Deleting a level that stayed + down for days would hide exactly the outage someone needs to see. + """ + points = normalise(series([90, 0, 0, 90, 88, 86])) + assert 0.0 in [level for _, level in points] + + +def test_a_swap_of_a_nearly_full_cartridge_is_not_eaten_by_the_dip_filter(): + """95, then 5, then 100 is a swap, not a dip that recovered. + + Comparing the absolute difference treated it as a recovery, because 100 and + 95 are close, and deleted the evidence: the run then spanned two cartridges + and the replacement went uncounted. Toner only falls, so a recovery comes + back at or BELOW where it left - a new cartridge comes back higher. + """ + points = normalise(series([98, 95, 5, 100, 96, 92])) + assert 5.0 in [level for _, level in points] + assert len(find_replacements(points)) == 1 + + +def test_consumption_between_two_swaps_is_not_mistaken_for_a_dip(): + """Swap to full, print for days, swap again: same shape, different thing. + + The readings between the swaps are far below both bounding levels, which is + what a dip looks like. What separates them is elapsed time. + """ + points = normalise(series([80, 60, 40, 20, 100, 80, 60, 95, 70])) + assert [level for _, level in points] == [80.0, 60.0, 40.0, 20.0, + 100.0, 80.0, 60.0, 95.0, 70.0] + assert len(find_replacements(points)) == 2 + + +def test_a_rise_that_stops_mid_range_is_not_a_cartridge_change(): + """Isolates the near-full rule from the dip filter. + + 50 -> 75 clears the rise and is NOT a recovering dip (75 comes back higher + than the 60 before it), so the dip filter leaves it alone. Only the rule + that a swap must LAND near full rejects it. A new cartridge does not read + 75 percent. + """ + points = normalise(series([60, 50, 75, 73, 71])) + assert 50.0 in [level for _, level in points] + assert find_replacements(points) == []