Two separate floors were collapsing a live cartridge into a spent one, and removing either alone changes nothing. EMPTY_LEVEL was 5, so everything at or below 5% was assigned daysleft 0 outright. The reasoning was that a printer at 1% is out of toner as far as anyone standing at it is concerned. But the row exists to say how long is left, and 2% draining a point a day has two days in it. Flooring put that cartridge beside ones that genuinely are empty with no way back to the difference. It is 0 now: empty means empty. int(level / rate) then truncated the division, so anything under a full day arrived as 0 whatever the floor did - a cartridge with six hours in it was indistinguishable from one with nothing, and the band read it as empty. daysleft is fractional now, rounded to two places, which is about a quarter of an hour: finer than the estimate deserves, but it costs nothing and keeps the ordering of two nearly-spent cartridges meaningful. daysText reads in whatever unit carries meaning: "6 hours", "1 hour", "29 min", "4 days". Below an hour it goes to minutes with a floor of one, because rounding hours would land back on "empty" - the same bug one rung down. BEHAVIOUR CHANGE worth knowing: a cartridge at 1-5% with NO history used to get daysleft 0 from the floor and land on the order list. A rate needs two readings; with none there is nothing to divide, and the old answer was right by accident - it said "empty" about a level nobody had watched move. It now reports 'no history' and shows under "No estimate yet". This reaches only printers newly added to Zabbix; anything that got to 3% the ordinary way has the history to forecast from. Five existing tests pinned the old rule. They recorded a real decision, so they are rewritten to the new one rather than deleted. One of them was passing for the wrong reason: its series (2.0, 1.7, 1.4, 1.1) drops 0.9, under MIN_DROP_FOR_ESTIMATE, so it never had a rate at all and only passed because the floor short-circuited ahead of the rate check. It now uses a real 20-day drop at a tenth of a point a day and asserts the ten days its docstring always described. The forecast fixture's black cartridge moves from 1% to 0% so the empty band keeps its API-level coverage, and a magenta at 3% covers hours-left end to end.
173 lines
6.1 KiB
JavaScript
173 lines
6.1 KiB
JavaScript
import { describe, it, expect, vi } from 'vitest'
|
|
import { mount, flushPromises } from '@vue/test-utils'
|
|
|
|
// The forecast identifies a cartridge by its NAME - '414X Black' - and keeps the
|
|
// part number on the hover. A part number alone ('W2020X') only identifies the
|
|
// toner to someone who already knows it, and the two places that name a supply
|
|
// (the order list and the Part column) had shown nothing else.
|
|
//
|
|
// The number has to survive somewhere: it is what an order is placed against.
|
|
// It stays in the title attribute and, verbatim and unlabelled, in the text that
|
|
// 'Copy list' writes to the clipboard. These specs pin both halves.
|
|
//
|
|
// marketingname is nullable, so every display falls back to the number.
|
|
|
|
vi.mock('@/api', () => ({
|
|
printersApi: { supplyForecast: vi.fn() },
|
|
}))
|
|
vi.mock('@/utils/apiError', () => ({ apiError: (e) => String(e) }))
|
|
|
|
import { printersApi } from '@/api'
|
|
import TonerForecast from './TonerForecast.vue'
|
|
|
|
function payload({ marketingname = '414X Black', partnumber = 'W2020X' } = {}) {
|
|
const part = { partnumber, marketingname, capacitytier: 'high', pageyield: 7500 }
|
|
return {
|
|
data: {
|
|
data: {
|
|
available: true,
|
|
days: 90,
|
|
horizondays: 14,
|
|
summary: { bands: { empty: 1 }, replacements: 3, toorder: 1 },
|
|
cartridges: [{
|
|
printerid: 1, printername: 'GageLab-Xerox-C415', assetnumber: 'P-1',
|
|
ipaddress: '10.49.215.12', name: 'Black toner', color: 'black',
|
|
currentlevel: 4, band: 'empty', daysleft: 0, burnrateperday: 1.8,
|
|
rateunstable: false, replacements: 3, basisdays: 12,
|
|
partnumbers: [part],
|
|
}],
|
|
unestimated: [],
|
|
orderlist: [{
|
|
partnumber, marketingname, color: 'black', supplytype: 'toner',
|
|
model: 'Versalink C415', alternates: [], quantity: 2,
|
|
printers: [{ printerid: 1, printername: 'GageLab-Xerox-C415', daysleft: 0 }],
|
|
}],
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
async function render(overrides) {
|
|
printersApi.supplyForecast.mockResolvedValue(payload(overrides))
|
|
const wrapper = mount(TonerForecast, {
|
|
global: { stubs: { 'router-link': { template: '<a><slot /></a>' } } },
|
|
})
|
|
await flushPromises()
|
|
return wrapper
|
|
}
|
|
|
|
describe('the supply is named, not numbered', () => {
|
|
it('shows the name in the order list', async () => {
|
|
const wrapper = await render()
|
|
const name = wrapper.find('.order-list .supplyname')
|
|
expect(name.text()).toBe('414X Black')
|
|
})
|
|
|
|
it('shows the name in the Part column', async () => {
|
|
const wrapper = await render()
|
|
const cells = wrapper.findAll('tbody .supplyname')
|
|
expect(cells.length).toBeGreaterThan(0)
|
|
expect(cells[0].text()).toBe('414X Black')
|
|
})
|
|
|
|
it('keeps the part number on the hover, both places', async () => {
|
|
const wrapper = await render()
|
|
for (const el of wrapper.findAll('.supplyname')) {
|
|
expect(el.attributes('title')).toBe('W2020X')
|
|
}
|
|
})
|
|
|
|
it('never renders the part number as the visible label', async () => {
|
|
const wrapper = await render()
|
|
for (const el of wrapper.findAll('.supplyname')) {
|
|
expect(el.text()).not.toBe('W2020X')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('a supply with no name on file', () => {
|
|
it('falls back to the part number rather than showing nothing', async () => {
|
|
const wrapper = await render({ marketingname: null })
|
|
for (const el of wrapper.findAll('.supplyname')) {
|
|
expect(el.text()).toBe('W2020X')
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('what purchasing receives is unchanged', () => {
|
|
it('copies the part number, not the name', async () => {
|
|
const written = []
|
|
Object.assign(navigator, {
|
|
clipboard: { writeText: (text) => { written.push(text); return Promise.resolve() } },
|
|
})
|
|
const wrapper = await render()
|
|
await wrapper.find('.order-head button').trigger('click')
|
|
await flushPromises()
|
|
expect(written).toHaveLength(1)
|
|
expect(written[0]).toContain('W2020X')
|
|
expect(written[0]).not.toContain('414X Black')
|
|
})
|
|
})
|
|
|
|
// --- how long is left ------------------------------------------------------
|
|
//
|
|
// daysleft used to be int(level / rate), so anything under a full day
|
|
// truncated to 0 and the view printed "empty". A cartridge with six hours in it
|
|
// still prints, and the row exists to say how long. Only a real zero is empty.
|
|
|
|
describe('the countdown reads in whatever unit carries meaning', () => {
|
|
async function daysCell(daysleft) {
|
|
const band = daysleft === null ? 'soon' : daysleft <= 0 ? 'empty' : 'soon'
|
|
const base = payload()
|
|
base.data.data.cartridges[0].daysleft = daysleft
|
|
base.data.data.cartridges[0].band = band
|
|
printersApi.supplyForecast.mockResolvedValue(base)
|
|
const wrapper = mount(TonerForecast, {
|
|
global: { stubs: { 'router-link': { template: '<a><slot /></a>' } } },
|
|
})
|
|
await flushPromises()
|
|
// Only the 'empty' band starts expanded - what is already out is what the
|
|
// page opens on. Every band renders a header whether or not it holds
|
|
// anything, so the right one has to be picked by its key rather than by
|
|
// position; clicking the first would close 'empty' instead.
|
|
if (band !== 'empty') {
|
|
const head = wrapper.findAll('.band-head')
|
|
.find(b => b.find(`.band-count.${band}`).exists())
|
|
await head.trigger('click')
|
|
await flushPromises()
|
|
}
|
|
return wrapper.find('tbody .days').text()
|
|
}
|
|
|
|
it('says empty only for an actual zero', async () => {
|
|
expect(await daysCell(0)).toBe('empty')
|
|
})
|
|
|
|
it('reads in hours below a day', async () => {
|
|
expect(await daysCell(0.25)).toBe('6 hours')
|
|
})
|
|
|
|
it('does not pluralise a single hour', async () => {
|
|
expect(await daysCell(1 / 24)).toBe('1 hour')
|
|
})
|
|
|
|
it('reads in minutes below an hour, never as empty', async () => {
|
|
const text = await daysCell(0.02)
|
|
expect(text).toBe('29 min')
|
|
expect(text).not.toBe('empty')
|
|
})
|
|
|
|
it('never rounds a live cartridge down to empty', async () => {
|
|
expect(await daysCell(0.0001)).toBe('1 min')
|
|
})
|
|
|
|
it('reads in whole days above a day', async () => {
|
|
expect(await daysCell(3.7)).toBe('4 days')
|
|
expect(await daysCell(1.2)).toBe('1 day')
|
|
})
|
|
|
|
it('still shows a dash when there is no estimate', async () => {
|
|
expect(await daysCell(null)).toBe('-')
|
|
})
|
|
})
|