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
1 change: 1 addition & 0 deletions e2e/authed-pages.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const AUTH_ROUTES = [
{ path: '/app/library', name: 'library' },
{ path: '/app/history', name: 'history' },
{ path: '/app/timer', name: 'timer' },
{ path: '/app/sparring', name: 'sparring' },
{ path: '/app/flowchart', name: 'flowchart' },
{ path: '/app/settings', name: 'settings' },
] as const;
Expand Down
141 changes: 141 additions & 0 deletions e2e/sparring.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { expect, test } from '@playwright/test';
import { setLocale, stubSupabaseSession } from './helpers/auth';

const corsHeaders = {
'access-control-allow-origin': '*',
'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type',
'access-control-allow-methods': 'POST, OPTIONS',
'content-type': 'application/json',
};

const opponentSpeech = (index: number) => ({
action: 'respond',
speech: {
content: `Opponent response ${index}: the policy needs a stronger comparative case against narrower alternatives.`,
strategy: 'Test the warrant, then weigh the alternative.',
targetedPoints: ['The comparative mechanism is still underdeveloped.'],
},
});

const scorecard = {
action: 'score',
scorecard: {
userScore: 78,
aiScore: 74,
categories: [
{ name: 'Logic', userScore: 20, aiScore: 18, feedback: 'The causal chain was clear.' },
{ name: 'Evidence', userScore: 18, aiScore: 18, feedback: 'The factual warrant needed more support.' },
{ name: 'Delivery', userScore: 19, aiScore: 19, feedback: 'Judged from organization, not vocal performance.' },
{ name: 'Strategy', userScore: 21, aiScore: 19, feedback: 'The user prioritized the central clash.' },
],
overallFeedback: 'The user won a narrow edge by weighing the main mechanism.',
keyMoments: ['The rebuttal answered the opponent’s strongest objection.'],
improvementAreas: ['Compare the alternative policy explicitly.'],
winner: 'user',
verdict: 'The user had the edge.',
},
};

test.beforeEach(async ({ page }) => {
await setLocale(page, 'en');
await stubSupabaseSession(page);
});

test('completes an LD sparring round through mocked opponent turns and judging', async ({ page }) => {
let responseNumber = 0;
const actions: string[] = [];

await page.route('**/functions/v1/ai**', async (route) => {
if (route.request().method() === 'OPTIONS') {
await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' });
return;
}

const body = route.request().postDataJSON() as { input?: { action?: string } };
const action = body.input?.action ?? 'unknown';
actions.push(action);
const output = action === 'score' ? scorecard : opponentSpeech(++responseNumber);
await route.fulfill({ status: 200, headers: corsHeaders, body: JSON.stringify({ output }) });
});

await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Spar with an opponent who listens.' })).toBeVisible();

await page.getByLabel('Motion').fill('This House would make public transport free at the point of use.');
await page.getByRole('button', { name: /Lincoln-Douglas · 5 speeches/ }).click();
await page.getByRole('button', { name: 'Start round' }).click();

const userSpeeches = [
['Affirmative constructive', 'Universal access removes the direct price barrier to participation.'],
['First affirmative rebuttal', 'Targeted discounts preserve eligibility gaps and administrative friction.'],
['Second affirmative rebuttal', 'The universal policy is more reliable because every marginal trip is covered.'],
] as const;

for (const [index, [turnLabel, content]] of userSpeeches.entries()) {
const draft = page.getByRole('textbox', { name: `Draft for ${turnLabel}` });
await expect(draft).toBeVisible();
await draft.fill(content);
await page.getByRole('button', { name: 'Submit speech' }).click();
if (turnLabel !== 'Second affirmative rebuttal') {
await expect(page.getByText(`Opponent response ${index + 1}:`)).toBeVisible();
}
}

await expect(page.getByText('Judge is weighing the round')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Here is what happened in the room.' })).toBeVisible();
await expect(page.getByText('You won the ballot')).toBeVisible();
await expect(page.getByText('The user had the edge.')).toBeVisible();
expect(actions).toEqual(['respond', 'respond', 'score']);

const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1);
expect(hasHorizontalOverflow).toBe(false);
});

