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: '' } } }, }) 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: '' } } }, }) 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('-') }) })