/**
 * core/template.js — focused tests for the §250 (v0.5.11) `?attr=${bool}`
 * silent-failure-trap close. Surfaced by FEEDBACK-27 against v0.5.10 source.
 *
 * Pre-§250: `?disabled=${true}` registered a literal `?disabled` DOM
 * attribute with no console warning. Consumer ergonomics gap — Lit-familiar
 * developers reach for the most ergonomic syntax + ship inert bindings.
 *
 * Post-§250: scan-time `console.warn` fires + the bogus part is degraded to
 * a no-op text-node, so nothing reaches the DOM. Migration path is
 * `.attr=${bool}` (property binding) — primitives reflect properties to DOM
 * attributes automatically.
 */

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { html, stamp, repeat } from './template.js';

describe('html template — §250 (v0.5.11) ?attr=${bool} silent-failure trap', () => {
  let container;
  let warnSpy;

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);
    warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
  });

  afterEach(() => {
    container.remove();
    warnSpy.mockRestore();
  });

  it('fires console.warn at scan time when ?attr=${bool} is encountered', () => {
    const tpl = html`<div ?disabled=${true}></div>`;
    stamp(tpl, container);

    expect(warnSpy).toHaveBeenCalledTimes(1);
    const msg = warnSpy.mock.calls[0][0];
    expect(msg).toMatch(/Lit-style boolean attribute/);
    expect(msg).toMatch(/\?disabled=/);
    expect(msg).toMatch(/\.disabled=/);  // migration path mentioned
  });

  it('does NOT register a literal "?attr" attribute on the DOM node', () => {
    const tpl = html`<div ?disabled=${true} data-test="probe"></div>`;
    stamp(tpl, container);

    const node = container.querySelector('[data-test="probe"]');
    expect(node).not.toBeNull();
    expect(node.getAttribute('?disabled')).toBeNull();
    expect(node.hasAttribute('?disabled')).toBe(false);
  });

  it('does NOT set the matching DOM attribute either (no Lit-attr semantics)', () => {
    // AdiaUI intentionally does not implement `?attr=${bool}` semantics.
    // The warn + degrade-to-no-op path means truthy `?disabled` does NOT
    // add `[disabled]` to the DOM. Consumer must use `.disabled=` for that.
    const tpl = html`<div ?disabled=${true} data-test="probe"></div>`;
    stamp(tpl, container);

    const node = container.querySelector('[data-test="probe"]');
    expect(node.hasAttribute('disabled')).toBe(false);
  });

  it('fires the warn even when the bound value is falsy', () => {
    // Lit's `?attr=${false}` removes the attribute. AdiaUI's contract is
    // "this syntax is not supported, period" — warn fires regardless of
    // truthiness to maximize visibility of the authoring error.
    const tpl = html`<div ?disabled=${false}></div>`;
    stamp(tpl, container);

    expect(warnSpy).toHaveBeenCalledTimes(1);
  });

  it('does not warn on canonical .prop=${val} (no false positive)', () => {
    // Make sure the new `?`-prefix branch doesn't accidentally fire on
    // adjacent canonical syntaxes.
    const tpl = html`<div .className=${'x'}></div>`;
    stamp(tpl, container);

    expect(warnSpy).not.toHaveBeenCalled();
  });

  it('does not warn on canonical @event=${handler} (no false positive)', () => {
    const tpl = html`<div @click=${() => {}}></div>`;
    stamp(tpl, container);

    expect(warnSpy).not.toHaveBeenCalled();
  });

  it('does not warn on canonical attr=${value} (no false positive)', () => {
    const tpl = html`<div data-id=${'42'}></div>`;
    stamp(tpl, container);

    expect(warnSpy).not.toHaveBeenCalled();
  });
});

