// Copyright (C) 2026 half144 and PS5 React contributors
// SPDX-License-Identifier: GPL-3.0-or-later
// Additional attribution term: see LICENSE-ATTRIBUTION.
import assert from 'node:assert/strict';
import {createRequire} from 'node:module';
import {resolve} from 'node:path';
import {test} from 'node:test';
import {fileURLToPath} from 'node:url';
import {createCompiler} from './compile.mjs';
import {transform} from './esbuild-plugin.mjs';
const root = resolve(fileURLToPath(import.meta.url), '../../..');
const er = process.env.PS5_REACT_ER ?? resolve(root, '.deps/embeddedReact');
const require = createRequire(resolve(er, 'bridges/quickjs/js/package.json'));
const babel = {core: require('@babel/core'), syntaxJsx: require('@babel/plugin-syntax-jsx').default};
const compiler = createCompiler({renderWidth: 1280, renderHeight: 720, appDir: '/app',
config: {theme: {extend: {fontFamily: {inter: './Inter.ttf'}}}}});
const compile = source => transform(babel, compiler, source, '/app/index.jsx').replace(/\s+/g, ' ').trim();
const fails = (source, pattern) => assert.throws(() => compile(source), pattern);
test('static className becomes a style object', () => {
assert.equal(compile(';'),
';');
});
test('explicit style keeps precedence', () => {
assert.equal(compile(';'), ';');
});
test('conditional and logical classes stay conditional', () => {
assert.equal(compile(';'),
';');
assert.equal(compile(';'), ';');
});
test('const identifiers resolve at build time', () => {
assert.match(compile('const card = "p-1"; ;'), /style=\{\{ padding: 4 \}\}/);
});
test('variants read the same-named prop and compose transforms', () => {
assert.equal(compile(';'),
';');
fails(';', /needs a focused=\{\.\.\.\} prop/);
assert.equal(compile(';'),
';');
fails(';', /read twice/);
});
test('relative leading in a branch uses the static font size', () => {
assert.match(compile(';'),
/a \? \{ lineHeight: 20 \} : null/);
});
test('tw templates from @ps5-react/core compile to objects', () => {
assert.match(compile('import {tw} from "@ps5-react/core"; const s = tw`m-1`;'), /const s = \{ margin: 4 \};/);
assert.match(compile('const tw = x => x; const s = tw`m-1`;'), /tw`m-1`/);
});
test('font paths are imported for baking', () => {
assert.match(compile(';'), /^import "\/app\/Inter\.ttf";.*fontFamily: "Inter"/);
});
test('dynamic or partial classes are rejected with a location', () => {
fails(';', /known at build time/);
fails(';', /whole words/);
assert.throws(() => compile(';'),
error => assert.deepEqual(error.loc, {line: 2, column: 17}) ?? true);
});
test('unknown classes suggest the closest valid utility', () => {
fails(';', /did you mean text-white\?/);
fails(';', /unknown utility$/);
});
const core = 'import {View, Text as T} from "@ps5-react/core"; ';
const motionImport = 'import { motion as __ps5Motion } from "@ps5-react/core";';
const tween = 'transition={{ type: "tween", duration: 0.15, ease: [0.4, 0, 0.2, 1] }}';
test('animation classes turn core primitives into motion components', () => {
assert.equal(compile(`${core}a; ;`),
`${motionImport}import { View, Text as T } from "@ps5-react/core";<__ps5Motion.Text style={{ padding: 4 }} ` +
`initial={{ y: 4, opacity: 0 }} animate={{ opacity: 1, y: 4 }} ${tween}>a;` +
'<__ps5Motion.View initial={{ rotate: 0 }} animate={{ rotate: 360 }} transition={{ type: "tween", ' +
'duration: 0.15, ease: [0.4, 0, 0.2, 1], rotate: { type: "tween", duration: 1, ease: "linear", repeat: 1 / 0 } }} />;');
assert.match(compile(`${core};`),
/initial=\{false\} exit=\{\{ opacity: 0 \}\}/);
});
test('transition animates the opacity and transforms of variants and conditions', () => {
assert.equal(compile(`${core};`),
`${motionImport}import { View, Text as T } from "@ps5-react/core";<__ps5Motion.View focused={f} ` +
'style={[{ backgroundColor: "#000" }, f ? { backgroundColor: "#fff" } : null]} initial={false} ' +
`animate={f ? { scale: 1.1 } : { scale: 1 }} ${tween} />;`);
assert.match(compile(`${core};`),
/animate=\{\{ opacity: 1, scale: 1, \.\.\.\(f \? \{ opacity: 0\.8, scale: 1\.05 \} : null\), \.\.\.\(p \? \{ scale: 0\.95 \} : null\) \}\}/);
assert.match(compile(`${core};`),
/View initial=\{false\} animate=\{\{ scale: 1, opacity: 0\.5, \.\.\.\(on \? \{ scale: 1\.1 \} : \{ scale: 1 \}\) \}\}/);
});
test('classes that animate nothing leave the element untouched', () => {
assert.equal(compile(`${core};`),
'import { View, Text as T } from "@ps5-react/core";;');
});
test('animation classes fail outside core primitives and static class lists', () => {
fails(';', /need View, Text or Image from @ps5-react\/core; .*motion\.create/);
fails('import {View} from "./ui"; ;', /motion\.create/);
fails(`${core};`, /must be static/);
fails(`${core};`, /animate prop conflicts/);
fails('import {tw} from "@ps5-react/core"; tw`animate-spin`;', /need a JSX element/);
assert.throws(() => compile(`${core};`),
error => /add animate-in/.test(error.message) && assert.deepEqual(error.loc, {line: 2, column: 17}) === undefined);
});
test('focusable elements without the prop read focus state through a style function', () => {
assert.equal(compile(';'),
' [{ padding: 4 }, _state.focused ? { backgroundColor: "#fff" } : null, ' +
'_state.pressed ? { backgroundColor: "#000" } : null]} />;');
assert.equal(compile(`${core.replace('Text as T', 'Pressable')};`),
'import { View, Pressable } from "@ps5-react/core"; _state.focused ? { padding: 4 } : null} />;');
assert.equal(compile(';'),
' _state.focused ? { padding: 4 } : null} />;');
assert.match(compile('const focused = 1; ;'),
/style=\{\(_state\) => \[focused \? \{ margin: 4 \} : null, _state\.focused \? \{ padding: 4 \} : null\]\}/);
fails(';', /or a focusable element/);
fails(';', /needs a selected=\{\.\.\.\} prop on this element$/);
});
test('an explicit focus prop keeps reading the prop', () => {
assert.equal(compile(';'),
';');
assert.equal(compile(';'),
' [f ? { padding: 4 } : null, _state.pressed ? { margin: 4 } : null]} />;');
});
test('the explicit style stays last inside the style function', () => {
assert.equal(compile(';'),
' [_state.focused ? { padding: 4 } : null, { margin: 1 }]} />;');
assert.equal(compile(';'),
' [{ padding: 4 }, typeof s === "function" ? s(_state) : s]} />;');
assert.equal(compile(' focused && a} className="focused:p-1" />;'),
' [_state.focused ? { padding: 4 } : null, (({ focused }) => focused && a)(_state)]} />;');
fails(';', /move the expression into a variable/);
});
test('focusable motion elements animate focus state with whileFocus and whilePress', () => {
assert.equal(compile(`${core};`),
`${motionImport}import { View, Text as T } from "@ps5-react/core";<__ps5Motion.View focusable ` +
'style={(_state) => [{ backgroundColor: "#000" }, _state.focused ? { backgroundColor: "#fff" } : null]} initial={false} ' +
`animate={{ scale: 1, opacity: 1 }} ${tween} whileFocus={{ scale: 1.05 }} whilePress={{ opacity: 0.8 }} />;`);
assert.match(compile(`${core};`),
/initial=\{false\} animate=\{s \? \{ scale: 1\.1 \} : \{ scale: 1 \}\} .* whileFocus=\{\{ scale: 1\.05 \}\} \/>/);
fails(`${core};`,
/need an explicit focused=\{\.\.\.\}/);
fails(`${core};`, /whileFocus prop conflicts/);
});