|
44 | 44 | * each turn, signal and debrief with fixed text in the run's language, and |
45 | 45 | * answers the crossfire write locally. |
46 | 46 | * |
| 47 | + * Sparring screenshots never call a model and never save: the page answers |
| 48 | + * each opponent turn and the judge with fixed text in the run's language |
| 49 | + * (holding them open for the "opponent" and "judging" shots), and answers |
| 50 | + * the attach_to_debate call locally. The run checks the turn-start taps. The |
| 51 | + * flow button's Flowchart reads the fixed maps described below. |
| 52 | + * |
47 | 53 | * The Timer runs on Playwright's clock, so its minutes pass at once, and the |
48 | 54 | * run checks that it signalled one beep at the last minute and two at time up. |
49 | 55 | * |
@@ -819,6 +825,147 @@ async function shootCrossfireScreens(context: BrowserContext, prefix: string, la |
819 | 825 | await page.close(); |
820 | 826 | } |
821 | 827 |
|
| 828 | +// A made-up Lincoln-Douglas round as Con, in English and Hungarian, and a scorecard whose totals match its categories, as the client checks. |
| 829 | +const SPARRING_TEXT = { |
| 830 | + en: { |
| 831 | + opponent: [ |
| 832 | + 'Private cars fill the centre with fumes that children breathe on their way to school. Closing the centre to them is the fastest way to clean that air, and every city that tried it kept the ban.', |
| 833 | + 'My opponent says shops will suffer, but footfall rises when streets are calm. People who walk stop more often and stay longer than people looking for a parking space.', |
| 834 | + 'The night-shift worker is covered: permits for carers and early shifts already exist. What remains is a choice between cleaner air for everyone and convenience for a few.', |
| 835 | + ], |
| 836 | + speeches: [ |
| 837 | + 'A ban punishes the people with the fewest choices. The nurse who drives in at five a.m. has no bus at that hour, and permits only help those who know how to apply.', |
| 838 | + 'Cleaner air matters, but low-emission zones get most of the benefit without shutting anyone out. Charge the most polluting cars and leave the centre open.', |
| 839 | + ], |
| 840 | + verdict: "Your second speech answered the night-shift worker case directly. Evidence was thin: name the transit data, don't just say it exists.", |
| 841 | + feedback: ['The burden on shift workers was clear and well framed.', 'You asserted that low-emission zones work without a source.', 'Delivery read clearly, with short and confident sentences.', 'You chose the right clash and stayed on it.'], |
| 842 | + overall: 'A disciplined round. Lead with your strongest example and back each claim with one named source.', |
| 843 | + moments: ['Turning the permit system into an access problem.'], |
| 844 | + next: ['Bring one figure for how many workers start before the buses run.'], |
| 845 | + }, |
| 846 | + hu: { |
| 847 | + opponent: [ |
| 848 | + 'A magánautók kipufogógázzal töltik meg a belvárost, amelyet a gyerekek iskolába menet belélegeznek. A belváros lezárása a leggyorsabb módja a levegő megtisztításának, és minden város megtartotta a tilalmat, amely kipróbálta.', |
| 849 | + 'Az ellenfelem szerint a boltok veszítenek, de a forgalom nő, ha az utcák csendesek. A gyalogosok gyakrabban megállnak és tovább maradnak, mint akik parkolóhelyet keresnek.', |
| 850 | + 'Az éjszakás dolgozókra is gondoltunk: a gondozóknak és a korai műszakosoknak már ma is jár engedély. A választás a tisztább levegő mindenkinek és néhányak kényelme között van.', |
| 851 | + ], |
| 852 | + speeches: [ |
| 853 | + 'A tilalom azokat bünteti, akiknek a legkevesebb választásuk van. A hajnali ötkor munkába autózó ápolónak abban az órában nincs busza, az engedély pedig csak annak segít, aki tudja, hogyan kérje.', |
| 854 | + 'A tiszta levegő fontos, de az alacsony kibocsátású övezetek kizárás nélkül hozzák az előnyök nagy részét. Fizessenek a legjobban szennyező autók, a belváros maradjon nyitva.', |
| 855 | + ], |
| 856 | + verdict: 'A második beszéded közvetlenül válaszolt az éjszakás dolgozó esetére. A bizonyítékok gyengék voltak: nevezd meg a közlekedési adatokat, ne csak állítsd, hogy léteznek.', |
| 857 | + feedback: ['A műszakban dolgozók terhét világosan és jól keretezted.', 'Forrás nélkül állítottad, hogy az alacsony kibocsátású övezetek működnek.', 'Az előadás világos volt, rövid és határozott mondatokkal.', 'A megfelelő ütközési pontot választottad, és kitartottál mellette.'], |
| 858 | + overall: 'Fegyelmezett forduló. Kezdd a legerősebb példáddal, és minden állítást támassz alá egy megnevezett forrással.', |
| 859 | + moments: ['Az engedélyrendszert hozzáférési problémává fordítottad.'], |
| 860 | + next: ['Hozz egy számot arról, hány dolgozó kezd a buszok indulása előtt.'], |
| 861 | + }, |
| 862 | +} as const; |
| 863 | + |
| 864 | +const SPARRING_CATEGORIES = [['Logic', 21, 18], ['Evidence', 15, 19], ['Delivery', 22, 17], ['Strategy', 20, 17]] as const; |
| 865 | + |
| 866 | +/** Fakes Sparring's AI calls and its debate filing for this page (see the header). `hold` keeps the next answer of that kind open until it is released. */ |
| 867 | +async function fakeSparring(page: Page, language: keyof typeof SPARRING_TEXT) { |
| 868 | + const text = SPARRING_TEXT[language]; |
| 869 | + const held: Record<'respond' | 'score', (() => void) | null> = { respond: null, score: null }; |
| 870 | + const holding = { respond: false, score: false }; |
| 871 | + await page.route(`https://${STAGING_HOST}/functions/v1/ai`, async (route) => { |
| 872 | + const body = route.request().postDataJSON() as { feature?: string; input?: { action?: 'respond' | 'score'; speeches?: { speaker: string }[] } } | null; |
| 873 | + if (body?.feature !== 'sparring') return route.abort(); |
| 874 | + const action = body.input?.action ?? 'respond'; |
| 875 | + if (holding[action]) await new Promise<void>((resolve) => { held[action] = resolve; }); |
| 876 | + let output: unknown; |
| 877 | + if (action === 'respond') { |
| 878 | + const index = (body.input?.speeches ?? []).filter((speech) => speech.speaker === 'ai').length; |
| 879 | + output = { action, speech: { content: text.opponent[index % text.opponent.length], strategy: 'Press the access argument.', targetedPoints: [], integrityToken: 'screenshot-integrity-token-0000000000000000' } }; |
| 880 | + } else { |
| 881 | + const categories = SPARRING_CATEGORIES.map(([name, userScore, aiScore], index) => ({ name, userScore, aiScore, feedback: text.feedback[index] })); |
| 882 | + output = { |
| 883 | + action, |
| 884 | + scorecard: { |
| 885 | + userScore: categories.reduce((sum, item) => sum + item.userScore, 0), |
| 886 | + aiScore: categories.reduce((sum, item) => sum + item.aiScore, 0), |
| 887 | + categories, |
| 888 | + overallFeedback: text.overall, |
| 889 | + keyMoments: [...text.moments], |
| 890 | + improvementAreas: [...text.next], |
| 891 | + winner: 'user', |
| 892 | + verdict: text.verdict, |
| 893 | + }, |
| 894 | + }; |
| 895 | + } |
| 896 | + return route.fulfill({ status: 200, headers: { 'access-control-allow-origin': '*' }, json: { output } }); |
| 897 | + }); |
| 898 | + await page.route(`${REST}/rpc/attach_to_debate`, (route) => route.fulfill({ status: 204, body: '' })); |
| 899 | + return { |
| 900 | + hold: (action: 'respond' | 'score') => { holding[action] = true; }, |
| 901 | + release: (action: 'respond' | 'score') => { |
| 902 | + holding[action] = false; |
| 903 | + held[action]?.(); |
| 904 | + held[action] = null; |
| 905 | + }, |
| 906 | + }; |
| 907 | +} |
| 908 | + |
| 909 | +/** Sparring from the Practice tab: the setup, the opponent's turn and yours, judging, and the scorecard, all from fixed outputs. */ |
| 910 | +async function shootSparringScreens(context: BrowserContext, prefix: string, language: string, problems: string[]) { |
| 911 | + const text = SPARRING_TEXT[language === 'hu' ? 'hu' : 'en']; |
| 912 | + const page = await context.newPage(); |
| 913 | + watchErrors(page, problems, `${prefix} sparring`); |
| 914 | + await pinSettings(page, language); |
| 915 | + const fake = await fakeSparring(page, language === 'hu' ? 'hu' : 'en'); |
| 916 | + await fakeFlowcharts(page); |
| 917 | + await page.goto(`${ORIGIN}/practice`); |
| 918 | + await page.getByTestId('practice-tool-sparring').click({ timeout: 30_000 }); |
| 919 | + await page.getByTestId('sparring-setup').waitFor({ timeout: 15_000 }); |
| 920 | + // The motion comes from the Current Debate, so the round attaches to it without the motion question. |
| 921 | + await page.waitForFunction(() => (document.querySelector('[data-testid="sparring-motion"]') as HTMLTextAreaElement | null)?.value.trim()); |
| 922 | + await page.getByTestId('sparring-format').click(); |
| 923 | + await page.getByRole('menuitem', { name: 'Lincoln-Douglas' }).click(); |
| 924 | + await settle(page); |
| 925 | + await shoot(page, `${prefix}-sparring-setup`); |
| 926 | + |
| 927 | + fake.hold('respond'); |
| 928 | + await page.getByRole('button', { name: /^(Start round|Forduló indítása|Iniciar ronda)$/ }).click(); |
| 929 | + await page.getByRole('menuitem', { name: /^(Con|Ellene|En contra)$/ }).click(); |
| 930 | + await page.getByTestId('sparring-thinking').waitFor({ timeout: 15_000 }); |
| 931 | + await page.waitForTimeout(400); |
| 932 | + await shoot(page, `${prefix}-sparring-opponent`); |
| 933 | + fake.release('respond'); |
| 934 | + |
| 935 | + for (const [index, speech] of text.speeches.entries()) { |
| 936 | + await page.getByTestId('sparring-input').waitFor({ timeout: 15_000 }); |
| 937 | + await page.getByTestId('sparring-input').fill(speech); |
| 938 | + if (index === 0) { |
| 939 | + await page.getByTestId('sparring-input').blur(); |
| 940 | + await page.waitForTimeout(400); |
| 941 | + await shoot(page, `${prefix}-sparring-your-turn`); |
| 942 | + // The student's clock runs from the moment the turn starts. |
| 943 | + await page.getByTestId('sparring-time').filter({ hasText: /^6:5\d$/ }).waitFor({ timeout: 5_000 }); |
| 944 | + // The flow button opens the Flowchart above the round, and the round is still there on the way back. |
| 945 | + await page.getByTestId('sparring-flow').click(); |
| 946 | + await page.getByTestId('flow-card').first().waitFor({ timeout: 30_000 }); |
| 947 | + await settle(page); |
| 948 | + await shoot(page, `${prefix}-sparring-flowchart`); |
| 949 | + await page.goBack(); |
| 950 | + await page.getByTestId('sparring-input').waitFor({ timeout: 15_000 }); |
| 951 | + if ((await page.getByTestId('sparring-input').inputValue()) !== speech) problems.push(`${prefix} sparring: the speech draft was lost after the Flowchart`); |
| 952 | + } |
| 953 | + if (index === text.speeches.length - 1) fake.hold('score'); |
| 954 | + await page.getByTestId('sparring-main').click(); |
| 955 | + } |
| 956 | + await page.getByTestId('sparring-judging').waitFor({ timeout: 15_000 }); |
| 957 | + await page.waitForTimeout(400); |
| 958 | + await shoot(page, `${prefix}-sparring-judging`); |
| 959 | + fake.release('score'); |
| 960 | + |
| 961 | + await page.getByTestId('sparring-scorecard').waitFor({ timeout: 15_000 }); |
| 962 | + await page.waitForTimeout(400); |
| 963 | + await shoot(page, `${prefix}-sparring-scorecard`); |
| 964 | + const taps = await page.evaluate(() => (window as unknown as { __debatelabTurnTaps?: number }).__debatelabTurnTaps ?? 0); |
| 965 | + if (taps !== text.speeches.length) problems.push(`${prefix} sparring: expected ${text.speeches.length} turn-start taps, got ${taps}`); |
| 966 | + await page.close(); |
| 967 | +} |
| 968 | + |
822 | 969 | /** |
823 | 970 | * The Timer from the Practice tab: idle, running, and after time up. The page's |
824 | 971 | * clock is Playwright's, so minutes pass at once; the web preview records the |
@@ -1054,6 +1201,8 @@ async function main() { |
1054 | 1201 | shots += 3; |
1055 | 1202 | await shootCrossfireScreens(signedIn, prefix, language, problems); |
1056 | 1203 | shots += 3; |
| 1204 | + await shootSparringScreens(signedIn, prefix, language, problems); |
| 1205 | + shots += 6; |
1057 | 1206 | await shootFlowchart(signedIn, prefix, language, problems); |
1058 | 1207 | shots += 10; |
1059 | 1208 | const oneOffPage = await signedIn.newPage(); |
|
0 commit comments