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.
svelte-kit-vice/src/uix/sema/cascade-scope.test.ts

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);
});
});

Powered by TurnKey Linux.