Files
thu-maic--openmaic/tests/generation/extract-interactive-elements.test.ts
wehub-resource-sync c48612c494
CI / E2E Tests (push) Has been cancelled
CI / Lint, Typecheck & Unit Tests (push) Has been cancelled
Docs Build / Build docs site (push) Has been cancelled
Publish @openmaic packages / Build, validate & publish (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:23 +08:00

251 lines
11 KiB
TypeScript

import { describe, expect, test } from 'vitest';
import { extractInteractiveElements } from '@/lib/generation/scene-generator';
describe('extractInteractiveElements', () => {
test('returns empty string on empty input', () => {
expect(extractInteractiveElements('')).toBe('');
});
test('collects real element ids so widget actions can select from them', () => {
const html = `
<main id="game-root">
<div id="score-val">0</div>
<button id="reset-btn" aria-label="Reset the game">Reset</button>
<div id="active-zone" class="pairing-rules dropzone" role="region">...</div>
<input id="angle-slider" type="range" name="angle" />
</main>
`;
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('Elements with id:');
expect(inventory).toContain('#score-val');
expect(inventory).toContain('#reset-btn');
expect(inventory).toContain('aria-label="Reset the game"');
expect(inventory).toContain('#active-zone');
expect(inventory).toContain('role=region');
expect(inventory).toContain('#angle-slider');
expect(inventory).toContain('type=range');
expect(inventory).toContain('name=angle');
expect(inventory).toContain('Notable classes:');
expect(inventory).toContain('.pairing-rules');
expect(inventory).toContain('.dropzone');
});
test('captures procedural-skill data-step-id attributes', () => {
const html = `
<li data-step-id="step-1" id="step-1-row">
<button id="step-1-control">Complete</button>
<div id="step-1-feedback"></div>
</li>
<li data-step-id="step-2" id="step-2-row"></li>
`;
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('#step-1-row');
expect(inventory).toContain('data-step-id="step-1"');
expect(inventory).toContain('#step-1-control');
expect(inventory).toContain('#step-2-row');
expect(inventory).toContain('data-step-id="step-2"');
});
test('ignores contents of <script> and <style>', () => {
const html = `
<style>
#should-not-appear { color: red; }
.fake-class { color: blue; }
</style>
<script id="widget-config" type="application/json">
{ "id": "not-a-real-id" }
</script>
<div id="real-id" class="real-class"></div>
`;
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('#real-id');
expect(inventory).toContain('.real-class');
expect(inventory).not.toContain('#should-not-appear');
expect(inventory).not.toContain('.fake-class');
expect(inventory).not.toContain('not-a-real-id');
// The <script id="widget-config"> tag itself lives inside the stripped
// block, so the widget-config id should not leak into the inventory.
expect(inventory).not.toContain('widget-config');
});
test('deduplicates repeated ids and classes', () => {
const html = `
<div id="dup" class="card"></div>
<div id="dup" class="card"></div>
<div class="card"></div>
`;
const inventory = extractInteractiveElements(html);
const dupMatches = inventory.match(/#dup/g) || [];
const cardMatches = inventory.match(/\.card\b/g) || [];
expect(dupMatches.length).toBe(1);
expect(cardMatches.length).toBe(1);
});
test('drops Tailwind/utility classes so semantic classes survive the cap', () => {
const html = `
<div id="game" class="flex items-center p-4 rounded-lg bg-white pairing-rules dna-card md:flex-row hover:bg-gray-100"></div>
<button class="btn-launch p-2 text-white"></button>
`;
const inventory = extractInteractiveElements(html);
// Semantic classes retained
expect(inventory).toContain('.pairing-rules');
expect(inventory).toContain('.dna-card');
expect(inventory).toContain('.btn-launch');
// Utility / responsive / hover classes dropped
expect(inventory).not.toContain('.flex ');
expect(inventory).not.toContain('.items-center');
expect(inventory).not.toContain('.p-4');
expect(inventory).not.toContain('.p-2');
expect(inventory).not.toContain('.rounded-lg');
expect(inventory).not.toContain('.bg-white');
expect(inventory).not.toContain('.md:flex-row');
expect(inventory).not.toContain('.hover:bg-gray-100');
expect(inventory).not.toContain('.text-white');
});
test('captures attributes after a > inside a quoted attribute value', () => {
const html = '<button id="go" aria-label="go >>" data-action="advance">Go</button>';
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('#go');
expect(inventory).toContain('aria-label="go >>"');
expect(inventory).toContain('data-action="advance"');
});
test('captures unquoted attribute values', () => {
const html = '<div id=main class=card></div><button id=go type=button>Go</button>';
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('#main');
expect(inventory).toContain('#go');
expect(inventory).toContain('type=button');
// Semantic class name preserved
expect(inventory).toContain('.card');
});
test('ignores HTML comments so commented-out markup does not forge inventory', () => {
const html = `
<!-- <button id="old-btn" class="ghost">Removed</button> -->
<button id="real-btn">Real</button>
`;
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('#real-btn');
expect(inventory).not.toContain('#old-btn');
expect(inventory).not.toContain('.ghost');
});
test('drops content after an unterminated <script open so template ids leak', () => {
// A truncated generation leaves the <script> unclosed. Anything after it
// is inside JS template strings and must not be inventoried.
const html =
'<button id="visible">Go</button>' +
'<script>document.getElementById("visible").innerHTML = `<div id="ghost-id">no</div>`;';
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('#visible');
expect(inventory).not.toContain('#ghost-id');
});
test('does not forge phantom attributes from within a quoted attribute value', () => {
// The reviewer's regression: an aria-label that contains substrings
// resembling other attributes must not lift them into the inventory. A
// per-attribute regex would fabricate `name=alpha` and `#fake` from the
// aria-label's contents; the tag-grammar-aware parser must not.
const html = '<button id="go" aria-label="try name=alpha or id=fake"></button>';
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('#go');
expect(inventory).toContain('aria-label="try name=alpha or id=fake"');
// The `#go` row must terminate at the closing quote of the aria-label —
// no ` name=...` segment lifted out of the label body.
const goLine = inventory.split('\n').find((line) => line.startsWith('#go')) || '';
expect(goLine).toMatch(/aria-label="[^"]*"$/);
// And a phantom `#fake` id row must never appear.
expect(inventory.split('\n')).not.toContain('#fake <button>');
});
test('collapses whitespace in attribute values and caps their length', () => {
const longLabel = 'a'.repeat(500);
const html =
'<button id="one" aria-label="line one\n line two line three"></button>' +
`<button id="two" aria-label="${longLabel}"></button>`;
const inventory = extractInteractiveElements(html);
// Multiline / repeated-space label is collapsed to a single line so it
// cannot forge extra inventory rows or fake prompt sections.
expect(inventory).toContain('aria-label="line one line two line three"');
// Long value is truncated so a hostile label cannot swallow the prompt.
const twoLine = inventory.split('\n').find((line) => line.includes('#two')) || '';
expect(twoLine.length).toBeLessThan(longLabel.length);
expect(twoLine).toContain('…');
});
test('keeps semantic classes that collide with utility prefixes when declared in <style>', () => {
// `.grid-cell`, `.fill-blank`, `.text-input`, `.select-btn`, `.ring-carbon`,
// etc. all look like Tailwind utilities by prefix but are the widget
// author's own hooks. When declared in the page's <style> block they must
// survive the utility filter.
const html = `
<style>
.grid-cell { padding: 4px; }
.fill-blank { border: 1px solid; }
.text-input:focus { outline: none; }
.select-btn { cursor: pointer; }
.ring-carbon { stroke: black; }
</style>
<div class="grid-cell fill-blank">
<input class="text-input" />
<button class="select-btn">Pick</button>
<span class="ring-carbon"></span>
</div>
`;
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('.grid-cell');
expect(inventory).toContain('.fill-blank');
expect(inventory).toContain('.text-input');
expect(inventory).toContain('.select-btn');
expect(inventory).toContain('.ring-carbon');
});
test('surfaces id-less elements via a Stable data attributes section', () => {
// The interactive-actions system prompt tells the model to target
// `[data-step-id="step-1"]` for procedural-skill widgets whose step rows
// typically have no id. Those elements must appear in the inventory or
// the "prefer inventory" rule silently sends the model back to convention
// guessing on exactly the widget family the conventions already served.
const html = `
<ol>
<li data-step-id="step-1">Inspect the device</li>
<li data-step-id="step-2">Confirm the reading</li>
</ol>
<button data-action="check">Check</button>
<button id="reset-btn" data-action="reset">Reset</button>
`;
const inventory = extractInteractiveElements(html);
// id-less rows land in the new section.
expect(inventory).toContain('Stable data attributes:');
expect(inventory).toContain('[data-step-id="step-1"] <li>');
expect(inventory).toContain('[data-step-id="step-2"] <li>');
expect(inventory).toContain('[data-action="check"] <button>');
// Elements that already carry an id keep their data-* as row decoration
// and MUST NOT be duplicated as a standalone Stable data-attributes row —
// the model already has a real selector for them.
expect(inventory).toContain('#reset-btn');
expect(inventory).not.toContain('[data-action="reset"] <button>');
});
test('does not emit the sentinel when only id-less data-attribute targets exist', () => {
// A procedural widget whose rows are all data-only should not fall back
// to "(no interactive elements detected)" — the extractor must surface
// its stable data selectors instead.
const html = '<li data-step-id="step-1">Only step</li>';
const inventory = extractInteractiveElements(html);
expect(inventory).toContain('[data-step-id="step-1"] <li>');
expect(inventory).not.toBe('');
});
});