describe('html template — FB-40 (v0.5.16) apostrophe in HTML comments does NOT mis-classify subsequent placeholders', () => {
  // FEEDBACK-06 §1 P0 (long resolved) made `inTag()` comment-aware:
  // when scan() hits `<!--` outside a string context, it jumps past
  // `-->` wholesale. FB-40 (filed 2026-05-16 post-v0.5.16 cut) reported
  // crashes attributed to apostrophes in comments. This suite verifies
  // empirically that the comment-aware logic still holds — the FB-40
  // diagnosis is mistaken about the mechanism (the existing fix covers
  // the apostrophe-in-comment case).

  let container;
  let warnSpy;

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);
    warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
  });

  afterEach(() => {
    container.remove();
    warnSpy.mockRestore();
  });

  it('@event=${handler} placeholder AFTER apostrophe-bearing comment is classified as attribute-position', () => {
    let received = null;
    const handler = (e) => { received = e; };
    const tpl = html`
      <div>
        <!-- editor-shell's named slots -->
        <button data-test="probe" @click=${handler}>X</button>
      </div>
    `;
    stamp(tpl, container);
    const btn = container.querySelector('[data-test="probe"]');
    expect(btn).not.toBeNull();
    btn.click();
    expect(received).not.toBeNull();
    expect(received.type).toBe('click');
  });

  it('double-quote in HTML comment does not mis-classify subsequent attribute placeholder', () => {
    const tpl = html`
      <div>
        <!-- the "Stop Override" button below -->
        <button data-test="probe" data-id=${'42'}></button>
      </div>
    `;
    stamp(tpl, container);
    const btn = container.querySelector('[data-test="probe"]');
    expect(btn).not.toBeNull();
    expect(btn.getAttribute('data-id')).toBe('42');
  });

  it('multiple apostrophes across multiple comments still keep subsequent placeholders attribute-position', () => {
    let received = null;
    const tpl = html`
      <div>
        <!-- one's first comment -->
        <span>x</span>
        <!-- two's second comment -->
        <button data-test="probe" @click=${(e) => { received = e; }}></button>
      </div>
    `;
    stamp(tpl, container);
    container.querySelector('[data-test="probe"]').click();
    expect(received).not.toBeNull();
  });

  it('apostrophe + em-dash (U+2014) in a single comment does not break parsing', () => {
    // dts-export-drawer.ts case from FB-40's cited crashes:
    // <!-- 2026-05-14 — migrated number inputs ... -->
    let received = null;
    const tpl = html`
      <div>
        <!-- 2026-05-14 — author's notes about migrated number inputs -->
        <button data-test="probe" @click=${(e) => { received = e; }}></button>
      </div>
    `;
    stamp(tpl, container);
    container.querySelector('[data-test="probe"]').click();
    expect(received).not.toBeNull();
  });
});

describe('html template — FB-47 (v0.5.19) stamp() cache + repeat() keyed reuse', () => {
  // FEEDBACK-47 claimed `stamp()` unconditionally calls `mount()` →
  // `replaceChildren()`, defeating `repeat()` keyed reconciliation. Source
  // inspection (template.js:94-102) shows `stamp()` has a per-container
  // cache keyed on `result.strings`; `mount()` runs ONLY on cache miss
  // (first stamp on this container OR new template strings). These tests
  // verify the cache behavior empirically against the same-template
  // re-stamp + repeat()-same-key paths.

  let container;

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);
  });

  afterEach(() => {
    container.remove();
  });

  it('stamp() with same template strings reuses the mounted instance (no replaceChildren)', () => {
    // Build a template factory whose strings literal is reference-stable
    // (the tagged-template caller — closure captures `strings` once).
    const make = (value) => html`<div class="probe">${value}</div>`;

    stamp(make('first'), container);
    const firstDiv = container.querySelector('.probe');
    expect(firstDiv).not.toBeNull();
    expect(firstDiv.textContent).toBe('first');

    // Mark the existing node so we can prove it survives the re-stamp.
    firstDiv.dataset.marker = 'preserved';

    stamp(make('second'), container);
    const secondDiv = container.querySelector('.probe');
    expect(secondDiv).toBe(firstDiv);                     // ✅ same node
    expect(secondDiv.dataset.marker).toBe('preserved');   // ✅ no replaceChildren
    expect(secondDiv.textContent).toBe('second');         // ✅ value updated in-place
  });

  it('repeat() with same key preserves wrapper children across re-renders', () => {
    // FB-47's central reproduction: same-key repeat() should preserve the
    // per-item wrapper-span AND the child element stamped into it on the
    // prior render. Both the outer template AND the per-item template must
    // share strings references across renders (single tagged-template-literal
    // sites) — JS spec gives each source site its own strings array, so
    // inline-duplicating the html`` between stamp calls would force a
    // cache miss and defeat preservation. Real consumers naturally satisfy
    // this by having one render function called repeatedly.
    const items = [{ id: 'a', label: 'first' }];
    const tplFn = (item) => html`<div class="probe" data-id=${item.id}>${item.label}</div>`;
    const renderAll = (its) => html`${repeat(its, (x) => x.id, tplFn)}`;

    stamp(renderAll(items), container);
    const firstDiv = container.querySelector('.probe[data-id="a"]');
    expect(firstDiv).not.toBeNull();

    // Append a manually-added child that the framework should NOT destroy.
    const probe = document.createElement('span');
    probe.id = 'fb47-manual-child';
    probe.textContent = 'sentinel';
    firstDiv.appendChild(probe);

    // Re-stamp with the same items array (same key 'a').
    items[0] = { id: 'a', label: 'second' };
    stamp(renderAll(items), container);

    const secondDiv = container.querySelector('.probe[data-id="a"]');
    expect(secondDiv).toBe(firstDiv);                     // ✅ wrapper reused
    expect(secondDiv.querySelector('#fb47-manual-child')).not.toBeNull();
    expect(secondDiv.firstChild.nodeValue).toBe('second');  // text updated in-place
  });

  it('Array.isArray() branch in applyValue does replace children on every re-render (the actual root cause)', () => {
    // FB-47's empirical observation IS valid for `.map()` — the
    // Array.isArray() branch in applyValue() (template.js:232-244) does
    // unconditional `container.replaceChildren()` + allocates fresh wrapper
    // spans per item. Switching consumers to repeat() avoids this. This
    // test pins the current behavior so any future "make .map() smart"
    // change is intentional, not accidental. Use a single render function
    // (one tagged-template-literal site for the outer template) so the
    // observed re-creation is isolated to the .map() Array branch, not
    // a cache miss from inline-duplicated source sites.
    const items = [{ id: 'a', label: 'first' }];
    const tplFn = (item) => html`<div class="probe" data-id=${item.id}>${item.label}</div>`;
    const renderAll = (its) => html`${its.map(tplFn)}`;

    stamp(renderAll(items), container);
    const firstDiv = container.querySelector('.probe[data-id="a"]');
    expect(firstDiv).not.toBeNull();

    items[0] = { id: 'a', label: 'second' };
    stamp(renderAll(items), container);

    const secondDiv = container.querySelector('.probe[data-id="a"]');
    expect(secondDiv).not.toBe(firstDiv);                 // ✅ NEW node — .map() re-creates
    expect(secondDiv.textContent).toBe('second');
  });
});

