|
| 1 | +/** |
| 2 | + * False positive detection for UI pattern starter code. |
| 3 | + * |
| 4 | + * When the generate-starters.ts script creates skeleton code from reference |
| 5 | + * implementations, it blanks function bodies but can miss "implementation |
| 6 | + * expressions" — variable assignments containing method chains with callbacks |
| 7 | + * (e.g. `const filtered = items.filter(f => ...)`). These leaked expressions |
| 8 | + * cause behavioral tests to pass before the user writes any code. |
| 9 | + * |
| 10 | + * This test iterates every UI pattern across all 4 frameworks and flags |
| 11 | + * starters that contain implementation logic which should have been blanked. |
| 12 | + */ |
| 13 | +import { describe, expect, it } from 'vitest'; |
| 14 | +import { angularStarters } from '../frontend-drills/ui-patterns/starters/angular'; |
| 15 | +import { nativeJsStarters } from '../frontend-drills/ui-patterns/starters/native-js'; |
| 16 | +import { reactStarters } from '../frontend-drills/ui-patterns/starters/react'; |
| 17 | +import { vueStarters } from '../frontend-drills/ui-patterns/starters/vue'; |
| 18 | +import { angularTests } from '../frontend-drills/ui-patterns/tests/angular'; |
| 19 | +import type { PatternTestCase } from '../frontend-drills/ui-patterns/tests/index'; |
| 20 | +import { nativeJsTests } from '../frontend-drills/ui-patterns/tests/native-js'; |
| 21 | +import { reactTests } from '../frontend-drills/ui-patterns/tests/react'; |
| 22 | +import { vueTests } from '../frontend-drills/ui-patterns/tests/vue'; |
| 23 | + |
| 24 | +// ─── Implementation patterns that should be blanked in starters ─── |
| 25 | + |
| 26 | +/** Regexes matching array/collection method chains with arrow callbacks. */ |
| 27 | +const IMPL_METHOD_CHAIN = [ |
| 28 | + /\.\s*filter\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 29 | + /\.\s*map\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 30 | + /\.\s*reduce\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 31 | + /\.\s*find\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 32 | + /\.\s*findIndex\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 33 | + /\.\s*some\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 34 | + /\.\s*every\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 35 | + /\.\s*sort\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 36 | + /\.\s*flatMap\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 37 | + /\.\s*forEach\s*\(\s*(?:\w+|\([^)]*\))\s*=>/, |
| 38 | +]; |
| 39 | + |
| 40 | +/** Check whether a starter string looks like an actual skeleton. */ |
| 41 | +function isSkeleton(code: string): boolean { |
| 42 | + return /\/\/\s*(TODO|Step\s+\d|Your\s+code|Your\s+answer|Implement)/i.test(code); |
| 43 | +} |
| 44 | + |
| 45 | +/** |
| 46 | + * Detect implementation expressions leaked into starter code. |
| 47 | + * |
| 48 | + * Only checks lines that are: |
| 49 | + * 1. Variable assignments (`const/let/var NAME = ...`) |
| 50 | + * 2. NOT function declarations (those are handled by blankFunctionBodies) |
| 51 | + * 3. NOT hook/state calls (useState, useRef, computed, ref, etc.) |
| 52 | + * 4. NOT simple destructuring from modules |
| 53 | + * 5. Contain method chains with arrow callbacks (.filter(f =>, .map(x =>, etc.) |
| 54 | + * |
| 55 | + * Returns array of { line, lineNumber, content } for each leak found. |
| 56 | + */ |
| 57 | +function findImplementationLeaks(starterCode: string): { lineNumber: number; content: string }[] { |
| 58 | + const lines = starterCode.split('\n'); |
| 59 | + const leaks: { lineNumber: number; content: string }[] = []; |
| 60 | + |
| 61 | + // Track brace depth to skip JSX/template regions |
| 62 | + let insideReturn = false; |
| 63 | + |
| 64 | + // Track brace depth for skipped blocks (computed bodies, event handlers, etc.) |
| 65 | + // When a skipped line opens a block, skip all lines until the block closes. |
| 66 | + let skipBraceDepth = 0; |
| 67 | + |
| 68 | + for (let i = 0; i < lines.length; i++) { |
| 69 | + const line = lines[i]; |
| 70 | + const trimmed = line.trim(); |
| 71 | + |
| 72 | + // If inside a skipped block, track braces and skip until balanced |
| 73 | + if (skipBraceDepth > 0) { |
| 74 | + for (const ch of line) { |
| 75 | + if (ch === '{') skipBraceDepth++; |
| 76 | + if (ch === '}') skipBraceDepth--; |
| 77 | + } |
| 78 | + continue; |
| 79 | + } |
| 80 | + |
| 81 | + // Detect return statement (JSX starts here — skip) |
| 82 | + if (/^\s*return\s*[({]/.test(line) || /^\s*return\s*$/.test(line)) { |
| 83 | + insideReturn = true; |
| 84 | + } |
| 85 | + |
| 86 | + // Skip lines inside JSX return block |
| 87 | + if (insideReturn) continue; |
| 88 | + |
| 89 | + // Only check const/let/var assignments |
| 90 | + if (!/^(const|let|var)\s+\w+\s*=/.test(trimmed)) continue; |
| 91 | + |
| 92 | + // Skip lines that are function declarations (already blanked or intentionally kept) |
| 93 | + // Arrow function declarations: `const fn = (params) => {` or `const fn = param => {` |
| 94 | + if (/^(const|let|var)\s+\w+\s*=\s*(?:\([^)]*\)|\w+)\s*=>\s*\{/.test(trimmed)) continue; |
| 95 | + // Function expressions: `const fn = function(` |
| 96 | + if (/^(const|let|var)\s+\w+\s*=\s*function\s*\(/.test(trimmed)) continue; |
| 97 | + |
| 98 | + // Skip hook/state calls — also skip their block bodies if they open one |
| 99 | + if (/=\s*use\w+\s*\(/.test(trimmed) || /=\s*React\.use\w+\s*\(/.test(trimmed)) { |
| 100 | + for (const ch of line) { |
| 101 | + if (ch === '{') skipBraceDepth++; |
| 102 | + if (ch === '}') skipBraceDepth--; |
| 103 | + } |
| 104 | + continue; |
| 105 | + } |
| 106 | + if (/=\s*(?:ref|reactive|computed|watch)\s*\(/.test(trimmed)) { |
| 107 | + for (const ch of line) { |
| 108 | + if (ch === '{') skipBraceDepth++; |
| 109 | + if (ch === '}') skipBraceDepth--; |
| 110 | + } |
| 111 | + continue; |
| 112 | + } |
| 113 | + |
| 114 | + // Skip destructuring from modules: const { ... } = React or const [...] = useState(...) |
| 115 | + if (/^(const|let|var)\s*[[{]/.test(trimmed)) continue; |
| 116 | + |
| 117 | + // Skip TODO/blanked lines |
| 118 | + if (/\/\/\s*TODO/i.test(trimmed)) continue; |
| 119 | + |
| 120 | + // Check for implementation method chains with callbacks |
| 121 | + const hasImplMethodChain = IMPL_METHOD_CHAIN.some((rx) => rx.test(trimmed)); |
| 122 | + |
| 123 | + // Also check multi-line: if this line doesn't end with ; and continues |
| 124 | + // to the next line(s), join them and check — but stop at statement boundaries |
| 125 | + if (!hasImplMethodChain && !trimmed.endsWith(';')) { |
| 126 | + const contLines = [trimmed]; |
| 127 | + for (let k = i + 1; k < Math.min(i + 5, lines.length); k++) { |
| 128 | + contLines.push(lines[k].trim()); |
| 129 | + if (lines[k].trim().endsWith(';')) break; |
| 130 | + } |
| 131 | + const joined = contLines.join(' '); |
| 132 | + if (IMPL_METHOD_CHAIN.some((rx) => rx.test(joined))) { |
| 133 | + leaks.push({ lineNumber: i + 1, content: trimmed }); |
| 134 | + continue; |
| 135 | + } |
| 136 | + } |
| 137 | + |
| 138 | + if (hasImplMethodChain) { |
| 139 | + leaks.push({ lineNumber: i + 1, content: trimmed }); |
| 140 | + } |
| 141 | + } |
| 142 | + |
| 143 | + return leaks; |
| 144 | +} |
| 145 | + |
| 146 | +// ─── Test data ─── |
| 147 | + |
| 148 | +interface FrameworkTestData { |
| 149 | + name: string; |
| 150 | + starters: Record<string, string>; |
| 151 | + tests: Record<string, PatternTestCase[]>; |
| 152 | +} |
| 153 | + |
| 154 | +const frameworks: FrameworkTestData[] = [ |
| 155 | + { name: 'React', starters: reactStarters, tests: reactTests }, |
| 156 | + { name: 'Vue', starters: vueStarters, tests: vueTests }, |
| 157 | + { name: 'Angular', starters: angularStarters, tests: angularTests }, |
| 158 | + { name: 'Native JS', starters: nativeJsStarters, tests: nativeJsTests }, |
| 159 | +]; |
| 160 | + |
| 161 | +// ─── Tests ─── |
| 162 | + |
| 163 | +describe('UI Pattern starter code — false positive detection', () => { |
| 164 | + for (const fw of frameworks) { |
| 165 | + describe(fw.name, () => { |
| 166 | + const patternIds = Object.keys(fw.starters); |
| 167 | + |
| 168 | + it(`should have starters for all patterns with tests`, () => { |
| 169 | + const testPatternIds = Object.keys(fw.tests); |
| 170 | + const missingStarters = testPatternIds.filter((id) => !fw.starters[id]); |
| 171 | + // Not a hard failure — some patterns may not have starters yet |
| 172 | + if (missingStarters.length > 0) { |
| 173 | + console.warn( |
| 174 | + `${fw.name}: ${missingStarters.length} patterns with tests but no starter: ${missingStarters.slice(0, 5).join(', ')}...`, |
| 175 | + ); |
| 176 | + } |
| 177 | + }); |
| 178 | + |
| 179 | + for (const patternId of patternIds) { |
| 180 | + const starter = fw.starters[patternId]; |
| 181 | + const tests = fw.tests[patternId]; |
| 182 | + |
| 183 | + // Only check patterns that have both a starter AND behavioral tests |
| 184 | + if (!starter || !tests || tests.length === 0) continue; |
| 185 | + |
| 186 | + // Only check starters that pass the isSkeleton gate (others are ignored by the page) |
| 187 | + if (!isSkeleton(starter)) continue; |
| 188 | + |
| 189 | + it(`${patternId} — starter should not contain implementation expressions`, () => { |
| 190 | + const leaks = findImplementationLeaks(starter); |
| 191 | + |
| 192 | + if (leaks.length > 0) { |
| 193 | + const details = leaks.map((l) => ` line ${l.lineNumber}: ${l.content}`).join('\n'); |
| 194 | + expect.soft(leaks.length, `Implementation leaked in ${patternId}:\n${details}`).toBe(0); |
| 195 | + } |
| 196 | + }); |
| 197 | + } |
| 198 | + }); |
| 199 | + } |
| 200 | +}); |
0 commit comments