Fix what the last round of device fixes broke, and two dips it missed
Some checks failed
CI / backend (push) Failing after 7m15s
CI / naming (push) Failing after 7m14s
CI / migrations-mysql (push) Has been cancelled
CI / frontend (push) Has been cancelled

A review of d60ed60 and 8b9b936 found 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.
This commit is contained in:
cproudlock
2026-08-21 08:49:38 -04:00
parent 0c0c7be439
commit 875fde9f48
8 changed files with 517 additions and 26 deletions

View File

@@ -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

View File

@@ -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'"
/>
<!-- Locked while editing, because an asset number is identity and
changing it silently re-keys the record. The exception is a
record that HAS no number: those exist (an update path used to
accept a blank), the server now rejects a blank on save, and
with the field disabled there was no way to supply one - the
row could not be saved at all until it was fixed elsewhere. -->
<small class="form-hint" v-if="assetNumberMissing">
This device has no asset number. Give it one to save.
</small>
<small class="form-hint" v-if="!isEdit && !form.assetnumber && generatedAssetNumber">
Will be created as <code>{{ generatedAssetNumber }}</code>
</small>
@@ -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 || ''

View File

@@ -90,7 +90,7 @@
<th class="collevel">Level</th>
<th>Runs out</th>
<th>Rate</th>
<th title="Cartridge changes detected, over the days of history behind this row">Replacements</th>
<th title="Cartridge changes seen anywhere in the history window, and how long the CURRENT cartridge has been in">Replacements</th>
</tr>
</thead>
<tbody>
@@ -131,10 +131,15 @@
<template v-else>-</template>
</td>
<td class="muted small">
<!-- "2 / 90d" read as a date, a ratio, or a version to
everyone who saw it. Say the unit. -->
<!-- Two DIFFERENT spans, so they must not be joined by a
word that implies one contains the other. Replacements are
counted across the whole history window; basisdays is only
how long the CURRENT cartridge has been in. "2 in 4.0d"
claimed two changes within four days, which is the shape
that reads as broken data - and here it would be the
label lying, not the data. -->
<template v-if="c.basisdays">
{{ c.replacements || 0 }} in {{ c.basisdays }}d
{{ c.replacements || 0 }}, this one {{ c.basisdays }}d
</template>
<template v-else>{{ c.replacements || 0 }}</template>
</td>

View File

@@ -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

View File

@@ -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 }
}

View File

@@ -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')
})
})

View File

@@ -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

View File

@@ -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) == []