describe('html template — FB-55 (v0.6.8) `.camelCase=${expr}` property-binding lowercase trap close', () => {
  // FEEDBACK-55: the HTML parser lowercases attribute names inside
  // <template>.innerHTML per HTML5 §13.2.5.32. Pre-fix, `.className=` arrived
  // at scan() as `.classname`, then `name.slice(1)` yielded `"classname"`,
  // and `applyValue()` wrote `p.n["classname"] = v` — an enumerable expando,
  // never invoking the camelCase property setter. Classes never applied; no
  // warn, no error.
  //
  // Fix: PROP_CASE_FIX static map (for built-in DOM camelCase props) +
  // prototype-walk fallback (for UIElement-defined custom-element props).
  // Backward-compatible: when no case-insensitive match exists, the
  // original lowercase name is preserved (genuine expando consumers
  // unaffected).
  //
  // RESPONSE-55 documents the trap end-to-end + reviewer-#A scope
  // expansion to UIElement primitives.

  let container;
  let warnSpy;

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);
    warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
  });

  afterEach(() => {
    container.remove();
    warnSpy.mockRestore();
  });

  it('.className=${expr} writes to the real className property (not lowercase expando)', () => {
    const tpl = html`<span .className=${'foo bar'}>x</span>`;
    stamp(tpl, container);
    const span = container.querySelector('span');
    expect(span.className).toBe('foo bar');              // ✅ real className set
    expect(span.getAttribute('class')).toBe('foo bar');  // ✅ reflected to DOM attr
    expect(span.classname).toBeUndefined();              // ✅ NO lowercase expando
  });

  it('.tabIndex=${n} writes to the real tabIndex property', () => {
    const tpl = html`<div .tabIndex=${3}></div>`;
    stamp(tpl, container);
    const div = container.querySelector('div');
    expect(div.tabIndex).toBe(3);
    expect(div.getAttribute('tabindex')).toBe('3');
  });

  it('.innerHTML=${str} survives unchanged (already-lowercase name)', () => {
    // .innerHTML is one of the few camelCase-looking names whose
    // canonical form is already lowercase (well, it's `innerHTML` but
    // the parser lowercases it to `innerhtml` AND PROP_CASE_FIX maps
    // back to `innerHTML`). Verify it lands on the property regardless.
    const tpl = html`<div .innerHTML=${'<span class="inner">payload</span>'}></div>`;
    stamp(tpl, container);
    const div = container.querySelector('div');
    expect(div.querySelector('.inner')?.textContent).toBe('payload');
  });

  it('.lowercaseProp=${v} on a stock element preserves expando semantics (backward compat)', () => {
    // The fallback prototype walk skips Object.prototype to avoid noisy
    // matches. A genuinely-lowercase property name with no matching
    // camelCase pair on the chain (e.g. `.myexpando`) lands on the
    // element as a lowercase expando — the documented escape hatch
    // for arbitrary data attachment. Verify no regression.
    const tpl = html`<div .myexpando=${'attached'}></div>`;
    stamp(tpl, container);
    const div = container.querySelector('div');
    expect(div.myexpando).toBe('attached');
  });

  it('.camelCaseProp=${v} on a UIElement-style custom element invokes the canonical setter', async () => {
    // Reviewer-addition #A from RESPONSE-55: UIElement defines
    // properties under camelCase keys via Object.defineProperty. The
    // pre-fix trap wrote `el['camelprop'] = v` (expando) instead of
    // invoking the setter. Verify the fix routes correctly through
    // the prototype-walk fallback.
    class FB55El extends HTMLElement {
      constructor() {
        super();
        let _camelProp = '';
        Object.defineProperty(this, 'camelProp', {
          get() { return _camelProp; },
          set(v) { _camelProp = v; this.dataset.lastSet = v; },
          configurable: true,
          enumerable: true,
        });
      }
    }
    if (!customElements.get('fb55-el')) customElements.define('fb55-el', FB55El);

    const tpl = html`<fb55-el .camelProp=${'via-setter'}></fb55-el>`;
    stamp(tpl, container);
    const el = container.querySelector('fb55-el');
    expect(el.camelProp).toBe('via-setter');             // ✅ getter returns set value
    expect(el.dataset.lastSet).toBe('via-setter');       // ✅ setter side-effect ran
    expect(el.camelprop).toBeUndefined();                // ✅ NO lowercase expando
  });

  it('.maxChroma=${n} on a UIElement-style ce reaches the camelCase property', () => {
    // Real-world surface: <color-picker max-chroma> and <color-input
    // max-chroma> declare `maxChroma` as a static property. Consumers
    // reaching for `.maxChroma=${value}` template binding must invoke
    // the setter, not write an expando.
    class FB55Numeric extends HTMLElement {
      constructor() {
        super();
        let _maxChroma = 0;
        Object.defineProperty(this, 'maxChroma', {
          get() { return _maxChroma; },
          set(v) { _maxChroma = v; },
          configurable: true, enumerable: true,
        });
      }
    }
    if (!customElements.get('fb55-num')) customElements.define('fb55-num', FB55Numeric);

    const tpl = html`<fb55-num .maxChroma=${0.42}></fb55-num>`;
    stamp(tpl, container);
    const el = container.querySelector('fb55-num');
    expect(el.maxChroma).toBe(0.42);
  });

  it('updates to .className=${expr} re-route through the setter on every value change', () => {
    // The PROP_CASE_FIX resolution is cached in parts[i].name at scan()
    // time. Subsequent update() ticks must continue to use the resolved
    // camelCase name. Verify by re-stamping with a new value.
    const make = (cls) => html`<span .className=${cls}>x</span>`;
    stamp(make('a'), container);
    let span = container.querySelector('span');
    expect(span.className).toBe('a');

    stamp(make('b'), container);
    span = container.querySelector('span');
    expect(span.className).toBe('b');                    // ✅ second update routes correctly
    expect(span.getAttribute('class')).toBe('b');
  });

  it('warning text no longer recommends .className= as the FIRST option for class= partial interp', () => {
    // FB-55 #2: pre-fix, the v0.5.5 §184 warn text put `.className=`
    // first; post-fix, `class="${expr}"` (the discoverable form) leads.
    // Both work now (FB-55 #1), but the recommended ordering matters
    // for new consumers reading the warning cold.
    const cls = 'foo';
    const tpl = html`<span class="prefix ${cls}">x</span>`;
    stamp(tpl, container);
    expect(warnSpy).toHaveBeenCalled();
    const msg = warnSpy.mock.calls[0][0];
    const classFullPos = msg.indexOf('class="${expression}"');
    const classNamePos = msg.indexOf('.className=${expression}');
    expect(classFullPos).toBeGreaterThan(-1);
    expect(classNamePos).toBeGreaterThan(-1);
    expect(classFullPos).toBeLessThan(classNamePos);     // ✅ class="" first, .className= second
  });

  it('warning text dropped the .classList= aspirational line (FB-55 #2)', () => {
    // Pre-fix the warn text mentioned `.classList=${{foo: true, bar: false}}`
    // as "if/when implemented". classList is a read-only DOMTokenList
    // accessor — no PROP_CASE_FIX could make it assignable, so the line
    // was misleading. Post-fix it's removed.
    const cls = 'foo';
    const tpl = html`<span class="prefix ${cls}">x</span>`;
    stamp(tpl, container);
    const msg = warnSpy.mock.calls[0][0];
    expect(msg).not.toMatch(/\.classList=/);
  });

  it('does not regress: canonical .style=${cssText}, .title=${str}, .value=${v} still work', () => {
    // These property names are already lowercase, so they survived
    // the parser pre-fix. Verify PROP_CASE_FIX didn't break them.
    const tpl = html`<input .title=${'tip'} .value=${'val'}>`;
    stamp(tpl, container);
    const input = container.querySelector('input');
    expect(input.title).toBe('tip');
    expect(input.value).toBe('val');
  });
});

