From 544c4385c354c22e4fe654d511967e2e2abc596d Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 09:14:16 +0000 Subject: [PATCH 1/2] feat(onboarding): start the first run with a debate The tour now opens on the Current Debate card with a Start debate button, pauses while the create dialog is open, then points at Library and Generate. It scrolls targets clear of the sticky header. With no Current Debate, a motion-bearing tool no longer shows the switch dialog. It offers to start a debate from the typed motion, or to run it once. A brand-new account kept the server's default English after its first sign-in, even when the student had picked Hungarian. A settings row the client never saved now keeps the local language and stores it. Playwright covers the first run for a fresh account in English and Hungarian, credential-free and in the full-stack gate. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/architecture.md | 2 +- docs/testing.md | 3 + e2e/first-run.spec.ts | 181 ++++++++++++++++++ e2e/full-stack/first-run.spec.ts | 148 ++++++++++++++ e2e/full-stack/stack.ts | 11 +- e2e/home-navigation.spec.ts | 11 +- .../features/debate/DebateMotionGate.tsx | 20 +- .../features/onboarding/OnboardingTour.tsx | 154 +++++++-------- src/lib/stores/settings-store.ts | 21 +- src/locales/en.json | 31 +-- src/locales/es.json | 29 +-- src/locales/hu.json | 31 +-- src/pages/HomePage.tsx | 4 +- src/test/debate/debate-gates.test.tsx | 47 +++++ src/test/stores/settings-store.test.ts | 34 ++++ 15 files changed, 553 insertions(+), 174 deletions(-) create mode 100644 e2e/first-run.spec.ts create mode 100644 e2e/full-stack/first-run.spec.ts diff --git a/docs/architecture.md b/docs/architecture.md index 176cc50f3..f84adf4a2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/testing.md b/docs/testing.md index 4e76d21bd..2dc9310bd 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 ``, 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. @@ -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); diff --git a/e2e/first-run.spec.ts b/e2e/first-run.spec.ts new file mode 100644 index 000000000..6818a2a97 --- /dev/null +++ b/e2e/first-run.spec.ts @@ -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 = []; + 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]); + }); +} diff --git a/e2e/full-stack/first-run.spec.ts b/e2e/full-stack/first-run.spec.ts new file mode 100644 index 000000000..82980586d --- /dev/null +++ b/e2e/full-stack/first-run.spec.ts @@ -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]); + }); +} diff --git a/e2e/full-stack/stack.ts b/e2e/full-stack/stack.ts index 4176368a7..d4937b7d4 100644 --- a/e2e/full-stack/stack.ts +++ b/e2e/full-stack/stack.ts @@ -97,12 +97,17 @@ export async function startDebate(page: Page, motion: string) { await expect(debateControl(page)).toHaveAttribute("aria-label", `Current debate: ${motion}`); } -/** Answers "Which debate is this for?" after submitting a motion that is not the Current Debate. */ +/** + * Answers the motion check after submitting a motion that is not the Current + * Debate: the first-debate offer when none is current, otherwise the switch choice. + */ export async function chooseForMotion(page: Page, choice: "create" | "once") { // Scope to the motion choice: the next step may open its own dialog, such as the paywall. - const dialog = page.getByRole("dialog", { name: /^(Which debate is this for\?|This is a different motion)$/ }); + const dialog = page.getByRole("dialog", { name: /^(Start a debate with this motion\?|This is a different motion)$/ }); await dialog.getByRole("button", { - name: choice === "create" ? "Create a new debate for this motion" : "Use once without saving to a debate", + name: choice === "create" + ? /^(Start debate|Create a new debate for this motion)$/ + : /^(Not now, use it once|Use once without saving to a debate)$/, }).click(); await expect(dialog).toBeHidden(); } diff --git a/e2e/home-navigation.spec.ts b/e2e/home-navigation.spec.ts index f607a0170..82aa52824 100644 --- a/e2e/home-navigation.spec.ts +++ b/e2e/home-navigation.spec.ts @@ -40,17 +40,18 @@ test('onboarding follows visible home targets when secondary navigation is close await page.goto('/app', { waitUntil: 'domcontentloaded' }); await expect(page.locator('.react-joyride__tooltip')).toBeVisible({ timeout: 15_000 }); - await page.locator('[data-test-id="button-primary"]').click(); - const tourTargets = ['generate', 'attack', 'brainstorm', 'factoids', 'ask', 'crossfire', 'sparring', 'library', 'timer', 'flowchart', 'history']; + // Current Debate first, then Library and one Prepare tool, all on Home. + const tourTargets = ['current-debate', 'mode-library', 'mode-generate']; for (const [index, key] of tourTargets.entries()) { - await expect(page.locator(`[data-tour="mode-${key}"]`)).toBeVisible(); + await expect(page.locator(`[data-tour="${key}"]`)).toBeVisible(); const nextButton = page.locator('[data-test-id="button-primary"]'); - await expect(nextButton).toHaveAccessibleName(new RegExp(`Step ${index + 2} of 14`)); + await expect(nextButton).toHaveAccessibleName(new RegExp(`Step ${index + 1} of 4`)); await nextButton.click(); } - await page.locator('[data-test-id="button-skip"]').click(); + await page.locator('[data-test-id="button-primary"]').click(); + await expect(page.locator('.react-joyride__tooltip')).toHaveCount(0); }); test('onboarding keeps its original start schedule after a delayed lazy chunk', async ({ page }) => { diff --git a/src/components/features/debate/DebateMotionGate.tsx b/src/components/features/debate/DebateMotionGate.tsx index f73fbed39..5f82b19a2 100644 --- a/src/components/features/debate/DebateMotionGate.tsx +++ b/src/components/features/debate/DebateMotionGate.tsx @@ -20,7 +20,9 @@ interface PendingChoice { /** * Before a motion-bearing tool submits, confirm which debate the work belongs * to. A motion other than the Current Debate's asks the user to switch to a - * matching debate, create a new one, or use the motion once. + * matching debate, create a new one, or use the motion once. With no Current + * Debate there is nothing to switch away from, so the user is offered a debate + * for the motion they typed instead. */ export function useDebateMotionGate(options: { disabled?: boolean } = {}): { /** Calls `proceed` right away when the target is clear, or after the user chooses. Cancelling never calls it. */ @@ -67,14 +69,16 @@ export function useDebateMotionGate(options: { disabled?: boolean } = {}): { - {current ? t('debate.mismatchTitle', 'This is a different motion') : t('debate.noDebateTitle', 'Which debate is this for?')} + {current ? t('debate.mismatchTitle', 'This is a different motion') : t('debate.offerTitle', 'Start a debate with this motion?')}
-

