feat: <clue-net> essential-gap tracker component
This commit is contained in:
@@ -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 = `<div class="clue-net"><div class="clue-empty" data-role="empty">No clues configured for this scenario.</div></div>`;
|
||||
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 `
|
||||
<div class="clue-row ${revealed ? 'revealed' : ''}" data-clue-id="${escapeHtml(c.id)}">
|
||||
<button class="clue-toggle" data-role="toggle">${revealed ? '●' : '○'}</button>
|
||||
<div class="clue-body">
|
||||
<div class="clue-label">${escapeHtml(c.label)}${c.essential ? ' <span class="clue-ess">essential</span>' : ''}${c.act ? ` <span class="clue-act">${escapeHtml(c.act)}</span>` : ''}</div>
|
||||
${showFallback ? `<div class="clue-fallback">Fallback: ${escapeHtml(c.fallback)}</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
this.innerHTML = `
|
||||
<div class="clue-net">
|
||||
<div class="clue-banner ${a.solvable ? 'ok' : 'warn'}" data-role="solvable">${banner}</div>
|
||||
<div class="clue-list">${rows}</div>
|
||||
</div>`;
|
||||
|
||||
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);
|
||||
@@ -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('<clue-net>', () => {
|
||||
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" <b>', essential: true, fallback: 'fb" <i>' }];
|
||||
el.revealed = [];
|
||||
const row = el.querySelector('[data-clue-id="x"]');
|
||||
expect(row.innerHTML).not.toContain('<b>');
|
||||
expect(row.querySelector('.clue-fallback').innerHTML).not.toContain('<i>');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user