Session handles are one word from a single 218-entry list — instruments, forms, notation and theory, tempo and dynamics — replacing the adjective-noun pair. A batch is shuffled, so it repeats only once the wordlist runs out. Project boxes show the parent directory instead of the full path: the last segment is already the name line above it. The last command is gone from the box, and picking a project no longer pre-selects it — the directory and what runs in it are independent choices. Both changes land in the TUI picker too, which mirrors the web form's rules. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
130 lines
4.2 KiB
TypeScript
130 lines
4.2 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
|
|
|
const BATCH_A = ["cello", "fugue", "lyre"];
|
|
const BATCH_B = ["oboe", "tuba", "harp"];
|
|
|
|
/** Stub the three fetches the form's initial load makes. Records every
|
|
* /api/names URL so tests can assert how many batches were fetched. */
|
|
function stubFetch(batches: string[][]): string[] {
|
|
let batch = 0;
|
|
const nameUrls: string[] = [];
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(async (input: RequestInfo | URL) => {
|
|
const url = String(input);
|
|
if (url.includes("/api/names")) {
|
|
nameUrls.push(url);
|
|
const names = batches[Math.min(batch, batches.length - 1)];
|
|
batch += 1;
|
|
return new Response(JSON.stringify({ names }), { status: 200 });
|
|
}
|
|
const body = url.includes("/api/info")
|
|
? { hostname: "h", version: "0", home_dir: "/home/user", presets: [] }
|
|
: [];
|
|
return new Response(JSON.stringify(body), { status: 200 });
|
|
}),
|
|
);
|
|
return nameUrls;
|
|
}
|
|
|
|
describe("spawnForm name batch (#53)", () => {
|
|
let handle: SpawnFormHandle | null = null;
|
|
let nameUrls: string[];
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
afterEach(() => {
|
|
handle?.destroy();
|
|
handle = null;
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
async function mount(seed?: Parameters<typeof createSpawnForm>[0]["seed"]) {
|
|
nameUrls = stubFetch([BATCH_A, BATCH_B]);
|
|
handle = createSpawnForm({ seed, onSuccess() {} });
|
|
document.body.appendChild(handle.el);
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
return handle.el;
|
|
}
|
|
|
|
const nameOf = (form: HTMLFormElement) =>
|
|
form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
|
|
|
function reroll(form: HTMLFormElement): void {
|
|
form.querySelector<HTMLButtonElement>(".reroll")!.click();
|
|
}
|
|
|
|
async function drain(): Promise<void> {
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
}
|
|
|
|
it("seeds its name from a fetched batch when no seed provides one", async () => {
|
|
const form = await mount();
|
|
expect(BATCH_A).toContain(nameOf(form));
|
|
expect(nameUrls).toHaveLength(1);
|
|
expect(nameUrls[0]).toContain("/api/names");
|
|
expect(nameUrls[0]).toContain("count=");
|
|
});
|
|
|
|
it("keeps a seeded name and defers fetching until the first reroll", async () => {
|
|
const form = await mount({ name: "my-session" });
|
|
expect(nameOf(form)).toBe("my-session");
|
|
expect(nameUrls ?? []).toHaveLength(0);
|
|
|
|
reroll(form);
|
|
await drain();
|
|
expect(nameOf(form)).not.toBe("my-session");
|
|
expect(nameUrls).toHaveLength(1);
|
|
});
|
|
|
|
it("rerolls locally from the batch with no extra round trips", async () => {
|
|
const form = await mount();
|
|
expect(nameUrls).toHaveLength(1);
|
|
const seen = new Set([nameOf(form)]);
|
|
|
|
for (let i = 0; i < BATCH_A.length; i++) {
|
|
reroll(form);
|
|
seen.add(nameOf(form));
|
|
}
|
|
await drain();
|
|
|
|
// Every value came from the one fetched batch.
|
|
expect(seen.size).toBeGreaterThanOrEqual(2);
|
|
for (const value of seen) {
|
|
expect([...BATCH_A, ...BATCH_B]).toContain(value);
|
|
}
|
|
expect(nameUrls).toHaveLength(2); // refetch fired when the batch ran out
|
|
expect(nameUrls[1]).toContain("/api/names");
|
|
});
|
|
|
|
it("refetches only once even if rerolls outpace the network", async () => {
|
|
const form = await mount();
|
|
// Drain the batch without awaiting between rerolls.
|
|
for (let i = 0; i < BATCH_A.length + 1; i++) reroll(form);
|
|
await drain();
|
|
// One refill in flight covers the whole run — no fetch storm.
|
|
expect(nameUrls.length).toBeLessThanOrEqual(2);
|
|
});
|
|
|
|
it("does not clobber an operator-typed name when the batch arrives late", async () => {
|
|
nameUrls = stubFetch([BATCH_A]);
|
|
handle = createSpawnForm({ onSuccess() {} });
|
|
const form = handle.el;
|
|
document.body.appendChild(form);
|
|
|
|
// The operator types before the fetch resolves.
|
|
const input = form.querySelector<HTMLInputElement>("input[name=name]")!;
|
|
input.value = "hand-typed";
|
|
input.dispatchEvent(new Event("input"));
|
|
for (let i = 0; i < 40; i++) await Promise.resolve();
|
|
|
|
expect(nameOf(form)).toBe("hand-typed");
|
|
});
|
|
});
|