{pending?.motion}

- {current && ( -

{t('debate.mismatchDesc', { motion: current.motion, defaultValue: 'Your Current Debate is “{{motion}}”.' })}

- )} +

{pending?.motion}

+

+ {current + ? t('debate.mismatchDesc', { motion: current.motion, defaultValue: 'Your Current Debate is “{{motion}}”.' }) + : t('debate.offerDesc', 'A debate keeps this motion, its sources, and your results together, and every tool uses it.')} +

@@ -100,10 +104,10 @@ export function useDebateMotionGate(options: { disabled?: boolean } = {}): { return { debateId: debate.id }; })} > - {t('debate.createFromMotion', 'Create a new debate for this motion')} + {current ? t('debate.createFromMotion', 'Create a new debate for this motion') : t('debate.startDebate', 'Start debate')}
diff --git a/src/components/features/onboarding/OnboardingTour.tsx b/src/components/features/onboarding/OnboardingTour.tsx index b4ae6e2f9..894c74103 100644 --- a/src/components/features/onboarding/OnboardingTour.tsx +++ b/src/components/features/onboarding/OnboardingTour.tsx @@ -1,84 +1,49 @@ // src/components/features/onboarding/OnboardingTour.tsx -import { useEffect, useCallback, useMemo, useState } from 'react'; +import { useEffect, useCallback, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import Joyride, { CallBackProps, STATUS, Step } from 'react-joyride'; +import Joyride, { ACTIONS, CallBackProps, EVENTS, STATUS, Step } from 'react-joyride'; import { useLocation } from 'react-router-dom'; +import { Button } from '@/components/ui/button'; import { useOnboardingStore } from '@/hooks/use-onboarding'; +import { useDebateStore } from '@/lib/stores/debate-store'; export function OnboardingTour({ eligibleAt }: { eligibleAt?: number } = {}) { const { t } = useTranslation(); const { isTourRunning, hasCompletedOnboarding, setOnboardingComplete, startTour, stopTour } = useOnboardingStore(); const location = useLocation(); + const currentDebateId = useDebateStore((state) => state.currentDebateId); + const debateDialog = useDebateStore((state) => state.dialog); + const openDebateDialog = useDebateStore((state) => state.openDialog); + const [stepIndex, setStepIndex] = useState(0); + // The tour starts with the Current Debate, because every tool works inside it. const allTourSteps = useMemo(() => [ { - target: 'body', + target: '[data-tour="current-debate"]', content: ( -
+

