test/client-billion-round.test.js
Rama main · réplica de lectura del repositorio original.
// @vitest-environment happy-dom
// The Billion tab's "Briefing" view and status line (public/modules/round.js):
// the round's brief, one section per project with its cards, answered cards
// folding where they stand, the "3 of 7 done" counter, Earlier closed by
// default, a typed draft and the scroll kept through an update, the Briefing
// / Chat switch, and the status line's words.
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../public/modules/ws.js', () => ({ send: vi.fn(() => true) }));
import { send } from '../public/modules/ws.js';
import { setWaitingItems, setRoundInfo, setBillionStatus, setWaitingActive, setChatMessages } from '../public/modules/state.js';
import { renderRound, renderBillionStatus, roundModel, statusLine, setSubTab, subTab, initSubTabs, handleRoundError, _resetRound, doneNumbers } from '../public/modules/round.js';
import { renderWaiting, _resetStepTimers } from '../public/modules/waiting.js';
const ROUND = { id: '2026-10-01 15:30', label: '10/1 pm', name: 'Afternoon briefing', at: '2026-10-01T15:30:00Z', releasedAt: '2026-10-01T15:30:05Z' };
const INFO = { type: 'round-state', on: true, max: 2, current: { ...ROUND, brief: 'Billing fix shipped. Two pricing calls below.' }, next: { id: '2026-10-02 08:30', label: '10/2 am', at: Date.parse('2026-10-02T08:30:00Z') } };
const q = (n, extra = {}) => ({ id: `q${n}`, n, text: `Question ${n}?`, at: `2026-10-01T10:0${n % 10}:00Z`, status: 'open', urgency: 'normal', project: 'general', round: ROUND.id, pos: n, ...extra });
const cards = () => [...document.querySelectorAll('.round-card')].map(c => c.dataset.q);
const show = (items, info = INFO) => { setWaitingItems(items); setRoundInfo(info); renderRound(); };
beforeEach(() => {
document.body.innerHTML = '<div id="waiting-board" data-sub="round"><div id="billion-status"></div>'
+ '<div id="billion-subtabs"><button data-sub="round"></button><button data-sub="chat"></button></div>'
+ '<div id="round-view"></div><div id="waiting-list"></div></div>';
localStorage.clear();
_resetRound();
setWaitingActive(true);
setChatMessages([]);
setBillionStatus(null);
vi.clearAllMocks();
});
describe('Briefing', () => {
it('shows the brief, then one section per project with its cards, in the order the round released them', () => {
show([q(1, { project: 'beta', pos: 1 }), q(2, { project: 'alpha', pos: 0 }), q(3, { project: 'beta', pos: 2 }),
q(4, { round: '2026-10-01 08:30', status: 'consolidated' }), q(5, { status: 'queued', round: undefined })]);
expect(document.querySelector('.round-title').textContent).toBe('Afternoon briefing · 10/1');
expect(document.querySelector('.round-brief').textContent).toBe('Billing fix shipped. Two pricing calls below.');
expect([...document.querySelectorAll('.round-dept-title')].map(h => h.textContent)).toEqual(['alpha', 'beta']);
expect(cards()).toEqual(['q2', 'q1', 'q3']);
expect(document.querySelector('.round-count').textContent).toBe('0 of 3 done');
});
it('puts a question sent outside the round first, as Needs you now', () => {
show([q(1), q(2, { round: undefined, urgency: 'blocking', outside: true })]);
expect([...document.querySelectorAll('.round-dept-title')].map(h => h.textContent)).toEqual(['Needs you now', 'general']);
expect(document.querySelector('.round-dept.urgent .waiting-urgent').textContent).toBe('!');
});
it('answers with a tap or a typed reply, and folds an answered card where it stands', () => {
show([q(1, { choices: ['yes', 'no'], recommended: 'no' }), q(2)]);
const choices = [...document.querySelectorAll('[data-q="q1"] .waiting-choice')];
expect(choices.map(b => b.firstChild.textContent)).toEqual(['no', 'yes']);
choices[0].click();
expect(send).toHaveBeenCalledWith({ type: 'waiting-answer', id: 'q1', answer: 'no' });
expect(choices[1].disabled).toBe(true);
const box = document.querySelector('[data-q="q2"] .round-reply-input');
box.value = 'Ship it Friday';
box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(send).toHaveBeenLastCalledWith({ type: 'waiting-answer', id: 'q2', answer: 'Ship it Friday' });
const before = document.querySelector('[data-q="q1"]');
show([q(1, { choices: ['yes', 'no'], status: 'answered', answer: 'no', answeredVia: 'app', answeredAt: new Date().toISOString() }), q(2)]);
const after = document.querySelector('[data-q="q1"]');
expect(after).toBe(before); // the same node: nothing moved
expect(after.classList.contains('done')).toBe(true);
expect(after.querySelector('.round-card-answer').textContent).toMatch(/^✓ you answered: no/);
expect(document.querySelector('.round-count').textContent).toBe('1 of 2 done');
after.querySelector('.chat-link').click();
expect(send).toHaveBeenLastCalledWith({ type: 'waiting-reopen', id: 'q1' });
});
it('keeps a draft, its focus and the scroll through an update that adds a card', () => {
show([q(1), q(2)]);
const view = document.getElementById('round-view');
const box = document.querySelector('[data-q="q2"] .round-reply-input');
box.value = 'half typed';
box.dispatchEvent(new Event('input'));
box.focus();
view.scrollTop = 40;
const scrolled = view.scrollTop;
show([q(1), q(2), q(3, { round: undefined, urgency: 'blocking' })]);
const again = document.querySelector('[data-q="q2"] .round-reply-input');
expect(again.value).toBe('half typed');
expect(document.activeElement).toBe(again);
expect(view.scrollTop).toBe(scrolled);
});
it('shows why a tap did not go through on its card', () => {
show([q(1, { choices: ['yes', 'no'] })]);
document.querySelector('.waiting-choice').click();
handleRoundError({ id: 'q1', error: 'Billion is not running' });
expect(document.querySelector('[data-q="q1"] .waiting-error').textContent).toBe('Billion is not running');
expect(document.querySelector('.waiting-choice').disabled).toBe(false);
});
it('keeps Earlier closed until asked, with what past rounds consolidated or got answered', () => {
show([q(1), q(4, { round: 'old', status: 'consolidated', consolidatedAt: '2026-10-01T15:30:05Z' }), q(5, { round: 'old', status: 'answered', answer: 'ok', answeredAt: '2026-10-01T09:00:00Z' })]);
const toggle = document.querySelector('.round-earlier-toggle');
expect([toggle.textContent, toggle.getAttribute('aria-expanded'), document.getElementById('round-earlier').hidden]).toEqual(['Earlier (2)', 'false', true]);
toggle.click();
expect([...document.querySelectorAll('.round-earlier-what')].map(r => r.textContent)).toEqual(['consolidated', 'answered: ok']);
});
it('says when the next briefing is when there is nothing for the owner', () => {
show([], { ...INFO, current: null });
expect(document.querySelector('.round-title').textContent).toBe('No briefing yet');
expect(document.querySelector('.round-empty').textContent).toMatch(/^Billion's questions come in briefings, twice a day\. Next briefing .* Until then they show here as soon as Billion asks\.$/);
});
it('before the first round, shows each question as Open questions rather than an emergency', () => {
show([q(1, { round: undefined })], { ...INFO, current: null });
expect([...document.querySelectorAll('.round-dept-title')].map(h => h.textContent)).toEqual(['Open questions']);
});
it('with rounds off, shows every open question by project', () => {
const model = roundModel([q(1, { round: undefined }), q(2, { round: undefined, project: 'x' }), q(3, { status: 'answered', round: undefined })], { on: false });
expect(model.sections.map(s => [s.title, s.items.map(i => i.id)])).toEqual([['general', ['q1']], ['x', ['q2']]]);
});
});
describe('the switch between This round and Chat', () => {
it('opens on Chat until a round has been released, so a new install sees the introduction and any setup notice', () => {
initSubTabs(vi.fn());
show([], { ...INFO, current: null });
expect(document.getElementById('waiting-board').dataset.sub).toBe('chat');
show([], INFO);
expect(document.getElementById('waiting-board').dataset.sub).toBe('round');
// The owner's own pick wins either way.
setSubTab('chat');
show([], INFO);
expect(subTab()).toBe('chat');
});
it('opens on This round once there has been one, remembers Chat, and draws the thread when it is shown', () => {
const chat = vi.fn();
setRoundInfo(INFO);
initSubTabs(chat);
expect(subTab()).toBe('round');
document.querySelector('#billion-subtabs [data-sub="chat"]').click();
expect(document.getElementById('waiting-board').dataset.sub).toBe('chat');
expect(document.querySelector('#billion-subtabs [data-sub="chat"]').getAttribute('aria-selected')).toBe('true');
expect(chat).toHaveBeenCalled();
expect(localStorage.getItem('agent007-billion-sub')).toBe('chat');
setSubTab('round');
expect(document.getElementById('waiting-board').dataset.sub).toBe('round');
});
});
describe('the status line', () => {
const next = Date.parse('2026-10-01T19:30:00Z');
const now = Date.parse('2026-10-01T16:00:00Z');
it('says what Billion is doing, how many workers run and when the next briefing is', () => {
expect(statusLine({ running: true, working: true, text: 'reviewing PR #120', workers: 3, nextRoundAt: next }, { on: true }, now))
.toMatch(/^Working: reviewing PR #120 · 3 workers running · next briefing \d{1,2}:30 (am|pm)$/);
expect(statusLine({ running: true, working: true, workers: 1 }, { on: true }, now)).toBe('Thinking… · 1 worker running');
expect(statusLine({ running: true, working: false }, { on: false }, now)).toBe('Idle');
expect(statusLine({ running: true, working: false, awaitingReply: true, text: 'x' }, {}, now)).toBe('Replying shortly…');
expect(statusLine({ running: false }, {}, now)).toBe('Billion is not running');
});
it('draws once per change, pulsing while Billion works', () => {
setBillionStatus({ running: true, working: true, text: 'reviewing PR #120', workers: 0 });
setRoundInfo({ on: false });
renderBillionStatus();
const line = document.getElementById('billion-status');
expect([line.dataset.mood, line.textContent]).toEqual(['busy', 'Working: reviewing PR #120']);
const dot = line.querySelector('.billion-status-dot');
renderBillionStatus();
expect(line.querySelector('.billion-status-dot')).toBe(dot);
setBillionStatus({ running: true, working: false, awaitingReply: true, pending: ['m1'] });
renderBillionStatus();
expect([line.dataset.mood, line.textContent]).toEqual(['idle', 'Replying shortly…']);
});
});
describe('numbered items, Done and "1d"', () => {
it('shows each item\'s number, sends Done for it, and reads "1d 3d" typed in any card\'s box', () => {
show([q(1, { num: 1, numRound: ROUND.id }), q(2, { num: 2, numRound: ROUND.id })]);
expect([...document.querySelectorAll('.round-card-num')].map(n => n.textContent)).toEqual(['1', '2']);
document.querySelector('[data-q="q2"] .round-done').click();
expect(send).toHaveBeenLastCalledWith({ type: 'round-done', id: 'q2', nums: [2] });
const box = document.querySelector('[data-q="q1"] .round-reply-input');
box.value = '1d 2d';
box.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(send).toHaveBeenLastCalledWith({ type: 'round-done', id: 'q1', nums: [1, 2] });
show([q(1, { num: 1, status: 'answered', answer: 'done', done: true, answeredVia: 'app', answeredAt: '2026-10-01T15:40:00Z' }), q(2, { num: 2 })]);
expect(document.querySelector('[data-q="q1"] .round-card-answer').textContent).toBe('✓ done');
});
it('reads "1d" the way the server does (test/owner-rounds.test.js has the same cases)', () => {
expect(['1d', '1d 3d', '1d, 3D', '2 d', '1', 'done', '1d3d', '12 dogs'].map(doneNumbers)).toEqual([[1], [1, 3], [1, 3], [2], null, null, null, null]);
});
});
describe('Start the round now', () => {
it('shows while questions wait for the next briefing, and asks the server to release it', () => {
show([q(1)], { ...INFO, queued: 0 });
expect(document.querySelector('.round-start')).toBeNull();
show([q(1)], { ...INFO, queued: 4 });
const btn = document.querySelector('.round-start');
expect(btn.textContent).toBe('Start the briefing now (4 waiting)');
btn.click();
expect(send).toHaveBeenLastCalledWith({ type: 'round-start' });
expect(document.querySelector('.round-start').disabled).toBe(true);
show([q(1)], { ...INFO, queued: 1, current: { ...ROUND, id: 'next' } });
expect(document.querySelector('.round-start').disabled).toBe(false);
});
});
describe('the progress box', () => {
const owner = id => ({ id, at: new Date().toISOString(), from: 'owner', text: `Question ${id}?` });
const row = id => document.querySelector(`.chat-msg[data-id="${id}"]`);
it('shows pending → live summaries → collapsed Work details when the answer binds, even before the status arrives', () => {
setChatMessages([owner('m1')]);
setBillionStatus({ running: true, working: true, pending: ['m1'], progress: { m1: [] } });
renderWaiting();
expect(row('m1').querySelector('.chat-progress-head').textContent).toMatch(/^Billion is working on this \d+ s$/);
setBillionStatus({ running: true, working: true, pending: ['m1'], progress: { m1: ['Checking launch metrics', 'Found two missing runs'] } });
renderWaiting();
expect(row('m1').querySelectorAll('li').length).toBe(2);
setChatMessages([owner('m1'), { id: 'a1', from: 'billion', text: 'Two runs need recovery.', replyTo: 'm1', workDetails: ['Checking launch metrics', 'Found two missing runs'] }]);
renderWaiting();
expect(document.querySelector('.chat-progress')).toBeNull();
expect(row('m1').classList.contains('pending')).toBe(false);
const details = row('a1').querySelector('details');
expect(details.open).toBe(false);
expect(details.querySelector('summary').textContent).toBe('Work details (2 steps)');
details.open = true;
renderWaiting();
expect(row('a1').querySelector('details')).toBe(details);
expect(details.open).toBe(true);
});
it('associates rapid messages separately and ignores agent updates and legacy raw steps', () => {
setChatMessages([owner('m1'), owner('m2'), { id: 'notice', from: 'billion', text: 'Worker finished.', notice: true }]);
setBillionStatus({ running: true, working: true, pending: ['m1', 'm2'], text: 'Global status', steps: ['SECRET_RAW_TOOL_OUTPUT'], progress: { m1: ['Checking first'], m2: [] } });
renderWaiting();
expect(row('notice').textContent).toContain('System update');
expect(row('m1').textContent).toContain('Checking first');
expect(row('m2').textContent).not.toContain('Checking first');
expect(row('m2').textContent).toContain('answering your earlier message first');
expect(document.body.textContent).not.toContain('SECRET_RAW_TOOL_OUTPUT');
expect(document.querySelectorAll('.chat-progress.active')).toHaveLength(1);
setChatMessages([owner('m1'), owner('m2'), { id: 'a1', from: 'billion', text: 'First answer', replyTo: 'm1' }]);
setBillionStatus({ running: true, working: false, pending: ['m2'], progress: { m2: ['Checking second'] } });
renderWaiting();
expect(row('m1').querySelector('.chat-progress')).toBeNull();
expect(row('m2').textContent).toContain('Checking second');
expect(document.querySelectorAll('.chat-progress.active')).toHaveLength(0);
expect(row('a1').querySelector('details')).toBeNull();
});
it('does not claim to work on a newer message while an expired earlier request still owns the reply', () => {
setChatMessages([owner('m1'), owner('m2')]);
setBillionStatus({ running: true, working: true, pending: ['m2'], currentRequest: 'm1', progress: { m1: ['Checking first'], m2: [] } });
renderWaiting();
expect(row('m1').querySelector('.chat-progress')).toBeNull();
expect(row('m2').textContent).toContain('answering your earlier message first');
expect(document.querySelector('.chat-progress.active')).toBeNull();
});
it('removes active progress when stopped and restores persisted request summaries after reconnect', () => {
const saved = { ...owner('m1'), workDetails: ['Evidence found'] };
setChatMessages([saved]);
setBillionStatus({ running: true, working: true, pending: ['m1'], currentRequest: 'm1', progress: { m1: ['Evidence found'] } });
renderWaiting();
expect(row('m1').querySelector('.chat-progress.active')).not.toBeNull();
setBillionStatus({ running: false, working: false, pending: [], currentRequest: 'm1', progress: { m1: ['Evidence found'] } });
renderWaiting();
expect(document.querySelector('.chat-progress')).toBeNull();
setBillionStatus(null);
renderWaiting();
expect(document.querySelector('.chat-progress')).toBeNull();
setBillionStatus({ running: true, working: false, pending: ['m1'], currentRequest: 'm1' });
renderWaiting();
expect(row('m1').querySelector('li').textContent).toBe('Evidence found');
expect(row('m1').querySelector('.chat-progress.active')).toBeNull();
});
it('drops blank and non-text summary entries without creating empty answer details', () => {
setChatMessages([owner('m1'), { id: 'a2', from: 'billion', replyTo: 'm2', text: 'Done', workDetails: [null, '', ' ', 42] }]);
setBillionStatus({ running: true, working: false, pending: ['m1'], progress: { m1: [null, '', ' ', 42] } });
renderWaiting();
expect(row('m1').querySelector('li')).toBeNull();
expect(row('m1').querySelector('.chat-progress-head').textContent).toMatch(/^Billion is working on this/);
expect(row('a2').querySelector('details')).toBeNull();
});
it('reconstructs saved details on reconnect, escapes HTML and omits empty details', () => {
const text = '<img src=x onerror=alert(1)>';
setChatMessages([owner('m1'), { id: 'a1', from: 'billion', text: 'Done', replyTo: 'm1', workDetails: [text] }, { id: 'a2', from: 'billion', text: 'Done', replyTo: 'm2', workDetails: [] }]);
setBillionStatus({ running: false, pending: [], progress: {} });
renderWaiting();
expect(row('a1').querySelector('details').open).toBe(false);
expect(row('a1').querySelector('li').textContent).toBe(text);
expect(row('a1').querySelector('img')).toBeNull();
expect(row('a2').querySelector('details')).toBeNull();
setChatMessages([owner('m3')]);
setBillionStatus({ running: true, working: true, pending: ['m3'], progress: { m3: [text] } });
renderWaiting();
expect(row('m3').querySelector('li').textContent).toBe(text);
expect(row('m3').querySelector('img')).toBeNull();
});
});
describe('the step log', () => {
const owner = (id, extra = {}) => ({ id, at: new Date().toISOString(), from: 'owner', text: `Question ${id}?`, awaitsReply: true, ...extra });
const row = id => document.querySelector(`.chat-msg[data-id="${id}"]`);
const ago = (s) => new Date(Date.now() - s * 1000).toISOString();
it('lists every step with its time, Billion\'s words and tool phrases apart, the newest highlighted with a running timer', () => {
_resetStepTimers();
vi.useFakeTimers();
try {
setChatMessages([owner('m1')]);
setBillionStatus({ running: true, working: true, pending: ['m1'], currentRequest: 'm1', progress: { m1: [
{ text: 'Checking the release run', at: ago(40), kind: 'status' },
{ text: 'Running a command', at: ago(30), kind: 'tool' },
{ text: 'Found two failed jobs', at: ago(12), kind: 'status' },
] } });
renderWaiting();
const items = [...row('m1').querySelectorAll('.chat-step')];
expect(items.map(li => li.querySelector('.chat-step-text').textContent)).toEqual(['Checking the release run', 'Running a command', 'Found two failed jobs']);
expect(items.map(li => li.classList.contains('tool'))).toEqual([false, true, false]);
expect(items.every(li => li.querySelector('time'))).toBe(true);
expect(items.map(li => li.classList.contains('current'))).toEqual([false, false, true]);
expect(row('m1').querySelectorAll('.chat-step-timer')).toHaveLength(1);
expect(items[2].querySelector('.chat-step-timer').textContent).toBe('12 s');
vi.advanceTimersByTime(3000);
expect(items[2].querySelector('.chat-step-timer').textContent).toBe('15 s');
} finally { _resetStepTimers(); vi.useRealTimers(); }
});
it('follows the newest step while the message waits', () => {
setChatMessages([owner('m1')]);
const steps = Array.from({ length: 30 }, (_, i) => ({ text: `Step ${i}`, at: ago(60 - i), kind: 'status' }));
setBillionStatus({ running: true, working: true, pending: ['m1'], currentRequest: 'm1', progress: { m1: steps } });
const log = () => row('m1').querySelector('.chat-step-log');
renderWaiting();
Object.defineProperty(log(), 'scrollHeight', { configurable: true, value: 900 });
renderWaiting();
expect(log().scrollTop).toBe(log().scrollHeight);
});
it('says a queued message waits for Billion to finish its current step, and a delivered one is being worked on', () => {
setChatMessages([owner('m1', { deliveredAt: ago(5) }), owner('m2', { queued: true })]);
setBillionStatus({ running: true, working: true, pending: ['m1', 'm2'], currentRequest: 'm1', progress: { m1: [], m2: [] } });
renderWaiting();
expect(row('m1').querySelector('.chat-progress-head').textContent).toBe('Billion is working on this 5 s');
expect(row('m1').querySelector('.chat-progress.active')).not.toBeNull();
expect(row('m2').querySelector('.chat-progress-head').textContent).toBe('Waiting for Billion to finish its current step');
expect(row('m2').querySelector('.chat-progress').classList.contains('queued')).toBe(true);
expect(row('m2').querySelector('.chat-progress').classList.contains('active')).toBe(false);
expect(row('m2').querySelector('.chat-step-timer')).toBeNull();
expect(document.body.textContent).not.toMatch(/waiting to start/i);
// Billion was given it: the same bubble now says it is being worked on.
setChatMessages([owner('m1', { deliveredAt: ago(5) }), owner('m2', { deliveredAt: ago(1) })]);
renderWaiting();
expect(row('m2').querySelector('.chat-progress-head').textContent).toMatch(/^Billion has this, and is answering your earlier message first/);
});
it('collapses the log into Work details with each step\'s time when the answer comes, and no timer', () => {
const steps = [{ text: 'Checking the board', at: ago(20), kind: 'status' }, { text: 'Checking the job board', at: ago(10), kind: 'tool' }];
setChatMessages([owner('m1', { awaitsReply: false }), { id: 'a1', from: 'billion', text: 'Two cards.', replyTo: 'm1', workDetails: steps }]);
setBillionStatus({ running: true, working: false, pending: [], progress: {} });
renderWaiting();
expect(document.querySelector('.chat-progress')).toBeNull();
const details = row('a1').querySelector('details.chat-work-details');
expect(details.open).toBe(false);
expect(details.querySelectorAll('.chat-step time')).toHaveLength(2);
expect(details.querySelector('.chat-step-timer')).toBeNull();
expect(details.querySelector('.current')).toBeNull();
});
});
describe('cancelling a queued message', () => {
const owner = (id, extra = {}) => ({ id, at: new Date().toISOString(), from: 'owner', text: `Question ${id}?`, ...extra });
const row = id => document.querySelector(`.chat-msg[data-id="${id}"]`);
it('offers Cancel on a queued message only, and sends chat-cancel on the second tap', () => {
setChatMessages([owner('m1', { queued: true, awaitsReply: true }), owner('m2', { awaitsReply: true })]);
setBillionStatus({ running: true, working: true, pending: ['m1', 'm2'], progress: {} });
renderWaiting();
expect(row('m2').querySelector('.chat-cancel')).toBeNull();
expect(row('m1').querySelector('.chat-meta').textContent).toContain('queued');
row('m1').querySelector('.chat-cancel').click();
expect(send).not.toHaveBeenCalled();
expect(row('m1').querySelector('.chat-cancel').textContent).toBe('Tap again to cancel');
row('m1').querySelector('.chat-cancel').click();
expect(send).toHaveBeenCalledWith({ type: 'chat-cancel', id: 'm1' });
expect(row('m1').querySelector('.chat-cancel').disabled).toBe(true);
});
it('shows a cancelled message struck through, with no progress box and no Cancel', () => {
setChatMessages([owner('m1', { cancelled: true, awaitsReply: false })]);
setBillionStatus({ running: true, working: true, pending: ['m1'], progress: {} });
renderWaiting();
expect(row('m1').classList.contains('cancelled')).toBe(true);
expect(row('m1').classList.contains('pending')).toBe(false);
expect(row('m1').querySelector('.chat-progress')).toBeNull();
expect(row('m1').querySelector('.chat-cancel')).toBeNull();
expect(row('m1').querySelector('.chat-meta').textContent).toContain('Cancelled');
});
it('says "Already sent to Billion" when delivery won the race', async () => {
document.body.insertAdjacentHTML('beforeend', '<div id="chat-toast" hidden></div>');
const { handleChatCancelled } = await import('../public/modules/waiting.js');
handleChatCancelled({ id: 'm1', delivered: true, error: 'Already sent to Billion.' });
expect(document.getElementById('chat-toast').textContent).toBe('Already sent to Billion');
});
});