2026-03-13 00:12:49 +00:00
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|
2026-03-13 09:46:38 +00:00
|
|
|
|
2026-04-11 19:46:37 +01:00
|
|
|
describe('checkAnswer adversarial profiling — target <5ms per check', () => {
|
|
|
|
|
// 5ms threshold accounts for CI variability, GC pauses, and cold caches
|
|
|
|
|
const TARGET_MS = 5
|
2026-03-13 09:46:38 +00:00
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
})
|
|
|
|
|
})
|