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 @@
-
-
+
- {{ c.replacements || 0 }} in {{ c.basisdays }}d
+ {{ c.replacements || 0 }}, this one {{ c.basisdays }}d
{{ c.replacements || 0 }}
|
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) == []