mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
test(story-mode): frontend overlay test in JSDOM, every scene drawn in every shape
Runs the real story-mode.js against pages the real server core builds from invented transcripts: panels, captions, balloons, the turn picker, the Director's cut wire, file and diff clicks with their line numbers, output links through the terminal's own path matcher, a live refresh that keeps existing panels' clocks, activity filtering, Escape one layer per press, teardown, error notes and the setting switch. A permissive fake 2D context lets every scene draw every panel kind across its whole animation, at four sizes, in both reading levels, without throwing. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7c21973c54
commit
dfe023d49d
1 file changed
+464
@@ -0,0 +1,464 @@
|
||||
/**
|
||||
* @fileoverview Frontend test for story-mode.js (Story Mode overlay). Builds a JSDOM window in-test
|
||||
* (like admin-ui.test.ts), evaluates the real module, and feeds it pages built by the real server
|
||||
* core from invented transcripts (test/story-fixtures.ts). Canvas 2D is a permissive fake, so every
|
||||
* scene's drawing code runs: a scene that throws for some panel shape fails here, not in a browser.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildStoryPage, buildStoryTurns, createStoryReducer, summarizeTurns } from '../src/story-mode.js';
|
||||
import type { StoryPage, StoryPanel } from '../src/types/story.js';
|
||||
import { bash, demoTurn, end, prompt, read, resetClock, said } from './story-fixtures.js';
|
||||
|
||||
const STORY_JS = readFileSync(new URL('../src/web/public/story-mode.js', import.meta.url), 'utf-8');
|
||||
// The real path matcher (findFilePathLinks / resolveLinkedFilePath) the terminal uses.
|
||||
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
|
||||
const SID = 'session-0001-abcdef';
|
||||
|
||||
type Win = Window & typeof globalThis & Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
function storyOf(lines: unknown[]) {
|
||||
const reducer = createStoryReducer();
|
||||
const turns = buildStoryTurns(lines.flatMap((l) => reducer.reduce(l)));
|
||||
return {
|
||||
supported: true,
|
||||
turns: summarizeTurns(turns),
|
||||
page: turns.length ? buildStoryPage(turns[turns.length - 1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** A 2D context that accepts every call; measureText answers a plausible width. */
|
||||
function fakeContext() {
|
||||
const target: Record<string, unknown> = {
|
||||
measureText: (text: string) => ({ width: String(text).length * 6 }),
|
||||
};
|
||||
return new Proxy(target, {
|
||||
get(t, key) {
|
||||
if (key in t) return t[key as string];
|
||||
return () => undefined;
|
||||
},
|
||||
set(t, key, value) {
|
||||
t[key as string] = value;
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let dom: JSDOM;
|
||||
let win: Win;
|
||||
let app: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
let responses: Array<{ status: number; body: unknown }>;
|
||||
let calls: string[];
|
||||
|
||||
function boot(initial: unknown) {
|
||||
dom = new JSDOM(
|
||||
`<!doctype html><body>
|
||||
<button class="btn-story-header btn-story-header--hidden"></button>
|
||||
<div class="file-preview-overlay" id="filePreviewOverlay"></div>
|
||||
<div class="folder-grid-overlay" id="folderGridOverlay"></div>
|
||||
</body>`,
|
||||
{ url: 'http://localhost/', runScripts: 'outside-only', pretendToBeVisual: true }
|
||||
);
|
||||
win = dom.window as unknown as Win;
|
||||
win.HTMLCanvasElement.prototype.getContext = (() => fakeContext()) as never;
|
||||
win.ResizeObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as never;
|
||||
win.IntersectionObserver = class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
} as never;
|
||||
win.matchMedia = ((q: string) => ({
|
||||
matches: false,
|
||||
media: q,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
})) as never;
|
||||
win.eval(CONSTANTS_JS);
|
||||
win.eval('class CodemanApp {}; window.CodemanApp = CodemanApp;');
|
||||
win.eval(STORY_JS);
|
||||
responses = [{ status: 200, body: { success: true, data: initial } }];
|
||||
calls = [];
|
||||
app = Object.create(win.CodemanApp.prototype);
|
||||
Object.assign(app, {
|
||||
activeSessionId: SID,
|
||||
sessions: new Map([[SID, { name: 'demo-shop', workingDir: '/work/demo-shop' }]]),
|
||||
loadAppSettingsFromStorage: () => ({ showStoryButton: true }),
|
||||
getDefaultSettings: () => ({}),
|
||||
showToast: vi.fn(),
|
||||
openFilePreview: vi.fn(),
|
||||
closeFilePreview: vi.fn(() => win.document.getElementById('filePreviewOverlay')!.classList.remove('visible')),
|
||||
filePreviewEdit: null,
|
||||
_api: async (path: string) => {
|
||||
calls.push(path);
|
||||
const next = responses.length > 1 ? responses.shift()! : responses[0];
|
||||
return { ok: next.status < 300, status: next.status, headers: { get: () => null }, json: async () => next.body };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const settle = async (n = 6) => {
|
||||
for (let i = 0; i < n; i++) await new Promise((r) => setTimeout(r, 0));
|
||||
};
|
||||
const $ = (sel: string) => win.document.querySelector(sel) as HTMLElement | null;
|
||||
const $$ = (sel: string) => [...win.document.querySelectorAll(sel)] as HTMLElement[];
|
||||
/** Panels have no layout in JSDOM; give them a size so scenes draw and click regions exist. */
|
||||
function sizeAndDraw() {
|
||||
for (const rt of app._story.panels) {
|
||||
rt.w = 600;
|
||||
rt.h = 270;
|
||||
rt.dpr = 1;
|
||||
rt.canvas.width = 600;
|
||||
rt.canvas.height = 270;
|
||||
rt.bgKey = '';
|
||||
app._storyDrawPanel(rt);
|
||||
}
|
||||
}
|
||||
const press = (key: string) =>
|
||||
win.dispatchEvent(new win.KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
|
||||
|
||||
beforeEach(() => resetClock());
|
||||
afterEach(() => {
|
||||
app?.closeStoryMode?.();
|
||||
dom?.window.close();
|
||||
});
|
||||
|
||||
describe('story-mode.js overlay', () => {
|
||||
it('draws the newest turn: panels, captions, balloons, turn picker', async () => {
|
||||
boot(storyOf([prompt('first job'), said('Done.'), end(2000), ...demoTurn()]));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
expect(calls).toEqual([`/api/sessions/${SID}/story`]);
|
||||
expect($$('.story-panel').map((p) => p.dataset.kind)).toEqual([
|
||||
'mission',
|
||||
'investigate',
|
||||
'plan',
|
||||
'backup',
|
||||
'loop',
|
||||
'save',
|
||||
'complete',
|
||||
]);
|
||||
expect($('.story-panel[data-kind="mission"] .story-balloon')!.textContent).toBe(
|
||||
'The checkout button does nothing on phones. Can you fix it?'
|
||||
);
|
||||
expect($('.story-panel[data-kind="mission"] .story-balloon')!.className).toContain('story-balloon--you');
|
||||
expect($('.story-panel[data-kind="investigate"] .story-cap-story')!.textContent).toBe(
|
||||
'Claude reads 2 files to find its way around.'
|
||||
);
|
||||
expect($('.story-splash-title')!.textContent).toBe('Mission complete!');
|
||||
expect($$('.story-turns option').map((o) => o.textContent)).toEqual([
|
||||
'1. first job',
|
||||
'2. The checkout button does nothing on phones. Can you fix it?',
|
||||
]);
|
||||
expect(($('.story-prev') as HTMLButtonElement).disabled).toBe(false);
|
||||
expect(($('.story-next') as HTMLButtonElement).disabled).toBe(true);
|
||||
expect($('.story-bar .story-live')!.hidden).toBe(true);
|
||||
expect($('.story-pencils')).toBeNull();
|
||||
expect($('.story-note')!.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it("the Director's cut shows one wire row per event and the tool captions", async () => {
|
||||
const data = storyOf(demoTurn());
|
||||
boot(data);
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
app._storySetDirector(true);
|
||||
expect($('.story-overlay')!.classList.contains('is-director')).toBe(true);
|
||||
expect($$('.story-ev')).toHaveLength((data.page as StoryPage).events.length);
|
||||
expect($$('.story-wire-group')[0].textContent).toBe('Panel 1 · The mission');
|
||||
expect($('.story-panel[data-kind="investigate"] .story-cap-dir')!.textContent).toBe('Read ×2 · Bash');
|
||||
});
|
||||
|
||||
it('a file card opens the file preview; the code page opens the diff with real line numbers', async () => {
|
||||
boot(storyOf(demoTurn()));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
sizeAndDraw();
|
||||
const card = $$('.story-panel[data-kind="investigate"] .story-hit').find(
|
||||
(b) => b.getAttribute('aria-label') === 'Open CheckoutButton.tsx'
|
||||
);
|
||||
expect(card).toBeTruthy();
|
||||
card!.click();
|
||||
expect(app.openFilePreview).toHaveBeenCalledWith('/work/demo-shop/src/checkout/CheckoutButton.tsx', SID);
|
||||
|
||||
const page = $$('.story-panel[data-kind="loop"] .story-hit').find(
|
||||
(b) => b.getAttribute('aria-label') === "See Claude's changes"
|
||||
);
|
||||
page!.click();
|
||||
expect($('.story-drawer')!.hidden).toBe(false);
|
||||
expect($('.story-drawer-kind')!.textContent).toBe('Diff');
|
||||
expect($$('.story-df')).toHaveLength(2);
|
||||
// The first hunk starts at line 10 in both files: context 10/10, an added line at new 11, context 11/12.
|
||||
const rows = $$('.story-df')[0].querySelectorAll('.story-ln:not(.story-ln--hunk)');
|
||||
expect([...rows].map((r) => [...r.querySelectorAll('.story-ln-no')].map((n) => n.textContent))).toEqual([
|
||||
['10', '10'],
|
||||
['', '11'],
|
||||
['11', '12'],
|
||||
]);
|
||||
($$('.story-df')[0].querySelector('.story-btn-s') as HTMLElement).click();
|
||||
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/src/styles/banner.css', SID);
|
||||
});
|
||||
|
||||
it('the test board opens the output, with paths in it as links', async () => {
|
||||
boot(
|
||||
storyOf([
|
||||
prompt('go'),
|
||||
...bash('b1', 'npm test', 'Run tests', 'FAIL src/a.test.ts:12\n Tests 1 failed | 3 passed (4)', true),
|
||||
end(1000),
|
||||
])
|
||||
);
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
sizeAndDraw();
|
||||
$('.story-panel[data-kind="check"] .story-hit')!.click();
|
||||
expect($('.story-drawer-kind')!.textContent).toBe('Output');
|
||||
const link = $('.story-drawer .story-pre .story-link');
|
||||
expect(link!.textContent).toBe('src/a.test.ts');
|
||||
link!.click();
|
||||
expect(app.openFilePreview).toHaveBeenLastCalledWith('/work/demo-shop/src/a.test.ts', SID);
|
||||
});
|
||||
|
||||
it('a live refresh adds panels with an entrance and keeps the clocks of the ones already there', async () => {
|
||||
const lines = [prompt('go'), ...read('r1', '/w/a.ts')];
|
||||
boot(storyOf(lines));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
expect($$('.story-panel').map((p) => p.dataset.kind)).toEqual(['mission', 'investigate']);
|
||||
expect($('.story-pencils')).toBeTruthy();
|
||||
expect($('.story-bar .story-live')!.hidden).toBe(false);
|
||||
const first = app._story.panels[1];
|
||||
first.clock = 500;
|
||||
responses = [
|
||||
{
|
||||
status: 200,
|
||||
body: {
|
||||
success: true,
|
||||
data: storyOf([...lines, ...bash('b1', 'npm test', 'Run tests', ' Tests 4 passed (4)'), end(9000)]),
|
||||
},
|
||||
},
|
||||
];
|
||||
await app._storyRefresh();
|
||||
await settle();
|
||||
expect($$('.story-panel').map((p) => p.dataset.kind)).toEqual(['mission', 'investigate', 'check', 'complete']);
|
||||
expect(app._story.panels[1]).toBe(first);
|
||||
expect(first.clock).toBeGreaterThanOrEqual(500); // kept (and still running), never restarted
|
||||
expect($('.story-panel[data-kind="check"]')!.classList.contains('story-fresh')).toBe(true);
|
||||
expect($('.story-panel[data-kind="investigate"]')!.classList.contains('story-fresh')).toBe(false);
|
||||
expect($('.story-pencils')).toBeNull();
|
||||
});
|
||||
|
||||
it('activity for this session refreshes, activity for another does not', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
boot(storyOf(demoTurn()));
|
||||
app.openStoryMode(SID);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
calls.length = 0;
|
||||
app._storyOnActivity({ data: JSON.stringify({ sessionId: 'someone-else' }) });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(calls).toEqual([]);
|
||||
app._storyOnActivity({ data: JSON.stringify({ sessionId: SID }) });
|
||||
app._storyOnActivity({ data: JSON.stringify({ sessionId: SID }) });
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(calls).toEqual([`/api/sessions/${SID}/story`]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('Escape closes one layer per press: file preview, then drawer, then the overlay', async () => {
|
||||
boot(storyOf(demoTurn()));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
sizeAndDraw();
|
||||
$$('.story-panel[data-kind="loop"] .story-hit')[0].click();
|
||||
$('#filePreviewOverlay')!.classList.add('visible');
|
||||
press('Escape');
|
||||
expect(app.closeFilePreview).toHaveBeenCalledTimes(1);
|
||||
expect($('.story-drawer')!.hidden).toBe(false);
|
||||
press('Escape');
|
||||
expect($('.story-drawer')!.hidden).toBe(true);
|
||||
expect($('#storyOverlay')!.hidden).toBe(false);
|
||||
press('Escape');
|
||||
expect($('#storyOverlay')!.hidden).toBe(true);
|
||||
expect(app._story.open).toBe(false);
|
||||
});
|
||||
|
||||
it('leaves Escape to a folder grid on top and to a preview being edited', async () => {
|
||||
boot(storyOf(demoTurn()));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
$('#folderGridOverlay')!.classList.add('visible');
|
||||
press('Escape');
|
||||
expect($('#storyOverlay')!.hidden).toBe(false);
|
||||
$('#folderGridOverlay')!.classList.remove('visible');
|
||||
$('#filePreviewOverlay')!.classList.add('visible');
|
||||
app.filePreviewEdit = { dirty: true };
|
||||
press('Escape');
|
||||
expect(app.closeFilePreview).not.toHaveBeenCalled();
|
||||
expect($('#storyOverlay')!.hidden).toBe(false);
|
||||
});
|
||||
|
||||
it('closing stops listening: no Escape handling and no refresh after close', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
boot(storyOf([prompt('go'), ...read('r1', '/w/a.ts')]));
|
||||
app.openStoryMode(SID);
|
||||
await vi.advanceTimersByTimeAsync(10);
|
||||
app.closeStoryMode();
|
||||
calls.length = 0;
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(calls).toEqual([]);
|
||||
expect($$('.story-panel')).toHaveLength(0);
|
||||
app._storyOnActivity({ data: JSON.stringify({ sessionId: SID }) });
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(calls).toEqual([]);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('says why when a session cannot be drawn, or the request fails', async () => {
|
||||
boot({ supported: false, reason: 'Story Mode draws Claude Code sessions. This session runs a different CLI.' });
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
expect($('.story-note')!.textContent).toMatch(/different CLI/);
|
||||
expect($$('.story-panel')).toHaveLength(0);
|
||||
responses = [{ status: 404, body: { success: false, error: 'Session not found' } }];
|
||||
await app._storyRefresh();
|
||||
expect($('.story-note')!.textContent).toBe('Session not found');
|
||||
});
|
||||
|
||||
it('turning the setting off hides the button and closes an open story', async () => {
|
||||
boot(storyOf(demoTurn()));
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
app.loadAppSettingsFromStorage = () => ({ showStoryButton: false });
|
||||
app.applyStoryButtonVisibility();
|
||||
expect($('.btn-story-header')!.classList.contains('btn-story-header--hidden')).toBe(true);
|
||||
expect($('#storyOverlay')!.hidden).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('story-mode.js scenes', () => {
|
||||
/** Every panel kind, in plain and awkward shapes, drawn across its whole animation. */
|
||||
function pageWith(panels: StoryPanel[], extra: Partial<StoryPage> = {}): StoryPage {
|
||||
const base = storyOf(demoTurn()).page as StoryPage;
|
||||
return { ...base, ...extra, panels };
|
||||
}
|
||||
const cap = { story: 's', director: 'd' };
|
||||
|
||||
it('no scene throws, whatever the panel holds', async () => {
|
||||
const base = storyOf(demoTurn()).page as StoryPage;
|
||||
const manyFiles = Array.from(
|
||||
{ length: 20 },
|
||||
(_, i) => `/w/src/a/very/deep/path/file${i}WithALongCamelCaseName.test.tsx`
|
||||
);
|
||||
const shapes: StoryPanel[] = [
|
||||
{ kind: 'mission', events: [0], caption: cap, balloon: { who: 'you', text: 'x'.repeat(500) } },
|
||||
{ kind: 'investigate', events: [], caption: cap, files: [] },
|
||||
{ kind: 'investigate', events: [], caption: cap, files: manyFiles, notes: ['grep -rn x'] },
|
||||
{ kind: 'investigate', events: [], caption: cap, files: ['/w/a'] },
|
||||
{
|
||||
kind: 'research',
|
||||
events: [],
|
||||
caption: cap,
|
||||
notes: ['https://example.com/a/very/long/url/' + 'x'.repeat(200), 'q'],
|
||||
},
|
||||
{ kind: 'research', events: [], caption: cap },
|
||||
{
|
||||
kind: 'plan',
|
||||
events: [],
|
||||
caption: cap,
|
||||
todos: Array.from({ length: 12 }, (_, i) => ({ text: `step ${i} ` + 'y'.repeat(80), done: i % 2 === 0 })),
|
||||
},
|
||||
{ kind: 'plan', events: [], caption: cap, notes: [] },
|
||||
{ kind: 'backup', events: [], caption: cap, helpers: [] },
|
||||
{
|
||||
kind: 'backup',
|
||||
events: [],
|
||||
caption: cap,
|
||||
helpers: Array.from({ length: 6 }, (_, i) => ({
|
||||
name: `h${i}`,
|
||||
type: 'Explore',
|
||||
task: 'z'.repeat(90),
|
||||
event: 0,
|
||||
})),
|
||||
},
|
||||
{ kind: 'fix', events: [], caption: cap, files: [], added: 0, removed: 0 },
|
||||
{
|
||||
kind: 'fix',
|
||||
events: base.panels.find((p) => p.kind === 'loop')!.events,
|
||||
caption: cap,
|
||||
files: manyFiles,
|
||||
added: 999,
|
||||
removed: 999,
|
||||
},
|
||||
{ kind: 'check', events: [], caption: cap },
|
||||
{ kind: 'check', events: [], caption: cap, ok: true, tests: { passed: 5000, failed: 0 } },
|
||||
{ kind: 'check', events: [], caption: cap, ok: false, tests: { passed: 0, failed: 3 } },
|
||||
{ kind: 'check', events: [], caption: cap, ok: false },
|
||||
{ kind: 'loop', events: [], caption: cap, rounds: 7, ok: false },
|
||||
{ kind: 'loop', events: base.panels.find((p) => p.kind === 'loop')!.events, caption: cap, rounds: 2, ok: true },
|
||||
{ kind: 'run', events: [], caption: cap, notes: [] },
|
||||
{ kind: 'run', events: [], caption: cap, notes: Array.from({ length: 10 }, () => 'w'.repeat(300)) },
|
||||
{ kind: 'save', events: [], caption: cap },
|
||||
{ kind: 'ask', events: [], caption: cap, question: { text: 'Which one?', answer: 'The first' } },
|
||||
{ kind: 'compact', events: [], caption: cap },
|
||||
{ kind: 'montage', events: [], caption: cap, notes: ['a'] },
|
||||
{ kind: 'complete', events: [], caption: cap, balloon: { who: 'claude', text: 'Done.' } },
|
||||
];
|
||||
for (const interrupted of [false, true]) {
|
||||
boot({ supported: true, turns: [], page: pageWith(shapes, { interrupted, helpers: interrupted ? 0 : 3 }) });
|
||||
app.openStoryMode(SID);
|
||||
await settle();
|
||||
expect($$('.story-panel')).toHaveLength(shapes.length);
|
||||
for (const director of [false, true]) {
|
||||
app._storySetDirector(director);
|
||||
for (const frame of [0, 1, 15, 30, 45, 72, 200, 1000]) {
|
||||
for (const rt of app._story.panels) {
|
||||
rt.clock = frame;
|
||||
for (const [w, h] of [
|
||||
[600, 270],
|
||||
[300, 300],
|
||||
[900, 270],
|
||||
[220, 430],
|
||||
]) {
|
||||
rt.w = w;
|
||||
rt.h = h;
|
||||
rt.dpr = 1;
|
||||
rt.bgKey = '';
|
||||
expect(() => app._storyDrawPanel(rt), `${rt.kind} at frame ${frame}, ${w}x${h}`).not.toThrow();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.closeStoryMode();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('story-mode.js layout', () => {
|
||||
it('fills every 6-column row exactly, and the last panel stretches to the end', () => {
|
||||
boot(null);
|
||||
const spans = (prefs: number[], cols = 6) => win.storyLayoutSpans(prefs, cols) as number[];
|
||||
for (const prefs of [[3, 3, 2, 2, 2, 6], [3, 2, 2, 2, 3, 2], [2], [3, 4, 2, 2], [6, 6], [2, 2, 2, 2, 2, 2, 2]]) {
|
||||
const s = spans(prefs);
|
||||
expect(s).toHaveLength(prefs.length);
|
||||
// Walk the rows: every row adds up to exactly 6.
|
||||
let row = 0;
|
||||
for (const span of s) {
|
||||
row += span;
|
||||
expect(row).toBeLessThanOrEqual(6);
|
||||
if (row === 6) row = 0;
|
||||
}
|
||||
expect(row).toBe(0);
|
||||
}
|
||||
expect(spans([1, 1, 1], 2)).toEqual([1, 1, 2]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user