import React from "react"; (globalThis as Record).React = React; import { renderToStaticMarkup } from "react-dom/server"; import { describe, it } from "node:test"; import assert from "node:assert/strict"; import { DraftShell, startDraftSession } from "../components/new-session.tsx"; import type { ChatSettings } from "../src/core/index.ts"; /** * The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session" * opens the draft — NOTHING is created until the first message is sent, and * that message becomes the session's title. These pins hold the two seams: * startDraftSession (the create-on-send contract) and DraftShell (the * visible surface: composer blob + stats bar + the fresh-chat empty state). */ /** A fetch double: records calls, answers /api/chats with a canned id. */ function fakeFetch(id: string) { const calls: { url: string; method: string; body: unknown }[] = []; const f = (async (url: string, init?: RequestInit) => { calls.push({ url: String(url), method: init?.method ?? "GET", body: init?.body ? JSON.parse(String(init.body)) : null }); return new Response(JSON.stringify({ id }), { status: 200 }); }) as typeof fetch; return { f, calls }; } describe("startDraftSession — the create-on-send contract", () => { it("creates the session titled with the first message, returns the prompt redirect", async () => { const { f, calls } = fakeFetch("c_new"); const target = await startDraftSession("teach me orbital mechanics", undefined, f); assert.equal(target, "/chat/c_new?prompt=teach%20me%20orbital%20mechanics"); assert.equal(calls.length, 1); assert.equal(calls[0]!.url, "/api/chats"); assert.equal(calls[0]!.method, "POST"); assert.deepEqual(calls[0]!.body, { title: "teach me orbital mechanics" }); }); it("persists the lifted pre-chat settings after creation", async () => { const { f, calls } = fakeFetch("c_2"); const settings: ChatSettings = { model: { provider: "openrouter", id: "qwen/qwen3.8-flash" }, thinkingLevel: "medium" }; await startDraftSession("hello", settings, f); assert.equal(calls.length, 2); assert.equal(calls[1]!.url, "/api/chats/c_2/settings"); assert.equal(calls[1]!.method, "PUT"); assert.deepEqual(calls[1]!.body, settings); }); it("no settings lift → no settings write (a single create call)", async () => { const { f, calls } = fakeFetch("c_3"); await startDraftSession("hello", undefined, f); assert.equal(calls.length, 1); }); it("empty or whitespace text never creates anything", async () => { const { f, calls } = fakeFetch("never"); assert.equal(await startDraftSession("", undefined, f), null); assert.equal(await startDraftSession(" ", undefined, f), null); assert.equal(calls.length, 0); }); }); describe("DraftShell — the sessionless surface", () => { it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => { const html = renderToStaticMarkup( {}} onSend={() => {}} settings={undefined} onSettingsSaved={() => {}} />, ); // The chat composer, ready for the first message. assert.ok(html.includes('data-testid="composer"')); // The pre-chat model control (its empty-chatId defer branch lifts here). assert.ok(html.includes('data-testid="stats-model"')); // The same empty state a fresh chat shows. assert.ok(html.includes("The teacher will open by probing what you already know.")); // The pointer-events pattern (953b22b): the wrapper stays inert… assert.ok(html.includes("pointer-events-none")); // …and the blob re-enables it. assert.ok(/rounded-2xl[^>]*pointer-events-auto|pointer-events-auto[^>]*rounded-2xl/.test(html)); }); it("renders the knowledge-context slider in the stats bar's left slot, at the decided default (absent settings = on, 1024)", () => { const html = renderToStaticMarkup( {}} onSend={() => {}} settings={undefined} onSettingsSaved={() => {}} />, ); assert.ok(html.includes('data-testid="graph-context-control"')); // INSIDE the stats row (the session-data slot), not a strip above the // composer: the control renders after the stats bar opens. assert.ok( html.indexOf('data-testid="stats-bar"') < html.indexOf('data-testid="graph-context-control"'), "the slider is the stats bar's left slot", ); // The slider's default position: 1024 of 4096, step 128 (decided). assert.ok(html.includes('min="0"') && html.includes('max="4096"') && html.includes('step="128"')); // The readout: "tok" abbreviated, but the FULL number (owner, 2026-09-15: // no k-rounding — 1024 reads as 1024). assert.ok(html.includes("1024 tok"), "the readout shows the default budget, not 0 — default is ON"); assert.ok(html.includes("Knowledge context"), "the control is labeled"); // Absent settings render the default — never 'off'. assert.ok(!html.includes(">off<")); }); it("an explicit 0 renders as off — the learner turned the graph off for this session", () => { const html = renderToStaticMarkup( {}} onSend={() => {}} settings={{ graphContextTokens: 0 }} onSettingsSaved={() => {}} />, ); assert.ok(html.includes('data-testid="graph-context-value"')); assert.ok(html.includes(">off<")); }); it("no chat id anywhere — nothing in the markup references a session", () => { const html = renderToStaticMarkup( {}} onSend={() => {}} settings={undefined} onSettingsSaved={() => {}} />, ); assert.ok(!/\/chat\/c[a-z0-9_]+/.test(html)); }); });