From bd86ec87ed513a6b78e9514a208a6e0d5b00c9a3 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 04:39:42 +0200 Subject: [PATCH 1/5] feat(frontend): add explicit Archera comparison section (#785) Adds a Compare with Archera action to the Archera education page, shown only when /api/insurance/status reports configured. Renders the DTO title, notes and both disclosures verbatim, exact decimal money (null as Unknown, no currency), fetched-at, product support supported/unknown only, and an explicit single-flight Refresh with no polling. All vendor strings are text. Refs #785 #786 Co-Authored-By: Claude Opus 5.5 --- frontend/src/__tests__/api-insurance.test.ts | 18 ++ .../src/__tests__/archera-comparison.test.ts | 298 ++++++++++++++++++ frontend/src/api/index.ts | 6 + frontend/src/api/insurance.ts | 16 + frontend/src/api/types.ts | 102 ++++++ frontend/src/archera-comparison.ts | 274 ++++++++++++++++ frontend/src/archera.ts | 7 + frontend/tests-e2e/archera-comparison.spec.ts | 84 +++++ 8 files changed, 805 insertions(+) create mode 100644 frontend/src/__tests__/api-insurance.test.ts create mode 100644 frontend/src/__tests__/archera-comparison.test.ts create mode 100644 frontend/src/api/insurance.ts create mode 100644 frontend/src/archera-comparison.ts create mode 100644 frontend/tests-e2e/archera-comparison.spec.ts diff --git a/frontend/src/__tests__/api-insurance.test.ts b/frontend/src/__tests__/api-insurance.test.ts new file mode 100644 index 00000000..bf0b27ca --- /dev/null +++ b/frontend/src/__tests__/api-insurance.test.ts @@ -0,0 +1,18 @@ +import { getInsuranceComparison, getInsuranceStatus } from '../api/insurance'; +import { apiRequest } from '../api/client'; + +jest.mock('../api/client', () => ({ apiRequest: jest.fn() })); + +describe('insurance api', () => { + beforeEach(() => jest.resetAllMocks()); + + it('status is a plain GET with no query or body', async () => { + await getInsuranceStatus(); + expect(apiRequest).toHaveBeenCalledWith('/insurance/status'); + }); + + it('comparison is a plain GET with no query or body', async () => { + await getInsuranceComparison(); + expect(apiRequest).toHaveBeenCalledWith('/insurance/comparison'); + }); +}); diff --git a/frontend/src/__tests__/archera-comparison.test.ts b/frontend/src/__tests__/archera-comparison.test.ts new file mode 100644 index 00000000..b08a6d6c --- /dev/null +++ b/frontend/src/__tests__/archera-comparison.test.ts @@ -0,0 +1,298 @@ +/** + * Archera comparison section (issue #785). The fixture is the Go DTO golden + * file read from its Go path, so the UI is tested against the real shape. + * Success rendering is therefore fixture-based; no live Archera calls. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { buildComparisonView, renderComparisonSection } from '../archera-comparison'; +import { getInsuranceComparison, getInsuranceStatus } from '../api/insurance'; +import type { ArcheraComparison, ArcheraOffer, ArcheraProductSupport } from '../api/types'; + +jest.mock('../api/insurance', () => ({ + getInsuranceStatus: jest.fn(), + getInsuranceComparison: jest.fn(), +})); + +const status = getInsuranceStatus as jest.Mock; +const comparison = getInsuranceComparison as jest.Mock; + +const GOLDEN_PATH = path.resolve(__dirname, '../../../internal/archera/testdata/comparison.golden.json'); +const golden = JSON.parse(fs.readFileSync(GOLDEN_PATH, 'utf8')) as ArcheraComparison; + +// satisfies makes tsc fail when the TS DTO gains or loses a key; the +// runtime check below fails when the Go golden file drifts from the TS keys. +const TOP_KEYS = { + title: 1, plan_id: 1, fetched_at: 1, currency: 1, currency_note: 1, premium_included: 1, + basis_note: 1, delta_basis_note: 1, current: 1, hypotheticals: 1, rows: 1, + non_gating_disclosure: 1, sponsorship_disclosure: 1, +} satisfies Record; +const SUPPORT_KEYS = { + status: 1, source: 1, evidence: 1, underwriting_allowance: 1, customer_eligibility: 1, +} satisfies Record; + +function clone(): ArcheraComparison { + return JSON.parse(JSON.stringify(golden)) as ArcheraComparison; +} + +function firstOffer(c: ArcheraComparison): ArcheraOffer { + const row = c.rows[0]; + if (!row) throw new Error('golden has no rows'); + return row.current; +} + +async function mount(): Promise { + document.body.replaceChildren(); + const root = document.createElement('div'); + document.body.appendChild(root); + await renderComparisonSection(root); + return root; +} + +async function clickAndLoad(root: HTMLElement, data: ArcheraComparison): Promise { + comparison.mockResolvedValueOnce(data); + root.querySelector('button')!.click(); + await flush(); +} + +async function flush(): Promise { + for (let i = 0; i < 5; i++) await Promise.resolve(); +} + +beforeEach(() => { + jest.resetAllMocks(); + status.mockResolvedValue({ configured: true, missing: [] }); +}); + +describe('golden drift', () => { + it('golden keys equal the TS DTO keys', () => { + expect(Object.keys(golden).sort()).toEqual(Object.keys(TOP_KEYS).sort()); + const ps = firstOffer(golden).archera_product_support; + expect(Object.keys(ps).sort()).toEqual(Object.keys(SUPPORT_KEYS).sort()); + }); +}); + +describe('status gating', () => { + it('configured=false: nothing rendered and no comparison request', async () => { + status.mockResolvedValue({ configured: false, missing: ['ARCHERA_ORG_ID'] }); + const root = await mount(); + expect(root.innerHTML).toBe(''); + expect(comparison).not.toHaveBeenCalled(); + }); + + it('404 (scoped session): nothing rendered and no comparison request', async () => { + status.mockRejectedValue(Object.assign(new Error('not found'), { status: 404 })); + const root = await mount(); + expect(root.innerHTML).toBe(''); + expect(comparison).not.toHaveBeenCalled(); + }); + + it.each([500, 0])('status error %s: one unavailable line, no comparison request', async code => { + status.mockRejectedValue(Object.assign(new Error('boom'), { status: code })); + const root = await mount(); + expect(root.textContent).toBe('Archera comparison status unavailable'); + expect(root.querySelector('button')).toBeNull(); + expect(comparison).not.toHaveBeenCalled(); + }); + + it('configured: button only, no comparison request until clicked', async () => { + const root = await mount(); + expect(root.querySelector('button')!.textContent).toBe('Compare with Archera'); + expect(comparison).not.toHaveBeenCalled(); + }); +}); + +describe('rendering the golden response', () => { + it('renders the DTO title, notes, both disclosures and fetched-at verbatim', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + expect(root.querySelector('h2')!.textContent).toBe(golden.title); + const txt = root.textContent!; + for (const s of [golden.currency_note, golden.basis_note, golden.delta_basis_note, + golden.non_gating_disclosure, golden.sponsorship_disclosure, + firstOffer(golden).archera_offer_name_note!]) { + expect(txt).toContain(s); + } + expect(root.querySelector('.archera-fetched-at')!.textContent).toContain('Fetched at'); + expect(root.querySelector('.archera-fetched-at')!.textContent).toContain('9 Oct 2026'); + expect(txt).not.toContain('org_id'); + }); + + it('renders disclosures from the response, not a hardcoded copy', async () => { + const c = clone(); + c.non_gating_disclosure = 'DISCLOSURE-A-CHANGED'; + c.sponsorship_disclosure = 'DISCLOSURE-B-CHANGED'; + const root = await mount(); + await clickAndLoad(root, c); + expect(root.textContent).toContain('DISCLOSURE-A-CHANGED'); + expect(root.textContent).toContain('DISCLOSURE-B-CHANGED'); + expect(root.textContent).not.toContain(golden.non_gating_disclosure); + }); + + it('shows no currency symbol, USD or dollar wording of its own', async () => { + const c = clone(); + c.basis_note = 'one-time amounts'; + const root = await mount(); + await clickAndLoad(root, c); + expect(root.textContent).not.toMatch(/\$|USD|dollar/i); + }); + + it('uses captions and column/row header scopes on every table', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + const tables = root.querySelectorAll('table'); + expect(tables.length).toBeGreaterThanOrEqual(3); + tables.forEach(t => { + expect(t.querySelector('caption')!.textContent).not.toBe(''); + t.querySelectorAll('th').forEach(th => expect(th.getAttribute('scope')).toMatch(/^(col|row)$/)); + }); + }); +}); + +describe('money', () => { + it('null renders Unknown, "0" stays 0, long decimals are verbatim', async () => { + const c = clone(); + c.current.monthly_730h_net_savings = null; + c.current.monthly_730h_gross_savings = '0'; + c.current.upfront_one_time_cost = '0.0000000000000000000000000001234567890123'; + const root = await mount(); + await clickAndLoad(root, c); + const rows = Array.from(root.querySelectorAll('table')[0]!.querySelectorAll('tr')); + const byLabel = (l: string) => rows.find(r => r.textContent!.startsWith(l))!.querySelector('td')!.textContent; + expect(byLabel('Net savings')).toBe('Unknown'); + expect(byLabel('Gross savings')).toBe('0'); + expect(byLabel('Upfront cost')).toBe('0.0000000000000000000000000001234567890123'); + }); +}); + +describe('vendor strings are text only', () => { + it('hostile strings create no elements', async () => { + const hostile = ''; + const c = clone(); + const o = firstOffer(c); + o.offer_id = hostile; + o.region = hostile; + o.commitment_type = hostile; + o.archera_offer_name = hostile; + o.archera_product_support.evidence = hostile; + c.rows[0]!.line_item_id = hostile; + c.hypotheticals[0]!.line_items[0]!.reason = hostile; + const root = await mount(); + await clickAndLoad(root, c); + expect(root.querySelector('img')).toBeNull(); + expect(root.querySelector('script')).toBeNull(); + expect(root.textContent).toContain(hostile); + }); +}); + +describe('product support', () => { + it('supported shows source and evidence', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + const ps = firstOffer(golden).archera_product_support; + expect(root.textContent).toContain('Supported (documented)'); + expect(root.textContent).toContain(ps.source!); + expect(root.textContent).toContain(ps.evidence!); + }); + + it.each(['unknown', 'unsupported', '', 'SUPPORTED', 'garbage'])('status %p renders not-known and never "unsupported"', async st => { + const c = clone(); + for (const r of c.rows) for (const o of [r.current, ...r.candidates]) { + o.archera_product_support = { + status: st, source: 'SRC-LEAK', evidence: 'EVD-LEAK', + underwriting_allowance: 'allowed', customer_eligibility: 'eligible', + }; + } + const root = await mount(); + await clickAndLoad(root, c); + const txt = root.textContent!; + expect(txt).toContain('Not known to be supported by Archera'); + expect(txt).not.toMatch(/unsupported/i); + expect(txt).not.toContain('SRC-LEAK'); + expect(txt).not.toContain('EVD-LEAK'); + expect(txt).not.toMatch(/allowed|eligible\b/); + expect(txt).toContain('Underwriting allowance: Unknown'); + expect(txt).toContain('Customer eligibility: Unknown'); + }); + + it('lease attached is labelled', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + expect(root.textContent).toContain('lease attached (lease-cur)'); + }); +}); + +describe('refresh, errors and accessibility', () => { + it('refresh re-requests once per click; in-flight clicks are ignored; nothing polls', async () => { + jest.useFakeTimers(); + try { + const root = await mount(); + const button = root.querySelector('button')!; + let resolve!: (c: ArcheraComparison) => void; + comparison.mockReturnValueOnce(new Promise(r => { resolve = r; })); + button.click(); + expect(button.disabled).toBe(true); + expect(button.getAttribute('aria-busy')).toBe('true'); + button.click(); + expect(comparison).toHaveBeenCalledTimes(1); + resolve(golden); + await flush(); + expect(button.disabled).toBe(false); + expect(button.textContent).toBe('Refresh'); + expect(document.activeElement).toBe(button); + jest.advanceTimersByTime(10 * 60 * 1000); + await flush(); + expect(comparison).toHaveBeenCalledTimes(1); + comparison.mockResolvedValueOnce(golden); + button.click(); + await flush(); + expect(comparison).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('never schedules timers or intervals', async () => { + const si = jest.spyOn(global, 'setInterval'); + const st = jest.spyOn(global, 'setTimeout'); + const root = await mount(); + await clickAndLoad(root, golden); + expect(si).not.toHaveBeenCalled(); + expect(st).not.toHaveBeenCalled(); + si.mockRestore(); + st.mockRestore(); + }); + + it('429 with retry_after_seconds shows the wait; Refresh stays enabled', async () => { + const root = await mount(); + comparison.mockRejectedValueOnce(Object.assign(new Error('Archera rate limit reached'), + { status: 429, details: { retry_after_seconds: 30 } })); + root.querySelector('button')!.click(); + await flush(); + const live = root.querySelector('[aria-live="polite"]')!; + expect(live.textContent).toContain('Retry after 30 seconds'); + expect(root.querySelector('button')!.disabled).toBe(false); + }); + + it('429 without retry_after_seconds says retry-after not given', async () => { + const root = await mount(); + comparison.mockRejectedValueOnce(Object.assign(new Error('Archera rate limit reached'), { status: 429 })); + root.querySelector('button')!.click(); + await flush(); + expect(root.querySelector('[aria-live="polite"]')!.textContent).toContain('Retry-after not given'); + }); + + it.each([502, 503])('server error %s message shown as text in the live region', async code => { + const root = await mount(); + comparison.mockRejectedValueOnce(Object.assign(new Error('fixed server text'), { status: code })); + root.querySelector('button')!.click(); + await flush(); + expect(root.querySelector('[aria-live="polite"]')!.textContent).toBe('fixed server text'); + }); + + it('unparseable fetched_at is shown raw', () => { + const c = clone(); + c.fetched_at = 'not-a-date'; + expect(buildComparisonView(c).querySelector('.archera-fetched-at')!.textContent).toBe('Fetched at not-a-date'); + }); +}); diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index b9e2b30a..154bbf48 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -253,3 +253,9 @@ export { setPlanAccounts, getFederationIaC } from './accounts'; + +// Re-export Archera comparison functions +export { + getInsuranceStatus, + getInsuranceComparison +} from './insurance'; diff --git a/frontend/src/api/insurance.ts b/frontend/src/api/insurance.ts new file mode 100644 index 00000000..a54cd71a --- /dev/null +++ b/frontend/src/api/insurance.ts @@ -0,0 +1,16 @@ +/** + * Archera comparison API functions (read-only, explicit user action). + */ + +import { apiRequest } from './client'; +import type { ArcheraComparison, InsuranceStatus } from './types'; + +/** Reports whether the comparison is configured. Makes no vendor call. */ +export async function getInsuranceStatus(): Promise { + return apiRequest('/insurance/status'); +} + +/** Fetches the Archera comparison now. One vendor request per call, no caching. */ +export async function getInsuranceComparison(): Promise { + return apiRequest('/insurance/comparison'); +} diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index e10824a2..bc0bc705 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -948,3 +948,105 @@ export interface RequestOptions extends RequestInit { */ timeoutMs?: number; } + +// --------------------------------------------------------------------------- +// Archera comparison (GET /api/insurance/*). Mirrors internal/archera/dto.go +// field for field; money is an exact decimal string or null (unknown, never 0). +// --------------------------------------------------------------------------- + +export interface InsuranceStatus { + configured: boolean; + missing: string[]; +} + +export interface ArcheraProductSupport { + status: string; + source?: string; + evidence?: string; + underwriting_allowance: string; + customer_eligibility: string; +} + +export interface ArcheraFinancials { + monthly_730h_commitment_cost_total: string | null; + monthly_730h_cloud_provider_cost: string | null; + monthly_730h_archera_premium: string | null; + monthly_730h_gross_savings: string | null; + monthly_730h_net_savings: string | null; + monthly_730h_covered_on_demand_cost: string | null; +} + +export interface ArcheraTotals extends ArcheraFinancials { + upfront_one_time_cost: string | null; +} + +export interface ArcheraOfferDelta { + monthly_730h_net_savings: string | null; + upfront_one_time_cost: string | null; + discount_rate: string | null; + breakeven_days: string | null; +} + +export interface ArcheraHypotheticalDelta { + monthly_730h_net_savings: string | null; + monthly_730h_commitment_cost: string | null; + upfront_one_time_cost: string | null; +} + +export interface ArcheraLineItem { + line_item_id: string; + reason: string; + actual_term: string | null; + actual_payment_option: string | null; + actual_commitment_type: string | null; +} + +export interface ArcheraHypothetical { + contract_term: string | null; + payment_option: string; + totals: ArcheraTotals; + delta_vs_current: ArcheraHypotheticalDelta; + line_items: ArcheraLineItem[]; +} + +export interface ArcheraOffer { + offer_id: string; + is_current: boolean; + provider: string; + commitment_type: string; + region: string | null; + contract_term: string | null; + payment_option: string | null; + lease_attached: boolean; + lease_menu_item_id: string | null; + archera_offer_name?: string | null; + archera_offer_name_note?: string; + archera_product_support: ArcheraProductSupport; + discount_rate: string | null; + breakeven_days: string | null; + monthly: ArcheraFinancials; + upfront_one_time_cost: string | null; + delta_vs_current: ArcheraOfferDelta; +} + +export interface ArcheraRow { + line_item_id: string; + current: ArcheraOffer; + candidates: ArcheraOffer[]; +} + +export interface ArcheraComparison { + title: string; + plan_id: string; + fetched_at: string; + currency: string | null; + currency_note: string; + premium_included: boolean; + basis_note: string; + delta_basis_note: string; + current: ArcheraTotals; + hypotheticals: ArcheraHypothetical[]; + rows: ArcheraRow[]; + non_gating_disclosure: string; + sponsorship_disclosure: string; +} diff --git a/frontend/src/archera-comparison.ts b/frontend/src/archera-comparison.ts new file mode 100644 index 00000000..2a691e04 --- /dev/null +++ b/frontend/src/archera-comparison.ts @@ -0,0 +1,274 @@ +/** + * Archera plan comparison section (issue #785), shown on the Archera + * education page. Read-only; fetched only on an explicit click. + * + * Rules enforced here: + * - Every string from the server (vendor text included) is set with + * textContent; there is no innerHTML. + * - Money is rendered verbatim as the exact decimal string the server + * sent. null renders as "Unknown", never 0. There is no currency + * symbol and no numeric parsing. + * - Titles, notes and both disclosures come from the response verbatim. + * - No polling, no timers, no client cache: each click is one request. + */ + +import { getInsuranceComparison, getInsuranceStatus } from './api/insurance'; +import type { + ArcheraComparison, + ArcheraFinancials, + ArcheraOffer, + ArcheraTotals, +} from './api/types'; + +const UNKNOWN = 'Unknown'; +const NOT_GIVEN = 'Not given'; +const NOT_KNOWN_SUPPORTED = 'Not known to be supported by Archera'; + +function el( + tag: K, + text?: string, + className?: string, +): HTMLElementTagNameMap[K] { + const node = document.createElement(tag); + if (text !== undefined) node.textContent = text; + if (className) node.className = className; + return node; +} + +function money(v: unknown): string { + return typeof v === 'string' ? v : UNKNOWN; +} + +function text(v: unknown): string { + return typeof v === 'string' && v !== '' ? v : NOT_GIVEN; +} + +function cell(tag: 'td' | 'th', content: string, scope?: 'row' | 'col'): HTMLTableCellElement { + const c = el(tag, content); + if (scope) c.scope = scope; + return c; +} + +function table(caption: string, headers: string[]): { wrap: HTMLElement; body: HTMLTableSectionElement } { + const wrap = el('div'); + wrap.style.overflowX = 'auto'; + const t = el('table'); + t.appendChild(el('caption', caption)); + const head = el('thead'); + const tr = el('tr'); + for (const h of headers) tr.appendChild(cell('th', h, 'col')); + head.appendChild(tr); + t.appendChild(head); + const body = el('tbody'); + t.appendChild(body); + wrap.appendChild(t); + return { wrap, body }; +} + +function formatFetchedAt(raw: unknown): string { + const s = typeof raw === 'string' ? raw : ''; + const d = new Date(s); + if (s === '' || Number.isNaN(d.getTime())) return `Fetched at ${s || NOT_GIVEN}`; + const opts: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'long' }; + const local = new Intl.DateTimeFormat(undefined, opts).format(d); + const utc = new Intl.DateTimeFormat('en-GB', { ...opts, timeZone: 'UTC' }).format(d); + return `Fetched at ${local} (${utc})`; +} + +function totalsRows(body: HTMLTableSectionElement, t: ArcheraTotals): void { + const rows: Array<[string, string | null]> = [ + ['Commitment cost total (monthly)', t.monthly_730h_commitment_cost_total], + ['Cloud provider cost (monthly)', t.monthly_730h_cloud_provider_cost], + ['Archera premium (monthly)', t.monthly_730h_archera_premium], + ['Gross savings (monthly)', t.monthly_730h_gross_savings], + ['Net savings (monthly)', t.monthly_730h_net_savings], + ['Covered on-demand cost (monthly)', t.monthly_730h_covered_on_demand_cost], + ['Upfront cost (one-time)', t.upfront_one_time_cost], + ]; + for (const [label, value] of rows) { + const tr = el('tr'); + tr.appendChild(cell('th', label, 'row')); + tr.appendChild(cell('td', money(value))); + body.appendChild(tr); + } +} + +function productSupport(o: ArcheraOffer): HTMLElement { + const box = el('div'); + const ps = o.archera_product_support; + if (ps && ps.status === 'supported') { + box.appendChild(el('div', 'Supported (documented)')); + if (ps.source) box.appendChild(el('div', `Source: ${ps.source}`)); + if (ps.evidence) box.appendChild(el('div', `Evidence: ${ps.evidence}`)); + } else { + box.appendChild(el('div', NOT_KNOWN_SUPPORTED)); + } + box.appendChild(el('div', 'Underwriting allowance: Unknown')); + box.appendChild(el('div', 'Customer eligibility: Unknown')); + return box; +} + +function offerName(o: ArcheraOffer): HTMLElement { + const box = el('div'); + if (typeof o.archera_offer_name === 'string' && o.archera_offer_name !== '') { + box.appendChild(el('div', `Archera's name for this offer: ${o.archera_offer_name}`)); + if (o.archera_offer_name_note) box.appendChild(el('div', o.archera_offer_name_note)); + } else { + box.textContent = NOT_GIVEN; + } + return box; +} + +function offerRow(body: HTMLTableSectionElement, o: ArcheraOffer): void { + const tr = el('tr'); + const id = cell('th', `${text(o.offer_id)}${o.is_current ? ' (current)' : ' (candidate)'}`, 'row'); + tr.appendChild(id); + tr.appendChild(cell('td', `${text(o.provider)} / ${text(o.commitment_type)}`)); + tr.appendChild(cell('td', text(o.region))); + tr.appendChild(cell('td', `${text(o.contract_term)} / ${text(o.payment_option)}`)); + tr.appendChild(cell('td', o.lease_attached ? `lease attached (${text(o.lease_menu_item_id)})` : 'no lease')); + const name = el('td'); + name.appendChild(offerName(o)); + tr.appendChild(name); + const support = el('td'); + support.appendChild(productSupport(o)); + tr.appendChild(support); + tr.appendChild(cell('td', money(o.discount_rate))); + tr.appendChild(cell('td', money(o.breakeven_days))); + const m: ArcheraFinancials = o.monthly; + tr.appendChild(cell('td', money(m.monthly_730h_net_savings))); + tr.appendChild(cell('td', money(o.upfront_one_time_cost))); + tr.appendChild(cell('td', money(o.delta_vs_current.monthly_730h_net_savings))); + tr.appendChild(cell('td', money(o.delta_vs_current.upfront_one_time_cost))); + body.appendChild(tr); +} + +const OFFER_HEADERS = [ + 'Offer', 'Provider / commitment type', 'Region', 'Term / payment option', 'Lease', + 'Archera offer name', 'Archera product support', 'Discount rate', 'Breakeven days', + 'Net savings (monthly)', 'Upfront cost (one-time)', + 'Net savings change vs current plan (monthly)', 'Upfront change vs current plan (one-time)', +]; + +/** Builds the full result view from the response. Exported for tests. */ +export function buildComparisonView(c: ArcheraComparison): HTMLElement { + const root = el('div'); + root.appendChild(el('h2', c.title)); + root.appendChild(el('p', formatFetchedAt(c.fetched_at), 'archera-fetched-at')); + root.appendChild(el('p', 'Archera does not document a cache lifetime. Use Refresh to fetch again.')); + for (const note of [c.currency_note, c.basis_note, c.delta_basis_note]) { + root.appendChild(el('p', note, 'archera-note')); + } + root.appendChild(el('p', c.non_gating_disclosure, 'archera-disclosure')); + root.appendChild(el('p', c.sponsorship_disclosure, 'archera-disclosure')); + + const cur = table('Current plan totals', ['Measure', 'Amount']); + totalsRows(cur.body, c.current); + root.appendChild(cur.wrap); + + if (c.hypotheticals.length > 0) { + const h = table('Hypothetical alternatives, compared with the current plan', [ + 'Contract term', 'Payment option', 'Commitment cost total (monthly)', 'Net savings (monthly)', + 'Upfront cost (one-time)', 'Net savings change vs current plan (monthly)', + 'Commitment cost change vs current plan (monthly)', 'Upfront change vs current plan (one-time)', + 'Line items', + ]); + for (const hy of c.hypotheticals) { + const tr = el('tr'); + tr.appendChild(cell('th', text(hy.contract_term), 'row')); + tr.appendChild(cell('td', text(hy.payment_option))); + tr.appendChild(cell('td', money(hy.totals.monthly_730h_commitment_cost_total))); + tr.appendChild(cell('td', money(hy.totals.monthly_730h_net_savings))); + tr.appendChild(cell('td', money(hy.totals.upfront_one_time_cost))); + tr.appendChild(cell('td', money(hy.delta_vs_current.monthly_730h_net_savings))); + tr.appendChild(cell('td', money(hy.delta_vs_current.monthly_730h_commitment_cost))); + tr.appendChild(cell('td', money(hy.delta_vs_current.upfront_one_time_cost))); + const li = el('td'); + for (const item of hy.line_items) { + li.appendChild(el('div', + `${text(item.line_item_id)}: ${text(item.reason)} (term ${text(item.actual_term)}, ` + + `payment ${text(item.actual_payment_option)}, type ${text(item.actual_commitment_type)})`)); + } + tr.appendChild(li); + h.body.appendChild(tr); + } + root.appendChild(h.wrap); + } + + for (const r of c.rows) { + root.appendChild(el('h3', `Line item ${text(r.line_item_id)}`)); + const t = table(`Offers for line item ${text(r.line_item_id)}`, OFFER_HEADERS); + offerRow(t.body, r.current); + for (const cand of r.candidates) offerRow(t.body, cand); + root.appendChild(t.wrap); + } + return root; +} + +function errorText(err: unknown): string { + const e = err as { message?: unknown; details?: Record }; + const base = typeof e?.message === 'string' && e.message !== '' ? e.message : 'Archera comparison failed'; + const secs = e?.details?.['retry_after_seconds']; + if ((e as { status?: number })?.status === 429) { + return typeof secs === 'number' && secs > 0 + ? `${base}. Retry after ${secs} seconds.` + : `${base}. Retry-after not given.`; + } + return base; +} + +/** + * Renders the comparison action into `root` when the backend reports the + * feature configured. Hidden for configured=false and for 404; any other + * status failure shows one line. No comparison request is made in any of + * those cases. + */ +export async function renderComparisonSection(root: HTMLElement): Promise { + let configured = false; + try { + configured = (await getInsuranceStatus()).configured === true; + } catch (err) { + if ((err as { status?: number })?.status !== 404) { + root.appendChild(el('p', 'Archera comparison status unavailable')); + } + return; + } + if (!configured) return; + + const section = el('section'); + section.setAttribute('aria-label', 'Archera plan comparison'); + const button = el('button', 'Compare with Archera', 'btn'); + button.type = 'button'; + const live = el('div'); + live.setAttribute('aria-live', 'polite'); + const result = el('div'); + section.append(button, live, result); + root.appendChild(section); + + let inFlight = false; + let loaded = false; + button.addEventListener('click', () => { + if (inFlight) return; + inFlight = true; + button.disabled = true; + button.setAttribute('aria-busy', 'true'); + live.textContent = 'Loading Archera comparison'; + void getInsuranceComparison() + .then(c => { + result.replaceChildren(buildComparisonView(c)); + live.textContent = 'Archera comparison loaded'; + loaded = true; + button.textContent = 'Refresh'; + }) + .catch((err: unknown) => { + live.textContent = errorText(err); + if (loaded) button.textContent = 'Refresh'; + }) + .finally(() => { + inFlight = false; + button.disabled = false; + button.removeAttribute('aria-busy'); + button.focus(); + }); + }); +} diff --git a/frontend/src/archera.ts b/frontend/src/archera.ts index 85981db6..b8e20ad1 100644 --- a/frontend/src/archera.ts +++ b/frontend/src/archera.ts @@ -23,6 +23,8 @@ * No backend, routing, or IaC changes (frontend-only). */ +import { renderComparisonSection } from './archera-comparison'; + /** Canonical Archera signup URL with CUDly attribution. */ export const ARCHERA_SIGNUP_URL = 'https://www.archera.ai/cudly'; @@ -354,6 +356,11 @@ function buildArcheraPage(root: HTMLElement): void { appendArcheraEducationBody(root); root.appendChild(buildSignupBlock()); + + const comparison = document.createElement('div'); + comparison.className = 'archera-comparison'; + root.appendChild(comparison); + void renderComparisonSection(comparison); } /** diff --git a/frontend/tests-e2e/archera-comparison.spec.ts b/frontend/tests-e2e/archera-comparison.spec.ts new file mode 100644 index 00000000..70737561 --- /dev/null +++ b/frontend/tests-e2e/archera-comparison.spec.ts @@ -0,0 +1,84 @@ +/** + * Archera comparison section on the education page (issue #785). + * Fixture-based: API responses are mocked with page.route using the Go DTO + * golden file, so this proves the render path only. No live Archera calls. + */ +import { test, expect, type Page } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { mockApi, seedAuth } from './fixtures/recs'; + +const golden = JSON.parse(fs.readFileSync( + path.resolve(__dirname, '../../internal/archera/testdata/comparison.golden.json'), 'utf8')); + +async function setup(page: Page, statusBody: unknown, statusCode = 200) { + const errors: string[] = []; + const comparisonCalls: string[] = []; + page.on('pageerror', e => errors.push(e.message)); + await seedAuth(page); + await mockApi(page); + await page.route('**/api/insurance/status', r => r.fulfill({ status: statusCode, json: statusBody })); + await page.route('**/api/insurance/comparison', r => { + comparisonCalls.push(r.request().url()); + return r.fulfill({ json: golden }); + }); + return { errors, comparisonCalls }; +} + +test('not configured: no action and no comparison request', async ({ page }) => { + const { errors, comparisonCalls } = await setup(page, { configured: false, missing: ['ARCHERA_ORG_ID'] }); + await page.goto('/archera-insurance'); + await expect(page.locator('.archera-page-inner h1')).toBeVisible(); + await expect(page.getByRole('button', { name: 'Compare with Archera' })).toHaveCount(0); + expect(comparisonCalls).toEqual([]); + expect(errors).toEqual([]); +}); + +test('configured: compare, disclosures, refresh', async ({ page }) => { + const { errors, comparisonCalls } = await setup(page, { configured: true, missing: [] }); + await page.goto('/archera-insurance'); + const button = page.getByRole('button', { name: 'Compare with Archera' }); + await button.scrollIntoViewIfNeeded(); + expect(comparisonCalls).toEqual([]); + await button.click(); + await expect(page.getByRole('heading', { name: golden.title })).toBeVisible(); + await expect(page.getByText(golden.non_gating_disclosure)).toBeVisible(); + await expect(page.getByText(golden.sponsorship_disclosure)).toBeVisible(); + await expect(page.locator('.archera-fetched-at')).toContainText('Fetched at'); + expect(comparisonCalls).toHaveLength(1); + const refresh = page.getByRole('button', { name: 'Refresh' }); + await expect(refresh).toBeFocused(); + await refresh.click(); + await expect.poll(() => comparisonCalls.length).toBe(2); + expect(errors).toEqual([]); +}); + +test('hostile vendor string stays text and 429 shows the wait', async ({ page }) => { + const { errors } = await setup(page, { configured: true, missing: [] }); + const hostile = JSON.parse(JSON.stringify(golden)); + hostile.rows[0].current.offer_id = ''; + await page.route('**/api/insurance/comparison', r => r.fulfill({ json: hostile })); + await page.goto('/archera-insurance'); + await page.getByRole('button', { name: 'Compare with Archera' }).click(); + await expect(page.getByText('').first()).toBeVisible(); + expect(await page.evaluate(() => (window as unknown as { __xss?: number }).__xss)).toBeUndefined(); + + await page.route('**/api/insurance/comparison', r => r.fulfill({ + status: 429, headers: { 'Retry-After': '30' }, + json: { error: 'Archera rate limit reached', retry_after_seconds: 30 }, + })); + await page.getByRole('button', { name: 'Refresh' }).click(); + await expect(page.locator('.archera-comparison [aria-live="polite"]')).toContainText('Retry after 30 seconds'); + await expect(page.getByRole('button', { name: 'Refresh' })).toBeEnabled(); + expect(errors).toEqual([]); +}); + +test('mobile width: comparison does not widen the page', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 800 }); + await setup(page, { configured: true, missing: [] }); + await page.goto('/archera-insurance'); + await page.getByRole('button', { name: 'Compare with Archera' }).click(); + await expect(page.getByRole('heading', { name: golden.title })).toBeVisible(); + const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth); + expect(overflow).toBeLessThanOrEqual(0); +}); From e5b9b3f78797e8a92f4dc405e5ff1a81b6a8ce9f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 05:52:08 +0200 Subject: [PATCH 2/5] fix(frontend): fix duplicate 429 text and cover every money and vendor cell A 429 without Retry-After showed "retry-after not given" twice because the server text already says it; the UI now appends it only when missing. Tests now assert each money column of every table against the distinct golden value, and poison every vendor string field to prove text-only rendering. Refs #785 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/archera-comparison.test.ts | 144 +++++++++++++++--- frontend/src/archera-comparison.ts | 6 +- 2 files changed, 130 insertions(+), 20 deletions(-) diff --git a/frontend/src/__tests__/archera-comparison.test.ts b/frontend/src/__tests__/archera-comparison.test.ts index b08a6d6c..6e32f316 100644 --- a/frontend/src/__tests__/archera-comparison.test.ts +++ b/frontend/src/__tests__/archera-comparison.test.ts @@ -165,23 +165,122 @@ describe('money', () => { }); }); +describe('money in every cell matches the golden value for that column', () => { + function grid(t: HTMLTableElement): Array> { + const heads = Array.from(t.querySelectorAll('thead th')).map(h => h.textContent!); + return Array.from(t.querySelectorAll('tbody tr')).map(tr => { + const out: Record = {}; + Array.from(tr.children).forEach((c, i) => { out[heads[i]!] = c.textContent!; }); + return out; + }); + } + const tableByCaption = (root: HTMLElement, cap: RegExp) => + Array.from(root.querySelectorAll('table')).find(t => cap.test(t.querySelector('caption')!.textContent!))!; + + it('current plan totals table', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + const rows = Array.from(tableByCaption(root, /^Current plan totals/).querySelectorAll('tbody tr')) + .map(tr => [tr.children[0]!.textContent, tr.children[1]!.textContent]); + const c = golden.current; + expect(rows).toEqual([ + ['Commitment cost total (monthly)', c.monthly_730h_commitment_cost_total], + ['Cloud provider cost (monthly)', c.monthly_730h_cloud_provider_cost], + ['Archera premium (monthly)', c.monthly_730h_archera_premium], + ['Gross savings (monthly)', c.monthly_730h_gross_savings], + ['Net savings (monthly)', c.monthly_730h_net_savings], + ['Covered on-demand cost (monthly)', c.monthly_730h_covered_on_demand_cost], + ['Upfront cost (one-time)', c.upfront_one_time_cost], + ]); + expect(new Set(rows.map(r => r[1])).size).toBe(7); + }); + + it('hypotheticals table', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + const g = grid(tableByCaption(root, /^Hypothetical/)); + expect(g).toHaveLength(golden.hypotheticals.length); + golden.hypotheticals.forEach((h, i) => { + const r = g[i]!; + expect(r['Contract term']).toBe(h.contract_term); + expect(r['Payment option']).toBe(h.payment_option); + expect(r['Commitment cost total (monthly)']).toBe(h.totals.monthly_730h_commitment_cost_total); + expect(r['Net savings (monthly)']).toBe(h.totals.monthly_730h_net_savings); + expect(r['Upfront cost (one-time)']).toBe(h.totals.upfront_one_time_cost); + expect(r['Net savings change vs current plan (monthly)']).toBe(h.delta_vs_current.monthly_730h_net_savings); + expect(r['Commitment cost change vs current plan (monthly)']).toBe(h.delta_vs_current.monthly_730h_commitment_cost); + expect(r['Upfront change vs current plan (one-time)']).toBe(h.delta_vs_current.upfront_one_time_cost); + h.line_items.forEach(li => { + expect(r['Line items']).toContain(`${li.line_item_id}: ${li.reason} (term ${li.actual_term}, payment ${li.actual_payment_option}, type ${li.actual_commitment_type})`); + }); + }); + }); + + it('offer tables: current and every candidate', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + golden.rows.forEach(row => { + const g = grid(tableByCaption(root, new RegExp(`^Offers for line item ${row.line_item_id}$`))); + const offers = [row.current, ...row.candidates]; + expect(g).toHaveLength(offers.length); + offers.forEach((o, i) => { + const r = g[i]!; + expect(r['Offer']).toBe(`${o.offer_id} (${o.is_current ? 'current' : 'candidate'})`); + expect(r['Provider / commitment type']).toBe(`${o.provider} / ${o.commitment_type}`); + expect(r['Region']).toBe(o.region); + expect(r['Term / payment option']).toBe(`${o.contract_term} / ${o.payment_option}`); + expect(r['Discount rate']).toBe(o.discount_rate); + expect(r['Breakeven days']).toBe(o.breakeven_days); + expect(r['Net savings (monthly)']).toBe(o.monthly.monthly_730h_net_savings); + expect(r['Upfront cost (one-time)']).toBe(o.upfront_one_time_cost); + expect(r['Net savings change vs current plan (monthly)']).toBe(o.delta_vs_current.monthly_730h_net_savings); + expect(r['Upfront change vs current plan (one-time)']).toBe(o.delta_vs_current.upfront_one_time_cost); + }); + }); + }); + + it('golden money values are distinct per column so a swap cannot hide', () => { + const vals = [golden.current, golden.hypotheticals[0]!.totals].flatMap(t => Object.values(t)); + expect(new Set(vals).size).toBe(vals.length); + }); +}); + describe('vendor strings are text only', () => { - it('hostile strings create no elements', async () => { - const hostile = ''; - const c = clone(); - const o = firstOffer(c); - o.offer_id = hostile; - o.region = hostile; - o.commitment_type = hostile; - o.archera_offer_name = hostile; - o.archera_product_support.evidence = hostile; - c.rows[0]!.line_item_id = hostile; - c.hypotheticals[0]!.line_items[0]!.reason = hostile; + const HOSTILE = (n: number) => `\u0007\u202e${n}`; + + // Replaces every string leaf except the enum-like ones that gate rendering. + function poison(node: unknown, tokens: Map, p: string): unknown { + if (typeof node === 'string') { + if (/(^|\.)(status|fetched_at)$/.test(p)) return node; + const v = HOSTILE(tokens.size); + tokens.set(p, v); + return v; + } + if (Array.isArray(node)) return node.map((x, i) => poison(x, tokens, `${p}[${i}]`)); + if (node && typeof node === 'object') { + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, poison(v, tokens, p ? `${p}.${k}` : k)])); + } + return node; + } + + // Leaves the UI deliberately does not display. + const NOT_RENDERED = /^(plan_id|rows\[\d+\]\.(current|candidates\[\d+\])\.(monthly\.monthly_730h_(commitment_cost_total|cloud_provider_cost|archera_premium|gross_savings|covered_on_demand_cost)|delta_vs_current\.(discount_rate|breakeven_days)|archera_product_support\.(underwriting_allowance|customer_eligibility))|hypotheticals\[\d+\]\.totals\.monthly_730h_(cloud_provider_cost|archera_premium|gross_savings|covered_on_demand_cost))$/; + + it('every rendered vendor string is text; no element is created from any of them', async () => { + const tokens = new Map(); + const c = poison(clone(), tokens, '') as ArcheraComparison; const root = await mount(); await clickAndLoad(root, c); - expect(root.querySelector('img')).toBeNull(); - expect(root.querySelector('script')).toBeNull(); - expect(root.textContent).toContain(hostile); + const rendered = root.textContent!; + const missing: string[] = []; + for (const [p, v] of tokens) { + if (!NOT_RENDERED.test(p) && !rendered.includes(v)) missing.push(p); + } + expect(missing).toEqual([]); + expect(root.querySelectorAll('img, script, svg, iframe, a').length).toBe(0); + // Only the structural elements the component itself builds exist. + const allowed = new Set(['H2', 'H3', 'P', 'DIV', 'TABLE', 'CAPTION', 'THEAD', 'TBODY', 'TR', 'TH', 'TD', 'SECTION', 'BUTTON']); + root.querySelectorAll('*').forEach(n => expect(allowed.has(n.tagName)).toBe(true)); }); }); @@ -274,12 +373,23 @@ describe('refresh, errors and accessibility', () => { expect(root.querySelector('button')!.disabled).toBe(false); }); - it('429 without retry_after_seconds says retry-after not given', async () => { + it('429 without retry_after_seconds uses the real server text, saying so exactly once', async () => { + const root = await mount(); + // Exact text from mapInsuranceError (internal/api/handler_insurance_comparison.go). + comparison.mockRejectedValueOnce(Object.assign(new Error('Archera rate limit reached; retry-after not given'), { status: 429 })); + root.querySelector('button')!.click(); + await flush(); + const t = root.querySelector('[aria-live="polite"]')!.textContent!; + expect(t).toBe('Archera rate limit reached; retry-after not given'); + expect(t.match(/retry-after not given/gi)).toHaveLength(1); + }); + + it('429 whose message lacks the wait text gets it appended once', async () => { const root = await mount(); - comparison.mockRejectedValueOnce(Object.assign(new Error('Archera rate limit reached'), { status: 429 })); + comparison.mockRejectedValueOnce(Object.assign(new Error('Rate limited'), { status: 429 })); root.querySelector('button')!.click(); await flush(); - expect(root.querySelector('[aria-live="polite"]')!.textContent).toContain('Retry-after not given'); + expect(root.querySelector('[aria-live="polite"]')!.textContent!.match(/retry-after not given/gi)).toHaveLength(1); }); it.each([502, 503])('server error %s message shown as text in the live region', async code => { diff --git a/frontend/src/archera-comparison.ts b/frontend/src/archera-comparison.ts index 2a691e04..e105db02 100644 --- a/frontend/src/archera-comparison.ts +++ b/frontend/src/archera-comparison.ts @@ -210,9 +210,9 @@ function errorText(err: unknown): string { const base = typeof e?.message === 'string' && e.message !== '' ? e.message : 'Archera comparison failed'; const secs = e?.details?.['retry_after_seconds']; if ((e as { status?: number })?.status === 429) { - return typeof secs === 'number' && secs > 0 - ? `${base}. Retry after ${secs} seconds.` - : `${base}. Retry-after not given.`; + if (typeof secs === 'number' && secs > 0) return `${base}. Retry after ${secs} seconds.`; + // The server text for a 429 without Retry-After already says so. + if (!/retry-after not given/i.test(base)) return `${base}. Retry-after not given.`; } return base; } From 930566c7ff40c5f31bef5a219ab6660297e73c5b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 08:51:21 +0200 Subject: [PATCH 3/5] test(frontend): cover fetched-at zones, aria-busy reset and empty vendor strings Pins the exact local and UTC fetched-at text under simulated zones, asserts aria-busy is cleared after success and error, and checks that an empty vendor string renders "Not given" wherever it is shown. Refs #785 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/archera-comparison.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/frontend/src/__tests__/archera-comparison.test.ts b/frontend/src/__tests__/archera-comparison.test.ts index 6e32f316..a9484e4b 100644 --- a/frontend/src/__tests__/archera-comparison.test.ts +++ b/frontend/src/__tests__/archera-comparison.test.ts @@ -245,6 +245,101 @@ describe('money in every cell matches the golden value for that column', () => { }); }); +describe('fetched-at shows local time and UTC', () => { + const OPTS: Intl.DateTimeFormatOptions = { dateStyle: 'medium', timeStyle: 'long' }; + const instant = new Date(golden.fetched_at); + const utcText = new Intl.DateTimeFormat('en-GB', { ...OPTS, timeZone: 'UTC' }).format(instant); + const RealDTF = Intl.DateTimeFormat; + + afterEach(() => { + Intl.DateTimeFormat = RealDTF; + }); + + // jest cannot change the process zone, so the browser's default zone is + // simulated: formatters built without an explicit timeZone get `tz`. + async function fetchedAt(tz: string): Promise { + Intl.DateTimeFormat = function (locale?: string | string[], o?: Intl.DateTimeFormatOptions) { + return new RealDTF(locale, o?.timeZone ? o : { ...o, timeZone: tz }); + } as unknown as typeof Intl.DateTimeFormat; + const root = await mount(); + await clickAndLoad(root, golden); + return root.querySelector('.archera-fetched-at')!.textContent!; + } + + it('non-UTC zone: local text and UTC text are both present and exact', async () => { + const got = await fetchedAt('America/New_York'); + const local = new Intl.DateTimeFormat(undefined, { ...OPTS, timeZone: 'America/New_York' }).format(instant); + expect(local).not.toBe(utcText); + expect(utcText).toContain('12:00:00'); + expect(got).toBe(`Fetched at ${local} (${utcText})`); + }); + + it('UTC zone: exact text', async () => { + const got = await fetchedAt('UTC'); + const local = new Intl.DateTimeFormat(undefined, { ...OPTS, timeZone: 'UTC' }).format(instant); + expect(got).toBe(`Fetched at ${local} (${utcText})`); + }); + + it('UTC part stays in UTC when the browser zone is far away', async () => { + const got = await fetchedAt('Pacific/Auckland'); + expect(got.endsWith(`(${utcText})`)).toBe(true); + }); +}); + +describe('aria-busy and empty vendor strings', () => { + it('aria-busy is absent after success and after error', async () => { + const root = await mount(); + const button = root.querySelector('button')!; + await clickAndLoad(root, golden); + expect(button.hasAttribute('aria-busy')).toBe(false); + comparison.mockRejectedValueOnce(Object.assign(new Error('fixed server text'), { status: 502 })); + button.click(); + expect(button.getAttribute('aria-busy')).toBe('true'); + await flush(); + expect(button.hasAttribute('aria-busy')).toBe(false); + }); + + const VENDOR_KEYS = new Set([ + 'offer_id', 'provider', 'commitment_type', 'region', 'contract_term', 'payment_option', + 'lease_menu_item_id', 'archera_offer_name', 'line_item_id', 'reason', 'actual_term', + 'actual_payment_option', 'actual_commitment_type', + ]); + function blank(node: unknown, key = ''): unknown { + if (typeof node === 'string') return VENDOR_KEYS.has(key) ? '' : node; + if (Array.isArray(node)) return node.map(x => blank(x, key)); + if (node && typeof node === 'object') { + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, blank(v, k)])); + } + return node; + } + + it('an empty vendor string renders "Not given" in every place it is shown', async () => { + const c = blank(clone()) as ArcheraComparison; + const root = await mount(); + await clickAndLoad(root, c); + const NG = 'Not given'; + const hyp = Array.from(root.querySelectorAll('table')).find(t => /^Hypothetical/.test(t.querySelector('caption')!.textContent!))!; + const hcells = Array.from(hyp.querySelectorAll('tbody tr')[0]!.children).map(x => x.textContent); + expect(hcells[0]).toBe(NG); + expect(hcells[1]).toBe(NG); + expect(hcells[8]).toBe(`${NG}: ${NG} (term ${NG}, payment ${NG}, type ${NG})`); + expect(root.querySelector('h3')!.textContent).toBe(`Line item ${NG}`); + expect(root.querySelector('caption + thead')).not.toBeNull(); + const offers = root.querySelectorAll('table')[2]!; + const trs = Array.from(offers.querySelectorAll('tbody tr')); + expect(trs.length).toBeGreaterThan(1); + trs.forEach((tr, i) => { + const cells = Array.from(tr.children).map(x => x.textContent); + expect(cells[0]).toBe(`${NG} (${i === 0 ? 'current' : 'candidate'})`); + expect(cells[1]).toBe(`${NG} / ${NG}`); + expect(cells[2]).toBe(NG); + expect(cells[3]).toBe(`${NG} / ${NG}`); + expect(cells[4]).toBe(i === 0 ? `lease attached (${NG})` : 'no lease'); + expect(cells[5]).toBe(NG); + }); + }); +}); + describe('vendor strings are text only', () => { const HOSTILE = (n: number) => `\u0007\u202e${n}`; From 9250ac5a43e892aaf0e928bded9d8dc04c38a7dd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Sat, 10 Oct 2026 09:16:57 +0200 Subject: [PATCH 4/5] test(frontend): kill remaining comparison view mutants found by a full sweep Asserts the offers-table caption text (including empty and hostile line item ids), the no-cache note, the live region texts, the Refresh label after a later error, and that a null money value renders Unknown in every money cell. Refs #785 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/archera-comparison.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/frontend/src/__tests__/archera-comparison.test.ts b/frontend/src/__tests__/archera-comparison.test.ts index a9484e4b..673838d8 100644 --- a/frontend/src/__tests__/archera-comparison.test.ts +++ b/frontend/src/__tests__/archera-comparison.test.ts @@ -286,6 +286,85 @@ describe('fetched-at shows local time and UTC', () => { }); }); +describe('captions, notes, live text and unknown money in every cell', () => { + const captions = (root: HTMLElement) => + Array.from(root.querySelectorAll('caption')).map(c => c.textContent); + + it('offer table captions carry the line item id; empty is "Not given"; hostile stays text', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + expect(captions(root)).toContain(`Offers for line item ${golden.rows[0]!.line_item_id}`); + + const empty = clone(); + empty.rows[0]!.line_item_id = ''; + const r2 = await mount(); + await clickAndLoad(r2, empty); + expect(captions(r2)).toContain('Offers for line item Not given'); + + const hostile = clone(); + hostile.rows[0]!.line_item_id = ''; + const r3 = await mount(); + await clickAndLoad(r3, hostile); + expect(captions(r3)).toContain('Offers for line item '); + expect(r3.querySelector('img')).toBeNull(); + }); + + it('shows the no-cache note', async () => { + const root = await mount(); + await clickAndLoad(root, golden); + expect(root.textContent).toContain('Archera does not document a cache lifetime. Use Refresh to fetch again.'); + }); + + it('live region text: loading, loaded; button stays Refresh after a later error', async () => { + const root = await mount(); + const button = root.querySelector('button')!; + const live = root.querySelector('.archera-comparison [aria-live="polite"]') ?? root.querySelector('[aria-live="polite"]')!; + let resolve!: (c: ArcheraComparison) => void; + comparison.mockReturnValueOnce(new Promise(r => { resolve = r; })); + button.click(); + expect(live.textContent).toBe('Loading Archera comparison'); + resolve(golden); + await flush(); + expect(live.textContent).toBe('Archera comparison loaded'); + expect(button.textContent).toBe('Refresh'); + comparison.mockRejectedValueOnce(Object.assign(new Error('x'), { status: 502 })); + button.click(); + await flush(); + expect(button.textContent).toBe('Refresh'); + }); + + function nullMoney(node: unknown, key = ''): unknown { + if (Array.isArray(node)) return node.map(x => nullMoney(x, key)); + if (node && typeof node === 'object') { + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, nullMoney(v, k)])); + } + return /^(monthly_730h_|upfront_one_time_cost$|discount_rate$|breakeven_days$)/.test(key) ? null : node; + } + + it('a null money value renders Unknown in every money cell of every table', async () => { + const root = await mount(); + await clickAndLoad(root, nullMoney(clone()) as ArcheraComparison); + const moneyHeads = /savings|cost|upfront|Upfront|Discount|Breakeven|premium|Amount/i; + let checked = 0; + root.querySelectorAll('table').forEach(t => { + const heads = Array.from(t.querySelectorAll('thead th')).map(h => h.textContent!); + t.querySelectorAll('tbody tr').forEach(tr => { + Array.from(tr.children).forEach((c, i) => { + const h = heads[i]!; + const rowLabel = tr.children[0]!.textContent!; + const isMoneyCell = h === 'Amount' ? true : (moneyHeads.test(h) && h !== 'Line items'); + if (isMoneyCell && !(h === 'Amount' && c === tr.children[0])) { + expect({ h, rowLabel, v: c.textContent }).toEqual({ h, rowLabel, v: 'Unknown' }); + checked++; + } + }); + }); + }); + // 7 totals + 7 hypothetical + 6 per offer * 2 offers + expect(checked).toBe(7 + 6 + 6 * 2); + }); +}); + describe('aria-busy and empty vendor strings', () => { it('aria-busy is absent after success and after error', async () => { const root = await mount(); From e2a0e76020e854542acda3e96955908973360822 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 12 Oct 2026 16:41:52 +0200 Subject: [PATCH 5/5] test(frontend): cover multi-item tables and Refresh replacing the view Adds a fixture with 2 rows x 2 candidates and 2 hypotheticals x 2 line items, every money value unique, asserting each cell in order, and a Compare then Refresh test asserting a single table set with the new values and none stale. Refs #785 Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/archera-comparison.test.ts | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/frontend/src/__tests__/archera-comparison.test.ts b/frontend/src/__tests__/archera-comparison.test.ts index 673838d8..6df31cc0 100644 --- a/frontend/src/__tests__/archera-comparison.test.ts +++ b/frontend/src/__tests__/archera-comparison.test.ts @@ -365,6 +365,101 @@ describe('captions, notes, live text and unknown money in every cell', () => { }); }); +describe('multiple rows, candidates, hypotheticals, line items; refresh replaces', () => { + // Rewrites every money string to a unique value so any index mixup shows. + function big(seed: number): ArcheraComparison { + const c = clone(); + let n = seed; + const uniq = (node: unknown, key = ''): unknown => { + if (Array.isArray(node)) return node.map(x => uniq(x, key)); + if (node && typeof node === 'object') { + return Object.fromEntries(Object.entries(node).map(([k, v]) => [k, uniq(v, k)])); + } + return typeof node === 'string' && /^(monthly_730h_|upfront_one_time_cost$|discount_rate$|breakeven_days$)/.test(key) + ? String(n++) : node; + }; + const base = uniq(c) as ArcheraComparison; + const h0 = base.hypotheticals[0]!; + base.hypotheticals = [h0, JSON.parse(JSON.stringify(uniq(h0))) as typeof h0]; + base.hypotheticals.forEach((h, i) => { + h.contract_term = `term-${seed}-${i}`; + const li0 = h.line_items[0]!; + h.line_items = [0, 1].map(j => ({ ...li0, line_item_id: `li-${seed}-${i}-${j}`, reason: `reason-${i}-${j}` })); + }); + const r0 = base.rows[0]!; + const mk = (i: number): typeof r0 => { + const r = JSON.parse(JSON.stringify(uniq(r0))) as typeof r0; + r.line_item_id = `row-${seed}-${i}`; + const cand0 = r.candidates[0]!; + r.candidates = [0, 1].map(j => ({ ...JSON.parse(JSON.stringify(uniq(cand0))), offer_id: `cand-${seed}-${i}-${j}` })); + r.current.offer_id = `cur-${seed}-${i}`; + return r; + }; + base.rows = [mk(0), mk(1)]; + return base; + } + + function offerMoney(o: ArcheraOffer): string[] { + return [o.discount_rate, o.breakeven_days, o.monthly.monthly_730h_net_savings, o.upfront_one_time_cost, + o.delta_vs_current.monthly_730h_net_savings, o.delta_vs_current.upfront_one_time_cost] as string[]; + } + + it('every row, candidate, hypothetical and line item renders its own values in order', async () => { + const c = big(1000); + const root = await mount(); + await clickAndLoad(root, c); + const tables = Array.from(root.querySelectorAll('table')); + // totals + hypotheticals + one per row + expect(tables).toHaveLength(2 + c.rows.length); + const hyp = tables[1]!; + const hrows = Array.from(hyp.querySelectorAll('tbody tr')); + expect(hrows).toHaveLength(2); + c.hypotheticals.forEach((h, i) => { + const cells = Array.from(hrows[i]!.children).map(x => x.textContent); + expect(cells.slice(0, 2)).toEqual([h.contract_term, h.payment_option]); + expect(cells.slice(2, 8)).toEqual([ + h.totals.monthly_730h_commitment_cost_total, h.totals.monthly_730h_net_savings, h.totals.upfront_one_time_cost, + h.delta_vs_current.monthly_730h_net_savings, h.delta_vs_current.monthly_730h_commitment_cost, + h.delta_vs_current.upfront_one_time_cost]); + const lis = Array.from(hrows[i]!.children[8]!.children).map(x => x.textContent); + expect(lis).toHaveLength(2); + h.line_items.forEach((li, j) => expect(lis[j]).toContain(`${li.line_item_id}: ${li.reason}`)); + }); + c.rows.forEach((r, i) => { + const t = tables[2 + i]!; + expect(t.querySelector('caption')!.textContent).toBe(`Offers for line item ${r.line_item_id}`); + const trs = Array.from(t.querySelectorAll('tbody tr')); + const offers = [r.current, ...r.candidates]; + expect(trs).toHaveLength(3); + offers.forEach((o, k) => { + const cells = Array.from(trs[k]!.children).map(x => x.textContent); + expect(cells[0]).toContain(o.offer_id); + expect(cells.slice(7)).toEqual(offerMoney(o)); + }); + }); + expect(root.querySelectorAll('h3')).toHaveLength(2); + }); + + it('Refresh replaces the old view: one set of tables, new values, no stale ones', async () => { + const first = big(2000); + const second = big(3000); + const root = await mount(); + await clickAndLoad(root, first); + const oldVal = first.current.monthly_730h_net_savings!; + expect(root.textContent).toContain(`row-2000-0`); + comparison.mockResolvedValueOnce(second); + root.querySelector('button')!.click(); + await flush(); + expect(root.querySelectorAll('table')).toHaveLength(2 + second.rows.length); + expect(root.querySelectorAll('h2')).toHaveLength(1); + expect(root.textContent).toContain('row-3000-0'); + expect(root.textContent).not.toContain('row-2000-0'); + expect(root.textContent).toContain(second.current.monthly_730h_net_savings!); + expect(root.textContent).not.toContain(`li-2000`); + void oldVal; + }); +}); + describe('aria-busy and empty vendor strings', () => { it('aria-busy is absent after success and after error', async () => { const root = await mount();