describe('html template — FB-57 (v0.6.8) nested SVG/MathML template namespace re-routing', () => {
  // FEEDBACK-57: nested `${html`<svg-child/>`}` interpolations produce
  // HTML-namespaced elements (HTMLUnknownElement) instead of SVGElement.
  // Root cause: each nested html`` is a SEPARATE cached template, and
  // `tpl.innerHTML = m` is parsed at document level without SVG context.
  // The <span style="display:contents"> wrapper from wrap() doesn't fix
  // it because display:contents is layout-level not namespace-level;
  // SVG layout is namespace-strict → invisible-but-present output.
  //
  // Fix: namespace-aware mount() — when the container is inside an SVG
  // (or MathML) subtree, recursively re-namespace the cloned fragment
  // via createElementNS. <foreignObject> and <annotation-xml
  // encoding=text/html> revert to HTML per HTML5 §12.2.5 foreign-content
  // insertion mode.
  //
  // RESPONSE-57 documents the trap end-to-end + the implementation-site
  // choice (mount() not getTemplate(), to keep cache coherent).

  const SVG_NS = 'http://www.w3.org/2000/svg';
  const MATHML_NS = 'http://www.w3.org/1998/Math/MathML';

  let container;

  beforeEach(() => {
    container = document.createElement('div');
    document.body.appendChild(container);
  });

  afterEach(() => {
    container.remove();
  });

  it('inline <svg><circle/></svg> in a single template keeps working (regression)', () => {
    // Pre-fix this case already worked because the HTML parser sees
    // <svg> in tpl.innerHTML and enters SVG insertion mode for its
    // descendants. Post-fix it must still work — the container is
    // HTML-namespaced (<div>), so foreignContentNS() returns null,
    // re-namespacing is skipped, and the inline parser's NS survives.
    const tpl = html`<svg viewBox="0 0 10 10"><circle cx="5" cy="5" r="3"/></svg>`;
    stamp(tpl, container);
    const svg = container.querySelector('svg');
    const circle = container.querySelector('circle');
    expect(svg.namespaceURI).toBe(SVG_NS);
    expect(circle.namespaceURI).toBe(SVG_NS);
  });

  it('nested ${html`<circle/>`} inside an SVG container now lands in SVG namespace', () => {
    // The canonical fix surface. Pre-fix, the inner template's <circle>
    // arrived as HTMLUnknownElement (NS xhtml). Post-fix, the wrap()
    // span is HTML-namespaced (correct — it has display:contents) but
    // its children get re-namespaced via the wrap-span's closest('svg')
    // matching the outer SVG.
    const svgRoot = document.createElementNS(SVG_NS, 'svg');
    container.appendChild(svgRoot);
    const dots = [{ x: 1 }, { x: 2 }, { x: 3 }];
    const tpl = html`${dots.map(d => html`<circle cx=${d.x} cy="5" r="2"/>`)}`;
    stamp(tpl, svgRoot);
    const circles = svgRoot.querySelectorAll('circle');
    expect(circles.length).toBe(3);
    for (const c of circles) {
      expect(c.namespaceURI).toBe(SVG_NS);                       // ✅ SVG-NS
    }
  });

  it('a directly-stamped html`<circle/>` template into an SVG container is SVG-namespaced', () => {
    // Even without array interpolation, stamping a single SVG-content
    // template into an SVG container must re-namespace.
    const svgRoot = document.createElementNS(SVG_NS, 'svg');
    container.appendChild(svgRoot);
    const tpl = html`<circle cx="5" cy="5" r="3"/>`;
    stamp(tpl, svgRoot);
    const circle = svgRoot.querySelector('circle');
    expect(circle.namespaceURI).toBe(SVG_NS);
  });

  it('<foreignObject> children REVERT to HTML namespace (per HTML5 spec)', () => {
    // <foreignObject> is the spec-defined HTML escape hatch inside SVG.
    // Re-namespacing must stop at the foreignObject boundary; its
    // children stay HTML-namespaced.
    const svgRoot = document.createElementNS(SVG_NS, 'svg');
    container.appendChild(svgRoot);
    const tpl = html`<foreignObject><div class="html-inside-svg">html content</div></foreignObject>`;
    stamp(tpl, svgRoot);
    const fo = svgRoot.querySelector('foreignObject');
    const innerDiv = svgRoot.querySelector('.html-inside-svg');
    expect(fo.namespaceURI).toBe(SVG_NS);                        // ✅ foreignObject itself is SVG
    expect(innerDiv.namespaceURI).toBe('http://www.w3.org/1999/xhtml');  // ✅ children HTML
  });

  it('MathML <math><mi/></math> inline template stays in MathML namespace via the HTML parser', () => {
    // Like inline-SVG: the HTML parser enters MathML insertion mode for
    // <math>, so inline children arrive correctly namespaced even
    // pre-fix. Post-fix this remains true (no re-namespacing because
    // the container is HTML).
    const tpl = html`<math><mi>x</mi></math>`;
    stamp(tpl, container);
    const math = container.querySelector('math');
    const mi = container.querySelector('mi');
    // happy-dom may or may not surface MathML namespace; smoke that the
    // elements at least exist and the math element is acknowledged.
    expect(math).not.toBeNull();
    expect(mi).not.toBeNull();
    expect(math.localName).toBe('math');
  });

  it('does not re-namespace when stamping into an HTML container (no overhead in the common case)', () => {
    // Probe the discriminator: a plain <div> container should skip the
    // re-namespacing path entirely. Verify by stamping a template
    // whose children are HTML elements and confirming they stay HTML.
    const tpl = html`<p class="probe">html paragraph</p>`;
    stamp(tpl, container);
    const p = container.querySelector('p.probe');
    expect(p.namespaceURI).toBe('http://www.w3.org/1999/xhtml');
  });
});

