You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
87 lines
3.5 KiB
87 lines
3.5 KiB
// @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);
|
|
});
|
|
});
|