Fix what the last round of device fixes broke, and two dips it missed
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 || ''
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
150
plugins/tools/frontend/labelPages.js
Normal file
150
plugins/tools/frontend/labelPages.js
Normal 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 }
|
||||
}
|
||||
113
plugins/tools/frontend/labelPages.spec.js
Normal file
113
plugins/tools/frontend/labelPages.spec.js
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
Reference in New Issue
Block a user