diff --git a/src/components/clue-net.js b/src/components/clue-net.js new file mode 100644 index 0000000..db6262e --- /dev/null +++ b/src/components/clue-net.js @@ -0,0 +1,55 @@ +// src/components/clue-net.js +import { analyzeClues } from '../clues/safety-net.js'; +import { escapeHtml } from '../core/escape-html.js'; + +export class ClueNet extends HTMLElement { + constructor() { + super(); + this._clues = []; + this._revealed = []; + } + connectedCallback() { this.render(); } + + set clues(v) { this._clues = Array.isArray(v) ? v : []; this.render(); } + get clues() { return this._clues; } + set revealed(v) { this._revealed = Array.isArray(v) ? v : []; this.render(); } + get revealed() { return this._revealed; } + + render() { + if (this._clues.length === 0) { + this.innerHTML = `
No clues configured for this scenario.
`; + return; + } + const a = analyzeClues(this._clues, this._revealed); + const seen = new Set(this._revealed); + const banner = a.solvable + ? 'Solvable ✓' + : `${a.missingEssential.length} essential clue${a.missingEssential.length === 1 ? '' : 's'} still needed`; + + const rows = this._clues.map((c) => { + const revealed = seen.has(c.id); + const showFallback = c.essential && !revealed && c.fallback; + return ` +
+ +
+
${escapeHtml(c.label)}${c.essential ? ' essential' : ''}${c.act ? ` ${escapeHtml(c.act)}` : ''}
+ ${showFallback ? `
Fallback: ${escapeHtml(c.fallback)}
` : ''} +
+
`; + }).join(''); + + this.innerHTML = ` +
+
${banner}
+
${rows}
+
`; + + this.querySelectorAll('[data-clue-id]').forEach((row) => { + row.querySelector('[data-role=toggle]').addEventListener('click', () => { + this.dispatchEvent(new CustomEvent('toggle-clue', { detail: { id: row.dataset.clueId }, bubbles: true })); + }); + }); + } +} +customElements.define('clue-net', ClueNet); diff --git a/tests/components/clue-net.test.js b/tests/components/clue-net.test.js new file mode 100644 index 0000000..75db287 --- /dev/null +++ b/tests/components/clue-net.test.js @@ -0,0 +1,54 @@ +// tests/components/clue-net.test.js +import { describe, it, expect, beforeEach } from 'vitest'; +import '../../src/components/clue-net.js'; + +const CLUES = [ + { id: 'c1', label: 'Door-cam still', essential: true, fallback: 'Radio names the time' }, + { id: 'c2', label: 'Colour detail', essential: false }, +]; + +describe('', () => { + let el; + beforeEach(() => { + document.body.innerHTML = ''; + el = document.createElement('clue-net'); + document.body.appendChild(el); + }); + + it('shows an empty state with no clues', () => { + expect(el.querySelector('[data-role=empty]')).not.toBe(null); + }); + + it('renders a row per clue and a not-yet-solvable banner', () => { + el.clues = CLUES; + el.revealed = []; + expect(el.querySelectorAll('[data-clue-id]').length).toBe(2); + expect(el.querySelector('[data-role=solvable]').textContent).toContain('1 essential'); + // the missing essential shows its fallback + expect(el.querySelector('[data-clue-id="c1"] .clue-fallback').textContent).toContain('Radio names the time'); + }); + + it('flips to solvable once the essential clue is revealed', () => { + el.clues = CLUES; + el.revealed = ['c1']; + expect(el.querySelector('[data-role=solvable]').textContent).toContain('Solvable'); + // revealed essential no longer shows a fallback prompt + expect(el.querySelector('[data-clue-id="c1"] .clue-fallback')).toBe(null); + }); + + it('emits toggle-clue when a reveal toggle is clicked', () => { + el.clues = CLUES; el.revealed = []; + let detail = null; + el.addEventListener('toggle-clue', (e) => { detail = e.detail; }); + el.querySelector('[data-clue-id="c1"] [data-role=toggle]').click(); + expect(detail).toEqual({ id: 'c1' }); + }); + + it('escapes author text', () => { + el.clues = [{ id: 'x', label: 'evil" ', essential: true, fallback: 'fb" ' }]; + el.revealed = []; + const row = el.querySelector('[data-clue-id="x"]'); + expect(row.innerHTML).not.toContain(''); + expect(row.querySelector('.clue-fallback').innerHTML).not.toContain(''); + }); +});