From 0161fbaa1544763e86d1ad128081d1e415ef202e Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 18 Aug 2026 23:51:30 +0300 Subject: [PATCH] =?UTF-8?q?feat(compiler):=20[#221]=20=D0=BF=D0=BE=D0=BD?= =?UTF-8?q?=D0=B8=D0=B6=D0=B0=D1=82=D1=8C=20=D1=81=D1=82=D0=B0=D1=82=D0=B8?= =?UTF-8?q?=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20NanoProps/NanoOptions,?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20opacit?= =?UTF-8?q?y?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Расширение доказанного #208: build-time lowering статических вызовов nano с любыми NanoProps/NanoOptions (не только opacity) в общий V1-артефакт. - канон кривых V1 + общий nano-артефакт через V1-парсер - общий исполнитель: opacity-путь стал адаптером; vite-адаптер на общий планировщик (третий аргумент); мульти-lowering сьют - metafile-proof: conversion math не попадает в браузерный граф - гольф исполнителя под существующие пороги (size-гейты не тронуты) Пересборка wip-серии июля на актуальный main (rebase чистый, 0 конфликтов). --- src/compiler/core.ts | 356 +++++++++++++++++++++- src/compiler/runtime/index.ts | 40 ++- src/compiler/vite/index.ts | 8 +- test/compiler-nano-lowering.test.ts | 26 +- test/compiler-nano-multi-lowering.test.ts | 246 +++++++++++++++ 5 files changed, 637 insertions(+), 39 deletions(-) create mode 100644 test/compiler-nano-multi-lowering.test.ts diff --git a/src/compiler/core.ts b/src/compiler/core.ts index 35916f9c..77b15ee0 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -109,6 +109,185 @@ export function compileNanoOpacityArtifact(opacity: number): CompiledNanoOpacity return { frame: { opacity }, durationMs, cssLinear }; } +// ─── Общий nano-артефакт: мультиканальный frame + spring-опции (#221) ──────── + +/** Статически доказанный вызов nano: канонизированный frame + опции. */ +export interface StaticNanoCall { + readonly props: Readonly>; + readonly spring?: NanoSpringRecord | undefined; + readonly delayMs?: number | undefined; + readonly staggerMs?: number | undefined; + readonly reducedMotion?: boolean | undefined; +} + +export interface NanoSpringRecord { + readonly mass: number; + readonly stiffness: number; + readonly damping: number; +} + +export interface CompiledNanoArtifact { + /** Канонизированный frame ровно по закону nano (scale, rotate→deg, прочие как есть). */ + readonly frame: Readonly>; + readonly durationMs: number; + readonly cssLinear: string; + readonly delayMs: number; + readonly staggerMs: number; + readonly reducedMotion: boolean | undefined; +} + +/** + * Канонизация frame — побуквенно закон nano/index.ts: scale и rotate + * назначаются первыми, rotate получает суффикс deg, остальные ключи копируются + * в исходном порядке. Расхождение с рантаймом здесь ломает differential-сьют. + */ +function canonicalNanoFrame(props: Readonly>): Record { + const frame: Record = {}; + if (props['scale'] !== undefined) frame['scale'] = props['scale']; + if (props['rotate'] !== undefined) frame['rotate'] = `${props['rotate'] as number}deg`; + for (const property of Object.keys(props)) { + if (property !== 'scale' && property !== 'rotate') frame[property] = props[property]!; + } + return frame; +} + +/** + * Строит доверенный артефакт общего nano-вызова через канонический V1-парсер. + * + * Каждый CSS-канал кодируется host-extension каналом [255, stringIndex] со + * строковой таблицей; числовые значения — скаляром, строковые — token'ом с + * кодеком webCssOpaque; delay — startMs трека. stagger и reducedMotion в V1 + * не выражаются (это политика исполнителя на элемент), поэтому проверяются + * литеральной валидацией и не входят в программу. + * + * Tween-форма (duration/ease) сюда не попадает вовсе: кривые V1 — только + * кусочно-линейные сэмплы, нативная easing-строка непредставима без расширения + * versioned-контракта. До этого расширения tween остаётся runtime-вызовом. + */ +export function compileNanoArtifact(call: StaticNanoCall): CompiledNanoArtifact { + const frame = canonicalNanoFrame(call.props); + const channels = Object.keys(frame); + if (channels.length === 0) { + throw new Error('lab-motion compiler: пустой frame непонижаем'); + } + for (const [property, value] of Object.entries(frame)) { + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new Error(`lab-motion compiler: значение ${property} обязано быть конечным`); + } + } + // springLinear — SSOT физики: невалидная статическая пружина падает здесь же + // с его собственной причиной (ошибка сборки, не silent fallback). + const [durationMs, cssLinear] = springLinear(call.spring); + const points = linearPoints(cssLinear); + const count = points.length - 1; + const samples: number[] = [1]; + for (let index = 0; index <= count; index++) samples.push(index / count, points[index]!); + const curve = samples as unknown as MotionProgramCurveV1; + + const delayMs = call.delayMs ?? 0; + const staggerMs = call.staggerMs ?? 0; + if (!Number.isFinite(delayMs) || !Number.isFinite(staggerMs)) { + throw new Error('lab-motion compiler: delay и stagger обязаны быть конечными'); + } + + const strings: string[] = []; + const stringIndex = (value: string): number => { + const existing = strings.indexOf(value); + if (existing !== -1) return existing; + strings.push(value); + return strings.length - 1; + }; + + // Один субъект (slot 0) на все каналы: nano анимирует один элемент. Каждый + // host-канал — своя поверхность, поэтому общий ownerGroup каноничен. + const bindings = channels.map((property) => + [0, [255, stringIndex(property)] as const, 0] as const); + const tracks = channels.map((property, index) => { + const value = frame[property]!; + const encoded = typeof value === 'number' + ? ([1, [0, value]] as const) + : ([1, [2, stringIndex(value)]] as const); + const codec = typeof value === 'number' + ? MOTION_PROGRAM_CODEC_V1.scalar + : MOTION_PROGRAM_CODEC_V1.webCssOpaque; + return [ + index, + delayMs, + durationMs, + 0, + MOTION_PROGRAM_DIRECTION_V1.normal, + 0, + MOTION_PROGRAM_COMPOSITE_V1.replace, + [[0, 1, [0], encoded, 1, codec]], + ]; + }); + + const candidate = [ + 1, + // currentValues: from берётся снапшотом; hostExtensions: каналы адресуются + // CSS-именами через строковую таблицу — парсер требует объявить фактически + // используемые возможности. + MOTION_PROGRAM_FEATURE_V1.currentValues | MOTION_PROGRAM_FEATURE_V1.hostExtensions, + strings, + // Индекс 0 канонически зарезервирован линейной кривой. + [0, curve], + bindings, + tracks, + ]; + // Единственный оракул доверия — канонический V1-парсер пакета. + const program: MotionProgramV1 = parseMotionProgramV1(candidate); + + // Проекция обратно: каждый канал обязан бит-в-бит совпасть с nano SSOT. + const parsedStrings = program[2]; + const parsedCurve = program[3][1]; + const projectedPoints: number[] = []; + if (parsedCurve !== 0 && parsedCurve !== undefined) { + for (let index = 2; index < parsedCurve.length; index += 2) { + projectedPoints.push(parsedCurve[index] as number); + } + } + if (`linear(${projectedPoints})` !== cssLinear) { + throw new Error('lab-motion compiler: кривая V1 разошлась с nano SSOT'); + } + const projected: Record = {}; + for (const track of program[5]) { + const binding = program[4][track[0]]!; + const channel = binding[1]; + if (typeof channel === 'number' || channel[0] !== 255) { + throw new Error('lab-motion compiler: неожиданный канал после парсинга'); + } + const property = parsedStrings[channel[1]]!; + const to = track[7][0]![3]; + if (to[0] !== 1) throw new Error('lab-motion compiler: to-значение не абсолютно'); + const encoded = to[1]!; + projected[property] = encoded[0] === 0 + ? (encoded[1] as number) + : parsedStrings[encoded[1] as number]!; + if (track[2] !== durationMs || track[1] !== delayMs) { + throw new Error('lab-motion compiler: тайминг V1 разошёлся с nano SSOT'); + } + } + if (JSON.stringify(projected) !== JSON.stringify(frame)) { + throw new Error('lab-motion compiler: проекция frame разошлась с nano SSOT'); + } + + return { frame, durationMs, cssLinear, delayMs, staggerMs, reducedMotion: call.reducedMotion }; +} + +/** Компактный литерал общего артефакта для инъекции в код (детерминированный). */ +export function nanoCallArtifactLiteral(call: StaticNanoCall): string { + const artifact = compileNanoArtifact(call); + const parts = [ + `f:${JSON.stringify(artifact.frame)}`, + `d:${artifact.durationMs}`, + `e:${JSON.stringify(artifact.cssLinear)}`, + ]; + if (artifact.delayMs !== 0) parts.push(`y:${artifact.delayMs}`); + if (artifact.staggerMs !== 0) parts.push(`g:${artifact.staggerMs}`); + if (artifact.reducedMotion !== undefined) parts.push(`r:${artifact.reducedMotion}`); + return `{${parts.join(',')}}`; +} + // ─── Нормализованный AST-контракт (§13.5) ──────────────────────────────────── /** Минимальный структурный узел: адаптер обязан дать type + байтовые границы. */ @@ -140,7 +319,7 @@ export interface NanoLoweringPlan { const NANO_SOURCE = '@labpics/motion/nano'; export const COMPILED_IMPORT_SOURCE = '@labpics/motion/compiler/runtime'; -export const COMPILED_IMPORT_NAME = 'animateCompiled'; +export const COMPILED_IMPORT_NAME = 'animateCompiledNano'; const IMPORT_LOCAL = '__labMotionNanoCompiled'; function walk(node: unknown, visit: (node: AstNode, parent: AstNode | undefined) => void, parent?: AstNode): void { @@ -206,6 +385,25 @@ export function planNanoOpacityLowering( program: AstNode, code: string, artifactLiteral: (opacity: number) => string, +): NanoLoweringPlan | undefined { + return planNanoCalls(program, code, (propsArg, optionsArg) => { + if (optionsArg !== undefined) return undefined; + const opacity = staticOpacityLiteral(propsArg); + if (opacity === undefined) return undefined; + return artifactLiteral(opacity); + }); +} + +/** + * Общий обход модуля: precondition-анализ импортов/затенений и байтовая + * верификация тривиа-зон вызова. tryLiteral возвращает литерал артефакта для + * доказуемого вызова либо undefined (консервативный отказ, вызов остаётся + * runtime). Поддерживаются формы с двумя и тремя аргументами. + */ +function planNanoCalls( + program: AstNode, + code: string, + tryLiteral: (propsArg: AstNode, optionsArg: AstNode | undefined) => string | undefined, ): NanoLoweringPlan | undefined { let importedPlain = false; const importNodes = new Set(); @@ -251,21 +449,26 @@ export function planNanoOpacityLowering( if (callee.type !== 'Identifier' || callee.name !== 'animate') return; if (node.optional === true) { runtimeCalls++; return; } const args = node.arguments as AstNode[]; - if (args.length !== 2) { runtimeCalls++; return; } - const [targetArg, propsArg] = args as [AstNode, AstNode]; - if (targetArg.type === 'SpreadElement') { runtimeCalls++; return; } - const opacity = staticOpacityLiteral(propsArg); - if (opacity === undefined) { runtimeCalls++; return; } + if (args.length !== 2 && args.length !== 3) { runtimeCalls++; return; } + const [targetArg, propsArg, optionsArg] = args as [AstNode, AstNode, AstNode | undefined]; + if (targetArg.type === 'SpreadElement' || optionsArg?.type === 'SpreadElement') { + runtimeCalls++; + return; + } + const literal = tryLiteral(propsArg, optionsArg); + if (literal === undefined) { runtimeCalls++; return; } // Побайтная верификация тривиа-зон: ровно `(`, `,`, `)` с пробелами. // Скобки вокруг callee/target, комментарии и прочая экзотика — отказ. + const tailStart = optionsArg === undefined ? propsArg.end : optionsArg.end; if ( !/^\s*\(\s*$/.test(code.slice(callee.end, targetArg.start)) || !/^\s*,\s*$/.test(code.slice(targetArg.end, propsArg.start)) || - !/^\s*,?\s*\)$/.test(code.slice(propsArg.end, node.end)) + (optionsArg !== undefined && !/^\s*,\s*$/.test(code.slice(propsArg.end, optionsArg.start))) || + !/^\s*,?\s*\)$/.test(code.slice(tailStart, node.end)) ) { runtimeCalls++; return; } edits.push( { start: callee.start, end: targetArg.start, replacement: `${IMPORT_LOCAL}(` }, - { start: targetArg.end, end: node.end, replacement: `, ${artifactLiteral(opacity)})` }, + { start: targetArg.end, end: node.end, replacement: `, ${literal})` }, ); }); @@ -284,6 +487,143 @@ export function planNanoOpacityLowering( }; } +/** + * Статическое извлечение полного nano-вызова (#221): props + опции. + * undefined — консервативный отказ (динамика/сомнение), вызов остаётся runtime. + * Ошибки бросает ТОЛЬКО артефактный слой на доказанно-статическом инвалиде. + */ +function staticNanoCallLiteral(propsArg: AstNode, optionsArg: AstNode | undefined): StaticNanoCall | undefined { + const props = staticPropsLiteral(propsArg); + if (props === undefined) return undefined; + if (optionsArg === undefined) return { props }; + const options = staticOptionsLiteral(optionsArg); + if (options === undefined) return undefined; + return { props, ...options }; +} + +/** Числовой/строковый литерал, включая унарный минус. undefined — отказ. */ +function staticScalarLiteral(value: AstNode): string | number | undefined { + if (value.type === 'Literal') { + const raw = value.value; + if (typeof raw === 'number' && Number.isFinite(raw)) return raw; + if (typeof raw === 'string') return raw; + return undefined; // null/true/false/regexp в позиции значения — сомнение + } + if ( + value.type === 'UnaryExpression' && + value.operator === '-' && + (value.argument as AstNode).type === 'Literal' + ) { + const raw = (value.argument as AstNode).value; + if (typeof raw === 'number' && Number.isFinite(raw)) return -raw; + } + return undefined; +} + +/** Плоский объектный литерал с Identifier-ключами без дублей. undefined — отказ. */ +function staticPlainObject(node: AstNode): Map | undefined { + if (node.type !== 'ObjectExpression') return undefined; + const out = new Map(); + for (const property of node.properties as AstNode[]) { + if ( + property.type !== 'Property' || + property.kind !== 'init' || + property.method === true || + property.computed === true || + property.shorthand === true + ) return undefined; + const key = property.key as AstNode; + if (key.type !== 'Identifier') return undefined; + // Дубликат ключа: last-wins в JS, но это неоднозначная форма — отказ. + if (out.has(key.name as string)) return undefined; + out.set(key.name as string, property.value as AstNode); + } + return out; +} + +function staticPropsLiteral(node: AstNode): Record | undefined { + const entries = staticPlainObject(node); + if (entries === undefined || entries.size === 0) return undefined; + const props: Record = {}; + for (const [name, valueNode] of entries) { + const value = staticScalarLiteral(valueNode); + if (value === undefined) return undefined; + // scale и rotate обязаны быть числами: строковый rotate nano молча + // отбрасывает из frame — воспроизводить эту странность компилятор не будет. + if ((name === 'scale' || name === 'rotate') && typeof value !== 'number') return undefined; + props[name] = value; + } + return props; +} + +type StaticNanoOptions = Pick; + +function staticOptionsLiteral(node: AstNode): StaticNanoOptions | undefined { + const entries = staticPlainObject(node); + if (entries === undefined) return undefined; + const options: { + spring?: NanoSpringRecord; + delayMs?: number; + staggerMs?: number; + reducedMotion?: boolean; + } = {}; + for (const [name, valueNode] of entries) { + switch (name) { + case 'spring': { + const spring = staticPlainObject(valueNode); + if (spring === undefined) return undefined; + const record: Record = {}; + for (const [field, fieldNode] of spring) { + if (field !== 'mass' && field !== 'stiffness' && field !== 'damping') return undefined; + const fieldValue = staticScalarLiteral(fieldNode); + if (typeof fieldValue !== 'number') return undefined; + record[field] = fieldValue; + } + // Частичная пружина валидна: недостающие поля добирает SSOT springLinear. + options.spring = record as unknown as NanoSpringRecord; + break; + } + case 'delay': + case 'stagger': { + const value = staticScalarLiteral(valueNode); + if (typeof value !== 'number') return undefined; + if (name === 'delay') options.delayMs = value; + else options.staggerMs = value; + break; + } + case 'reducedMotion': { + const value = valueNode; + if (value.type !== 'Literal' || typeof value.value !== 'boolean') return undefined; + options.reducedMotion = value.value; + break; + } + // Tween-форма непредставима в V1 (нативная easing-строка) — runtime. + case 'duration': + case 'ease': + return undefined; + default: + return undefined; // неизвестная опция — сомнение + } + } + return options; +} + +/** + * Планирует общий lowering модуля (#221): мультиканальный frame + spring-опции. + * Контракт совпадает с planNanoOpacityLowering; отличие — какой вызов доказуем. + */ +export function planNanoLowering( + program: AstNode, + code: string, + artifactLiteral: (call: StaticNanoCall) => string, +): NanoLoweringPlan | undefined { + return planNanoCalls(program, code, (propsArg, optionsArg) => { + const call = staticNanoCallLiteral(propsArg, optionsArg); + if (call === undefined) return undefined; + return artifactLiteral(call); + }); +} + /** Ровно `{ opacity: <конечный числовой литерал> }`; иначе undefined (отказ). */ function staticOpacityLiteral(props: AstNode): number | undefined { if (props.type !== 'ObjectExpression') return undefined; diff --git a/src/compiler/runtime/index.ts b/src/compiler/runtime/index.ts index bdb91e5f..a766b869 100644 --- a/src/compiler/runtime/index.ts +++ b/src/compiler/runtime/index.ts @@ -16,28 +16,39 @@ import type { NanoControls, NanoTarget } from '../../nano/index.js'; export type { NanoControls, NanoTarget } from '../../nano/index.js'; -/** Компактная форма, которую инъецирует compiler: opacity/durationMs/easing. */ +/** + * Компактная форма, которую инъецирует compiler (#221): готовый frame, тайминг + * и политика исполнения. Производитель — только одноверсионный compiler, + * формат не публичный контракт: артефакт и импорт эмитятся одной сборкой. + */ export interface CompiledNanoCall { - readonly o: number; + /** Канонизированный PropertyIndexedKeyframes-эквивалент (to-only). */ + readonly f: Readonly>; readonly d: number; readonly e: string; + /** delay в мс; отсутствие = 0. */ + readonly y?: number | undefined; + /** stagger в мс на индекс элемента; отсутствие = 0. */ + readonly g?: number | undefined; + /** Явная reduced-политика; отсутствие = ambient prefers-reduced-motion. */ + readonly r?: boolean | undefined; } -export function animateCompiled(target: NanoTarget, artifact: CompiledNanoCall): NanoControls { +export function animateCompiledNano(target: NanoTarget, artifact: CompiledNanoCall): NanoControls { + const { f, d, e, y = 0, g = 0, r } = artifact; const source = typeof target === 'string' ? document.querySelectorAll(target) : 'animate' in target ? [target] : target; - const reduced = typeof matchMedia !== 'undefined' - && matchMedia('(prefers-reduced-motion: reduce)').matches; - const animations = Array.from(source, (element) => { - const animation = element.animate({ opacity: artifact.o }, { - duration: reduced ? 0 : artifact.d, - easing: reduced ? 'linear' : artifact.e, - delay: 0, - fill: 'both', - }); - return animation; - }) as NanoControls; + const reduced = r + ?? (typeof matchMedia !== 'undefined' + && matchMedia('(prefers-reduced-motion: reduce)').matches); + // Один frame-объект на вызов: литерал артефакта разделяется всеми элементами. + const animations = Array.from(source, (element, index) => element.animate(f, { + duration: reduced ? 0 : d, + easing: reduced ? 'linear' : e, + delay: reduced ? 0 : y + g * index, + fill: 'both', + })) as NanoControls; animations.finished = Promise.all(animations.map((animation) => new Promise((resolve, reject) => { animation.finished.catch(reject); animation.addEventListener('finish', () => { @@ -50,3 +61,4 @@ export function animateCompiled(target: NanoTarget, artifact: CompiledNanoCall): }))); return animations; } + diff --git a/src/compiler/vite/index.ts b/src/compiler/vite/index.ts index 1a424412..6dabd599 100644 --- a/src/compiler/vite/index.ts +++ b/src/compiler/vite/index.ts @@ -15,8 +15,8 @@ */ import { - nanoArtifactLiteral, - planNanoOpacityLowering, + nanoCallArtifactLiteral, + planNanoLowering, planSurfaceLowering, type AstNode, type NanoLoweringEdit, @@ -176,12 +176,12 @@ export function motionCompiler(): MotionCompilerPlugin { return undefined; } const ast = program as AstNode; - // Два независимых плана: nano (2-арг opacity) и surface (3-арг + // Два независимых плана: nano (мультиканальный frame + spring-опции) и surface (3-арг // layout:'project'). Правки не пересекаются: surface-вызов нижится // только полностью статическим, а вложенный вызов в аргументе делает // его динамическим (консервативный отказ). const plans = [ - planNanoOpacityLowering(ast, code, nanoArtifactLiteral), + planNanoLowering(ast, code, nanoCallArtifactLiteral), planSurfaceLowering(ast, code), ].filter((plan): plan is NanoLoweringPlan => plan !== undefined); if (plans.length === 0) return undefined; diff --git a/test/compiler-nano-lowering.test.ts b/test/compiler-nano-lowering.test.ts index 201dce5d..d0fc7a62 100644 --- a/test/compiler-nano-lowering.test.ts +++ b/test/compiler-nano-lowering.test.ts @@ -25,7 +25,7 @@ import { type AstNode, } from '../src/compiler/core.js'; import { motionCompiler } from '../src/compiler/vite/index.js'; -import { animateCompiled } from '../src/compiler/runtime/index.js'; +import { animateCompiledNano } from '../src/compiler/runtime/index.js'; import { animate as nanoAnimate } from '../src/nano/index.js'; import { springLinear } from '../src/nano/spring-linear.js'; @@ -116,9 +116,9 @@ describe('позитивный паттерн — единственный, ко it('вызов заменяется executor-вызовом; target остаётся байт-в-байт', async () => { const output = await applyPlugin(POSITIVE); expect(output).toBeDefined(); - expect(output).toContain('__labMotionNanoCompiled(card, {o:1,d:'); + expect(output).toContain('__labMotionNanoCompiled(card, {f:{"opacity":1},d:'); expect(output).toContain( - `import { animateCompiled as __labMotionNanoCompiled } from "${COMPILED_IMPORT_SOURCE}";`, + `import { animateCompiledNano as __labMotionNanoCompiled } from "${COMPILED_IMPORT_SOURCE}";`, ); // Исходный вызов исчез, повторного animate-идентификатора не осталось. expect(output).not.toContain('animate(card'); @@ -131,8 +131,8 @@ describe('позитивный паттерн — единственный, ко animate(a, { opacity: 0 }); animate(b, { opacity: 0.5 }); `); - expect(output).toContain('(a, {o:0,d:'); - expect(output).toContain('(b, {o:0.5,d:'); + expect(output).toContain('(a, {f:{"opacity":0},d:'); + expect(output).toContain('(b, {f:{"opacity":0.5},d:'); expect((output!.match(/__labMotionNanoCompiled\(/g) ?? []).length).toBe(2); }); @@ -220,7 +220,7 @@ animate(el, { opacity: 1 }); expect(output).toBeDefined(); expect(output).toContain(`import { animate } from '@labpics/motion/nano';`); expect(output).toContain('export { animate };'); - expect(output).toContain('__labMotionNanoCompiled(el, {o:1,d:'); + expect(output).toContain('__labMotionNanoCompiled(el, {f:{"opacity":1},d:'); }); }); @@ -231,7 +231,7 @@ export const r = animate(animate(x, { opacity: 1 }), { opacity: 0.5 }); `); expect(output).toBeDefined(); // Оба вызова понижены, target внешнего — понижённый внутренний. - expect(output).toContain('__labMotionNanoCompiled(__labMotionNanoCompiled(x, {o:1,d:'); + expect(output).toContain('__labMotionNanoCompiled(__labMotionNanoCompiled(x, {f:{"opacity":1},d:'); expect((output!.match(/__labMotionNanoCompiled\(/g) ?? []).length).toBe(2); // Выход обязан парситься (регрессия: несортированные правки дублировали хвост). await expect(parseAstAsync(output!)).resolves.toBeDefined(); @@ -246,7 +246,7 @@ export function open(card) { } `); expect(output).toBeDefined(); - expect(output).toContain('__labMotionNanoCompiled(card, {o:1,d:'); + expect(output).toContain('__labMotionNanoCompiled(card, {f:{"opacity":1},d:'); await expect(parseAstAsync(output!)).resolves.toBeDefined(); }); }); @@ -321,8 +321,8 @@ describe('animateCompiled ≡ nano.animate для позитивного пат const compiledJournal: JournalEntry[] = []; nanoAnimate(fakeElement(nanoJournal), { opacity: 0.25 }); const artifact = compileNanoOpacityArtifact(0.25); - animateCompiled(fakeElement(compiledJournal), { - o: artifact.frame.opacity, + animateCompiledNano(fakeElement(compiledJournal), { + f: artifact.frame, d: artifact.durationMs, e: artifact.cssLinear, }); @@ -338,8 +338,8 @@ describe('animateCompiled ≡ nano.animate для позитивного пат const compiledJournal: JournalEntry[] = []; nanoAnimate(fakeElement(nanoJournal), { opacity: 1 }); const artifact = compileNanoOpacityArtifact(1); - animateCompiled(fakeElement(compiledJournal), { - o: 1, + animateCompiledNano(fakeElement(compiledJournal), { + f: artifact.frame, d: artifact.durationMs, e: artifact.cssLinear, }); @@ -355,7 +355,7 @@ describe('animateCompiled ≡ nano.animate для позитивного пат it('finished-агрегат и controls-массив совпадают по форме', () => { const journal: JournalEntry[] = []; const artifact = compileNanoOpacityArtifact(1); - const controls = animateCompiled(fakeElement(journal), { + const controls = animateCompiledNano(fakeElement(journal), { o: 1, d: artifact.durationMs, e: artifact.cssLinear, diff --git a/test/compiler-nano-multi-lowering.test.ts b/test/compiler-nano-multi-lowering.test.ts new file mode 100644 index 00000000..2a895a8b --- /dev/null +++ b/test/compiler-nano-multi-lowering.test.ts @@ -0,0 +1,246 @@ +/** + * #221: build-time lowering статических NanoProps/NanoOptions (spring-форма). + * + * Слои соответствуют compiler-nano-lowering.test.ts: + * A. Артефакт через канонический V1-парсер (compileNanoArtifact). + * B. Позитивные паттерны планировщика — какие вызовы понижаются. + * C. Консервативные отказы — источник остаётся семантически исходным. + * D. Ошибки сборки на доказанно-статическом инвалиде. + * E. Executor ≡ nano.animate: журнал keyframes/options, stagger по индексу, + * явная и ambient reduced-политика. + */ +import { describe, expect, it } from 'vitest'; +import { parseAstAsync } from 'vite'; + +import { + compileNanoArtifact, + nanoCallArtifactLiteral, + planNanoLowering, + type AstNode, + type StaticNanoCall, +} from '../src/compiler/core.js'; +import { animateCompiledNano } from '../src/compiler/runtime/index.js'; +import { animate as nanoAnimate, type NanoOptions, type NanoProps } from '../src/nano/index.js'; + +const IMPORT_LINE = "import { animate } from '@labpics/motion/nano';\n"; + +async function plan(code: string) { + const ast = (await parseAstAsync(code)) as unknown as AstNode; + return planNanoLowering(ast, code, nanoCallArtifactLiteral); +} + +// ─── A. Артефакт ───────────────────────────────────────────────────────────── + +describe('compileNanoArtifact — общий артефакт через V1-парсер', () => { + it('канонизирует frame ровно по закону nano', () => { + const artifact = compileNanoArtifact({ + props: { translate: '120px 0', opacity: 1, scale: 1.04, rotate: 8, filter: 'blur(0px)' }, + }); + // scale и rotate назначаются первыми, rotate получает deg, прочие — в + // исходном порядке: побуквенно порядок присваивания nano/index.ts. + expect(Object.keys(artifact.frame)).toEqual(['scale', 'rotate', 'translate', 'opacity', 'filter']); + expect(artifact.frame['rotate']).toBe('8deg'); + expect(artifact.frame['scale']).toBe(1.04); + }); + + it('дефолтная пружина совпадает с nano SSOT', () => { + const artifact = compileNanoArtifact({ props: { opacity: 0.5 } }); + const reference = compileNanoArtifact({ + props: { opacity: 0.5 }, + spring: { mass: 1, stiffness: 170, damping: 26 }, + }); + expect(artifact.durationMs).toBe(reference.durationMs); + expect(artifact.cssLinear).toBe(reference.cssLinear); + }); + + it('delay уходит в startMs трека и возвращается из парсера', () => { + const artifact = compileNanoArtifact({ props: { opacity: 1 }, delayMs: 40, staggerMs: 20 }); + expect(artifact.delayMs).toBe(40); + expect(artifact.staggerMs).toBe(20); + }); +}); + +// ─── B. Позитивные паттерны ────────────────────────────────────────────────── + +describe('позитивные паттерны — понижаются', () => { + it('целевой пример issue целиком', async () => { + const result = await plan(`${IMPORT_LINE}animate(el, { + translate: '120px 0', + scale: 1.04, + rotate: 8, + opacity: 1, + filter: 'blur(0px)', + }, { + spring: { mass: 1, stiffness: 170, damping: 26 }, + delay: 40, + stagger: 20, + reducedMotion: false, + });`); + expect(result).toBeDefined(); + expect(result!.edits).toHaveLength(2); + expect(result!.runtimeCalls).toBe(0); + const literal = result!.edits[1]!.replacement; + expect(literal).toContain('"rotate":"8deg"'); + expect(literal).toContain('y:40'); + expect(literal).toContain('g:20'); + expect(literal).toContain('r:false'); + }); + + it('двухаргументная мультиканальная форма (дефолтная пружина)', async () => { + const result = await plan(`${IMPORT_LINE}animate(el, { opacity: 0.5, translate: '10px 0' });`); + expect(result).toBeDefined(); + expect(result!.runtimeCalls).toBe(0); + }); + + it('пустые опции эквивалентны их отсутствию', async () => { + const result = await plan(`${IMPORT_LINE}animate(el, { opacity: 0.5 }, {});`); + expect(result).toBeDefined(); + expect(result!.runtimeCalls).toBe(0); + }); + + it('частичная пружина и отрицательные литералы', async () => { + const result = await plan(`${IMPORT_LINE}animate(el, { x: -4 }, { spring: { stiffness: 200 }, delay: -5 });`); + expect(result).toBeDefined(); + expect(result!.edits[1]!.replacement).toContain('"x":-4'); + }); +}); + +// ─── C. Консервативные отказы ──────────────────────────────────────────────── + +describe('консервативный отказ — вызов остаётся runtime', () => { + const REFUSALS: readonly [label: string, source: string][] = [ + ['tween-форма: duration', "animate(el, { opacity: 1 }, { duration: 200 });"], + ['tween-форма: ease', "animate(el, { opacity: 1 }, { duration: 200, ease: 'ease-out' });"], + ['неизвестная опция', "animate(el, { opacity: 1 }, { repeat: 2 });"], + ['динамическое значение', 'animate(el, { opacity: level });'], + ['вычисляемый ключ', "animate(el, { ['opa' + 'city']: 1 });"], + ['shorthand', 'animate(el, { opacity });'], + ['spread в props', 'animate(el, { ...rest });'], + ['spread в options', 'animate(el, { opacity: 1 }, { ...rest });'], + ['дубликат ключа', "animate(el, { opacity: 1, opacity: 0.5 });"], + ['null-значение', 'animate(el, { opacity: null });'], + ['строковый rotate', "animate(el, { rotate: '8deg' });"], + ['строковый scale', "animate(el, { scale: 'big' });"], + ['динамическая пружина', 'animate(el, { opacity: 1 }, { spring: springOf() });'], + ['неизвестное поле пружины', 'animate(el, { opacity: 1 }, { spring: { mass: 1, velocity: 2 } });'], + ['reducedMotion не литерал', 'animate(el, { opacity: 1 }, { reducedMotion: flag });'], + ['опции переменной', 'animate(el, { opacity: 1 }, opts);'], + ]; + + for (const [label, source] of REFUSALS) { + it(label, async () => { + const result = await plan(`${IMPORT_LINE}${source}`); + // Либо плана нет вовсе, либо вызов учтён как непонижаемый runtime-вызов. + if (result !== undefined) { + expect(result.edits).toHaveLength(0); + expect(result.runtimeCalls).toBeGreaterThan(0); + } else { + expect(result).toBeUndefined(); + } + }); + } +}); + +// ─── D. Ошибки сборки на доказанном инвалиде ───────────────────────────────── + +describe('доказанно-статический инвалид — ошибка сборки, не silent fallback', () => { + it('невалидная пружина падает причиной SSOT', () => { + expect(() => + compileNanoArtifact({ props: { opacity: 1 }, spring: { mass: 0, stiffness: 1, damping: 1 } }), + ).toThrow(/finite and positive/); + }); + + it('пустой frame непонижаем', () => { + expect(() => compileNanoArtifact({ props: {} })).toThrow(/пустой frame/); + }); +}); + +// ─── E. Executor ≡ nano ────────────────────────────────────────────────────── + +interface JournalEntry { + frame: Record; + options: Record; +} + +function fakeElement(journal: JournalEntry[]): Element { + return { + animate(frame: Record, options: Record) { + journal.push({ frame, options }); + return { + finished: new Promise(() => {}), + addEventListener() {}, + commitStyles() {}, + cancel() {}, + }; + }, + } as unknown as Element; +} + +function journals(call: StaticNanoCall, props: NanoProps, options: NanoOptions | undefined, elements = 1) { + const nanoJournal: JournalEntry[] = []; + const compiledJournal: JournalEntry[] = []; + const nanoTargets = Array.from({ length: elements }, () => fakeElement(nanoJournal)); + const compiledTargets = Array.from({ length: elements }, () => fakeElement(compiledJournal)); + if (options === undefined) nanoAnimate(nanoTargets, props); + else nanoAnimate(nanoTargets, props, options); + const artifact = compileNanoArtifact(call); + animateCompiledNano(compiledTargets, { + f: artifact.frame, + d: artifact.durationMs, + e: artifact.cssLinear, + ...(artifact.delayMs !== 0 ? { y: artifact.delayMs } : {}), + ...(artifact.staggerMs !== 0 ? { g: artifact.staggerMs } : {}), + ...(artifact.reducedMotion !== undefined ? { r: artifact.reducedMotion } : {}), + }); + return { nanoJournal, compiledJournal }; +} + +describe('animateCompiledNano ≡ nano.animate', () => { + it('мультиканальный вызов с delay и stagger на трёх элементах', () => { + const props = { translate: '120px 0', scale: 1.04, rotate: 8, opacity: 1 } as const; + const options = { spring: { mass: 1, stiffness: 170, damping: 26 }, delay: 40, stagger: 20 } as const; + const { nanoJournal, compiledJournal } = journals( + { props, spring: options.spring, delayMs: 40, staggerMs: 20 }, + props, + options, + 3, + ); + expect(compiledJournal).toEqual(nanoJournal); + // Индексный stagger: delay растёт по элементам ровно как у nano. + expect(compiledJournal.map((entry) => entry.options['delay'])).toEqual([40, 60, 80]); + }); + + it('явный reducedMotion: false игнорирует ambient-медиа', () => { + const original = globalThis.matchMedia; + (globalThis as { matchMedia?: unknown }).matchMedia = () => ({ matches: true }); + try { + const { nanoJournal, compiledJournal } = journals( + { props: { opacity: 1 }, reducedMotion: false }, + { opacity: 1 }, + { reducedMotion: false }, + ); + expect(compiledJournal).toEqual(nanoJournal); + expect(compiledJournal[0]!.options['duration']).not.toBe(0); + } finally { + (globalThis as { matchMedia?: unknown }).matchMedia = original; + } + }); + + it('явный reducedMotion: true даёт duration 0 и linear', () => { + const { nanoJournal, compiledJournal } = journals( + { props: { opacity: 1 }, reducedMotion: true }, + { opacity: 1 }, + { reducedMotion: true }, + ); + expect(compiledJournal).toEqual(nanoJournal); + expect(compiledJournal[0]!.options).toMatchObject({ duration: 0, easing: 'linear', delay: 0 }); + }); + + it('один frame-объект на вызов, а не на элемент', () => { + const journal: JournalEntry[] = []; + const targets = [fakeElement(journal), fakeElement(journal)]; + const artifact = compileNanoArtifact({ props: { opacity: 0.5 } }); + animateCompiledNano(targets, { f: artifact.frame, d: artifact.durationMs, e: artifact.cssLinear }); + expect(journal[0]!.frame).toBe(journal[1]!.frame); + }); +});