Fix what the last round of device fixes broke, and two dips it missed
A review ofd60ed60and8b9b936found five things. Three were introduced by those commits. REFUSING A NAMED DEVICE MADE A MARKER PC CLAIM ITS OPERATION. _sync_partmarker returned [] both for "not a marker PC" and for "a marker PC that linked nothing", and the caller reads [] as the first - so a bay whose asset-id.txt named something unresolvable fell through to the ordinary machine link, and for a marker PC the machine number IS the operation. It took an active link to a record that can only have one holder while several markers share it, and the warning said "not linked". The previous behaviour minted a twin; this traded that for a contested operation. None now means "not a marker PC" and is the only answer that lets the machine link run; the response normalises it away so the API shape is unchanged. A DORMANT CHALLENGER WAS PROMOTED BY DELETING A FILE. Recording a challenger dormant leaves a row that the next cycle finds as `reuse` and reactivated with no incumbent check - so a second PC took a live device by its enrollment file becoming unreadable. Both reuse branches re-check incumbency now, which is what _sync_machine_link always did. AN INCUMBENT UNDER ANOTHER COLLECTOR LABEL WAS INVISIBLE. Incumbency was queried on our own label, but on a CMM the instrument IS the reported bay, so the incumbent's link is the machine sync's row. A second PC naming that instrument found no incumbent and linked actively: two live holders of one instrument, each invisible to the other. Incumbency now counts any collector-owned label. A row made BY HAND carries none of them and is still excluded - a person's link is not the collector's to archive. THE NETWORK FORM LOCKED OUT THE ROWS IT NEEDED TO FIX. Asset number is disabled while editing, correctly, but the payload is built in script so the blank was still sent - and the new server-side guard rejects it. A device with no asset number could not be saved at all, and the field could not be typed into. It now unlocks only for a record that loaded without one, with a hint saying why. "2 in 4.0d" WAS THE LABEL LYING. Replacements are counted across the whole history window; basisdays is only how long the current cartridge has been in. Joining them with "in" claimed two changes inside four days - the exact shape reported as unbelievable, except here the data was right. Now "2, this one 4.0d". Two toner dips the same review found: A MULTI-POLL OUTAGE STILL MINTED A PHANTOM SWAP. Only single readings were dropped, so 90, 0, 0, 90 survived and 0 -> 90 scored as a change. Dips of any length are handled now. One bad sample stays a candidate whatever the polling cadence, because a reading is an instant; several consecutive low ones only count as one outage when they are close together, since days at zero is a real empty period. That time bound also separates an outage from a swap, ordinary consumption, and a second swap, which have the same shape in levels alone. THE DIP FILTER ATE REAL SWAPS OF NEARLY-FULL CARTRIDGES. Recovery was tested with an absolute difference, so 95 then 5 then 100 read as a recovery because 100 and 95 are close, and the swap evidence was deleted. Toner only falls: a recovery comes back at or BELOW where it left, a new cartridge comes back higher. The review also proved by reverting each feature that the previous tests did not pin the median burn rate or the near-full rule - both passed with the bug restored. Verified by the same method that all four toner behaviours now fail when reverted, and the burst assertion is tight enough to tell 0.2 from 0.88.
This commit is contained in:
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user