test('uses the school 4-6-6-3 roles and coaching in the default round', async ({ page }) => {
const actions: string[] = [];
await page.route('**/functions/v1/ai**', async (route) => {
if (route.request().method() === 'OPTIONS') {
await route.fulfill({ status: 200, headers: corsHeaders, body: 'ok' });
return;
}
const body = route.request().postDataJSON() as { input?: { action?: string } };
actions.push(body.input?.action ?? 'unknown');
await route.fulfill({ status: 200, headers: corsHeaders, body: JSON.stringify({ output: opponentSpeech(1) }) });
});

await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' });
await page.getByLabel('Motion').fill('This House would require community service for graduation.');
await page.getByRole('button', { name: 'Start round' }).click();
await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(0);

const introductor = page.getByRole('textbox', { name: 'Draft for Pro Introductor' });
await expect(introductor).toBeVisible();
await expect(page.getByText(/Open with an exordio, define the key terms/)).toBeVisible();
await introductor.fill('Our framework is civic responsibility. We define service as supervised work that directly benefits the local community.');
await page.getByRole('button', { name: 'Submit speech' }).click();

await expect(page.getByText('Opponent response 1:')).toBeVisible();
await expect(page.getByRole('textbox', { name: 'Draft for Pro R1' })).toBeVisible();
await expect(page.getByText(/Build the core arguments with claims, warrants, support, and impacts/)).toBeVisible();
expect(actions).toEqual(['respond']);
});

test('keeps the setup usable on mobile with reduced motion', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.goto('/app/sparring', { waitUntil: 'domcontentloaded' });
await expect(page.getByRole('heading', { name: 'Spar with an opponent who listens.' })).toBeVisible();
const logo = page.locator('header img[alt="DebateLab"]');
await expect(logo).toHaveAttribute('src', '/favicon.png');
await expect.poll(() => logo.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBeGreaterThan(0);
await expect(page.getByRole('button', { name: /School 4-6-6-3 · 8 speeches/ })).toHaveAttribute('aria-pressed', 'true');
await expect(page.getByText('Pro Introductor')).toBeVisible();
await expect(page.getByText('Pro Conclusor')).toBeVisible();

const hasHorizontalOverflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1);
expect(hasHorizontalOverflow).toBe(false);

const animationNames = await page.locator('[class*="animate-"]').evaluateAll((elements) => elements
.map((element) => getComputedStyle(element).animationName)
.filter((name) => name !== 'none'));
expect(animationNames).toEqual([]);
});
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
<meta name="robots" content="index, follow, max-snippet:-1, max-image-preview:large, max-video-preview:-1" />
<meta name="language" content="English" />
<meta name="theme-color" content="#0f172a" />
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="canonical" href="https://debate.pi-stack.org/" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta property="og:type" content="website" />
Expand Down
2 changes: 1 addition & 1 deletion plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ This folder holds implementation plans for remaining DebateLab product work. Kee
- [Admin announcements](./admin-announcements.md)
- [Ask and sitewide search quality](./search-quality.md)
- [Control honesty and deterministic toggles](./control-honesty.md)
- [Debate sparring and streaming](./sparring-and-streaming.md)
- [Model cost accounting](./model-cost-accounting.md)
- [Prompt evaluation sheet (manual)](./prompt-eval-sheet.md)
- [Quality & Truth release checklist](./release-checklist.md)
Expand All @@ -19,6 +18,7 @@ This folder holds implementation plans for remaining DebateLab product work. Kee

## Done

- [Debate sparring](./sparring-and-streaming.md)
- [Bug and polish cleanup](./done/bugs.txt)
- [Admin trial gifts](./done/admin-trial-gifts.md)
- [Top users email fallback](./done/top-users-email-fallback.md)
17 changes: 15 additions & 2 deletions plans/sparring-and-streaming.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Debate Sparring Mode — Implementation Plan

