From 39b850415719d1931cb8b34591e4d4d8c04f09f5 Mon Sep 17 00:00:00 2001 From: Dorian Date: Fri, 13 Mar 2026 00:12:49 +0000 Subject: [PATCH] test: add checkAnswer edge case tests with findings (15 cases) Documents: unicode accent stripping not supported, 3-char reverse containment false positives, first-match-not-best-match ordering, yes/true equivalence in boolean checks. Co-Authored-By: Claude Opus 4.6 --- server/src/engine/answers-edge.test.ts | 97 ++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 server/src/engine/answers-edge.test.ts diff --git a/server/src/engine/answers-edge.test.ts b/server/src/engine/answers-edge.test.ts new file mode 100644 index 0000000..7b120ad --- /dev/null +++ b/server/src/engine/answers-edge.test.ts @@ -0,0 +1,97 @@ +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) + }) +})