import { describe, it, expect } from 'vitest' import { checkAnswer } from './answers.js' describe('checkAnswer edge cases', () => { it('unicode answers — exact accented match works', () => { expect(checkAnswer('café', ['café'])).toBeGreaterThanOrEqual(0.9) }) // FINDING: accent-stripped matching NOT supported — "cafe" != "café" it('unicode — accent stripping not supported (known limitation)', () => { expect(checkAnswer('cafe', ['café'])).toBe(0) }) it('special characters in answers', () => { expect(checkAnswer('C++', ['C++'])).toBeGreaterThanOrEqual(0.9) expect(checkAnswer('c++', ['C++'])).toBeGreaterThanOrEqual(0.9) expect(checkAnswer('AT&T', ['AT&T'])).toBeGreaterThanOrEqual(0.9) }) it('numeric word forms vs digits', () => { expect(checkAnswer('42', ['forty-two'])).toBeGreaterThanOrEqual(0.8) expect(checkAnswer('forty-two', ['42'])).toBeGreaterThanOrEqual(0.8) expect(checkAnswer('twenty one', ['21'])).toBeGreaterThanOrEqual(0.8) expect(checkAnswer('21', ['twenty-one'])).toBeGreaterThanOrEqual(0.8) }) it('extra whitespace handling', () => { expect(checkAnswer(' Bitcoin ', ['Bitcoin'])).toBeGreaterThanOrEqual(0.9) expect(checkAnswer('Bitcoin\t\n', ['Bitcoin'])).toBeGreaterThanOrEqual(0.9) expect(checkAnswer(' ', ['Bitcoin'])).toBe(0) }) it('substring of correct answer — reverse containment', () => { // FINDING: "Bit" (3 chars) matches "Bitcoin" via reverse containment (answer contains response) // This is a known false positive for short substrings at >= 3 chars expect(checkAnswer('Bit', ['Bitcoin'])).toBe(0.8) expect(checkAnswer('Bitcoin', ['Bitcoin Core'])).toBeGreaterThanOrEqual(0.8) }) it('answer containing correct plus extra text', () => { expect(checkAnswer('The answer is Bitcoin obviously', ['Bitcoin'])).toBeGreaterThanOrEqual(0.8) expect(checkAnswer('I think it is 42 satoshis', ['42'])).toBeGreaterThanOrEqual(0.8) }) it('empty and null inputs', () => { expect(checkAnswer(null, ['Bitcoin'])).toBe(0) expect(checkAnswer('', ['Bitcoin'])).toBe(0) expect(checkAnswer('Bitcoin', [])).toBe(0) }) it('case variations', () => { expect(checkAnswer('BITCOIN', ['bitcoin'])).toBeGreaterThanOrEqual(0.9) expect(checkAnswer('bItCoIn', ['Bitcoin'])).toBeGreaterThanOrEqual(0.9) }) it('plural/singular matching', () => { expect(checkAnswer('blocks', ['block'])).toBeGreaterThanOrEqual(0.8) expect(checkAnswer('node', ['nodes'])).toBeGreaterThanOrEqual(0.8) expect(checkAnswer('halving', ['halvings'])).toBeGreaterThanOrEqual(0.8) }) it('contraction matching', () => { expect(checkAnswer("can't", ['cannot'])).toBeGreaterThanOrEqual(0.8) expect(checkAnswer("don't", ['do not'])).toBeGreaterThanOrEqual(0.8) }) it('multiple valid answers — returns max confidence across all', () => { // FINDING: checkAnswer iterates answers in order, returns first match // "Satoshi" matches "Satoshi Nakamoto" first via reverse containment (0.8) // rather than exact-matching "Satoshi" (1.0). Returns first, not best. const score = checkAnswer('Satoshi', ['Satoshi Nakamoto', 'Satoshi']) expect(score).toBeGreaterThanOrEqual(0.8) // gets 0.8 from first match expect(checkAnswer('Nakamoto', ['Satoshi Nakamoto', 'Satoshi'])).toBeGreaterThanOrEqual(0.8) }) it('adversarial input — very long string', () => { const longStr = 'a'.repeat(10000) + ' Bitcoin ' + 'b'.repeat(10000) // Should still find the answer without catastrophic backtracking const start = performance.now() checkAnswer(longStr, ['Bitcoin']) const elapsed = performance.now() - start expect(elapsed).toBeLessThan(50) // should be very fast }) it('true/false matching', () => { expect(checkAnswer('true', ['True'])).toBeGreaterThanOrEqual(0.9) // FINDING: "yes" matches "True" because true/false keyword check is broad // Treats yes/true as equivalent. Not necessarily wrong for trivia. expect(checkAnswer('yes', ['True'])).toBe(0.9) expect(checkAnswer('false', ['False'])).toBeGreaterThanOrEqual(0.9) }) it('boolean-like answers', () => { expect(checkAnswer('True', ['true'])).toBeGreaterThanOrEqual(0.9) expect(checkAnswer('FALSE', ['false'])).toBeGreaterThanOrEqual(0.9) }) }) describe('checkAnswer adversarial profiling — target <5ms per check', () => { // 5ms threshold accounts for CI variability, GC pauses, and cold caches const TARGET_MS = 5 it('2000-char response', () => { const longAnswer = 'x'.repeat(2000) const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer(longAnswer, ['Bitcoin', '42', 'Satoshi Nakamoto']) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) it('2000-char response containing the answer buried deep', () => { const longAnswer = 'z'.repeat(1900) + ' Bitcoin ' + 'z'.repeat(91) const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer(longAnswer, ['Bitcoin']) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) it('regex metacharacters in response — no backtracking', () => { const regexBomb = '(a+)+$'.repeat(200) + '.*?.*?.*?' + '['.repeat(100) const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer(regexBomb, ['42', 'Bitcoin']) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) it('regex metacharacters in accepted answer — escaped safely', () => { const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer('42', ['(a+)+$', '.*+', '[test]']) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) it('unicode heavy response — CJK, emoji, combining chars', () => { const unicode = '比特币₿🚀'.repeat(300) + ' Bitcoin ' + '漢字'.repeat(100) const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer(unicode, ['Bitcoin', '比特币']) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) it('unicode combining characters and diacritics', () => { // Zalgo text: base char + many combining marks const zalgo = 'B' + '\u0300\u0301\u0302\u0303\u0304'.repeat(50) + 'itcoin' const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer(zalgo, ['Bitcoin']) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) it('many accepted answers (20) with long response', () => { const answers = Array.from({ length: 20 }, (_, i) => `answer_variant_${i}_satoshi`) const response = 'a'.repeat(1000) + ' answer_variant_19_satoshi ' + 'b'.repeat(1000) const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer(response, answers) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) it('pathological whitespace — tabs, newlines, mixed', () => { const ws = '\t\n\r '.repeat(500) + 'Bitcoin' + ' \t\n'.repeat(500) const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer(ws, ['Bitcoin']) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) it('numeric answer in huge response — boundary regex safe', () => { // Tests the dynamic RegExp(numStr) path with number buried in text const response = 'word '.repeat(400) + '21000000' + ' word'.repeat(400) const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer(response, ['21000000']) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) it('contraction-heavy 2000-char response', () => { const contractions = "can't don't won't doesn't isn't aren't wasn't weren't hasn't haven't hadn't couldn't shouldn't wouldn't it's that's they're we're you're " const response = contractions.repeat(15) // ~2000 chars const start = performance.now() for (let i = 0; i < 100; i++) checkAnswer(response, ['cannot', 'will not', 'they are']) const avg = (performance.now() - start) / 100 expect(avg).toBeLessThan(TARGET_MS) }) })