From f89a218eaeab98fc12decc20ddc0488dfafab23f Mon Sep 17 00:00:00 2001 From: Tim Evans Date: Mon, 20 Jul 2026 16:44:44 +0100 Subject: [PATCH] feat: clue safety-net engine (essential-gap + solvability) --- src/clues/safety-net.js | 13 ++++++++++++ tests/clues/safety-net.test.js | 38 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 src/clues/safety-net.js create mode 100644 tests/clues/safety-net.test.js diff --git a/src/clues/safety-net.js b/src/clues/safety-net.js new file mode 100644 index 0000000..ac41b3d --- /dev/null +++ b/src/clues/safety-net.js @@ -0,0 +1,13 @@ +export function analyzeClues(clues = [], revealed = []) { + const seen = new Set(revealed); + const essential = clues.filter((c) => c.essential); + const missingEssential = essential.filter((c) => !seen.has(c.id)); + return { + total: clues.length, + revealedCount: clues.filter((c) => seen.has(c.id)).length, + essentialTotal: essential.length, + essentialRevealed: essential.length - missingEssential.length, + missingEssential, + solvable: missingEssential.length === 0, + }; +} diff --git a/tests/clues/safety-net.test.js b/tests/clues/safety-net.test.js new file mode 100644 index 0000000..286005d --- /dev/null +++ b/tests/clues/safety-net.test.js @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { analyzeClues } from '../../src/clues/safety-net.js'; + +const CLUES = [ + { id: 'c1', label: 'The door-cam still', essential: true, fallback: 'The radio names the time' }, + { id: 'c2', label: "Priya's phone", essential: true, fallback: 'The neighbour saw her leave' }, + { id: 'c3', label: 'A nice-to-have colour detail', essential: false }, +]; + +describe('analyzeClues', () => { + it('reports counts and solvability from the revealed set', () => { + const r = analyzeClues(CLUES, ['c1']); + expect(r.total).toBe(3); + expect(r.revealedCount).toBe(1); + expect(r.essentialTotal).toBe(2); + expect(r.essentialRevealed).toBe(1); + expect(r.solvable).toBe(false); + expect(r.missingEssential.map((c) => c.id)).toEqual(['c2']); + expect(r.missingEssential[0].fallback).toBe('The neighbour saw her leave'); + }); + + it('is solvable once every essential clue is revealed', () => { + const r = analyzeClues(CLUES, ['c1', 'c2']); + expect(r.solvable).toBe(true); + expect(r.missingEssential).toEqual([]); + }); + + it('treats an empty clue list as vacuously solvable', () => { + const r = analyzeClues([], []); + expect(r.solvable).toBe(true); + expect(r.essentialTotal).toBe(0); + expect(r.total).toBe(0); + }); + + it('defaults revealed to empty', () => { + expect(analyzeClues(CLUES).essentialRevealed).toBe(0); + }); +});