{t('onboarding.welcome')}

- {t('onboarding.welcomeDesc')} -

-
- ), - placement: 'center', - disableBeacon: true, - }, - { - target: '[data-tour="mode-generate"], [data-tour="nav-generate"]', - content: ( -
-

{t('onboarding.generate')}

-

- {t('onboarding.generateDesc')} -

-
- ), - placement: 'bottom', - }, - { - target: '[data-tour="mode-attack"], [data-tour="nav-attack"]', - content: ( -
-

{t('onboarding.attack')}

-

- {t('onboarding.attackDesc')} -

-
- ), - placement: 'bottom', - }, - { - target: '[data-tour="mode-brainstorm"], [data-tour="nav-brainstorm"]', - content: ( -
-

{t('onboarding.brainstorm')}

-

- {t('onboarding.brainstormDesc')} + {currentDebateId ? t('onboarding.debateCurrentDesc') : t('onboarding.debateFirstDesc')}

+ {!currentDebateId && ( + // Joyride lets clicks through its highlight only after a mouse move, so touch needs this button. + + )}
), placement: 'bottom', - }, - { - target: '[data-tour="mode-factoids"], [data-tour="nav-factoids"]', - content: , - placement: 'bottom', - }, - { - target: '[data-tour="mode-ask"], [data-tour="nav-ask"]', - content: , - placement: 'bottom', - }, - { - target: '[data-tour="mode-crossfire"], [data-tour="nav-crossfire"]', - content: , - placement: 'bottom', - }, - { - target: '[data-tour="mode-sparring"], [data-tour="nav-sparring"]', - content: , - placement: 'bottom', + disableBeacon: true, + // One primary action per step: before a debate exists that is Start debate. + ...(currentDebateId ? {} : { + styles: { + buttonNext: { + backgroundColor: 'transparent', + border: '1px solid hsl(var(--border))', + color: 'hsl(var(--foreground))', + }, + }, + }), }, { target: '[data-tour="mode-library"], [data-tour="nav-library"]', @@ -86,23 +51,8 @@ export function OnboardingTour({ eligibleAt }: { eligibleAt?: number } = {}) { placement: 'bottom', }, { - target: '[data-tour="mode-timer"], [data-tour="nav-timer"]', - content: , - placement: 'bottom', - }, - { - target: '[data-tour="mode-flowchart"], [data-tour="nav-flowchart"]', - content: , - placement: 'bottom', - }, - { - target: '[data-tour="mode-history"], [data-tour="nav-history"]', - content: , - placement: 'bottom', - }, - { - target: '[data-tour="nav-settings"]', - content: , + target: '[data-tour="mode-generate"], [data-tour="nav-generate"]', + content: , placement: 'bottom', }, { @@ -117,7 +67,7 @@ export function OnboardingTour({ eligibleAt }: { eligibleAt?: number } = {}) { ), placement: 'center', }, - ], [t]); + ], [currentDebateId, openDebateDialog, t]); const resolveTourSteps = useCallback(() => { if (typeof document === 'undefined') return allTourSteps; @@ -140,6 +90,12 @@ export function OnboardingTour({ eligibleAt }: { eligibleAt?: number } = {}) { }); }, [allTourSteps]); const [tourSteps, setTourSteps] = useState(allTourSteps); + // Joyride scrolls a target to 20px from the top, which hides it under the sticky header. + const [scrollOffset, setScrollOffset] = useState(20); + const prepareTour = useCallback(() => { + setTourSteps(resolveTourSteps()); + setScrollOffset((document.querySelector('header')?.getBoundingClientRect().height ?? 0) + 24); + }, [resolveTourSteps]); // Start tour automatically for new users useEffect(() => { @@ -150,15 +106,15 @@ export function OnboardingTour({ eligibleAt }: { eligibleAt?: number } = {}) { ? 500 : Math.max(0, 500 - (performance.now() - eligibleAt)); const timer = setTimeout(() => { - setTourSteps(resolveTourSteps()); + prepareTour(); startTour(); }, autoStartDelay); return () => clearTimeout(timer); - }, [eligibleAt, hasCompletedOnboarding, location.pathname, resolveTourSteps, startTour]); + }, [eligibleAt, hasCompletedOnboarding, location.pathname, prepareTour, startTour]); useEffect(() => { - if (isTourRunning) setTourSteps(resolveTourSteps()); - }, [isTourRunning, location.pathname, resolveTourSteps]); + if (isTourRunning) prepareTour(); + }, [isTourRunning, location.pathname, prepareTour]); // Prevent broken tooltips if user navigates away from the app mid-tour useEffect(() => { @@ -167,20 +123,40 @@ export function OnboardingTour({ eligibleAt }: { eligibleAt?: number } = {}) { } }, [isTourRunning, location.pathname, stopTour]); + // Starting a debate from the first step pauses the tour while its dialog is + // open, then moves on once the new debate is current. + const debateBeforeDialog = useRef(undefined); + useEffect(() => { + if (debateDialog === 'create') { + if (debateBeforeDialog.current === undefined) debateBeforeDialog.current = currentDebateId; + return; + } + if (debateDialog !== null || debateBeforeDialog.current === undefined) return; + const started = currentDebateId !== null && currentDebateId !== debateBeforeDialog.current; + debateBeforeDialog.current = undefined; + if (started && isTourRunning) setStepIndex((index) => (index === 0 ? 1 : index)); + }, [currentDebateId, debateDialog, isTourRunning]); + const handleJoyrideCallback = useCallback((data: CallBackProps) => { - const { status } = data; + const { action, index, status, type } = data; const finishedStatuses: string[] = [STATUS.FINISHED, STATUS.SKIPPED]; if (finishedStatuses.includes(status)) { setOnboardingComplete(); + setStepIndex(0); + } else if (type === EVENTS.STEP_AFTER || type === EVENTS.TARGET_NOT_FOUND) { + setStepIndex(index + (action === ACTIONS.PREV ? -1 : 1)); } }, [setOnboardingComplete]); return ( ()( allowProModels ? DEFAULT_PRO_MODEL : DEFAULT_MODEL, ); const configsNeedMigration = !featureConfigsAreCanonical(data.feature_configs, mergedConfigs); + // The sign-up trigger provisions the row with defaults and only this client + // fills in feature_configs, so an empty object means it was never saved. + // Keep what the student already chose instead of the defaults. + const savedConfigs = asRecord(data.feature_configs); + const neverSaved = savedConfigs !== null && Object.keys(savedConfigs).length === 0; - const language = isAppLanguage(data.language) ? data.language : 'en'; - const outputLanguage = isAppLanguage(data.output_language) ? data.output_language : 'en'; - const theme = (data.theme as 'light' | 'dark' | 'system') || 'system'; + const language = neverSaved ? get().language : isAppLanguage(data.language) ? data.language : 'en'; + const outputLanguage = neverSaved ? get().outputLanguage : isAppLanguage(data.output_language) ? data.output_language : 'en'; + const theme = neverSaved ? get().theme : (data.theme as 'light' | 'dark' | 'system') || 'system'; const customSystemPrompt = data.custom_system_prompt || undefined; await applyAppLanguage(language); set({ @@ -311,7 +316,15 @@ export const useSettingsStore = create()( customSystemPrompt, wikiResearchEnabled: get().wikiResearchEnabled, }); - if (configsNeedMigration) { + if (neverSaved) { + await updateSettings({ + language, + output_language: outputLanguage, + theme, + feature_configs: mergedConfigs, + custom_system_prompt: customSystemPrompt ?? null, + }); + } else if (configsNeedMigration) { await persistMigratedFeatureConfigs(mergedConfigs); } }, diff --git a/src/locales/en.json b/src/locales/en.json index be6e4f240..41ac14dcd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1266,27 +1266,10 @@ }, "onboarding": { "welcome": "Welcome to DebateLab! 🎉", - "welcomeDesc": "Let's take a quick tour through every prep mode.", "generate": "Generate", - "generateDesc": "Create complete cases, organize arguments, and export polished prep.", - "attack": "Attack", - "attackDesc": "Stress-test arguments and find weak points before round.", - "brainstorm": "Brainstorm", - "brainstormDesc": "Explore angles, examples, and tradeoffs for a motion.", - "factoids": "Factoids", - "factoidsDesc": "Research evidence-ready facts and source leads.", - "ask": "Ask", - "askDesc": "Use the debate coach for follow-up questions and strategy.", + "generateDesc": "Build a full case for your motion. Brainstorm, Factoids, and Flowchart sit beside it under Prepare.", "library": "Library", - "libraryDesc": "Keep sources that ground AI responses in real evidence.", - "timer": "Timer", - "timerDesc": "Practice speeches with tournament timing.", - "flowchart": "Flowchart", - "flowchartDesc": "Map arguments, attacks, and rebuttals visually.", - "history": "History", - "historyDesc": "Return to saved cases, chats, and prep sessions.", - "settings": "Settings", - "settingsDesc": "Control language, theme, account, and model preferences.", + "libraryDesc": "Add sources to your Current Debate. Every tool grounds its answers in them.", "ready": "You're Ready! 🚀", "readyDesc": "Pick the mode that matches what you need for the next round.", "back": "Back", @@ -1294,7 +1277,9 @@ "getStarted": "Get Started!", "next": "Next", "nextWithProgress": "Next (Step {step} of {steps})", - "skip": "Skip Tour" + "skip": "Skip Tour", + "debateFirstDesc": "Everything in DebateLab belongs to one debate: the motion you are preparing, its sources, and your results. Start your first debate here.", + "debateCurrentDesc": "Everything in DebateLab belongs to your Current Debate: its motion, its sources, and your results. Open or change it here." }, "timer": { "title": "Debate Timer", @@ -2079,7 +2064,6 @@ "gateTitle": "Still preparing this debate?", "gateDesc": "Last activity {{when}}. Choose how to continue.", "mismatchTitle": "This is a different motion", - "noDebateTitle": "Which debate is this for?", "mismatchDesc": "Your Current Debate is “{{motion}}”.", "createFromMotion": "Create a new debate for this motion", "useOnce": "Use once without saving to a debate", @@ -2137,7 +2121,10 @@ "bp": "British Parliamentary", "policy": "Policy", "classroom": "Classroom" - } + }, + "offerTitle": "Start a debate with this motion?", + "offerDesc": "A debate keeps this motion, its sources, and your results together, and every tool uses it.", + "offerNotNow": "Not now, use it once" }, "debateContext": { "using": "Using {{list}}", diff --git a/src/locales/es.json b/src/locales/es.json index 515962493..d2b11782f 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1266,7 +1266,6 @@ }, "onboarding": { "welcome": "¡Bienvenido a DebateLab! 🎉", - "welcomeDesc": "Hagamos un recorrido rápido para ayudarte a crear tu primer caso de debate.", "ready": "¡Estás Listo! 🚀", "readyDesc": "Explora las otras funciones: Atacar argumentos, Chat con Coach e Investigar datos.", "back": "Atrás", @@ -1276,25 +1275,11 @@ "nextWithProgress": "Siguiente (Paso {step} de {steps})", "skip": "Saltar Tour", "generate": "Generar", - "generateDesc": "Crea casos completos, organiza argumentos y exporta preparación pulida.", - "attack": "Atacar", - "attackDesc": "Pon a prueba argumentos y encuentra puntos débiles antes del debate.", - "brainstorm": "Lluvia de ideas", - "brainstormDesc": "Explora enfoques, ejemplos e intercambios para una moción.", - "factoids": "Datos", - "factoidsDesc": "Investiga datos listos para evidencia y pistas de fuentes.", - "ask": "Preguntar", - "askDesc": "Usa el coach de debate para preguntas de seguimiento y estrategia.", + "generateDesc": "Construye un caso completo para tu moción. Lluvia de ideas, Datos y Diagrama están a su lado, en Preparar.", "library": "Biblioteca", - "libraryDesc": "Guarda las fuentes que fundamentan las respuestas de la IA con evidencia real.", - "timer": "Cronómetro", - "timerDesc": "Practica discursos con tiempos de torneo.", - "flowchart": "Diagrama", - "flowchartDesc": "Representa visualmente argumentos, ataques y refutaciones.", - "history": "Historial", - "historyDesc": "Vuelve a casos, chats y sesiones de preparación guardados.", - "settings": "Ajustes", - "settingsDesc": "Controla el idioma, el tema, la cuenta y las preferencias de modelo." + "libraryDesc": "Añade fuentes a tu debate actual. Todas las herramientas basan sus respuestas en ellas.", + "debateFirstDesc": "Todo en DebateLab pertenece a un debate: la moción que preparas, sus fuentes y tus resultados. Inicia aquí tu primer debate.", + "debateCurrentDesc": "Todo en DebateLab pertenece a tu debate actual: su moción, sus fuentes y tus resultados. Ábrelo o cámbialo aquí." }, "timer": { "title": "Temporizador de Debate", @@ -2079,7 +2064,6 @@ "gateTitle": "¿Sigues preparando este debate?", "gateDesc": "Última actividad {{when}}. Elige cómo continuar.", "mismatchTitle": "Esta es una moción diferente", - "noDebateTitle": "¿Para qué debate es esto?", "mismatchDesc": "Tu debate actual es «{{motion}}».", "createFromMotion": "Crear un nuevo debate para esta moción", "useOnce": "Usar una vez sin guardar en un debate", @@ -2137,7 +2121,10 @@ "bp": "Parlamentario británico", "policy": "Policy", "classroom": "Aula" - } + }, + "offerTitle": "¿Iniciar un debate con esta moción?", + "offerDesc": "Un debate mantiene juntos la moción, sus fuentes y tus resultados, y todas las herramientas lo usan.", + "offerNotNow": "Ahora no, usarla una vez" }, "debateContext": { "using": "Usando {{list}}", diff --git a/src/locales/hu.json b/src/locales/hu.json index 6bfd53361..765cea586 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1266,35 +1266,20 @@ }, "onboarding": { "welcome": "Üdvözlünk a DebateLab-ban! 🎉", - "welcomeDesc": "Tegyünk egy gyors körsétát, hogy segítsünk elkészíteni az első vitaesetedet.", "ready": "Készen Állsz! 🚀", "readyDesc": "Fedezd fel a többi funkciót: Érvek támadása, Edző chat és Tények kutatása.", "back": "Vissza", "close": "Bezárás", "getStarted": "Kezdjük!", "next": "Következő", - "nextWithProgress": "Következő ({step}. lépés / {steps})", + "nextWithProgress": "Következő ({step}/{steps})", "skip": "Túra Kihagyása", "generate": "Generálás", - "generateDesc": "Hozz létre teljes eseteket, rendezd az érveket, és exportálj kidolgozott felkészülési anyagot.", - "attack": "Támadás", - "attackDesc": "Teszteld az érveket, és találd meg a gyenge pontokat a vita előtt.", - "brainstorm": "Ötletelés", - "brainstormDesc": "Tárj fel szempontokat, példákat és kompromisszumokat egy indítványhoz.", - "factoids": "Tények", - "factoidsDesc": "Kutass bizonyítékként használható tényeket és forrásötleteket.", - "ask": "Kérdés", - "askDesc": "Használd a vitaedzőt követő kérdésekhez és stratégiához.", + "generateDesc": "Készíts teljes esetet a tételedhez. Az Ötletelés, a Tények és a Folyamatábra mellette, a Felkészülés alatt található.", "library": "Forráskönyvtár", - "libraryDesc": "Őrizd meg azokat a forrásokat, amelyek valós bizonyítékokkal támasztják alá az MI-válaszokat.", - "timer": "Időmérő", - "timerDesc": "Gyakorold a beszédeket versenyidőzítéssel.", - "flowchart": "Érvelési folyamatábra", - "flowchartDesc": "Térképezd fel vizuálisan az érveket, támadásokat és cáfolatokat.", - "history": "Előzmények", - "historyDesc": "Térj vissza a mentett esetekhez, beszélgetésekhez és felkészülési anyagokhoz.", - "settings": "Beállítások", - "settingsDesc": "Kezeld a nyelvi, téma-, fiók- és modellbeállításokat." + "libraryDesc": "Adj forrásokat az aktuális vitádhoz. Minden eszköz ezekre építi a válaszait.", + "debateFirstDesc": "A DebateLabban minden egy vitához tartozik: a tétel, amelyre készülsz, a forrásai és az eredményeid. Itt indíthatod el az első vitádat.", + "debateCurrentDesc": "A DebateLabban minden az aktuális vitádhoz tartozik: a tételéhez, a forrásaihoz és az eredményeidhez. Itt nyithatod meg vagy cserélheted le." }, "timer": { "title": "Vita Időmérő", @@ -2079,7 +2064,6 @@ "gateTitle": "Még mindig erre a vitára készülsz?", "gateDesc": "Utolsó tevékenység: {{when}}. Válaszd ki, hogyan folytatod.", "mismatchTitle": "Ez egy másik tétel", - "noDebateTitle": "Melyik vitához tartozik ez?", "mismatchDesc": "Az aktuális vitád: „{{motion}}”.", "createFromMotion": "Új vita létrehozása ehhez a tételhez", "useOnce": "Egyszeri használat vitába mentés nélkül", @@ -2137,7 +2121,10 @@ "bp": "Brit parlamenti", "policy": "Szakpolitikai", "classroom": "Tantermi" - } + }, + "offerTitle": "Indítasz egy vitát ezzel a tétellel?", + "offerDesc": "A vita együtt tartja a tételt, a forrásait és az eredményeidet, és minden eszköz ezt használja.", + "offerNotNow": "Most nem, csak egyszer használom" }, "debateContext": { "using": "Felhasználva: {{list}}", diff --git a/src/pages/HomePage.tsx b/src/pages/HomePage.tsx index 0cc317a1d..aaf5d77a2 100644 --- a/src/pages/HomePage.tsx +++ b/src/pages/HomePage.tsx @@ -112,7 +112,7 @@ function CurrentDebateSummary({ t }: { t: TFunction }) { if (!debate) { return ( - +

{t('debate.noCurrentTitle', 'No current debate')}

@@ -125,7 +125,7 @@ function CurrentDebateSummary({ t }: { t: TFunction }) { } return ( - +

{t('nav.currentDebate', 'Current debate')}

diff --git a/src/test/debate/debate-gates.test.tsx b/src/test/debate/debate-gates.test.tsx index 5dcf140d1..923172e5f 100644 --- a/src/test/debate/debate-gates.test.tsx +++ b/src/test/debate/debate-gates.test.tsx @@ -129,6 +129,53 @@ describe('motion mismatch choice', () => { expect(useDebateStore.getState().debates.map((debate) => debate.motion)).toContain('This House would ban homework'); }); + const mockCreatedDebate = (id: string, motion: string) => { + supabaseMock.from.mockImplementationOnce(() => ({ + insert: () => ({ select: () => ({ single: async () => ({ + data: { id, motion, settings: {}, instructions: '', last_activity_at: new Date().toISOString(), created_at: new Date().toISOString() }, + error: null as unknown, + }) }) }), + }) as unknown as ReturnType); + }; + + it('offers a first debate instead of the switch choice when there is no Current Debate', async () => { + mockCreatedDebate('first-debate', 'This House would abolish zoos'); + const onTarget = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(onTarget).not.toHaveBeenCalled(); + const dialog = screen.getByRole('dialog', { name: 'Start a debate with this motion?' }); + expect(dialog).toHaveTextContent('This House would abolish zoos'); + expect(dialog).not.toHaveTextContent('different motion'); + const buttons = Array.from(dialog.querySelectorAll('button')).map((button) => button.textContent).filter((text) => text !== 'Close'); + expect(buttons).toEqual(['Start debate', 'Not now, use it once']); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Start debate' })); + }); + await waitFor(() => expect(onTarget).toHaveBeenCalledWith({ debateId: 'first-debate' })); + expect(useDebateStore.getState().currentDebateId).toBe('first-debate'); + }); + + it('lets a user with no Current Debate decline the offer and run the motion once', () => { + const onTarget = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + fireEvent.click(screen.getByRole('button', { name: 'Not now, use it once' })); + expect(onTarget).toHaveBeenCalledWith({ debateId: null }); + expect(useDebateStore.getState().debates).toHaveLength(0); + }); + + it('still offers an existing debate with the same motion when none is current', async () => { + const zoos = seedCurrentDebate({ motion: 'This House would abolish zoos' }); + useDebateStore.setState({ currentDebateId: null }); + const onTarget = vi.fn(); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Submit' })); + expect(screen.getByRole('dialog', { name: 'Start a debate with this motion?' })).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Switch to this existing debate' })); + await waitFor(() => expect(onTarget).toHaveBeenCalledWith({ debateId: zoos.id })); + }); + it('does nothing when the user dismisses the choice', () => { seedCurrentDebate({ motion: 'This House would ban homework' }); const onTarget = vi.fn(); diff --git a/src/test/stores/settings-store.test.ts b/src/test/stores/settings-store.test.ts index 3931ee54c..319916b92 100644 --- a/src/test/stores/settings-store.test.ts +++ b/src/test/stores/settings-store.test.ts @@ -75,6 +75,40 @@ describe("settings-store", () => { expect(state.featureConfigs.attack.model).toBe("openai/gpt-5.6-luna"); }); + it("keeps the language and theme a new account chose before its first sign-in", async () => { + setMockAuthUser({ id: "user_1" } as any); + useSettingsStore.getState().setLanguage("hu"); + useSettingsStore.getState().setOutputLanguage("hu"); + useSettingsStore.getState().setTheme("dark"); + await new Promise((resolve) => setTimeout(resolve, 0)); + resetMockState(); + setMockAuthUser({ id: "user_1" } as any); + // The sign-up trigger provisions this row; the client has never saved it. + setMockTableResponse("user_settings", { + single: { language: "en", output_language: "en", theme: "system", custom_system_prompt: null, feature_configs: {} }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + expect(useSettingsStore.getState().language).toBe("hu"); + expect(useSettingsStore.getState().outputLanguage).toBe("hu"); + expect(i18n.language).toBe("hu"); + const settingsWrite = supabaseMock.from.mock.results.find(({ value }) => value.update.mock.calls.length)?.value; + expect(useSettingsStore.getState().theme).toBe("dark"); + expect(settingsWrite.update).toHaveBeenCalledWith(expect.objectContaining({ language: "hu", output_language: "hu", theme: "dark" })); + }); + + it("applies the server language once the account has saved settings", async () => { + useSettingsStore.getState().setLanguage("hu"); + setMockTableResponse("user_settings", { + single: { language: "es", output_language: "es", theme: "system", feature_configs: { generate: { temperature: 0.4 } } }, + }); + + await useSettingsStore.getState().hydrateFromServer(); + + expect(useSettingsStore.getState().language).toBe("es"); + }); + it("resetLocalData restores defaults", () => { const store = useSettingsStore.getState(); store.setTheme("dark"); From dc02f3b6f2e40c4b4dc4414d7f1772c098bb389e Mon Sep 17 00:00:00 2001 From: Adam Date: Thu, 1 Oct 2026 09:34:10 +0000 Subject: [PATCH 2/2] test(e2e): answer the first-debate offer in the sparring spec Co-Authored-By: Claude Opus 5.5 (1M context) --- e2e/sparring.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/e2e/sparring.spec.ts b/e2e/sparring.spec.ts index 64ff22b1d..97f0fe3cd 100644 --- a/e2e/sparring.spec.ts +++ b/e2e/sparring.spec.ts @@ -69,7 +69,7 @@ test('completes an LD sparring round through mocked opponent turns and judging', await page.getByRole('option', { name: /Lincoln-Douglas · 5 speeches/ }).click(); await page.getByRole('button', { name: 'Start round' }).click(); // No Current Debate yet: run this round once without saving it to a debate. - await page.getByRole('button', { name: 'Use once without saving to a debate' }).click(); + await page.getByRole('dialog', { name: 'Start a debate with this motion?' }).getByRole('button', { name: 'Not now, use it once' }).click(); const userSpeeches = [ ['Affirmative constructive', 'Universal access removes the direct price barrier to participation.'], @@ -113,7 +113,7 @@ test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ await page.getByLabel('Motion').fill('This House would require community service for graduation.'); await page.getByRole('button', { name: 'Start round' }).click(); // No Current Debate yet: run this round once without saving it to a debate. - await page.getByRole('button', { name: 'Use once without saving to a debate' }).click(); + await page.getByRole('dialog', { name: 'Start a debate with this motion?' }).getByRole('button', { name: 'Not now, use it once' }).click(); await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0); const introductor = page.getByRole('textbox', { name: 'Draft for Pro Introductor' });