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/eidos/motion.test.ts

257 lines
10 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { describe, expect, it } from 'vitest';
import { validateEidosConfig } from './lib/config';
import { createEngineMotion } from '$motion';
import { BUILTIN_CSS_PRESETS, BUILTIN_KEYFRAMES } from './lib/motion/presets/css';
import { renderStaticCss } from './lib/render-css';
import { createThemeBaseEidosConfig } from './lib/themes/base';
import type { EidosMotionPresets } from './lib/motion/registry';
describe('eidos motion — CSS generation', () => {
const css = renderStaticCss(createThemeBaseEidosConfig());
it('emits an @keyframes for every built-in keyframe', () => {
for (const name of Object.keys(BUILTIN_KEYFRAMES)) {
expect(css).toContain(`@keyframes ${name} {`);
}
});
it('emits enter (open) + exit (closed) rules per css preset, composing keyframes', () => {
expect(css).toContain(`[data-animation-style='scale-fade'][data-state='open']`);
expect(css).toContain(`[data-animation-style='scale-fade'][data-state='closed']`);
// composed keyframes under one animation; the duration is per-component
// overridable (`--motion-duration-{enter,exit}`) with the preset token as
// the fallback. Enter holds `backwards`, exit `forwards`.
expect(css).toContain(
'scale-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards'
);
expect(css).toContain(
'fade-in var(--motion-duration-enter, var(--duration-moderate)) var(--motion-ease-enter, var(--ease-out)) backwards'
);
expect(css).toContain(
'scale-out var(--motion-duration-exit, var(--duration-fast)) var(--motion-ease-exit, var(--ease-in)) forwards'
);
// the duration + easing override custom props are registered (inherits:false)
expect(css).toContain('@property --motion-duration-enter');
expect(css).toContain('@property --motion-ease-exit');
});
it('emits the stagger choreography hook on enter rules (Material list)', () => {
// per-item index registered as a non-inheriting integer
expect(css).toContain('@property --motion-stagger-index');
expect(css).toContain("syntax: '<integer>'");
// enter rules carry the index × each delay (default 0 → no stagger)
expect(css).toContain(
'animation-delay: calc(var(--motion-stagger-index, 0) * var(--motion-stagger-each, 0ms))'
);
});
it('emits the Material transition presets (shared-axis + fade-through)', () => {
expect(css).toContain(`[data-animation-style='shared-axis-x'][data-state='open']`);
expect(css).toContain(`[data-animation-style='shared-axis-y'][data-state='open']`);
expect(css).toContain(`[data-animation-style='fade-through'][data-state='open']`);
expect(css).toContain('@keyframes scale-through');
// shared-axis composes a directional slide with a fade
expect(css).toMatch(/shared-axis-x'\]\[data-state='open'\] \{\s*animation: slide-axis-x-in/);
});
it('emits side-aware variants for slide-fade (data-side)', () => {
expect(css).toContain(
`[data-animation-style='slide-fade'][data-side='top'][data-state='open']`
);
expect(css).toContain(
`[data-animation-style='slide-fade'][data-side='bottom'][data-state='open']`
);
expect(css).toContain(
`[data-animation-style='slide-fade'][data-side='left'][data-state='open']`
);
});
it('honors reduced motion per policy (projected attr + media query)', () => {
// opacity-only preset drops transforms, keeps a fade
expect(css).toContain(
`[data-motion='reduce'] [data-animation-style='scale-fade'][data-state='open']`
);
expect(css).toContain('@media (prefers-reduced-motion: reduce)');
// instant preset kills the animation
expect(css).toContain(
`[data-motion='reduce'] [data-animation-style='collapse'][data-state='open']`
);
expect(css).toMatch(
/\[data-motion='reduce'\] \[data-animation-style='collapse'\]\[data-state='open'\] \{\s*animation: none !important;/
);
// 'none' presets (fade) get NO per-preset reduced override
expect(css).not.toContain(`[data-motion='reduce'] [data-animation-style='fade'][data-state`);
});
it('emits event-moment signatures (data-event-*) from motion.signatures', () => {
const withSig = renderStaticCss({
...createThemeBaseEidosConfig(),
motion: {
keyframes: { 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } } },
signatures: {
'announce-threat': {
family: 'signal',
intent: 'threat',
keyframes: 'fade-in',
duration: 'slow'
}
}
}
});
expect(withSig).toContain(
`[data-event-family='signal'][data-event-intent='threat'][data-event-phase='active']`
);
expect(withSig).toContain('fade-in var(--duration-slow)');
});
});
describe('eidos motion — runtime registry', () => {
const runtime = createEngineMotion(createThemeBaseEidosConfig().motion);
it('registers the built-in presets', () => {
for (const name of Object.keys(BUILTIN_CSS_PRESETS)) {
expect(runtime.has(name)).toBe(true);
expect(runtime.resolve(name)?.driver).toBe('css');
}
expect(runtime.list()).toEqual(expect.arrayContaining(Object.keys(BUILTIN_CSS_PRESETS)));
});
it('returns undefined for an unknown preset', () => {
expect(runtime.resolve('does-not-exist')).toBeUndefined();
expect(runtime.has('does-not-exist')).toBe(false);
});
it('css presets resolve enter/exit to a settled handle (declarative path)', async () => {
// CSS presets never touch the element — soma's Presence drives them.
const el = {} as HTMLElement;
await expect(runtime.enter(el, 'scale-fade').finished).resolves.toBeUndefined();
await expect(runtime.exit(el, 'fade').finished).resolves.toBeUndefined();
// an unknown name is a no-op settled handle too
await expect(runtime.enter(el, 'nope').finished).resolves.toBeUndefined();
});
it('register adds a preset', () => {
const local = createEngineMotion();
expect(local.has('fade')).toBe(false);
local.register('fade', BUILTIN_CSS_PRESETS.fade);
expect(local.resolve('fade')?.driver).toBe('css');
});
});
describe('eidos motion — validation', () => {
it('passes for the base config', () => {
expect(validateEidosConfig(createThemeBaseEidosConfig()).ok).toBe(true);
});
it('flags a css preset referencing an unknown keyframe', () => {
const report = validateEidosConfig({
...createThemeBaseEidosConfig(),
motion: {
keyframes: { 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } } },
presets: {
broken: { driver: 'css', enter: { keyframes: 'no-such-keyframe' } }
}
}
});
expect(report.ok).toBe(false);
expect(report.issues.some((issue) => issue.path.startsWith('motion.presets.broken'))).toBe(
true
);
});
it("flags an 'opacity-only' preset missing the fade keyframes", () => {
const report = validateEidosConfig({
...createThemeBaseEidosConfig(),
motion: {
keyframes: { 'slide-in': { from: { translate: '0 4px' }, to: { translate: '0 0' } } },
presets: {
slide: {
driver: 'css',
enter: { keyframes: 'slide-in' },
reduce: 'opacity-only'
}
}
}
});
expect(report.ok).toBe(false);
expect(report.issues.some((issue) => issue.message.includes('fade-in'))).toBe(true);
});
it('flags a signature referencing an unknown keyframe', () => {
const report = validateEidosConfig({
...createThemeBaseEidosConfig(),
motion: {
keyframes: { 'fade-in': { from: { opacity: '0' }, to: { opacity: '1' } } },
signatures: { bad: { family: 'commit', keyframes: 'no-such-kf' } }
}
});
expect(report.ok).toBe(false);
expect(report.issues.some((issue) => issue.path.startsWith('motion.signatures.bad'))).toBe(
true
);
});
});
describe('eidos motion — F6 token rigor', () => {
const css = renderStaticCss(createThemeBaseEidosConfig());
it('emits the extended duration scale (perceptual holds for the firma)', () => {
expect(css).toContain('--duration-slower: 400ms;');
expect(css).toContain('--duration-deliberate: 600ms;');
expect(css).toContain('--duration-emphatic: 800ms;');
expect(css).toContain('--duration-sustained: 1000ms;');
});
it('emits the emphasized easing + shared-axis (xl) + fade-through (through) tokens', () => {
expect(css).toContain('--ease-emphasized:');
expect(css).toContain('--motion-distance-xl: 30px;');
expect(css).toContain('--motion-scale-through: 0.92;');
});
it('tokenizes the firma durations — announce pulses escalate by intent severity', () => {
expect(css).toContain('animation: announce-pulse-neutral var(--duration-deliberate)');
expect(css).toContain('animation: announce-pulse-risk var(--duration-emphatic)');
expect(css).toContain('animation: announce-pulse-threat var(--duration-sustained)');
// press / commit snap onto the existing scale (sub-perceptual)
expect(css).toContain('animation: press-squeeze var(--duration-fast)');
expect(css).toContain('animation: commit-settle var(--duration-moderate)');
});
it('points the Material keyframes at the new tokens (no hardcoded 30px / 0.92)', () => {
expect(css).toContain('translate: var(--motion-distance-xl) 0');
expect(css).toContain('scale: var(--motion-scale-through)');
});
it('emits the expressive motion set (Carbon productive/expressive) as a scoped override', () => {
expect(css).toContain("[data-motion-set='expressive']");
// expressive remaps the easing curves; productive (`:root`) stays the default
expect(css).toMatch(/\[data-motion-set='expressive'\] \{[^}]*--ease-out:/);
});
it('does NOT remap expressive tokens that the set leaves to productive', () => {
// the base set only overrides default/out/in — `spring` is untouched
expect(css).not.toMatch(/\[data-motion-set='expressive'\] \{[^}]*--ease-spring:/);
});
});
describe('eidos motion — F7 typegen registry', () => {
it('declares every built-in CSS preset as a type-safe registry name', () => {
// `registered` is the closed built-in set; the `satisfies` makes each a
// `keyof EidosMotionPresets` — a TYPO fails compile (the registry's
// `(string & {})` escape is NOT in play here). The runtime check pins it to
// the ACTUAL presets, so adding a built-in without registering it fails too.
const registered = [
'fade',
'scale-fade',
'slide-fade',
'slide-full',
'collapse',
'shared-axis-x',
'shared-axis-y',
'fade-through'
] as const satisfies readonly (keyof EidosMotionPresets)[];
expect([...Object.keys(BUILTIN_CSS_PRESETS)].sort()).toEqual([...registered].sort());
});
});

Powered by TurnKey Linux.