Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Collaborative Workspace (`/app/workspaces/*`, the `workspace-invites` function,

[`src/lib/stores/session-store.ts`](../src/lib/stores/session-store.ts) owns active AI results, conversations, saved work, sources, folders, flowcharts, autosave state, and persisted form state. It persists selected local state and hydrates authenticated rows from Supabase.

[`src/lib/stores/debate-store.ts`](../src/lib/stores/debate-store.ts) owns the signed-in user's debates, the Current Debate pointer, session-only one-off mode, and the shared Switch and Start dialogs. Every source and every automatically recorded result carries a `debateId`; one-off work and unsorted legacy rows have none. The Library, History, the AI source context, and discovered-source autosave all read from one debate at a time, so material never crosses debates. Motion-bearing tools confirm a motion that differs from the Current Debate (switch, create, or use once) before submitting, and the app blocks with Continue, Start new, or Switch after five days without work in the Current Debate.
[`src/lib/stores/debate-store.ts`](../src/lib/stores/debate-store.ts) owns the signed-in user's debates, the Current Debate pointer, session-only one-off mode, and the shared Switch and Start dialogs. Every source and every automatically recorded result carries a `debateId`; one-off work and unsorted legacy rows have none. The Library, History, the AI source context, and discovered-source autosave all read from one debate at a time, so material never crosses debates. Motion-bearing tools confirm a motion that differs from the Current Debate (switch, create, or use once) before submitting; with no Current Debate they instead offer to start one from the typed motion. The first-run tour starts with the Current Debate card, then points at Library and Generate. The app blocks with Continue, Start new, or Switch after five days without work in the Current Debate.

[`src/lib/stores/settings-store.ts`](../src/lib/stores/settings-store.ts) owns language, theme, feature provider settings, prompts, and research preferences. It persists locally and synchronizes authenticated settings.

Expand Down
3 changes: 3 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ The server is never reused. CI uses retries and two workers. On failure, traces,

`e2e/hungarian-parity.spec.ts` is the focused Hungarian browser contract. On desktop and mobile Chromium it checks persisted `<html lang="hu">`, public and authenticated page chrome, route titles, onboarding, saved-chat empty state, Library controls, Timer semantics, localized close labels, and runtime errors. It deliberately does not reject arbitrary English text because source titles, user content, model names, and citations may legitimately remain in their source language.

`e2e/first-run.spec.ts` drives the same first run against stubbed Supabase routes in English and Hungarian, so it proves the client flow only.

`src/test/i18n.test.ts` requires identical key sets and non-empty values across all three catalogs. Its explicit identity allowlist is limited to values that should remain canonical, such as product names, file formats, prices, model-facing abbreviations, and debate-format codes. A matching key set and a passing allowlist test prove catalog structure, not Hungarian grammar or natural phrasing.

The speech unit test proves that Hungarian selects `recognition.lang = 'hu-HU'`. Credential-free browser automation has no real microphone or speech service, so it does not prove recognition accuracy or hardware support.
Expand Down Expand Up @@ -177,6 +179,7 @@ Each test signs in through the Login page with a fresh demo code and deletes tha
- sign-in, reload, sign-out, a rejected code, and an expired token that refreshes before an AI request;
- case generation grounded in a 250 KB Library source, provider failure with retry, a zero quota, and a saved case that a fresh browser loads from History;
- Attack, Brainstorm, Factoids, streamed Ask (it fails if the client falls back to the non-streaming path), and Ask with citations;
- a fresh account's first run in English and Hungarian: the tour starts the first debate, the language picked before sign-up survives the first sign-in, and a tool offers a debate from the typed motion when none is current;
- Current Debate context: every request carries the debate's motion, instructions, and sources; Review drops a source and pins a saved case for exactly one request; the pin is stored; Crossfire leaves instructions out; and one-off work sends nothing;
- a judged Lincoln-Douglas sparring round and a Crossfire debrief after a Jev signal attaches to an answer;
- Library activation and deletion, output-language sync across devices, flowchart saves, and Delete All My Data (it must leave another account's rows intact);
Expand Down
181 changes: 181 additions & 0 deletions e2e/first-run.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { expect, test, type Page } from '@playwright/test';
import { setLocale, stubSupabaseSession } from './helpers/auth';

// A fresh account: the server holds no debates until the journey starts one.
// The REST routes below stand in for Supabase, so this proves the client flow
// only; e2e/full-stack/first-run.spec.ts repeats it against real Auth and RLS.
const USER_ID = '00000000-0000-0000-0000-000000000001';

const copy = {
en: {
welcome: 'Welcome to DebateLab!',
firstDebate: 'Start your first debate here.',
startDebate: 'Start debate',
motionField: 'Motion',
library: 'Library',
generate: 'Generate',
ready: "You're Ready!",
finish: 'Get Started!',
generateMotion: 'Motion / Topic *',
generateSubmit: 'Generate Debate Case',
offerTitle: 'Start a debate with this motion?',
workingIn: 'Working in:',
tourMotion: 'This House would ban homework',
gateMotion: 'This House would abolish zoos',
},
hu: {
welcome: 'Üdvözlünk a DebateLab-ban!',
firstDebate: 'Itt indíthatod el az első vitádat.',
startDebate: 'Vita indítása',
motionField: 'Tétel',
library: 'Forráskönyvtár',
generate: 'Generálás',
ready: 'Készen Állsz!',
finish: 'Kezdjük!',
generateMotion: 'Indítvány / Téma *',
generateSubmit: 'Vitaeset Generálása',
offerTitle: 'Indítasz egy vitát ezzel a tétellel?',
workingIn: 'Munka ebben:',
tourMotion: 'Ez a Ház betiltaná a házi feladatot',
gateMotion: 'Ez a Ház bezárná az állatkerteket',
},
} as const;

type DebateRow = { id: string; motion: string; settings: unknown; instructions: string; last_activity_at: string; created_at: string };

async function installFreshAccount(page: Page) {
const debates: DebateRow[] = [];
const aiDebateIds: Array<string | null> = [];
let currentId: string | null = null;

await page.route('**/*', async (route) => {
const request = route.request();
const url = new URL(request.url());
if (url.hostname === 'localhost' || url.hostname === '127.0.0.1') return route.continue();

if (url.pathname.endsWith('/auth/v1/user')) {
return route.fulfill({ json: { id: USER_ID, aud: 'authenticated', role: 'authenticated', email: 'e2e@example.com' } });
}

const single = (request.headers().accept ?? '').includes('vnd.pgrst.object');
if (url.pathname.endsWith('/rest/v1/debates')) {
if (request.method() === 'POST') {
const body = request.postDataJSON() as { motion: string; settings?: unknown; instructions?: string };
const now = new Date().toISOString();
const row: DebateRow = {
id: `00000000-0000-4000-8000-${String(debates.length + 1).padStart(12, '0')}`,
motion: body.motion,
settings: body.settings ?? {},
instructions: body.instructions ?? '',
last_activity_at: now,
created_at: now,
};
debates.unshift(row);
return route.fulfill({ status: 201, json: single ? row : [row] });
}
if (request.method() === 'GET') return route.fulfill({ json: debates });
return route.fulfill({ status: 204, body: '' });
}
if (url.pathname.endsWith('/rest/v1/current_debates')) {
if (request.method() === 'POST') {
currentId = (request.postDataJSON() as { debate_id: string }).debate_id;
return route.fulfill({ status: 201, body: '' });
}
return route.fulfill({ json: currentId ? [{ debate_id: currentId }] : [] });
}
if (url.pathname.includes('/rest/v1/')) {
// PostgREST answers a single-row read of an empty table with 406.
if (single) return route.fulfill({ status: 406, json: { code: 'PGRST116', message: 'JSON object requested, multiple (or no) rows returned' } });
return route.fulfill({ headers: { 'content-range': '*/0' }, json: [] });
}
if (url.pathname.endsWith('/functions/v1/ai')) {
const body = request.postDataJSON() as { debateContext?: { debateId: string } };
aiDebateIds.push(body.debateContext?.debateId ?? null);
// The journey ends at the request; the model's answer is not under test.
return route.fulfill({ status: 503, json: { error: 'Not under test' } });
}
return route.fulfill({ status: 404, body: '' });
});

return { debates, aiDebateIds };
}

async function freshStart(page: Page, locale: 'en' | 'hu', tourDone: boolean) {
await setLocale(page, locale);
await stubSupabaseSession(page);
await page.addInitScript((done) => {
// Seed once: a reload must keep what the app wrote.
if (window.localStorage.getItem('debate-trainer-onboarding')) return;
window.localStorage.setItem('debate-trainer-onboarding', JSON.stringify({
state: { hasCompletedOnboarding: done, isTourRunning: false },
version: 0,
}));
}, tourDone);
return installFreshAccount(page);
}

for (const locale of ['en', 'hu'] as const) {
const text = copy[locale];

test(`[${locale}] the first tour step starts the first debate, then shows Library and Generate`, async ({ page }) => {
const backend = await freshStart(page, locale, false);
await page.goto('/app', { waitUntil: 'domcontentloaded' });

const card = page.getByTestId('home-current-debate');
const tooltip = page.locator('.react-joyride__tooltip');
const next = page.locator('[data-test-id="button-primary"]');
await expect(tooltip).toContainText(text.welcome, { timeout: 15_000 });
await expect(tooltip).toContainText(text.firstDebate);
await expect(next).toHaveAccessibleName(/1.*4/);

// The student starts the debate from inside the first step.
await tooltip.getByRole('button', { name: text.startDebate }).click();
const dialog = page.getByRole('dialog');
await expect(tooltip).toHaveCount(0);
await dialog.getByLabel(text.motionField).fill(text.tourMotion);
await dialog.getByRole('button', { name: text.startDebate }).click();
await expect(dialog).toBeHidden();
await expect(card).toContainText(text.tourMotion);
expect(backend.debates.map((debate) => debate.motion)).toEqual([text.tourMotion]);

// The tour resumes on Library, then one Prepare tool, then ends.
await expect(tooltip).toContainText(text.library);
await expect(next).toHaveAccessibleName(/2.*4/);
await next.click();
await expect(tooltip).toContainText(text.generate);
await expect(next).toHaveAccessibleName(/3.*4/);
await next.click();
await expect(tooltip).toContainText(text.ready);
await page.getByRole('button', { name: text.finish }).click();
await expect(tooltip).toHaveCount(0);

// Generate now works inside the debate the tour started.
await page.locator('main [data-tour="mode-generate"]').click();
await expect(page.getByTestId('current-debate-strip')).toContainText(`${text.workingIn} ${text.tourMotion}`);
await expect(page.getByRole('textbox', { name: text.generateMotion })).toHaveValue(text.tourMotion);

// Finishing is remembered across a reload.
await page.goto('/app', { waitUntil: 'domcontentloaded' });
await expect(card).toBeVisible();
await page.waitForTimeout(800);
await expect(tooltip).toHaveCount(0);
});

test(`[${locale}] a tool offers a first debate from the typed motion instead of the switch dialog`, async ({ page }) => {
const backend = await freshStart(page, locale, true);
await page.goto('/app/generate', { waitUntil: 'domcontentloaded' });

await page.getByRole('textbox', { name: text.generateMotion }).fill(text.gateMotion);
await page.getByRole('button', { name: text.generateSubmit }).click();

const offer = page.getByRole('dialog', { name: text.offerTitle });
await expect(offer).toContainText(text.gateMotion);
await offer.getByRole('button', { name: text.startDebate }).click();
await expect(offer).toBeHidden();

await expect(page.getByTestId('current-debate-strip')).toContainText(`${text.workingIn} ${text.gateMotion}`);
expect(backend.debates.map((debate) => debate.motion)).toEqual([text.gateMotion]);
// The request that followed belongs to the new debate.
await expect.poll(() => backend.aiDebateIds).toEqual([backend.debates[0].id]);
});
}
148 changes: 148 additions & 0 deletions e2e/full-stack/first-run.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { Page } from "@playwright/test";
import { admin, type DemoUser, expect, MARK, providerCall, stackReady, test } from "./stack";

test.skip(!stackReady, "Local full stack is required (run ./.superset/db-gate.sh)");

// A brand-new account, signed up through a demo code, meets the app the way a
// student does: language chosen before sign-in, tour not yet seen, no debates.
const copy = {
en: {
demoTab: "Demo code",
demoCode: "Demo code",
signIn: "Sign in with demo code",
welcome: "Welcome to DebateLab!",
firstDebate: "Start your first debate here.",
startDebate: "Start debate",
motionField: "Motion",
library: "Library",
generate: "Generate",
finish: "Get Started!",
skip: "Skip Tour",
generateMotion: "Motion / Topic *",
generateSubmit: "Generate Debate Case",
offerTitle: "Start a debate with this motion?",
workingIn: "Working in:",
tourMotion: "This House would ban homework",
gateMotion: "This House would abolish zoos",
},
hu: {
demoTab: "Demókód",
demoCode: "Demókód",
signIn: "Bejelentkezés demókóddal",
welcome: "Üdvözlünk a DebateLab-ban!",
firstDebate: "Itt indíthatod el az első vitádat.",
startDebate: "Vita indítása",
motionField: "Tétel",
library: "Forráskönyvtár",
generate: "Generálás",
finish: "Kezdjük!",
skip: "Túra Kihagyása",
generateMotion: "Indítvány / Téma *",
generateSubmit: "Vitaeset Generálása",
offerTitle: "Indítasz egy vitát ezzel a tétellel?",
workingIn: "Munka ebben:",
tourMotion: "Ez a Ház betiltaná a házi feladatot",
gateMotion: "Ez a Ház bezárná az állatkerteket",
},
} as const;

type Locale = keyof typeof copy;

async function signUpFresh(page: Page, user: DemoUser, locale: Locale) {
// The shared fixture marks the tour as seen and picks English; a new student has neither.
await page.addInitScript((language) => {
if (sessionStorage.getItem("first-run-seeded")) return;
sessionStorage.setItem("first-run-seeded", "1");
localStorage.setItem("debate-trainer-onboarding", JSON.stringify({ state: { hasCompletedOnboarding: false, isTourRunning: false }, version: 0 }));
localStorage.setItem("debate-trainer-settings", JSON.stringify({ state: { language, outputLanguage: language, theme: "system" }, version: 0 }));
}, locale);
const text = copy[locale];
await page.goto("/login");
await page.getByRole("tab", { name: text.demoTab }).click();
await page.getByRole("textbox", { name: text.demoCode }).fill(user.code);
await page.getByRole("button", { name: text.signIn }).click();
await expect(page).toHaveURL(/\/app$/, { timeout: 20_000 });
}

async function serverDebates(user: DemoUser) {
const userId = await user.userId();
const [{ data: debates }, { data: current }] = await Promise.all([
admin.from("debates").select("id,motion").eq("user_id", userId),
admin.from("current_debates").select("debate_id").eq("user_id", userId).maybeSingle(),
]);
return { debates: debates ?? [], currentId: current?.debate_id ?? null };
}

for (const locale of ["en", "hu"] as const) {
const text = copy[locale];

test(`[${locale}] a fresh account starts its first debate from the tour`, async ({ page, user }) => {
test.setTimeout(90_000);
await signUpFresh(page, user, locale);

const tooltip = page.locator(".react-joyride__tooltip");
const next = page.locator('[data-test-id="button-primary"]');
await expect(tooltip).toContainText(text.welcome, { timeout: 15_000 });
await expect(tooltip).toContainText(text.firstDebate);
await expect(page.locator("html")).toHaveAttribute("lang", locale);

await tooltip.getByRole("button", { name: text.startDebate }).click();
const dialog = page.getByRole("dialog");
await dialog.getByLabel(text.motionField).fill(text.tourMotion);
await dialog.getByRole("button", { name: text.startDebate }).click();
await expect(dialog).toBeHidden();

await expect(tooltip).toContainText(text.library);
await next.click();
await expect(tooltip).toContainText(text.generate);
await next.click();
await page.getByRole("button", { name: text.finish }).click();
await expect(tooltip).toHaveCount(0);

const { debates, currentId } = await serverDebates(user);
expect(debates.map((debate) => debate.motion)).toEqual([text.tourMotion]);
expect(currentId).toBe(debates[0].id);
// The language picked before sign-up survives the first sign-in and is saved.
const { data: settings } = await admin.from("user_settings").select("language").eq("user_id", await user.userId()).single();
expect(settings?.language).toBe(locale);

// The first tool already works inside that debate, with no motion check.
await page.locator('main [data-tour="mode-generate"]').click();
await expect(page.getByTestId("current-debate-strip")).toContainText(`${text.workingIn} ${text.tourMotion}`);
await expect(page.getByRole("textbox", { name: text.generateMotion })).toHaveValue(text.tourMotion);
await page.getByRole("button", { name: text.generateSubmit }).click();
await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 });
await expect(page.getByRole("dialog")).toHaveCount(0);
expect((await providerCall("generate")).user).toContain(`Motion: ${text.tourMotion}`);
await expect.poll(async () => {
const { data } = await admin.from("cases").select("debate_id").eq("user_id", await user.userId());
return data?.map((row) => row.debate_id);
}).toEqual([debates[0].id]);
});

test(`[${locale}] a fresh account that skips the tour is offered a debate from its motion`, async ({ page, user }) => {
test.setTimeout(90_000);
await signUpFresh(page, user, locale);
await page.locator('[data-test-id="button-skip"]').click();
await expect(page.locator(".react-joyride__tooltip")).toHaveCount(0);

await page.locator('main [data-tour="mode-generate"]').click();
await page.getByRole("textbox", { name: text.generateMotion }).fill(text.gateMotion);
await page.getByRole("button", { name: text.generateSubmit }).click();

const offer = page.getByRole("dialog", { name: text.offerTitle });
await expect(offer).toContainText(text.gateMotion);
await offer.getByRole("button", { name: text.startDebate }).click();
await expect(offer).toBeHidden();

await expect(page.getByText(`${MARK} case thesis`)).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("current-debate-strip")).toContainText(`${text.workingIn} ${text.gateMotion}`);
const { debates, currentId } = await serverDebates(user);
expect(debates.map((debate) => debate.motion)).toEqual([text.gateMotion]);
expect(currentId).toBe(debates[0].id);
await expect.poll(async () => {
const { data } = await admin.from("cases").select("debate_id").eq("user_id", await user.userId());
return data?.map((row) => row.debate_id);
}).toEqual([debates[0].id]);
});
}
Loading
Loading