describe('html template — FB-94/FB-95 (v0.7.x) ARIA-transparent wrapper spans', () => {
  let container;
  beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); });
  afterEach(() => container.remove());

  // The display:contents wrapper span the engine emits for interpolated parts
  // must carry role="presentation" — display:contents removes the box from
  // layout but the span still generates a generic a11y node, which breaks
  // required-owned / required-parent relationships when it sits between a
  // role-bearing parent and role-bearing children rendered via .map()/repeat()
  // (tree→treeitem = FB-94, menu→menuitem = FB-95). Verified end-to-end with
  // axe-core; these lock the substrate behaviour against regression.
  const isContentsSpan = (s) => /display:\s*contents/.test(s.getAttribute('style') || '');

  it('a `.map()` of templates wraps each item in a role="presentation" display:contents span', () => {
    stamp(html`<ul>${['a', 'b', 'c'].map((x) => html`<li>${x}</li>`)}</ul>`, container);
    const spans = [...container.querySelectorAll('span')].filter(isContentsSpan);
    expect(spans.length).toBeGreaterThan(0);
    spans.forEach((s) => expect(s.getAttribute('role')).toBe('presentation'));
  });

  it('a conditional `${cond ? html`` : null}` branch wraps in role="presentation"', () => {
    stamp(html`<div>${true ? html`<span data-x>hi</span>` : null}</div>`, container);
    const wrap = [...container.querySelectorAll('div > span')].find(isContentsSpan);
    expect(wrap).toBeTruthy();
    expect(wrap.getAttribute('role')).toBe('presentation');
  });

  it('`repeat()` keyed items wrap in role="presentation"', () => {
    stamp(html`<ul>${repeat(['a', 'b'], (x) => x, (x) => html`<li>${x}</li>`)}</ul>`, container);
    const spans = [...container.querySelectorAll('span')].filter(isContentsSpan);
    expect(spans.length).toBeGreaterThan(0);
    spans.forEach((s) => expect(s.getAttribute('role')).toBe('presentation'));
  });
});