**Status:** Implemented locally on 2026-08-31. Production migration and deployment remain separate release operations.

## Overview
A turn-based AI debate practice mode where the user argues one side and the AI argues the other, simulating a real debate round with timed speeches, rebuttals, and scoring.

Expand All @@ -18,7 +20,7 @@ Add a `sparring` feature handler in `supabase/functions/ai/index.ts` or a dedica

## User Flow

1. **Setup** — User picks motion, side (Pro/Con), format (Standard/LD/PF), difficulty (Easy/Medium/Hard)
1. **Setup** — User picks motion, side (Pro/Con), format (School 4-6-6-3/LD/PF), difficulty (Easy/Medium/Hard)
2. **Round Start** — Timer begins, user types their opening speech
3. **AI Response** — AI generates opponent's speech based on user's arguments
4. **Rebuttal** — User responds to AI's points, timer running
Expand Down Expand Up @@ -82,7 +84,18 @@ interface SparringScorecard {
- Save completed sessions to History (new `savedSparring` in session store)
- Export sparring transcript to Word/PDF

## Estimated Effort
## Delivered

- The school 4-6-6-3 format as the default, with explicit Introductor, R1, R2, and Conclusor duties for both sides, plus Lincoln-Douglas and Public Forum
- Deadline-based timers, pause/resume/reset, offline-safe tab persistence, retries, and keyboard submit
- Transcript-aware opponent turns, signed AI-turn integrity, atomic/idempotent judging, and a four-category scorecard
- History, settings, navigation, home, auth reset, delete-all, DOCX/PDF export, service-worker preload, and en/es/hu integration
- Unit, integration, Deno, desktop/mobile browser, reduced-motion, overflow, and live provider-contract coverage

Streaming remains tracked independently in `plans/streaming-ai.md`; it is not part of the turn-based sparring contract.

## Original Estimated Effort
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

- Setup + Round UI: 4 hours
- AI integration (prompts, edge function): 3 hours
- Scorecard + judging: 2 hours
Expand Down
Binary file added public/favicon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions public/manifest.webmanifest
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@
"theme_color": "#0f172a",
"icons": [
{
"src": "/favicon.ico",
"sizes": "48x48",
"type": "image/x-icon"
"src": "/favicon.png",
"sizes": "1024x1024",
"type": "image/png"
}
]
}
2 changes: 2 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const APP_SHELL = [
'/app/ask',
'/app/library',
'/app/timer',
'/app/sparring',
'/app/flowchart',
'/app/history',
'/app/settings',
Expand All @@ -20,6 +21,7 @@ const APP_SHELL = [
'/theme-init.js',
'/manifest.webmanifest',
'/favicon.ico',
'/favicon.png',
'/hero-visual.png',
];

Expand Down
2 changes: 2 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const SettingsPage = lazy(() => import("./pages/SettingsPage"));
const HistoryPage = lazy(() => import("./pages/HistoryPage"));
const LibraryPage = lazy(() => import("./pages/LibraryPage"));
const TimerPage = lazy(() => import("./pages/TimerPage"));
const SparringPage = lazy(() => import("./pages/SparringPage"));
const AdminPage = lazy(() => import("./pages/AdminPage"));
const SharedPage = lazy(() => import("./pages/SharedPage"));
const FlowchartPage = lazy(() => import("./pages/FlowchartPage"));
Expand Down Expand Up @@ -123,6 +124,7 @@ function AnimatedRoutes() {
<Route path="/app/history" element={<HistoryPage />} />
<Route path="/app/library" element={<LibraryPage />} />
<Route path="/app/timer" element={<TimerPage />} />
<Route path="/app/sparring" element={<SparringPage />} />
<Route path="/app/flowchart" element={<FlowchartPage />} />
<Route path="/app/settings" element={<SettingsPage />} />
<Route path="/app/admin" element={<AdminPage />} />
Expand Down
Loading
Loading