// @vitest-environment jsdom import { describe, expect, it } from 'vitest'; import { resolveSignature, type SemaCascadeRule } from './resolver'; import { SEMA_MAP } from './sema-map'; import { SOUNDS } from './sound-names'; /** * Cascade scoping — a rule matches THE TARGET, never an ancestor of it. * * `safeMatches` used to accept `target.closest(selector)` as a match, which * turned every pack rule into an ancestor rule. It only looked harmless * because the `data-event-*` tokens are transient: a rule needs its ancestor * to be carrying the very event it names, and holds are ~240ms. * * Except when they are NOT transient. A `signal.warn + risk` resolves to * `persistence: 'untilFix'` (`holds.ts`), so a form that failed validation * keeps `data-event="signal-warn-invalid"` on itself INDEFINITELY — and from * that moment every click and every commit inside the form matched the form's * own rule and replayed the error earcon, until the next submit. The user * heard an error while fixing the error. * * Descendant scoping still works, because it is expressed in the SELECTOR and * `matches()` evaluates it against the element in its real tree — which is how * `media-player` scopes its rules over the Button and Slider morfos * (`[data-media-player] [data-button][data-event="…"]`). Nothing in the * catalogue needed `closest()`: at the time of this fix, zero pack rules were * declared without an event conjunct and zero used an `ancestor` matcher. */ describe('cascade scoping', () => { function build() { const form = document.createElement('form'); form.setAttribute('data-form', ''); // The persistent stamp of a failed validation (untilFix). form.setAttribute('data-event', 'signal-warn-invalid'); const button = document.createElement('button'); button.setAttribute('data-button', ''); form.append(button); document.body.append(form); return { form, button }; } /** The form's own rule — the one that plays the error sample. */ const formRule: SemaCascadeRule = { selector: '[data-form][data-event="signal-warn-invalid"]', sound: 'snap' }; it('does not let an ancestor rule reach a descendant signal', () => { const { button } = build(); const eff = resolveSignature( { target: button, name: 'contact-activate', family: 'contact' }, { map: SEMA_MAP, cascade: [formRule] } ); // The button's own contact must keep the contact family base — the // form's error rule is not addressed to it. expect(eff.sound?.pitch).not.toBe(SOUNDS.snap.pitch); expect(eff.sound?.pitch).toBe(SOUNDS.touch.pitch); }); it('still honours descendant scoping written in the selector', () => { const { button } = build(); const scoped: SemaCascadeRule = { selector: '[data-form] [data-button][data-event="contact-activate"]', sound: 'alert' }; button.setAttribute('data-event', 'contact-activate'); const eff = resolveSignature( { target: button, name: 'contact-activate', family: 'contact' }, { map: SEMA_MAP, cascade: [scoped] } ); expect(eff.sound?.pitch).toBe(SOUNDS.alert.pitch); }); it('still matches a rule addressed to the target itself — and the intent still lands on it', () => { const { form } = build(); const eff = resolveSignature( { target: form, name: 'signal-warn-invalid', family: 'signal', intent: 'risk' }, { map: SEMA_MAP, cascade: [formRule] } ); // The rule matched, and its NAME is what sounds. `snap` has no `.risk` // variant, so the intent is ignored — the author's degradation rule. expect(eff.sound?.pitch).toBe(SOUNDS.snap.pitch); }); });