|
| 1 | +/** |
| 2 | + * Canned edge-function responses for specs that exercise UI states rather than |
| 3 | + * AI quality. Intercepting the network keeps the app's real request path — and |
| 4 | + * its schema validation — under test, unlike the deleted in-app mock provider |
| 5 | + * which bypassed both. |
| 6 | + */ |
| 7 | +import type { Page } from '@playwright/test'; |
| 8 | + |
| 9 | +const CORS_HEADERS = { |
| 10 | + 'access-control-allow-origin': '*', |
| 11 | + 'access-control-allow-headers': 'authorization, x-client-info, apikey, content-type', |
| 12 | + 'access-control-allow-methods': 'POST, OPTIONS', |
| 13 | + 'content-type': 'application/json', |
| 14 | +}; |
| 15 | + |
| 16 | +// The client rejects a generate response whose contention count does not match |
| 17 | +// the requested length (see expectedContentionCount in src/lib/ai/client.ts). |
| 18 | +const CONTENTION_COUNT: Record<string, number> = { brief: 2, standard: 3, comprehensive: 4 }; |
| 19 | + |
| 20 | +const CONTENTIONS = [ |
| 21 | + { |
| 22 | + id: '1', |
| 23 | + claim: 'Access improves for the people who need it most', |
| 24 | + reasoning: 'Fares are a regressive barrier concentrated on low-income riders.', |
| 25 | + evidence: 'Comparable jurisdictions report ridership gains after fare removal.', |
| 26 | + needsSource: false, |
| 27 | + impact: 'More people reach work, school, and healthcare.', |
| 28 | + }, |
| 29 | + { |
| 30 | + id: '2', |
| 31 | + claim: 'Congestion and emissions fall as mode share shifts', |
| 32 | + reasoning: 'Removing the marginal cost of a trip shifts journeys off the road.', |
| 33 | + evidence: 'Mode-share studies show measurable shifts at zero fare.', |
| 34 | + needsSource: false, |
| 35 | + impact: 'Shorter journeys and lower urban emissions.', |
| 36 | + }, |
| 37 | + { |
| 38 | + id: '3', |
| 39 | + claim: 'Fare collection overhead outweighs much of the revenue it protects', |
| 40 | + reasoning: 'Gates, inspectors, and ticketing systems consume a large share of receipts.', |
| 41 | + evidence: 'Operators report collection costs well above headline estimates.', |
| 42 | + needsSource: false, |
| 43 | + impact: 'The net funding gap is far smaller than opponents claim.', |
| 44 | + }, |
| 45 | + { |
| 46 | + id: '4', |
| 47 | + claim: 'Civic participation rises when mobility is not rationed by price', |
| 48 | + reasoning: 'Unpriced trips are taken for non-essential civic and social reasons too.', |
| 49 | + evidence: 'Ridership growth after fare removal skews to off-peak trips.', |
| 50 | + needsSource: false, |
| 51 | + impact: 'Denser civic life, not just cheaper commutes.', |
| 52 | + }, |
| 53 | +]; |
| 54 | + |
| 55 | +const generate = (motion: string, length: string) => ({ |
| 56 | + caseStructure: { |
| 57 | + thesis: `We support the motion that ${motion}`, |
| 58 | + framework: 'Net societal benefit, weighed on access and cost.', |
| 59 | + keyDefinitions: ['Public transport: publicly funded scheduled transit.'], |
| 60 | + burdenOfProof: 'We must show the policy produces a net gain in practice.', |
| 61 | + }, |
| 62 | + contentions: CONTENTIONS.slice(0, CONTENTION_COUNT[length] ?? 2), |
| 63 | + predictedOpponentCase: { |
| 64 | + likelyArguments: ['The lost farebox revenue has to come from somewhere.'], |
| 65 | + strategicWeaknesses: ['Assumes fare revenue is the only funding model.'], |
| 66 | + keyClashes: ['Who actually bears the cost.'], |
| 67 | + }, |
| 68 | + counters: [ |
| 69 | + { |
| 70 | + targetArgument: 'Funding gap', |
| 71 | + counterpoint: 'Fare collection itself carries substantial overhead.', |
| 72 | + rebuttal: 'Net cost is far below headline farebox revenue.', |
| 73 | + }, |
| 74 | + ], |
| 75 | + metadata: { side: 'pro', format: 'bp', generatedAt: new Date().toISOString() }, |
| 76 | +}); |
| 77 | + |
| 78 | +const attack = { |
| 79 | + overallAssessment: { |
| 80 | + score: 62, |
| 81 | + summary: 'The case is coherent but leaves its central warrant underdefended.', |
| 82 | + strengthAreas: ['Clear thesis', 'Explicit framework'], |
| 83 | + }, |
| 84 | + weaknesses: [ |
| 85 | + { |
| 86 | + id: 'w1', |
| 87 | + severity: 'major', |
| 88 | + type: 'evidential', |
| 89 | + description: 'The congestion claim has no comparative evidence.', |
| 90 | + whyWeak: 'Correlation between fare policy and congestion is asserted, not shown.', |
| 91 | + howOpponentAttacks: 'Name a city where ridership rose and congestion did not fall.', |
| 92 | + improvedVersion: 'Cite a before/after study with a control corridor.', |
| 93 | + }, |
| 94 | + ], |
| 95 | + fallaciesDetected: [ |
| 96 | + { name: 'Hasty generalization', location: 'Second contention', explanation: 'One city generalized to all.' }, |
| 97 | + ], |
| 98 | + crossExQuestions: [ |
| 99 | + { question: 'Which funding line replaces the farebox?', purpose: 'Force a concrete mechanism.', expectedTrap: 'A vague appeal to general taxation.' }, |
| 100 | + ], |
| 101 | + weighingAnalysis: { |
| 102 | + currentWeighing: 'Access is asserted to outweigh cost without comparison.', |
| 103 | + missingComparisons: ['Cost per additional rider versus targeted subsidy.'], |
| 104 | + improvementSuggestions: ['Weigh against the strongest alternative, not the status quo.'], |
| 105 | + }, |
| 106 | +}; |
| 107 | + |
| 108 | +const idea = { |
| 109 | + id: 'i1', |
| 110 | + title: 'Access as a precondition for participation', |
| 111 | + description: 'Mobility gates work, education, and civic life.', |
| 112 | + side: 'pro', |
| 113 | + strength: 'strong', |
| 114 | + tradeoffs: ['Requires a durable funding source.'], |
| 115 | + relatedTo: [], |
| 116 | +}; |
| 117 | + |
| 118 | +const brainstorm = { |
| 119 | + needsMoreInfo: false, |
| 120 | + topFive: [{ rank: 1, idea, whyTop: 'It reaches the widest impact with the clearest warrant.' }], |
| 121 | + nextSteps: ['Find a before/after ridership study.'], |
| 122 | + warningsAndTradeoffs: ['Do not overclaim the congestion effect.'], |
| 123 | +}; |
| 124 | + |
| 125 | +const factoids = { |
| 126 | + factoids: [ |
| 127 | + { |
| 128 | + id: 'f1', |
| 129 | + shortClaim: 'Fare removal raised ridership in Tallinn', |
| 130 | + keyFigure: '+14% ridership', |
| 131 | + context: 'Measured over the first year of the free-fare scheme.', |
| 132 | + limitations: 'A single city with unusual baseline car ownership.', |
| 133 | + citation: { title: 'Free public transport evaluation', publisher: 'Municipal transport authority', verified: 'verified' }, |
| 134 | + howToUse: 'Use as an existence proof, not as a universal rate.', |
| 135 | + relevanceScore: 80, |
| 136 | + }, |
| 137 | + ], |
| 138 | + searchSuggestions: ['fare-free transit ridership study'], |
| 139 | + warnings: [], |
| 140 | + verificationStatus: 'complete', |
| 141 | +}; |
| 142 | + |
| 143 | +const ask = (message: string) => ({ |
| 144 | + response: `Great question about ${message.slice(0, 60)} — weigh access against cost by naming who bears each.`, |
| 145 | + followUpQuestions: ['What is the strongest alternative policy?'], |
| 146 | + uncertainties: ['The congestion effect size varies widely by city.'], |
| 147 | +}); |
| 148 | + |
| 149 | +type AiRequest = { feature?: string; input?: Record<string, unknown> }; |
| 150 | + |
| 151 | +// The deleted mock provider delayed 1-2s per feature, which is what made the |
| 152 | +// loading states observable. Specs assert on those states, so keep the latency. |
| 153 | +const RESPONSE_DELAY_MS = 1_200; |
| 154 | + |
| 155 | +/** |
| 156 | + * Serves canned output for every `ai` edge-function call. Specs that need a |
| 157 | + * specific payload should add their own narrower `page.route` first. |
| 158 | + */ |
| 159 | +export async function stubAIResponses(page: Page) { |
| 160 | + await page.route('**/functions/v1/ai**', async (route) => { |
| 161 | + if (route.request().method() === 'OPTIONS') { |
| 162 | + await route.fulfill({ status: 200, headers: CORS_HEADERS, body: 'ok' }); |
| 163 | + return; |
| 164 | + } |
| 165 | + const body = (route.request().postDataJSON() ?? {}) as AiRequest; |
| 166 | + const motion = typeof body.input?.motion === 'string' ? body.input.motion : 'the motion'; |
| 167 | + const message = typeof body.input?.message === 'string' ? body.input.message : 'this'; |
| 168 | + const length = typeof body.input?.length === 'string' ? body.input.length : 'brief'; |
| 169 | + const output = { |
| 170 | + generate: () => generate(motion, length), |
| 171 | + attack: () => attack, |
| 172 | + brainstorm: () => brainstorm, |
| 173 | + factoids: () => factoids, |
| 174 | + ask: () => ask(message), |
| 175 | + }[body.feature ?? '']?.(); |
| 176 | + |
| 177 | + if (!output) { |
| 178 | + await route.fulfill({ status: 400, headers: CORS_HEADERS, body: JSON.stringify({ error: `No fixture for feature ${body.feature}` }) }); |
| 179 | + return; |
| 180 | + } |
| 181 | + await new Promise((resolve) => setTimeout(resolve, RESPONSE_DELAY_MS)); |
| 182 | + await route.fulfill({ status: 200, headers: CORS_HEADERS, body: JSON.stringify({ output, discoveredSources: [] }) }); |
| 183 | + }); |
| 184 | +} |
0 commit comments