Files
learnharness/tests/render-new-session.test.tsx
T
amosblomqvist 93fdf71d4d draft: the pick bar comes to the new-practice screen (round 10 slice 2)
The SAME pill the home screen carries — above the composer, dashed
'Pick propositions from the map' when empty, count + Clear when picked
— now lives on the /chat/new?mode=practice draft. The draft has no
graph behind it, so the pill opens the standalone ChainTableOverlay
(select mode, controlled selection, Done bar) over the /api/graph
snapshot, fetched lazily on first open. The first send carries the
picks as graphContext: the created session is born WITH its ids-first
context marker (target validation's truth set) instead of pickless —
the same contract as the home screen's practice send. Teach drafts
render no pill; practice with no picks stays lean on the wire.
2026-10-05 06:51:23 +03:00

203 lines
7.9 KiB
TypeScript

import React from "react";
(globalThis as Record<string, unknown>).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";
/**
* 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", 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("no settings PUT on create — the server stamps the newborn (per-session models, owner 2026-09-28)", async () => {
const { f, calls } = fakeFetch("c_3");
await startDraftSession("hello", f);
assert.equal(calls.length, 1, "a single create call — the birth stamp is server-side");
});
it("empty or whitespace text never creates anything", async () => {
const { f, calls } = fakeFetch("never");
assert.equal(await startDraftSession("", f), null);
assert.equal(await startDraftSession(" ", 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(
<DraftShell
busy={false}
text=""
onChange={() => {}}
onSend={() => {}}
/>,
);
// The chat composer, ready for the first message.
assert.ok(html.includes('data-testid="composer"'));
// The draft's model control binds the strong slot — the
// new-session default (per-session models, owner 2026-09-28).
assert.ok(html.includes('data-testid="stats-model"'));
// No CTA by default: a configured surface stays quiet.
assert.ok(!html.includes('data-testid="setup-cta"'));
// 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 setup CTA above the composer blob when passed (fix wave chunk B: /chat/new mirrors home)", () => {
const html = renderToStaticMarkup(
<DraftShell
busy={false}
text=""
onChange={() => {}}
onSend={() => {}}
setupCta={
<button type="button" data-testid="setup-cta">
Set up your models — chats start after the three picks
</button>
}
/>,
);
// The same affordance home carries, above the draft shell's blob.
assert.ok(html.includes('data-testid="setup-cta"'));
// Above the composer: the CTA's markup precedes the blob's.
const cta = html.indexOf('data-testid="setup-cta"');
const blob = html.search(/rounded-2xl[^>]*pointer-events-auto|pointer-events-auto[^>]*rounded-2xl/);
assert.ok(cta !== -1 && blob !== -1 && cta < blob, "CTA above the blob");
});
it("no chat id anywhere — nothing in the markup references a session", () => {
const html = renderToStaticMarkup(
<DraftShell
busy={false}
text=""
onChange={() => {}}
onSend={() => {}}
/>,
);
assert.ok(!/\/chat\/c[a-z0-9_]+/.test(html));
});
});
describe("the practice draft (?mode=practice, 2026-10-04)", () => {
it("the shell swaps copy: practice title, empty state, placeholder", () => {
const html = renderToStaticMarkup(
<DraftShell
busy={false}
text=""
onChange={() => {}}
onSend={() => {}}
mode="practice"
/>,
);
assert.match(html, /New practice session/);
assert.match(html, /Describe what to practice/);
assert.match(html, /What do you want to practice\?/);
});
it("THE PICK BAR (owner round 10): the practice draft carries the same pill above its composer", () => {
const html = renderToStaticMarkup(
<DraftShell
busy={false}
text=""
onChange={() => {}}
onSend={() => {}}
mode="practice"
/>,
);
assert.match(html, /Pick propositions from the map/);
// picked: count + Clear, the home register verbatim
const picked = renderToStaticMarkup(
<DraftShell
busy={false}
text=""
onChange={() => {}}
onSend={() => {}}
mode="practice"
picks={[{ id: "n_1", name: "Parity", statement: "Odd + odd = even." }]}
/>,
);
assert.match(picked, /1 proposition selected/);
assert.match(picked, /Clear/);
// teach mode with nothing picked renders nothing (home's law)
const teach = renderToStaticMarkup(
<DraftShell busy={false} text="" onChange={() => {}} onSend={() => {}} mode="teach" />,
);
assert.ok(!teach.includes("Pick propositions from the map"));
});
it("startDraftSession carries the picks as graphContext (the home practice contract)", async () => {
let body: Record<string, unknown> | null = null;
const f = (async (_url: string, init?: RequestInit) => {
body = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ id: "c10" }), { status: 201 });
}) as unknown as typeof fetch;
await startDraftSession("drill me", f, "practice", [{ id: "n_1", name: "Parity", statement: "Odd + odd = even." }]);
assert.deepEqual(body, {
title: "drill me",
kind: "practice",
graphContext: { propositions: [{ id: "n_1", name: "Parity", statement: "Odd + odd = even." }] },
});
});
it("practice with NO picks stays lean — no graphContext key on the wire", async () => {
let body: Record<string, unknown> | null = null;
const f = (async (_url: string, init?: RequestInit) => {
body = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ id: "c11" }), { status: 201 });
}) as unknown as typeof fetch;
await startDraftSession("free practice", f, "practice", []);
assert.deepEqual(body, { title: "free practice", kind: "practice" });
});
it("startDraftSession carries kind:\"practice\" to the create route", async () => {
let body: Record<string, unknown> | null = null;
const f = (async (_url: string, init?: RequestInit) => {
body = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ id: "c9" }), { status: 201 });
}) as unknown as typeof fetch;
const target = await startDraftSession("the parity chain", f, "practice");
assert.equal(target, "/chat/c9?prompt=the%20parity%20chain");
assert.deepEqual(body, { title: "the parity chain", kind: "practice" });
});
it("the teaching default is unchanged — no kind key on the wire", async () => {
let body: Record<string, unknown> | null = null;
const f = (async (_url: string, init?: RequestInit) => {
body = JSON.parse(String(init?.body));
return new Response(JSON.stringify({ id: "c8" }), { status: 201 });
}) as unknown as typeof fetch;
await startDraftSession("orbital mechanics", f);
assert.deepEqual(body, { title: "orbital mechanics" });
});
});