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