Repository navigation
feat: add school debate sparring mode #13
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
0cf192d
feat: ship debate sparring mode
Programcode f5d72fa
feat: make school debate format first class
Programcode 8979f7b
fix: serve DebateLab logo with correct image type
Programcode 3ff9eb7
fix: address sparring review findings
Programcode 9c7892f
fix: start sparring rounds at the first turn
Programcode 603f4f0
merge: integrate Crossfire from main
Programcode b79466b
fix: address merged feature review findings
Programcode File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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([]); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.