// @vitest-environment jsdom // // `/` — the SPA bill-splitter. A 100% client-side page (`renderMode='spa'`) // built from React state + localStorage; no rpc client, no loader, no // framework hooks. We assert the config export, that it renders, and — the // real observable behavior — that entering a bill + choosing a tip preset + // people count updates the computed per-person total. import { afterEach, beforeEach, describe, expect, test } from 'vitest' import { act, createElement, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { I18nProvider } from '@voltro/i18n' import enCatalog from '../locales/en' import BillSplitter, { renderMode } from './page' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true let container: HTMLDivElement let root: Root // The page uses , so it needs an . Wrap with the real English // catalog (the framework auto-wires this provider in the running app). const render = (node: ReactNode): void => { container = document.createElement('div') document.body.appendChild(container) act(() => { root = createRoot(container) root.render( createElement(I18nProvider, { locale: 'en', messages: enCatalog, defaultLocale: 'en', children: node }), ) }) } // Native value setter so React's onChange sees the programmatic update. const setInput = (el: HTMLInputElement, value: string): void => { const setter = Object.getOwnPropertyDescriptor( window.HTMLInputElement.prototype, 'value', )!.set! act(() => { setter.call(el, value) el.dispatchEvent(new Event('input', { bubbles: true })) }) } // This Node build's jsdom does not provide localStorage (it needs // --localstorage-file). The page talks to the standard Web Storage API, so we // install a spec-compliant in-memory Storage — the same surface a browser gives // it — and exercise the real persistence path against it. let hadLocalStorage: boolean beforeEach(() => { hadLocalStorage = typeof window.localStorage !== 'undefined' if (!hadLocalStorage) { const store = new Map() const storage: Storage = { get length() { return store.size }, clear: () => store.clear(), getItem: (k) => (store.has(k) ? store.get(k)! : null), key: (i) => Array.from(store.keys())[i] ?? null, removeItem: (k) => { store.delete(k) }, setItem: (k, v) => { store.set(k, String(v)) }, } Object.defineProperty(window, 'localStorage', { configurable: true, value: storage }) } window.localStorage.clear() }) afterEach(() => { if (root) act(() => root.unmount()) container?.remove() document.body.innerHTML = '' if (!hadLocalStorage) { Reflect.deleteProperty(window, 'localStorage') } }) describe('index — config export', () => { test('is a client-only SPA page', () => { expect(renderMode).toBe('spa') }) }) describe('index — render', () => { test('renders the bill-splitter heading and the tip presets', () => { render(createElement(BillSplitter)) expect(container.querySelector('h1')!.textContent).toBe('Bill splitter') const presetButtons = Array.from( container.querySelectorAll('.presets button'), ).map((b) => b.textContent) expect(presetButtons).toEqual(['10%', '15%', '18%', '20%']) }) }) describe('index — computed split', () => { test('computes the per-person total from bill, tip preset, and people', () => { render(createElement(BillSplitter)) // $100 bill. setInput(container.querySelector('#bill') as HTMLInputElement, '100') // Choose the 20% preset. const twentyPct = Array.from(container.querySelectorAll('.presets button')).find( (b) => b.textContent === '20%', ) as HTMLButtonElement act(() => { twentyPct.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) // 4 people. setInput(container.querySelector('#people') as HTMLInputElement, '4') // total = 100 + 20% = 120; per-person = 30. const perPerson = container.querySelector('.result .big')!.textContent ?? '' expect(perPerson).toContain('30') expect(container.textContent).toContain('120') // total tip+bill shown }) test('persists the chosen tip % to localStorage', () => { render(createElement(BillSplitter)) const tenPct = Array.from(container.querySelectorAll('.presets button')).find( (b) => b.textContent === '10%', ) as HTMLButtonElement act(() => { tenPct.dispatchEvent(new MouseEvent('click', { bubbles: true })) }) const stored: string[] = [] for (let i = 0; i < window.localStorage.length; i++) { const key = window.localStorage.key(i) if (key !== null) stored.push(window.localStorage.getItem(key) ?? '') } expect(stored).toContain('10') }) })