diff --git a/.gitignore b/.gitignore index e205b43..3bc988c 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,11 @@ releases .claude # This project uses `pnpm` for package management package-lock.json +.vitest-attachments + +# benchmark arms and outputs +bench/.arms +bench/dist +docs/perf/results/**/*.cpuprofile +# Raw per-run benchmark records (regenerated by npm run bench; summaries are committed) +docs/perf/results/**/*.json diff --git a/.prettierignore b/.prettierignore index 9fd9758..54b1af8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,3 +6,6 @@ build releases .tmp .env +bench/.arms +bench/dist +bench/public diff --git a/bench/harness/analyze.mjs b/bench/harness/analyze.mjs new file mode 100644 index 0000000..18eab85 --- /dev/null +++ b/bench/harness/analyze.mjs @@ -0,0 +1,658 @@ +// Turns raw page data into per-run results, and result files into +// docs/perf/results//summary.md. No browser here. +import { readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** Chunk of a script URL: the bundle's fixed chunk names (bench/vite.config.ts). */ +export function chunkOf(url) { + if (url === '' || url === undefined) { + return null; + } + if (url.endsWith('bench-probe.js')) { + return 'harness'; + } + const m = /\/assets\/([a-z-]+)\.js(\?|$)/.exec(url); + if (m !== null) { + const name = m[1]; + if ( + name === 'framework' || + name === 'reactivity' || + name === 'renderer' || + name === 'user' + ) { + return name; + } + } + return 'other'; +} + +export const BUCKETS = [ + 'framework', + 'reactivity', + 'renderer', + 'user', + 'harness', + 'other', +]; + +const siteName = (frame, chunk) => + `${frame.functionName === '' ? '(anonymous)' : frame.functionName} (${chunk}:${ + frame.lineNumber + 1 + })`; + +/** + * Sampling heap profile → bytes by chunk and by allocating function. As in + * the renderer's alloc-probe: each node's bytes go to the innermost frame + * with a script URL on its stack (builtins and natives have none). + */ +export function chargeAllocations(head) { + const buckets = Object.fromEntries(BUCKETS.map((b) => [b, 0])); + buckets.unattributed = 0; + const sites = new Map(); + const visit = (node, owner) => { + const frame = node.callFrame.url !== '' ? node.callFrame : owner; + if (node.selfSize > 0) { + if (frame === null) { + buckets.unattributed += node.selfSize; + } else { + const chunk = chunkOf(frame.url); + buckets[chunk] += node.selfSize; + const key = siteName(frame, chunk); + sites.set(key, (sites.get(key) ?? 0) + node.selfSize); + } + } + for (const child of node.children) { + visit(child, frame); + } + }; + visit(head, null); + return { buckets, sites }; +} + +const SPECIAL = new Set([ + '(root)', + '(program)', + '(idle)', + '(garbage collector)', +]); + +/** + * CPU profile → self time (ms) by chunk and by function. A sample's time is + * the gap to the next sample (DevTools' rule); `(idle)` is left out of the + * total, `(program)` and `(garbage collector)` are kept as buckets. A native + * function (no script URL: a WebGL call, a builtin) is charged to the chunk + * of its nearest caller with one, and named `fn [native]`. + */ +export function chargeProfile(profile) { + const byId = new Map(); + const parent = new Map(); + for (const n of profile.nodes) { + byId.set(n.id, n); + for (const c of n.children ?? []) { + parent.set(c, n.id); + } + } + const ownerChunk = (id) => { + for (let p = parent.get(id); p !== undefined; p = parent.get(p)) { + const url = byId.get(p).callFrame.url; + if (url !== '') { + return chunkOf(url); + } + } + return 'native'; + }; + const self = new Map(); + const samples = profile.samples; + const deltas = profile.timeDeltas; + let t = profile.startTime; + const stamps = new Array(samples.length); + for (let i = 0; i < samples.length; i++) { + t += deltas[i]; + stamps[i] = t; + } + for (let i = 0; i < samples.length; i++) { + const next = i + 1 < samples.length ? stamps[i + 1] : profile.endTime; + const dt = Math.max(0, next - stamps[i]); + self.set(samples[i], (self.get(samples[i]) ?? 0) + dt); + } + const chunks = {}; + const fns = new Map(); + let total = 0; + for (const [id, us] of self) { + const node = byId.get(id); + const frame = node.callFrame; + let chunk; + let key; + if (SPECIAL.has(frame.functionName) && frame.url === '') { + if (frame.functionName === '(idle)') { + continue; + } + chunk = frame.functionName; + key = frame.functionName; + } else if (frame.url === '') { + chunk = ownerChunk(id); + key = `${frame.functionName === '' ? '(anonymous)' : frame.functionName} [native] (${chunk})`; + } else { + chunk = chunkOf(frame.url); + key = siteName(frame, chunk); + } + const ms = us / 1000; + total += ms; + chunks[chunk] = (chunks[chunk] ?? 0) + ms; + fns.set(key, (fns.get(key) ?? 0) + ms); + } + return { total, chunks, fns }; +} + +/** mean, median, p95, min, max of the numbers in `xs` (nulls dropped). */ +export function stats(xs) { + const v = xs.filter((x) => typeof x === 'number' && Number.isFinite(x)); + if (v.length === 0) { + return null; + } + v.sort((a, b) => a - b); + const at = (q) => v[Math.min(v.length - 1, Math.floor(q * v.length))]; + const mid = v.length >> 1; + const median = v.length % 2 === 1 ? v[mid] : (v[mid - 1] + v[mid]) / 2; + return { + n: v.length, + mean: v.reduce((a, b) => a + b, 0) / v.length, + median, + p95: at(0.95), + min: v[0], + max: v[v.length - 1], + }; +} + +const TIME_FIELDS = [ + 'handler', + 'tail', + 'frame', + 'total', + 'frames', + 'animCpu', + 'undrawnCpu', + 'otherRaf', + 'cpu', + 'settle', + 'frameDelay', + 'keyup', +]; + +/** The ops of each kind (Scenario.opKind), or null when they have none. */ +const byKind = (ops, f) => { + const kinds = new Map(); + for (const o of ops) { + if (o.kind === null || o.kind === undefined) { + return null; + } + if (!kinds.has(o.kind)) { + kinds.set(o.kind, []); + } + kinds.get(o.kind).push(o); + } + const out = {}; + for (const [k, list] of kinds) { + out[k] = f(list); + } + return out; +}; + +/** Per-run statistics of a time-mode op list, overall and by kind. */ +export function timeStats(ops) { + const out = timeStatsOf(ops); + out.byKind = byKind(ops, timeStatsOf); + return out; +} + +function timeStatsOf(ops) { + const out = {}; + for (const f of TIME_FIELDS) { + out[f] = stats(ops.map((o) => o[f])); + } + out.noFrame = ops.filter((o) => o.frame === null).length; + out.tailCut = ops.filter((o) => o.tailCut).length; + out.timedOut = ops.filter((o) => o.timedOut).length; + return out; +} + +/** + * Per-run statistics of a count-mode op list, overall and by kind. `hooks` is + * what the probe's installCounters() found plus what it learned over the run + * (bench/harness/probe.mjs): a count whose hook did not install is null, + * reported as n/a, never 0. + */ +export function countStats(ops, hooks) { + const out = countStatsOf(ops, hooks); + out.byKind = byKind(ops, (list) => countStatsOf(list, hooks)); + return out; +} + +/** Per op, summed over the ops. */ +function countStatsOf(ops, hooks) { + const sum = (k) => ops.reduce((a, o) => a + o.counts[k], 0); + const n = ops.length; + const frames = ops.reduce((a, o) => a + o.frames, 0); + const hits = sum('cacheHits'); + const misses = sum('cacheMisses'); + const found = (hook) => hook !== null && hook !== undefined; + const nodes = hooks.nodeAccessors > 0; + const shaders = !(hooks.shaderUnwrapped > 0); + const walks = found(hooks.walkHook); + const drawn = found(hooks.drawHook); + const loaded = found(hooks.loadedHook); + const text = found(hooks.textHook); + const cache = found(hooks.cacheHook); + const flex = hooks.flexHooks > 0; + // The layout activity behind the final-layout frame: flex, text layout and + // text `loaded`; with one of them unhooked it would read too early. + const layout = flex && text && loaded; + const per = (ok, k) => (ok ? sum(k) / n : null); + return { + flexPasses: per(flex, 'flex'), + writes: per(nodes, 'writes'), + shaderWrites: per(shaders, 'shaderWrites'), + frameWrites: per(nodes, 'frameWrites'), + animations: per(hooks.animateHooks > 0, 'animations'), + walks: per(walks, 'walks'), + walksPerFrame: + walks && drawn && frames > 0 + ? ops.reduce((a, o) => a + o.frameWalks, 0) / frames + : null, + drawnFrames: drawn ? frames / n : null, + created: per(found(hooks.createHook), 'created'), + creationProps: per(found(hooks.createHook), 'creationProps'), + loadedText: per(loaded, 'loadedText'), + loadedOther: per(loaded, 'loadedOther'), + loadedTextEmits: per(loaded, 'loadedTextEmits'), + loadedOtherEmits: per(loaded, 'loadedOtherEmits'), + textLayouts: per(text, 'textLayouts'), + textLayoutMs: per(text, 'textLayoutMs'), + cacheHitRate: cache && hits + misses > 0 ? hits / (hits + misses) : null, + cacheLookups: cache ? (hits + misses) / n : null, + finalLayoutFrame: layout ? stats(ops.map((o) => o.finalLayoutFrame)) : null, + finalLayoutMs: layout ? stats(ops.map((o) => o.finalLayoutMs)) : null, + }; +} + +// --------------------------------------------------------------------------- +// Summary + +const median = (xs) => { + const s = stats(xs); + return s === null ? null : s.median; +}; +const fmt = (x, digits = 2) => + x === null || x === undefined || Number.isNaN(x) ? 'n/a' : x.toFixed(digits); +/** "median (min–max)" over runs. */ +const cell = (xs, digits = 2) => { + const s = stats(xs); + if (s === null) { + return 'n/a'; + } + if (s.n === 1) { + return fmt(s.median, digits); + } + return `${fmt(s.median, digits)} (${fmt(s.min, digits)}–${fmt(s.max, digits)})`; +}; +const ratio = (a, b) => + a === null || b === null || b === 0 ? 'n/a' : (a / b).toFixed(2); +const kb = (b) => (b === null ? null : b / 1024); + +/** Reads every `....json` in `dir`. */ +export function readResults(dir) { + const out = []; + for (const f of readdirSync(dir)) { + if (!/^[^.]+\.[^.]+\.(time|alloc|profile|count)\.\d+\.json$/.test(f)) { + continue; + } + out.push(JSON.parse(readFileSync(join(dir, f), 'utf8'))); + } + return out; +} + +const ARM_ORDER = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + +/** The op kinds (Scenario.opKind) seen in these runs' stats, sorted. */ +const kindsOf = (runs) => { + const kinds = new Set(); + for (const r of runs) { + for (const k of Object.keys(r.stats.byKind ?? {})) { + kinds.add(k); + } + } + return [...kinds].sort(); +}; + +export function writeSummary(dir) { + const results = readResults(dir); + const by = new Map(); // scenario -> arm -> mode -> [result] + const meta = { + gpu: new Set(), + throttle: new Set(), + load: [], + quick: false, + chrome: new Set(), + coi: new Set(), + clock: [], + flex: new Set(), + sources: new Map(), // arm -> Set of "solid …, renderer …" + }; + const failed = []; + for (const r of results) { + if (r.errors.length > 0) { + failed.push(`${r.scenario}.${r.arm}.${r.mode}.${r.run}`); + continue; + } + if (!by.has(r.scenario)) { + by.set(r.scenario, new Map()); + } + const arms = by.get(r.scenario); + if (!arms.has(r.arm)) { + arms.set(r.arm, { time: [], alloc: [], profile: [], count: [] }); + } + arms.get(r.arm)[r.mode].push(r); + if (r.env !== undefined) { + meta.gpu.add(r.env.gpu); + meta.chrome.add(r.env.chrome); + meta.coi.add(r.env.crossOriginIsolated); + meta.clock.push(r.env.clockResolutionUs); + } + if (r.source !== undefined) { + if (!meta.sources.has(r.arm)) { + meta.sources.set(r.arm, new Set()); + } + meta.sources + .get(r.arm) + .add(`solid ${r.source.solid}, renderer ${r.source.renderer}`); + } + meta.throttle.add(r.throttle); + meta.flex.add(r.flex); + meta.load.push(r.loadAvg); + meta.quick = meta.quick || r.quick === true; + } + const L = []; + L.push(`# Benchmark summary`); + L.push(''); + L.push( + `Generated by \`node bench/run.mjs\` from the ${results.length} result files in this directory. Methodology: [docs/perf/README.md](../../README.md).`, + ); + L.push(''); + L.push(`- CPU throttling: ${[...meta.throttle].join(', ')}x`); + L.push(`- GPU: ${[...meta.gpu].join('; ')}`); + L.push(`- Flex: ${[...meta.flex].join(', ')}`); + for (const a of [...meta.sources.keys()].sort(ARM_ORDER)) { + L.push(`- Arm ${a}: ${[...meta.sources.get(a)].join('; ')}`); + } + L.push(`- Chromium: ${[...meta.chrome].join(', ')}`); + L.push( + `- crossOriginIsolated: ${[...meta.coi].join(', ')}; clock resolution ${fmt(Math.max(...meta.clock), 1)} µs at worst`, + ); + L.push( + `- 1-minute load average at run start: ${fmt(Math.min(...meta.load), 1)}–${fmt(Math.max(...meta.load), 1)}`, + ); + if (failed.length > 0) { + L.push( + `- **Left out, with errors** (see their JSON): ${failed.join(', ')}`, + ); + } + if (meta.quick) { + L.push('- **Some runs used `--quick`** (fewer ops): smoke numbers only.'); + } + L.push(''); + L.push( + 'Each cell is the median over runs of the per-run value, with the min–max over runs in parentheses. Times are ms of wall clock under CPU throttling. The per-run value of a time is its **mean over ops**: the throttler is a duty cycle (at 6x about 0.17 ms running, 0.85 ms suspended), so a sub-millisecond interval reads either unthrottled or one suspension longer, and only the mean estimates rate × CPU (see the README).', + ); + L.push(''); + + const scenarios = [...by.keys()].sort(); + // Scenario.probe: the state after the measured ops against the state after + // the warmup (whole cycles: equal), and the final state across arms. + const stateNotes = []; + for (const s of scenarios) { + const finals = new Map(); + for (const [a, modesOf] of by.get(s)) { + for (const r of [ + ...modesOf.time, + ...modesOf.alloc, + ...modesOf.profile, + ...modesOf.count, + ]) { + if (r.state === undefined) { + continue; + } + if (r.state.cycleOk !== true) { + stateNotes.push( + `${s} ${a} ${r.mode} #${r.run}: the state after the measured ops differs from the state after the warmup`, + ); + } + if (r.mode === 'time' && !finals.has(a)) { + finals.set(a, JSON.stringify(r.state.end)); + } + } + } + if (new Set(finals.values()).size > 1) { + stateNotes.push( + `${s}: the final state differs between arms (${[...finals.keys()].sort().join(', ')}); see \`state.end\` in the time results`, + ); + } + } + if (stateNotes.length > 0) { + L.push('## State checks (Scenario.probe)'); + L.push(''); + for (const n of stateNotes) { + L.push(`- ${n}`); + } + L.push(''); + } + // Time + L.push('## Time per op (ms, per-run mean over ops)'); + L.push(''); + L.push( + '`total` = handler + tail + frame (keydown to the end of the frame that shows it); `cpu` adds every later frame until idle; `p95 total` is the per-run 95th percentile.', + ); + L.push(''); + L.push( + '| scenario | arm | handler | tail | frame | total | p95 total | cpu | drawn frames | settle | runs |', + ); + L.push('| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |'); + for (const s of scenarios) { + const arms = by.get(s); + for (const a of [...arms.keys()].sort(ARM_ORDER)) { + const t = arms.get(a).time; + if (t.length === 0) { + continue; + } + for (const kind of [null, ...kindsOf(t)]) { + const st = (r) => (kind === null ? r.stats : r.stats.byKind?.[kind]); + const m = (f, k = 'mean') => t.map((r) => st(r)?.[f]?.[k] ?? null); + L.push( + `| ${s} | ${a}${kind === null ? '' : ` [${kind}]`} | ${cell(m('handler'), 3)} | ${cell(m('tail'), 3)} | ${cell(m('frame'), 3)} | ${cell(m('total'), 3)} | ${cell(m('total', 'p95'), 3)} | ${cell(m('cpu'), 3)} | ${cell(m('frames'), 1)} | ${cell(m('settle'), 1)} | ${t.length} |`, + ); + } + } + } + L.push(''); + // Ratios + L.push('## Ratios (medians over runs)'); + L.push(''); + L.push( + "A/C: arm A's value over arm C's (above 1: C is faster or allocates less). The arms' Solid and renderer are listed above: when they are the same code, A/C is the in-session noise floor.", + ); + L.push(''); + L.push( + '| scenario | A/C total | A/C handler | A/C frame | A/C bytes/op | A/C writes/op |', + ); + L.push('| --- | --- | --- | --- | --- | --- |'); + for (const s of scenarios) { + const arms = by.get(s); + const tm = (a, f) => + arms.has(a) + ? median(arms.get(a).time.map((r) => r.stats[f]?.mean ?? null)) + : null; + const al = (a) => + arms.has(a) + ? median(arms.get(a).alloc.map((r) => r.bytesPerOp.total)) + : null; + const wr = (a) => + arms.has(a) ? median(arms.get(a).count.map((r) => r.stats.writes)) : null; + L.push( + `| ${s} | ${ratio(tm('A', 'total'), tm('C', 'total'))} | ${ratio(tm('A', 'handler'), tm('C', 'handler'))} | ${ratio(tm('A', 'frame'), tm('C', 'frame'))} | ${ratio(al('A'), al('C'))} | ${ratio(wr('A'), wr('C'))} |`, + ); + } + L.push(''); + // Allocation + L.push( + '## Allocation per op (KiB, sampling heap profiler, collected objects included)', + ); + L.push(''); + L.push( + "`total` is the app: framework + reactivity + renderer + user. `harness` (the op loop) and `no script` (allocations with no script frame on the stack, mostly Blink objects such as the harness's own MessageEvents) are shown beside it, not in it.", + ); + L.push(''); + L.push( + '| scenario | arm | total | framework | reactivity | renderer | user | harness | no script | runs |', + ); + L.push('| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |'); + const tops = []; + for (const s of scenarios) { + const arms = by.get(s); + for (const a of [...arms.keys()].sort(ARM_ORDER)) { + const al = arms.get(a).alloc; + if (al.length === 0) { + continue; + } + const b = (k) => al.map((r) => kb(r.bytesPerOp[k])); + L.push( + `| ${s} | ${a} | ${cell(b('total'), 2)} | ${cell(b('framework'), 2)} | ${cell(b('reactivity'), 2)} | ${cell(b('renderer'), 2)} | ${cell(b('user'), 2)} | ${cell(b('harness'), 2)} | ${cell(b('unattributed'), 2)} | ${al.length} |`, + ); + const sites = new Map(); + for (const r of al) { + for (const [k, v] of r.topSites) { + sites.set(k, (sites.get(k) ?? 0) + v / al.length); + } + } + tops.push([ + s, + a, + [...sites.entries()].sort((x, y) => y[1] - x[1]).slice(0, 5), + ]); + } + } + L.push(''); + if (tops.length > 0) { + L.push( + 'Top allocating functions (bytes per op, mean over runs; harness excluded):', + ); + L.push(''); + for (const [s, a, list] of tops) { + L.push( + `- ${s} ${a}: ${list.map(([k, v]) => `\`${k}\` ${v.toFixed(0)}`).join(', ') || 'none'}`, + ); + } + L.push(''); + } + // Count + L.push('## Counts per op (instrumented build; its timings are not reported)'); + L.push(''); + L.push( + "`node writes` and `shader writes` are setter calls on renderer nodes and on shader `props` made by app code (Solid and the scenario). A write made while renderer code is on the stack (a renderer frame, `createNode`/`createTextNode`/`createShader`/`animate`, another setter, a texture's event dispatch) is not counted; the app's listeners that the renderer calls (`loaded`, `idle`) are app code. `in frames`: the part of those writes made during a renderer frame (Solid's flex on `loaded`). Creation is counted on its own, never as writes: `created` nodes and their `creation props` (keys with a defined value in the bag passed to `createNode`/`createTextNode`; renderer 1.x applies the bag in its constructor, 2.x through its setters). `loaded`: emitted / heard by a listener (renderer 1.x emits on every text layout and texture load, 2.x queues one only for a node with a listener).", + ); + L.push(''); + L.push( + '| scenario | arm | flex passes | node writes | shader writes | in frames | animations | walks/drawn frame | drawn frames | created | creation props | loaded text (emitted / heard) | loaded other (emitted / heard) | runs |', + ); + L.push( + '| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- |', + ); + const textRows = []; + for (const s of scenarios) { + const arms = by.get(s); + for (const a of [...arms.keys()].sort(ARM_ORDER)) { + const c = arms.get(a).count; + if (c.length === 0) { + continue; + } + for (const kind of [null, ...kindsOf(c)]) { + const st = (r) => (kind === null ? r.stats : r.stats.byKind?.[kind]); + const v = (k) => c.map((r) => st(r)?.[k] ?? null); + L.push( + `| ${s} | ${a}${kind === null ? '' : ` [${kind}]`} | ${cell(v('flexPasses'), 1)} | ${cell(v('writes'), 1)} | ${cell(v('shaderWrites'), 1)} | ${cell(v('frameWrites'), 1)} | ${cell(v('animations'), 1)} | ${cell(v('walksPerFrame'), 2)} | ${cell(v('drawnFrames'), 1)} | ${cell(v('created'), 1)} | ${cell(v('creationProps'), 1)} | ${cell(v('loadedTextEmits'), 1)} / ${cell(v('loadedText'), 1)} | ${cell(v('loadedOtherEmits'), 1)} / ${cell(v('loadedOther'), 1)} | ${c.length} |`, + ); + } + const v = (k) => c.map((r) => r.stats[k]); + if (c[0].text === true) { + textRows.push( + `| ${s} | ${a} | ${cell(v('textLayouts'), 1)} | ${cell(v('textLayoutMs'), 3)} | ${cell(v('cacheHitRate'), 2)} | ${cell( + c.map((r) => r.stats.finalLayoutFrame?.median ?? null), + 1, + )} | ${cell( + c.map((r) => r.stats.finalLayoutMs?.median ?? null), + 1, + )} |`, + ); + } + } + } + L.push(''); + if (textRows.length > 0) { + L.push('### Text metrics per op (count mode)'); + L.push(''); + L.push( + '| scenario | arm | text layout calls | text layout ms | layout cache hit rate | frames to final layout | ms to final layout |', + ); + L.push('| --- | --- | --- | --- | --- | --- | --- |'); + L.push(...textRows); + L.push(''); + } + // Profile + const profRows = []; + for (const s of scenarios) { + const arms = by.get(s); + for (const a of [...arms.keys()].sort(ARM_ORDER)) { + const p = arms.get(a).profile; + if (p.length === 0) { + continue; + } + const chunks = new Map(); + const fns = new Map(); + for (const r of p) { + for (const [k, v] of Object.entries(r.selfPerOp.chunks)) { + chunks.set(k, (chunks.get(k) ?? 0) + v / p.length); + } + for (const [k, v] of r.selfPerOp.top) { + fns.set(k, (fns.get(k) ?? 0) + v / p.length); + } + } + profRows.push([ + s, + a, + chunks, + [...fns.entries()].sort((x, y) => y[1] - x[1]).slice(0, 20), + ]); + } + } + if (profRows.length > 0) { + L.push('## Profile: self time per op (ms, mean over runs)'); + L.push(''); + for (const [s, a, chunks, fns] of profRows) { + L.push(`### ${s} ${a}`); + L.push(''); + L.push( + `By chunk: ${[...chunks.entries()] + .sort((x, y) => y[1] - x[1]) + .map(([k, v]) => `${k} ${v.toFixed(3)}`) + .join(', ')}`, + ); + L.push(''); + L.push('| # | function (chunk:line) | self ms/op |'); + L.push('| --- | --- | --- |'); + fns.forEach(([k, v], i) => + L.push(`| ${i + 1} | \`${k}\` | ${v.toFixed(3)} |`), + ); + L.push(''); + } + } + writeFileSync(join(dir, 'summary.md'), L.join('\n')); + return join(dir, 'summary.md'); +} diff --git a/bench/harness/inclusive.mjs b/bench/harness/inclusive.mjs new file mode 100644 index 0000000..909a417 --- /dev/null +++ b/bench/harness/inclusive.mjs @@ -0,0 +1,125 @@ +// Inclusive CPU time per named function, from the raw profiles that +// `node bench/run.mjs --modes profile --save-profiles` writes beside its +// results (..profile..cpuprofile). +// +// node bench/harness/inclusive.mjs [scenario ...] +// +// For each scenario and arm: µs per op of every function in WATCH (a sample +// counts once per function however deep the recursion), averaged over the +// runs found, plus the self time per chunk. Functions nest (a Row's onRight +// runs inside the key dispatch), so the rows do not add up. +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { chunkOf } from './analyze.mjs'; + +/** [label, chunk, function-name pattern] */ +const WATCH = [ + ['key event (Solid listener)', 'framework', /^handleKeyEvents$/], + [' dispatch walk', 'framework', /^propagateKeyPress$/], + [' capture phase', 'framework', /^runCapturePhase$/], + [' bubble phase', 'framework', /^runBubblePhase$/], + [ + 'Row/Column navigation', + 'framework', + /^(handleNavigation|moveSelection|selectChild|navigableHandleNavigation)$/, + ], + [' scroll', 'framework', /^(withScrolling|scrollToIndex(\$\d)?)$/], + ['post-mutation pass', 'framework', /^runPostMutation$/], + [' flex layout', 'framework', /^(updateLayout|calculateFlex)$/], + ['focus change', 'framework', /^setActiveElementCore$/], + [' focus path', 'framework', /^updateFocusPath$/], + ['state styles', 'framework', /^_stateChanged$/], + [ + ' States ops', + 'framework', + /^(has|add|remove|merge|toggle|States|_super)$/, + ], + ['prop writes to the renderer', 'framework', /^_sendToLightningAnimatable$/], + [' animateProp', 'renderer', /^animateProp$/], + [ + 'shader prop writes', + 'framework', + /^(set|_writeShaderTarget|parseAndAssignShaderProps)$/, + ], + ['Solid setProp/spread', 'framework', /^(setProp|setProperty|spread)$/], + ['node creation', 'framework', /^(render|createElement|insertNode)$/], + [' renderer createNode', 'renderer', /^(createNode|createTextNode)$/], + ['reactivity (solid-js)', 'reactivity', /./], + ['renderer frame', 'renderer', /^(frame|tick)$/], + [' scene walk', 'renderer', /^(run|visit)$/], + [' draw', 'renderer', /^(draw|drawQuads|flush)$/], +]; + +const dir = process.argv[2]; +const only = new Set(process.argv.slice(3)); +if (dir === undefined) { + console.error('usage: node bench/harness/inclusive.mjs [scenario ...]'); + process.exit(1); +} + +const groups = new Map(); +for (const f of readdirSync(dir)) { + const m = /^(.+)\.([A-Z][a-z0-9-]*)\.profile\.(\d+)\.cpuprofile$/.exec(f); + if (m === null || (only.size > 0 && !only.has(m[1]))) continue; + const result = JSON.parse( + readFileSync(join(dir, f.replace(/\.cpuprofile$/, '.json')), 'utf8'), + ); + const key = `${m[1]} ${m[2]}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push({ + profile: JSON.parse(readFileSync(join(dir, f), 'utf8')), + measured: result.measured, + }); +} + +function analyze({ profile, measured }) { + const byId = new Map(); + const parent = new Map(); + for (const n of profile.nodes) { + byId.set(n.id, n); + for (const c of n.children ?? []) parent.set(c, n.id); + } + const incl = new Map(WATCH.map(([label]) => [label, 0])); + const self = new Map(); + const { samples, timeDeltas } = profile; + for (let i = 0; i < samples.length; i++) { + const dt = (timeDeltas[i + 1] ?? timeDeltas[i]) / 1000; // ms + const leaf = byId.get(samples[i]); + const leafChunk = + chunkOf(leaf.callFrame.url) ?? leaf.callFrame.functionName; + self.set(leafChunk, (self.get(leafChunk) ?? 0) + dt); + const seen = new Set(); + for (let id = samples[i]; id !== undefined; id = parent.get(id)) { + const cf = byId.get(id).callFrame; + const chunk = chunkOf(cf.url); + for (const [label, wantChunk, re] of WATCH) { + if (seen.has(label) || chunk !== wantChunk) continue; + if (re.test(cf.functionName)) { + seen.add(label); + incl.set(label, incl.get(label) + dt); + } + } + } + } + const perOp = (m) => + new Map([...m].map(([k, v]) => [k, (v * 1000) / measured])); // µs/op + return { incl: perOp(incl), self: perOp(self) }; +} + +const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length; +for (const [key, runs] of [...groups].sort()) { + const results = runs.map(analyze); + console.log(`\n## ${key} (${runs.length} run(s), µs per op)`); + for (const [label] of WATCH) { + const v = mean(results.map((r) => r.incl.get(label))); + if (v >= 0.5) console.log(`${label.padEnd(32)} ${v.toFixed(1)}`); + } + const chunks = new Set(results.flatMap((r) => [...r.self.keys()])); + const selfLine = [...chunks] + .map((c) => [c, mean(results.map((r) => r.self.get(c) ?? 0))]) + .filter(([, v]) => v >= 0.5) + .sort((a, b) => b[1] - a[1]) + .map(([c, v]) => `${c} ${v.toFixed(1)}`) + .join(', '); + console.log(`self by chunk: ${selfLine}`); +} diff --git a/bench/harness/probe.mjs b/bench/harness/probe.mjs new file mode 100644 index 0000000..eeccbb2 --- /dev/null +++ b/bench/harness/probe.mjs @@ -0,0 +1,1056 @@ +// The in-page half of the harness. `pageProbe` is serialized and injected +// with page.addInitScript (sourceURL bench-probe.js, so that the profilers +// charge what it does to the harness, not to the app), so it runs before the +// app's first script and must not close over anything in this module. +// +// What it installs at document start: +// - a requestAnimationFrame wrap that times every callback (performance.now +// around it) into preallocated typed arrays, and marks the callbacks that +// drew (gl.clear ran inside: both renderer majors clear once per drawn +// frame). A callback that ever drew is the renderer's loop; its later +// callbacks that did not draw are its undrawn frames (v1's idle polls, v2's +// upload-only frames). The wrap of a given callback is made once, so the +// wrap allocates nothing per frame. +// - an `idle` listener on the renderer, attached the moment main.tsx assigns +// `window.__bench` (a setter on window), after Solid's own. +// - `window.__benchProbe`, which the runner drives: ready(), info(), +// installCounters() (count mode), runOps(), countDetails(). +// +// One operation (runOp): aligned to just after a rendering step (a rAF, then +// a MessageChannel task), it calls scenario.step(i); a key name returned is +// dispatched as a keydown on `document`, timed alone (`handler`); otherwise +// the step itself is the handler. `tail` runs from the handler's end to the +// first task after the microtask checkpoint (a MessageChannel message posted +// at the handler's end), or to the start of the first rAF callback if a +// frame comes first (`tailCut`). Then it waits until the renderer is idle +// (its `idle` event, then `gapMs` with no drawn frame), or `quietMs` pass +// with no drawn frame at all (an op that changes nothing draws nothing), or +// `idleTimeoutMs` pass (`timedOut`). The keyup goes out only then, so it +// never lands in the op's frames; its handler is timed as `keyup`, and the +// probe waits for idle again before the next op. +// +// v1 and v2 below are @solidtv/renderer 1.x and 2.x. The arms here run 1.x; +// the 2.x hooks are kept for that renderer and never install on 1.x. +export function pageProbe(opts) { + const counting = opts.mode === 'count'; + const perf = window.performance; + const now = () => perf.now(); + const raf = window.requestAnimationFrame.bind(window); + const setTimer = window.setTimeout.bind(window); + const clearTimer = window.clearTimeout.bind(window); + + // Count mode counters. The count build's flex wrap increments `flex` and + // `flexHooks` (bench/vite.config.ts); installCounters() patches the rest. + const C = { + flex: 0, + flexHooks: 0, + writes: 0, + shaderWrites: 0, + frameWrites: 0, + created: 0, + creationProps: 0, + animations: 0, + walks: 0, + loadedText: 0, + loadedOther: 0, + loadedTextEmits: 0, + loadedOtherEmits: 0, + textLayouts: 0, + textLayoutMs: 0, + cacheHits: 0, + cacheMisses: 0, + }; + if (counting) { + window.__benchCount = C; + } + + // Drawn-frame detection: one gl.clear per drawn frame in both majors. + let clears = 0; + let drawHook = null; + const glTypes = [window.WebGLRenderingContext, window.WebGL2RenderingContext]; + for (let g = 0; g < glTypes.length; g++) { + const type = glTypes[g]; + if (type === undefined) { + continue; + } + const clear = type.prototype.clear; + type.prototype.clear = function (mask) { + clears++; + clear.call(this, mask); + }; + drawHook = 'gl.clear'; + } + + // The frame log: a ring of CAP callbacks. + const CAP = 1 << 15; + const MASK = CAP - 1; + const KIND_DRAWN = 1; + const KIND_UNDRAWN = 2; + const KIND_OTHER = 3; + const fStart = new Float64Array(CAP); + const fEnd = new Float64Array(CAP); + const fKind = new Uint8Array(CAP); + // Count mode: layout activity (flex passes + text layouts + text `loaded`) + // and walks inside each callback. + const fAct = new Float64Array(counting ? CAP : 1); + const fWalks = new Float64Array(counting ? CAP : 1); + let fCount = 0; + let inRaf = false; + // Count mode: > 0 while renderer code is on the stack (a renderer frame, a + // renderer method the counters wrap, a setter, an emitter's dispatch to + // the renderer's own listeners). A write counts only at 0: made by app + // code (Solid, the scenario), including the app's listeners that the + // renderer calls (they run at 0). + let rdepth = 0; + const rendererCallbacks = new WeakSet(); + const wrappers = new WeakMap(); + window.requestAnimationFrame = function (callback) { + let wrapped = wrappers.get(callback); + if (wrapped === undefined) { + wrapped = function (time) { + const c0 = clears; + let a0 = 0; + let w0 = 0; + if (counting) { + a0 = C.flex + C.textLayouts + C.loadedText; + w0 = C.walks; + } + const internal = counting && rendererCallbacks.has(callback); + if (internal) { + rdepth++; + } + inRaf = true; + const start = now(); + callback(time); + const end = now(); + inRaf = false; + if (internal) { + rdepth--; + } + const i = fCount & MASK; + fStart[i] = start; + fEnd[i] = end; + let kind = KIND_OTHER; + if (clears !== c0) { + rendererCallbacks.add(callback); + kind = KIND_DRAWN; + } else if (rendererCallbacks.has(callback)) { + kind = KIND_UNDRAWN; + } + fKind[i] = kind; + if (counting) { + fAct[i] = C.flex + C.textLayouts + C.loadedText - a0; + fWalks[i] = C.walks - w0; + } + fCount++; + }; + wrappers.set(callback, wrapped); + } + return raf(wrapped); + }; + + // Idle: the renderer's `idle` event, both majors. + let bench; + let idleCount = 0; + let idleFrame = -1; + let idleWaiter = null; + const onIdle = () => { + idleCount++; + // The callback running now is recorded at index fCount when it returns. + idleFrame = inRaf ? fCount : fCount - 1; + if (idleWaiter !== null) { + idleWaiter(); + } + }; + Object.defineProperty(window, '__bench', { + configurable: true, + enumerable: true, + get() { + return bench; + }, + set(value) { + bench = value; + value.renderer.on('idle', onIdle); + }, + }); + + // One task source for the harness's own continuations. + const channel = new MessageChannel(); + let onMessage = null; + channel.port1.onmessage = () => { + const f = onMessage; + onMessage = null; + if (f !== null) { + f(); + } + }; + const postTask = (f) => { + onMessage = f; + channel.port2.postMessage(0); + }; + const sleep = (ms) => new Promise((resolve) => setTimer(resolve, ms)); + /** Resolves in a task right after the next rendering step. */ + const frameAligned = () => + new Promise((resolve) => raf(() => postTask(resolve))); + + const drawnSince = (from) => { + for (let k = from; k < fCount; k++) { + if (fKind[k & MASK] === KIND_DRAWN) { + return true; + } + } + return false; + }; + + /** Resolves with true on timeout: see the header. */ + const settle = (frame0, idle0, t0) => + new Promise((resolve) => { + let gapTimer = 0; + let quietTimer = 0; + let finished = false; + const finish = (timedOut) => { + if (finished) { + return; + } + finished = true; + idleWaiter = null; + clearTimer(gapTimer); + clearTimer(quietTimer); + resolve(timedOut); + }; + const atIdle = () => { + clearTimer(gapTimer); + // Frames after the one that emitted idle (v2 emits it inside the + // drawn frame's callback, which is logged once it returns). + const mark = idleFrame + 1; + gapTimer = setTimer(() => { + gapTimer = 0; + if (!drawnSince(mark)) { + finish(false); + } + }, opts.gapMs); + }; + idleWaiter = atIdle; + if (idleCount !== idle0) { + atIdle(); + } + const quiet = () => { + if (now() - t0 > opts.idleTimeoutMs) { + finish(true); + return; + } + if (gapTimer === 0 && !drawnSince(frame0)) { + finish(false); + return; + } + quietTimer = setTimer(quiet, opts.quietMs); + }; + quietTimer = setTimer(quiet, opts.quietMs); + }); + + const KEY_CODES = { + ArrowLeft: 37, + ArrowUp: 38, + ArrowRight: 39, + ArrowDown: 40, + Enter: 13, + Escape: 27, + Backspace: 8, + }; + const keyEvent = (type, key) => + new KeyboardEvent(type, { + key, + code: key, + keyCode: KEY_CODES[key] || 0, + bubbles: true, + cancelable: true, + }); + + const snapshot = () => ({ + flex: C.flex, + writes: C.writes, + shaderWrites: C.shaderWrites, + frameWrites: C.frameWrites, + created: C.created, + creationProps: C.creationProps, + animations: C.animations, + walks: C.walks, + loadedText: C.loadedText, + loadedOther: C.loadedOther, + loadedTextEmits: C.loadedTextEmits, + loadedOtherEmits: C.loadedOtherEmits, + textLayouts: C.textLayouts, + textLayoutMs: C.textLayoutMs, + cacheHits: C.cacheHits, + cacheMisses: C.cacheMisses, + }); + const diff = (a, b) => { + const d = {}; + for (const k in a) { + d[k] = b[k] - a[k]; + } + return d; + }; + + /** The op's frames, [frame0, end]: see the header. */ + const frameStats = (rec, frame0, idle0, t0, tailEnd) => { + const end = + idleCount !== idle0 && idleFrame >= frame0 ? idleFrame : fCount - 1; + let first = -1; + let frames = 0; + let animCpu = 0; + let undrawnCpu = 0; + let otherRaf = 0; + let lastDrawnEnd = tailEnd; + let drawnOrdinal = 0; + let walks = 0; + for (let k = frame0; k <= end && k < fCount; k++) { + const j = k & MASK; + const d = fEnd[j] - fStart[j]; + const kind = fKind[j]; + if (kind === KIND_DRAWN) { + frames++; + drawnOrdinal++; + if (first < 0) { + first = k; + rec.frame = d; + rec.frameDelay = fStart[j] - tailEnd; + } else { + animCpu += d; + } + lastDrawnEnd = fEnd[j]; + } else if (kind === KIND_UNDRAWN) { + undrawnCpu += d; + } else { + otherRaf += d; + } + if (counting && kind !== KIND_OTHER) { + walks += fWalks[j]; + if (fAct[j] > 0) { + rec.finalLayoutFrame = drawnOrdinal; + rec.finalLayoutMs = fEnd[j] - t0; + } + } + } + rec.frames = frames; + rec.animCpu = animCpu; + rec.undrawnCpu = undrawnCpu; + rec.otherRaf = otherRaf; + rec.settle = lastDrawnEnd - t0; + rec.total = rec.handler + rec.tail + (rec.frame === null ? 0 : rec.frame); + rec.cpu = + rec.handler + + rec.tail + + (rec.frame === null ? 0 : rec.frame) + + animCpu + + undrawnCpu + + otherRaf; + if (counting) { + rec.frameWalks = walks; + } + }; + + const runOp = (i) => + new Promise((resolve) => { + const scenario = bench.scenario; + const kind = + typeof scenario.opKind === 'function' ? scenario.opKind(i) : null; + const frame0 = fCount; + const idle0 = idleCount; + const c0 = counting ? snapshot() : null; + const t0 = now(); + const key = scenario.step(i); + const isKey = typeof key === 'string'; + let handler; + let t1; + if (isKey) { + const event = keyEvent('keydown', key); + const tk = now(); + document.dispatchEvent(event); + t1 = now(); + handler = t1 - tk; + } else { + t1 = now(); + handler = t1 - t0; + } + postTask(() => { + let t2 = now(); + let tailCut = false; + if (fCount !== frame0 && fStart[frame0 & MASK] < t2) { + t2 = fStart[frame0 & MASK]; + tailCut = true; + } + const cTail = counting ? snapshot() : null; + settle(frame0, idle0, t0).then((timedOut) => { + const rec = { + i, + kind, + key: isKey ? key : null, + handler, + tail: t2 - t1, + tailCut, + frame: null, + frameDelay: null, + timedOut, + }; + if (counting) { + rec.finalLayoutFrame = null; + rec.finalLayoutMs = null; + const tailAct = + cTail.flex + + cTail.textLayouts + + cTail.loadedText - + (c0.flex + c0.textLayouts + c0.loadedText); + if (tailAct > 0) { + rec.finalLayoutFrame = 0; + rec.finalLayoutMs = t2 - t0; + } + } + frameStats(rec, frame0, idle0, t0, t2); + if (counting) { + rec.counts = diff(c0, snapshot()); + } + if (!isKey) { + resolve(rec); + return; + } + const up = keyEvent('keyup', key); + const upFrame0 = fCount; + const upIdle0 = idleCount; + const tu = now(); + document.dispatchEvent(up); + const tu1 = now(); + rec.keyup = tu1 - tu; + postTask(() => { + settle(upFrame0, upIdle0, tu1).then((upTimedOut) => { + let upFrames = 0; + for (let k = upFrame0; k < fCount; k++) { + if (fKind[k & MASK] === KIND_DRAWN) { + upFrames++; + } + } + rec.keyupFrames = upFrames; + rec.keyupTimedOut = upTimedOut; + resolve(rec); + }); + }); + }); + }); + }); + + // Count mode: runtime hooks on the renderer (both majors). See + // docs/perf/README.md, "Count mode". Every hook is found by name; one that + // finds nothing stays null, and the runner reports what it feeds as n/a + // (bench/harness/analyze.mjs, countStats). One that throws, or whose + // install throws, is counted in `hookErrors` and logged once with + // console.error, which the runner records as a run error: the renderer's + // frame loop swallows what a frame throws (handleLoopError, hooked below), + // so an unseen exception would otherwise read as zeroes. + const detail = { + nodeAccessors: 0, + nodeWrites: new Map(), + shaderWrites: new Map(), + shaderUnwrapped: 0, + animateHooks: 0, + walkHook: null, + textHook: null, + cacheHook: null, + loadedHook: null, + createHook: null, + loopHook: null, + hookErrors: new Map(), + }; + // An exception passes every hook on the stack (and the loop's handler): + // it is charged once, to the innermost. + const charged = new WeakSet(); + const hookFailed = (name, error) => { + if (error !== null && typeof error === 'object') { + if (charged.has(error)) { + return; + } + charged.add(error); + } + const known = detail.hookErrors.get(name); + if (known !== undefined) { + known.count++; + return; + } + const message = + error !== null && typeof error === 'object' && error.stack + ? String(error.stack) + : String(error); + detail.hookErrors.set(name, { count: 1, first: message }); + console.error(`bench count: ${name}: ${message}`); + }; + /** `fn` as a hook: what it throws is counted as the hook's error, then thrown on. */ + const guarded = (name, fn) => + function () { + try { + return fn.apply(this, arguments); + } catch (e) { + hookFailed(name, e); + throw e; + } + }; + /** One hook's install: a throw leaves that hook uninstalled (null), not the run dead. */ + const attempt = (name, install) => { + try { + install(); + } catch (e) { + hookFailed(`${name} (install)`, e); + } + }; + const installCounters = () => { + const r = bench.renderer; + const patched = new WeakSet(); + const bump = (map, name) => map.set(name, (map.get(name) || 0) + 1); + const wrappedShaders = new WeakSet(); + // A shader node's `props` object (v1: own non-configurable accessors per + // node; v2: a facade whose prototype's accessors are non-configurable) + // is swapped for a forwarding object that counts each write. + const wrapShader = (sn) => { + if (sn === null || typeof sn !== 'object' || wrappedShaders.has(sn)) { + return; + } + wrappedShaders.add(sn); + const props = sn.props; + if (props === null || typeof props !== 'object') { + return; + } + let field = null; + for (const k of Object.keys(sn)) { + if (sn[k] === props) { + field = k; + break; + } + } + if (field === null) { + detail.shaderUnwrapped++; + return; + } + // Every prop name: for…in lists the enumerable ones (v2's facade), and + // v1 defines each prop as a non-enumerable accessor on the node's own + // `definedProps`, which only getOwnPropertyNames lists. Missing them + // would swap in an empty wrapper: no write counted (0, not n/a), and + // the shader's next update would read undefined props and throw. + const names = []; + for (const name in props) { + names.push(name); + } + for (const name of Object.getOwnPropertyNames(props)) { + const d = Object.getOwnPropertyDescriptor(props, name); + if (d.get !== undefined && !names.includes(name)) { + names.push(name); + } + } + if (names.length === 0) { + return; + } + const wrapper = {}; + for (const name of names) { + Object.defineProperty(wrapper, name, { + enumerable: true, + configurable: true, + get() { + return props[name]; + }, + set(v) { + if (rdepth === 0) { + C.shaderWrites++; + if (inRaf) { + C.frameWrites++; + } + bump(detail.shaderWrites, name); + } + rdepth++; + try { + props[name] = v; + } finally { + rdepth--; + } + }, + }); + } + sn[field] = wrapper; + }; + const patchProto = (proto) => { + for ( + let p = proto; + p !== null && p !== Object.prototype; + p = Object.getPrototypeOf(p) + ) { + if (patched.has(p)) { + continue; + } + patched.add(p); + for (const name of Object.getOwnPropertyNames(p)) { + const d = Object.getOwnPropertyDescriptor(p, name); + if (d === undefined || d.configurable !== true) { + continue; + } + if (typeof d.set === 'function') { + const set = d.set; + const isShader = name === 'shader'; + detail.nodeAccessors++; + Object.defineProperty(p, name, { + get: d.get, + enumerable: d.enumerable, + configurable: true, + set(v) { + if (rdepth === 0) { + C.writes++; + if (inRaf) { + C.frameWrites++; + } + bump(detail.nodeWrites, name); + } + rdepth++; + try { + set.call(this, v); + } finally { + rdepth--; + } + if (isShader) { + wrapShader(v); + } + }, + }); + } else if ( + (name === 'animate' || name === 'animateProp') && + typeof d.value === 'function' + ) { + const fn = d.value; + detail.animateHooks++; + p[name] = function () { + if (rdepth === 0) { + C.animations++; + } + rdepth++; + try { + return fn.apply(this, arguments); + } finally { + rdepth--; + } + }; + } else if (name === 'insertBefore' && typeof d.value === 'function') { + // Renderer 2.x only, inactive on 1.x (no insertBefore there): a + // second way to attach a child; the `parent` setter is the + // other, so both count as a write (`insertBefore` in topWrites). + const fn = d.value; + p[name] = function () { + if (rdepth === 0) { + C.writes++; + if (inRaf) { + C.frameWrites++; + } + bump(detail.nodeWrites, name); + } + rdepth++; + try { + return fn.apply(this, arguments); + } finally { + rdepth--; + } + }; + } + } + } + }; + const nodeProto = Object.getPrototypeOf(r.root); + patchProto(nodeProto); + // Every node made later: its class may be one not seen in the tree yet. + // The creation bag is counted on its own (`creationProps`: its keys with + // a defined value), the same on both majors: renderer 1.x applies it in + // the constructor, 2.x through the public setters, and neither counts + // as a write. + const created = []; + for (const method of ['createNode', 'createTextNode']) { + const create = r[method]; + if (typeof create !== 'function') { + continue; + } + created.push(method); + r[method] = function (props) { + if (rdepth === 0) { + C.created++; + if (props !== null && typeof props === 'object') { + for (const k in props) { + if (props[k] !== undefined) { + C.creationProps++; + } + } + } + } + rdepth++; + let node; + try { + node = create.apply(this, arguments); + } finally { + rdepth--; + } + patchProto(Object.getPrototypeOf(node)); + return node; + }; + } + if (created.length > 0) { + detail.createHook = created.join(' + '); + } + const createShader = r.createShader; + r.createShader = function () { + rdepth++; + let sn; + try { + sn = createShader.apply(this, arguments); + } finally { + rdepth--; + } + wrapShader(sn); + return sn; + }; + const visit = (node) => { + patchProto(Object.getPrototypeOf(node)); + const sn = node.shader; + if (sn !== undefined && sn !== null) { + wrapShader(sn); + } + const children = node.children; + if (children !== undefined && children !== null) { + for (let i = 0; i < children.length; i++) { + visit(children[i]); + } + } + }; + visit(r.root); + // The emitter's emit, wherever the chain defines it. `loaded` on nodes: + // every emit (v1 emits for every layout and texture load, listener or + // not; v2 queues one only for a node with a listener) and the ones a + // listener hears (both majors keep listeners in `eventListeners`). + // Listeners on nodes and on the renderer are the app's (they run at + // rdepth 0); any other emitter's (a texture's) are the renderer's own. + const listened = (emitter, event) => { + const map = emitter.eventListeners; + const list = map !== null && map !== undefined ? map[event] : undefined; + return list !== undefined && list !== null && list.length > 0; + }; + attempt('loadedHook', () => { + let ep = nodeProto; + while (ep !== null && !Object.prototype.hasOwnProperty.call(ep, 'emit')) { + ep = Object.getPrototypeOf(ep); + } + if (ep === null) { + return; + } + const emit = ep.emit; + ep.emit = guarded('loadedHook', function (event, data) { + const isNode = + Object.prototype.isPrototypeOf.call(nodeProto, this) === true; + if (event === 'loaded' && isNode) { + const text = + data !== undefined && data !== null && data.type === 'text'; + if (text) { + C.loadedTextEmits++; + } else { + C.loadedOtherEmits++; + } + if (listened(this, event)) { + if (text) { + C.loadedText++; + } else { + C.loadedOther++; + } + } + } + const app = isNode || this === r; + const saved = rdepth; + rdepth = app ? 0 : rdepth + 1; + try { + return emit.apply(this, arguments); + } finally { + rdepth = saved; + } + }); + detail.loadedHook = 'emit on the node prototype chain'; + }); + attempt('walkHook', () => { + if (r.scene !== undefined && typeof r.scene.run === 'function') { + // Renderer 2.x only, inactive on 1.x: one ScenePass.run per walk. + const scene = r.scene; + const run = scene.run; + scene.run = guarded('walkHook', function () { + C.walks++; + return run.apply(this, arguments); + }); + detail.walkHook = 'v2 ScenePass.run'; + } else if ( + r.stage !== undefined && + typeof r.stage.drawFrame === 'function' + ) { + // Renderer v1: Stage.drawFrame's update loop reads `reprocessFrame` + // twice per iteration (at its top and in its `while`). + const stage = r.stage; + let value = stage.reprocessFrame; + let reads = 0; + let inDraw = false; + Object.defineProperty(stage, 'reprocessFrame', { + configurable: true, + get() { + if (inDraw) { + reads++; + } + return value; + }, + set(v) { + value = v; + }, + }); + const drawFrame = stage.drawFrame; + stage.drawFrame = guarded('walkHook', function () { + inDraw = true; + reads = 0; + try { + return drawFrame.apply(this, arguments); + } finally { + inDraw = false; + C.walks += reads / 2; + } + }); + detail.walkHook = 'v1 Stage.drawFrame update-loop iterations'; + } + }); + // Text layout and its cache. Each hook forwards every argument it is + // called with, so a changed signature (LayoutCache.get took a key string, + // then a font record id and the node's text props) cannot break the + // renderer's own call, which the frame loop would swallow. + attempt('textHook', () => { + const tn = r.textNodes; + if (tn !== undefined && tn !== null) { + // Renderer 2.x only, inactive on 1.x (no textNodes there): + // TextNodes.lay (every layout, from a walk's visit or from + // TextNode.measure()), else TextNodes.layoutText (every walk visit + // with DIRTY_LAYOUT, in early 2.0 alphas). + const name = + typeof tn.lay === 'function' + ? 'lay' + : typeof tn.layoutText === 'function' + ? 'layoutText' + : null; + if (name !== null) { + const layout = tn[name]; + tn[name] = guarded('textHook', function () { + const s = now(); + const out = layout.apply(this, arguments); + C.textLayoutMs += now() - s; + C.textLayouts++; + return out; + }); + detail.textHook = `v2 TextNodes.${name}`; + } + const cache = tn.cache; + if ( + cache !== undefined && + cache !== null && + typeof cache.get === 'function' + ) { + const get = cache.get; + cache.get = guarded('cacheHook', function () { + const layout = get.apply(this, arguments); + if (layout === undefined || layout === null) { + C.cacheMisses++; + } else { + C.cacheHits++; + } + return layout; + }); + detail.cacheHook = 'v2 LayoutCache.get'; + } + } else if ( + r.stage !== undefined && + r.stage.textRenderers !== undefined && + r.stage.textRenderers.sdf !== undefined && + typeof r.stage.textRenderers.sdf.renderText === 'function' + ) { + // Renderer v1: SdfTextRenderer.renderText (cache lookup + layout). + // A layout object returned before is a cache hit. + const sdf = r.stage.textRenderers.sdf; + const renderText = sdf.renderText; + const seen = new WeakSet(); + sdf.renderText = guarded('textHook', function () { + const s = now(); + const out = renderText.apply(this, arguments); + C.textLayoutMs += now() - s; + C.textLayouts++; + const layout = + out !== undefined && out !== null ? out.layout : undefined; + if (layout !== undefined && layout !== null) { + if (seen.has(layout)) { + C.cacheHits++; + } else { + seen.add(layout); + C.cacheMisses++; + } + } + return out; + }); + detail.textHook = 'v1 SdfTextRenderer.renderText'; + detail.cacheHook = 'v1 renderText layout identity'; + } + }); + // Both majors' frame loops swallow what a frame, or a listener it calls, + // throws: the frame is not drawn and is tried again, so a throwing hook + // would read as zero frames and zero counts. They hand it to a no-op by + // default: 1.x reads stage.options.handleLoopError, a copy the Stage made + // of the setting when it was built, so replacing the setting later does + // nothing there; 2.x (inactive on 1.x) reads settings.handleLoopError on + // every error. Hook the one the loop reads. + attempt('loop', () => { + const options = + r.stage !== undefined && r.stage !== null ? r.stage.options : undefined; + let owner = null; + let label = null; + if ( + options !== undefined && + options !== null && + typeof options.handleLoopError === 'function' + ) { + owner = options; + label = 'v1 stage.options.handleLoopError'; + } else if ( + r.settings !== undefined && + r.settings !== null && + typeof r.settings.handleLoopError === 'function' + ) { + owner = r.settings; + label = 'v2 settings.handleLoopError'; + } + if (owner !== null) { + const handle = owner.handleLoopError; + owner.handleLoopError = function (error) { + hookFailed('renderer frame loop', error); + return handle.apply(this, arguments); + }; + detail.loopHook = label; + } + }); + return { + nodeAccessors: detail.nodeAccessors, + animateHooks: detail.animateHooks, + createHook: detail.createHook, + walkHook: detail.walkHook, + textHook: detail.textHook, + cacheHook: detail.cacheHook, + loadedHook: detail.loadedHook, + loopHook: detail.loopHook, + drawHook, + flexHooks: C.flexHooks, + }; + }; + + window.__benchProbe = { + /** Fonts, the scenario's own ready(), then 300 ms without a drawn frame. */ + async ready(timeoutMs) { + const deadline = now() + timeoutMs; + await bench.fontsLoaded; + if (typeof bench.scenario.ready === 'function') { + await bench.scenario.ready(); + } + for (;;) { + const mark = fCount; + await sleep(300); + if (!drawnSince(mark)) { + return; + } + if (now() > deadline) { + throw new Error('the scene did not settle after mount'); + } + } + }, + info() { + let res = Infinity; + for (let k = 0; k < 2000; k++) { + const a = perf.now(); + let b = perf.now(); + while (b === a) { + b = perf.now(); + } + if (b - a < res) { + res = b - a; + } + } + let gpu = null; + const canvas = document.querySelector('canvas'); + if (canvas !== null) { + const gl = canvas.getContext('webgl') || canvas.getContext('webgl2'); + if (gl !== null) { + const ext = gl.getExtension('WEBGL_debug_renderer_info'); + gpu = gl.getParameter( + ext !== null ? ext.UNMASKED_RENDERER_WEBGL : gl.RENDERER, + ); + } + } + const s = bench.scenario; + return { + arm: bench.arm, + rendererMajor: bench.rendererMajor, + instrumented: bench.instrumented, + flex: bench.flex, + scenario: s.id, + title: s.title, + text: s.text === true, + warmup: s.warmup === undefined ? 30 : s.warmup, + measured: s.measured === undefined ? 60 : s.measured, + cycle: typeof s.cycle === 'number' && s.cycle > 0 ? s.cycle : null, + scenarios: bench.scenarios, + crossOriginIsolated: window.crossOriginIsolated === true, + clockResolutionUs: res * 1000, + gpu, + userAgent: window.navigator.userAgent, + flexHooks: C.flexHooks, + }; + }, + installCounters, + /** The scenario's state snapshot (Scenario.probe), or null. */ + state() { + const p = bench.scenario.probe; + return typeof p === 'function' ? JSON.parse(JSON.stringify(p())) : null; + }, + async runOps(first, count, record) { + const out = []; + for (let k = 0; k < count; k++) { + await frameAligned(); + const rec = await runOp(first + k); + if (record) { + out.push(rec); + } + } + return out; + }, + /** Count mode: writes by name over everything since installCounters(), and the hooks' errors. */ + countDetails() { + return { + nodeWrites: Array.from(detail.nodeWrites.entries()), + shaderWrites: Array.from(detail.shaderWrites.entries()), + // Shader nodes whose `props` could not be swapped for a counter. + shaderUnwrapped: detail.shaderUnwrapped, + // [hook, times it threw, the first throw], including install failures. + hookErrors: Array.from(detail.hookErrors.entries()).map(([k, v]) => [ + k, + v.count, + v.first, + ]), + }; + }, + /** For debugging the harness: the frame log's tail and the idle count. */ + debug(n) { + const out = []; + for (let k = Math.max(0, fCount - n); k < fCount; k++) { + const j = k & MASK; + out.push([k, fKind[j], fStart[j], fEnd[j] - fStart[j]]); + } + return { fCount, idleCount, idleFrame, clears, frames: out }; + }, + resetCountDetails() { + detail.nodeWrites.clear(); + detail.shaderWrites.clear(); + }, + }; +} diff --git a/bench/index.html b/bench/index.html new file mode 100644 index 0000000..bf51571 --- /dev/null +++ b/bench/index.html @@ -0,0 +1,18 @@ + + + + + SolidTV bench + + + +
+ + + diff --git a/bench/prepare-arms.mjs b/bench/prepare-arms.mjs new file mode 100644 index 0000000..270236b --- /dev/null +++ b/bench/prepare-arms.mjs @@ -0,0 +1,101 @@ +// Prepares the benchmark arms under bench/.arms (gitignored). +// +// A: solid 1.6.4 on renderer 1.9.3 (what ships today) +// C: this working tree (src/) on its installed @solidtv/renderer +// (node_modules/@solidtv/renderer, whatever version that is) +// +// Re-running is cheap: an arm already prepared at the pinned ref is kept. +import { execSync } from 'node:child_process'; +import { + existsSync, + mkdirSync, + readFileSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const solidRoot = resolve(here, '..'); +const armsDir = join(here, '.arms'); +const rendererC = realpathSync( + join(solidRoot, 'node_modules', '@solidtv', 'renderer'), +); +const rendererCVersion = JSON.parse( + readFileSync(join(rendererC, 'package.json'), 'utf8'), +).version; + +export const PINS = { + solidA: '71c170f', // v1.6.4 + rendererA: '1.9.3', // npm +}; + +export const ARMS = { + A: { + solid: join(armsDir, 'solid-a'), + renderer: join(armsDir, 'renderer-a'), + rendererMajor: 1, + rendererVersion: PINS.rendererA, + }, + C: { + solid: solidRoot, + renderer: rendererC, + rendererMajor: Number(rendererCVersion.split('.')[0]), + rendererVersion: rendererCVersion, + }, +}; + +const sh = (cmd, cwd) => + execSync(cmd, { cwd, stdio: 'inherit', shell: '/bin/bash' }); + +function stamped(dir, pin) { + const f = join(dir, '.bench-pin'); + return existsSync(f) && readFileSync(f, 'utf8') === pin; +} +function stamp(dir, pin) { + writeFileSync(join(dir, '.bench-pin'), pin); +} + +function gitArchive(repo, ref, dir) { + rmSync(dir, { recursive: true, force: true }); + mkdirSync(dir, { recursive: true }); + sh(`git -C "${repo}" archive ${ref} | tar -x -C "${dir}"`); +} + +export function prepareArms() { + mkdirSync(armsDir, { recursive: true }); + + if (!stamped(ARMS.A.solid, PINS.solidA)) { + gitArchive(solidRoot, PINS.solidA, ARMS.A.solid); + stamp(ARMS.A.solid, PINS.solidA); + } + if (!stamped(ARMS.A.renderer, PINS.rendererA)) { + rmSync(ARMS.A.renderer, { recursive: true, force: true }); + mkdirSync(ARMS.A.renderer, { recursive: true }); + const tgz = execSync( + `npm pack @solidtv/renderer@${PINS.rendererA} --silent`, + { + cwd: armsDir, + }, + ) + .toString() + .trim() + .split('\n') + .pop(); + sh( + `tar -xzf "${tgz}" -C "${ARMS.A.renderer}" --strip-components=1 && rm "${tgz}"`, + armsDir, + ); + stamp(ARMS.A.renderer, PINS.rendererA); + } + // C builds from the working tree and the installed renderer's dist as + // they are: nothing to prepare. + return ARMS; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + prepareArms(); + console.log(JSON.stringify(ARMS, null, 2)); +} diff --git a/bench/public/fonts/README.md b/bench/public/fonts/README.md new file mode 100644 index 0000000..e78ebca --- /dev/null +++ b/bench/public/fonts/README.md @@ -0,0 +1,11 @@ +# Bench fonts + +`Roboto-Regular.msdf.{json,png}` and `Roboto-Bold.msdf.{json,png}` are MSDF +atlases of **Roboto** (Christian Robertson, Google), the same files as +solid-demo-app's `public/fonts`. The benchmark app loads them as the families +`Roboto` and `Roboto700` (`bench/src/main.tsx`). + +Roboto: Copyright 2011 Google Inc. Licensed under the Apache License, Version +2.0 (https://www.apache.org/licenses/LICENSE-2.0). Source: +https://github.com/googlefonts/roboto and +https://fonts.google.com/specimen/Roboto. diff --git a/bench/public/fonts/Roboto-Bold.msdf.json b/bench/public/fonts/Roboto-Bold.msdf.json new file mode 100644 index 0000000..5a0a05f --- /dev/null +++ b/bench/public/fonts/Roboto-Bold.msdf.json @@ -0,0 +1,4254 @@ +{ + "pages": [ + "Roboto-Bold.png" + ], + "chars": [ + { + "id": 40, + "index": 12, + "char": "(", + "width": 15, + "height": 46, + "xoffset": 1, + "yoffset": -3.5, + "xadvance": 14.745117, + "chnl": 15, + "x": 0, + "y": 0, + "page": 0 + }, + { + "id": 41, + "index": 13, + "char": ")", + "width": 16, + "height": 46, + "xoffset": -1, + "yoffset": -3.5, + "xadvance": 14.806641, + "chnl": 15, + "x": 16, + "y": 0, + "page": 0 + }, + { + "id": 91, + "index": 63, + "char": "[", + "width": 13, + "height": 46, + "xoffset": 0, + "yoffset": -5.5, + "xadvance": 11.668945, + "chnl": 15, + "x": 33, + "y": 0, + "page": 0 + }, + { + "id": 93, + "index": 65, + "char": "]", + "width": 13, + "height": 46, + "xoffset": -2, + "yoffset": -5.5, + "xadvance": 11.668945, + "chnl": 15, + "x": 47, + "y": 0, + "page": 0 + }, + { + "id": 106, + "index": 78, + "char": "j", + "width": 15, + "height": 44, + "xoffset": -4, + "yoffset": -1.5, + "xadvance": 10.910156, + "chnl": 15, + "x": 61, + "y": 0, + "page": 0 + }, + { + "id": 123, + "index": 95, + "char": "{", + "width": 16, + "height": 44, + "xoffset": -1, + "yoffset": -3.5, + "xadvance": 13.863281, + "chnl": 15, + "x": 77, + "y": 0, + "page": 0 + }, + { + "id": 125, + "index": 97, + "char": "}", + "width": 16, + "height": 44, + "xoffset": -1, + "yoffset": -3.5, + "xadvance": 13.863281, + "chnl": 15, + "x": 94, + "y": 0, + "page": 0 + }, + { + "id": 36, + "index": 8, + "char": "$", + "width": 24, + "height": 43, + "xoffset": 0, + "yoffset": -5.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 0, + "y": 47, + "page": 0 + }, + { + "id": 64, + "index": 36, + "char": "@", + "width": 38, + "height": 42, + "xoffset": 0, + "yoffset": 0.5, + "xadvance": 37.59082, + "chnl": 15, + "x": 61, + "y": 45, + "page": 0 + }, + { + "id": 81, + "index": 53, + "char": "Q", + "width": 29, + "height": 40, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 28.998047, + "chnl": 15, + "x": 25, + "y": 88, + "page": 0 + }, + { + "id": 87, + "index": 59, + "char": "W", + "width": 39, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 36.729492, + "chnl": 15, + "x": 55, + "y": 88, + "page": 0 + }, + { + "id": 124, + "index": 96, + "char": "|", + "width": 8, + "height": 39, + "xoffset": 2, + "yoffset": -0.5, + "xadvance": 10.623047, + "chnl": 15, + "x": 25, + "y": 47, + "page": 0 + }, + { + "id": 92, + "index": 64, + "char": "\\", + "width": 23, + "height": 36, + "xoffset": -2, + "yoffset": -0.5, + "xadvance": 17.71875, + "chnl": 15, + "x": 0, + "y": 91, + "page": 0 + }, + { + "id": 47, + "index": 19, + "char": "/", + "width": 19, + "height": 36, + "xoffset": -2, + "yoffset": -0.5, + "xadvance": 15.688477, + "chnl": 15, + "x": 95, + "y": 88, + "page": 0 + }, + { + "id": 98, + "index": 70, + "char": "b", + "width": 24, + "height": 36, + "xoffset": 0, + "yoffset": -1.5, + "xadvance": 23.645508, + "chnl": 15, + "x": 34, + "y": 47, + "page": 0 + }, + { + "id": 100, + "index": 72, + "char": "d", + "width": 24, + "height": 36, + "xoffset": -1, + "yoffset": -1.5, + "xadvance": 23.666016, + "chnl": 15, + "x": 100, + "y": 45, + "page": 0 + }, + { + "id": 102, + "index": 74, + "char": "f", + "width": 18, + "height": 36, + "xoffset": -1, + "yoffset": -2.5, + "xadvance": 15.052734, + "chnl": 15, + "x": 111, + "y": 0, + "page": 0 + }, + { + "id": 104, + "index": 76, + "char": "h", + "width": 23, + "height": 36, + "xoffset": 0, + "yoffset": -1.5, + "xadvance": 23.501953, + "chnl": 15, + "x": 115, + "y": 82, + "page": 0 + }, + { + "id": 107, + "index": 79, + "char": "k", + "width": 25, + "height": 36, + "xoffset": 0, + "yoffset": -1.5, + "xadvance": 22.435547, + "chnl": 15, + "x": 125, + "y": 37, + "page": 0 + }, + { + "id": 108, + "index": 80, + "char": "l", + "width": 10, + "height": 36, + "xoffset": 1, + "yoffset": -1.5, + "xadvance": 11.135742, + "chnl": 15, + "x": 130, + "y": 0, + "page": 0 + }, + { + "id": 109, + "index": 81, + "char": "m", + "width": 36, + "height": 27, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 36.360352, + "chnl": 15, + "x": 141, + "y": 0, + "page": 0 + }, + { + "id": 37, + "index": 9, + "char": "%", + "width": 31, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 31.007813, + "chnl": 15, + "x": 139, + "y": 74, + "page": 0 + }, + { + "id": 38, + "index": 10, + "char": "&", + "width": 30, + "height": 35, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 27.5625, + "chnl": 15, + "x": 151, + "y": 28, + "page": 0 + }, + { + "id": 48, + "index": 20, + "char": "0", + "width": 24, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 171, + "y": 64, + "page": 0 + }, + { + "id": 51, + "index": 23, + "char": "3", + "width": 25, + "height": 35, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 182, + "y": 0, + "page": 0 + }, + { + "id": 54, + "index": 26, + "char": "6", + "width": 25, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 208, + "y": 0, + "page": 0 + }, + { + "id": 56, + "index": 28, + "char": "8", + "width": 24, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 196, + "y": 36, + "page": 0 + }, + { + "id": 57, + "index": 29, + "char": "9", + "width": 24, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 221, + "y": 36, + "page": 0 + }, + { + "id": 63, + "index": 35, + "char": "?", + "width": 22, + "height": 35, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 20.897461, + "chnl": 15, + "x": 234, + "y": 0, + "page": 0 + }, + { + "id": 67, + "index": 39, + "char": "C", + "width": 28, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 27.480469, + "chnl": 15, + "x": 196, + "y": 72, + "page": 0 + }, + { + "id": 71, + "index": 43, + "char": "G", + "width": 28, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 28.608398, + "chnl": 15, + "x": 225, + "y": 72, + "page": 0 + }, + { + "id": 77, + "index": 49, + "char": "M", + "width": 35, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 36.791016, + "chnl": 15, + "x": 55, + "y": 123, + "page": 0 + }, + { + "id": 79, + "index": 51, + "char": "O", + "width": 29, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 28.998047, + "chnl": 15, + "x": 0, + "y": 129, + "page": 0 + }, + { + "id": 83, + "index": 55, + "char": "S", + "width": 27, + "height": 35, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 25.819336, + "chnl": 15, + "x": 0, + "y": 165, + "page": 0 + }, + { + "id": 103, + "index": 75, + "char": "g", + "width": 24, + "height": 35, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 23.973633, + "chnl": 15, + "x": 171, + "y": 100, + "page": 0 + }, + { + "id": 105, + "index": 77, + "char": "i", + "width": 11, + "height": 35, + "xoffset": 0, + "yoffset": -1.5, + "xadvance": 11.135742, + "chnl": 15, + "x": 30, + "y": 129, + "page": 0 + }, + { + "id": 112, + "index": 84, + "char": "p", + "width": 24, + "height": 35, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 23.645508, + "chnl": 15, + "x": 139, + "y": 110, + "page": 0 + }, + { + "id": 113, + "index": 85, + "char": "q", + "width": 24, + "height": 35, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 23.727539, + "chnl": 15, + "x": 0, + "y": 201, + "page": 0 + }, + { + "id": 121, + "index": 93, + "char": "y", + "width": 25, + "height": 35, + "xoffset": -2, + "yoffset": 7.5, + "xadvance": 21.082031, + "chnl": 15, + "x": 25, + "y": 201, + "page": 0 + }, + { + "id": 33, + "index": 5, + "char": "!", + "width": 11, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 11.422852, + "chnl": 15, + "x": 28, + "y": 165, + "page": 0 + }, + { + "id": 35, + "index": 7, + "char": "#", + "width": 27, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 25.019531, + "chnl": 15, + "x": 40, + "y": 165, + "page": 0 + }, + { + "id": 49, + "index": 21, + "char": "1", + "width": 17, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 115, + "y": 119, + "page": 0 + }, + { + "id": 50, + "index": 22, + "char": "2", + "width": 25, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 51, + "y": 200, + "page": 0 + }, + { + "id": 52, + "index": 24, + "char": "4", + "width": 26, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 68, + "y": 158, + "page": 0 + }, + { + "id": 53, + "index": 25, + "char": "5", + "width": 24, + "height": 34, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 77, + "y": 193, + "page": 0 + }, + { + "id": 55, + "index": 27, + "char": "7", + "width": 25, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 24.09668, + "chnl": 15, + "x": 95, + "y": 154, + "page": 0 + }, + { + "id": 59, + "index": 31, + "char": ";", + "width": 12, + "height": 34, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 11.012695, + "chnl": 15, + "x": 42, + "y": 129, + "page": 0 + }, + { + "id": 65, + "index": 37, + "char": "A", + "width": 32, + "height": 34, + "xoffset": -2, + "yoffset": -0.5, + "xadvance": 28.259766, + "chnl": 15, + "x": 196, + "y": 108, + "page": 0 + }, + { + "id": 66, + "index": 38, + "char": "B", + "width": 26, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 26.803711, + "chnl": 15, + "x": 229, + "y": 108, + "page": 0 + }, + { + "id": 68, + "index": 40, + "char": "D", + "width": 27, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 27.295898, + "chnl": 15, + "x": 164, + "y": 136, + "page": 0 + }, + { + "id": 69, + "index": 41, + "char": "E", + "width": 24, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 23.625, + "chnl": 15, + "x": 133, + "y": 146, + "page": 0 + }, + { + "id": 70, + "index": 42, + "char": "F", + "width": 23, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 23.009766, + "chnl": 15, + "x": 102, + "y": 189, + "page": 0 + }, + { + "id": 72, + "index": 44, + "char": "H", + "width": 28, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 29.674805, + "chnl": 15, + "x": 192, + "y": 143, + "page": 0 + }, + { + "id": 73, + "index": 45, + "char": "I", + "width": 10, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 12.243164, + "chnl": 15, + "x": 246, + "y": 36, + "page": 0 + }, + { + "id": 74, + "index": 46, + "char": "J", + "width": 24, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 23.460938, + "chnl": 15, + "x": 158, + "y": 171, + "page": 0 + }, + { + "id": 75, + "index": 47, + "char": "K", + "width": 28, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 26.660156, + "chnl": 15, + "x": 126, + "y": 181, + "page": 0 + }, + { + "id": 76, + "index": 48, + "char": "L", + "width": 23, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 22.743164, + "chnl": 15, + "x": 126, + "y": 216, + "page": 0 + }, + { + "id": 78, + "index": 50, + "char": "N", + "width": 28, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 29.654297, + "chnl": 15, + "x": 150, + "y": 216, + "page": 0 + }, + { + "id": 80, + "index": 52, + "char": "P", + "width": 27, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 27.09082, + "chnl": 15, + "x": 221, + "y": 143, + "page": 0 + }, + { + "id": 82, + "index": 54, + "char": "R", + "width": 27, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 26.803711, + "chnl": 15, + "x": 179, + "y": 206, + "page": 0 + }, + { + "id": 84, + "index": 56, + "char": "T", + "width": 28, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 25.983398, + "chnl": 15, + "x": 207, + "y": 178, + "page": 0 + }, + { + "id": 85, + "index": 57, + "char": "U", + "width": 27, + "height": 34, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 27.644531, + "chnl": 15, + "x": 207, + "y": 213, + "page": 0 + }, + { + "id": 86, + "index": 58, + "char": "V", + "width": 31, + "height": 34, + "xoffset": -2, + "yoffset": -0.5, + "xadvance": 27.459961, + "chnl": 15, + "x": 254, + "y": 71, + "page": 0 + }, + { + "id": 88, + "index": 60, + "char": "X", + "width": 30, + "height": 34, + "xoffset": -2, + "yoffset": -0.5, + "xadvance": 26.680664, + "chnl": 15, + "x": 235, + "y": 213, + "page": 0 + }, + { + "id": 89, + "index": 61, + "char": "Y", + "width": 30, + "height": 34, + "xoffset": -2, + "yoffset": -0.5, + "xadvance": 25.962891, + "chnl": 15, + "x": 236, + "y": 178, + "page": 0 + }, + { + "id": 90, + "index": 62, + "char": "Z", + "width": 27, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 25.450195, + "chnl": 15, + "x": 249, + "y": 143, + "page": 0 + }, + { + "id": 119, + "index": 91, + "char": "w", + "width": 34, + "height": 26, + "xoffset": -1, + "yoffset": 7.5, + "xadvance": 30.864258, + "chnl": 15, + "x": 77, + "y": 228, + "page": 0 + }, + { + "id": 116, + "index": 88, + "char": "t", + "width": 17, + "height": 32, + "xoffset": -2, + "yoffset": 1.5, + "xadvance": 14.191406, + "chnl": 15, + "x": 256, + "y": 106, + "page": 0 + }, + { + "id": 58, + "index": 30, + "char": ":", + "width": 11, + "height": 27, + "xoffset": 1, + "yoffset": 6.5, + "xadvance": 11.853516, + "chnl": 15, + "x": 182, + "y": 36, + "page": 0 + }, + { + "id": 97, + "index": 69, + "char": "a", + "width": 24, + "height": 27, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 22.517578, + "chnl": 15, + "x": 274, + "y": 106, + "page": 0 + }, + { + "id": 99, + "index": 71, + "char": "c", + "width": 23, + "height": 27, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 21.902344, + "chnl": 15, + "x": 91, + "y": 125, + "page": 0 + }, + { + "id": 101, + "index": 73, + "char": "e", + "width": 24, + "height": 27, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 22.702148, + "chnl": 15, + "x": 266, + "y": 213, + "page": 0 + }, + { + "id": 110, + "index": 82, + "char": "n", + "width": 23, + "height": 27, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 23.522461, + "chnl": 15, + "x": 183, + "y": 178, + "page": 0 + }, + { + "id": 111, + "index": 83, + "char": "o", + "width": 25, + "height": 27, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 23.748047, + "chnl": 15, + "x": 267, + "y": 178, + "page": 0 + }, + { + "id": 114, + "index": 86, + "char": "r", + "width": 17, + "height": 27, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 15.319336, + "chnl": 15, + "x": 277, + "y": 134, + "page": 0 + }, + { + "id": 115, + "index": 87, + "char": "s", + "width": 23, + "height": 27, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 21.594727, + "chnl": 15, + "x": 291, + "y": 206, + "page": 0 + }, + { + "id": 117, + "index": 89, + "char": "u", + "width": 23, + "height": 27, + "xoffset": 0, + "yoffset": 7.5, + "xadvance": 23.501953, + "chnl": 15, + "x": 293, + "y": 162, + "page": 0 + }, + { + "id": 126, + "index": 98, + "char": "~", + "width": 27, + "height": 13, + "xoffset": 0, + "yoffset": 12.5, + "xadvance": 27.234375, + "chnl": 15, + "x": 179, + "y": 241, + "page": 0 + }, + { + "id": 43, + "index": 15, + "char": "+", + "width": 24, + "height": 26, + "xoffset": -1, + "yoffset": 4.5, + "xadvance": 22.927734, + "chnl": 15, + "x": 295, + "y": 134, + "page": 0 + }, + { + "id": 118, + "index": 90, + "char": "v", + "width": 25, + "height": 26, + "xoffset": -2, + "yoffset": 7.5, + "xadvance": 21.225586, + "chnl": 15, + "x": 315, + "y": 190, + "page": 0 + }, + { + "id": 120, + "index": 92, + "char": "x", + "width": 25, + "height": 26, + "xoffset": -2, + "yoffset": 7.5, + "xadvance": 21.369141, + "chnl": 15, + "x": 317, + "y": 161, + "page": 0 + }, + { + "id": 122, + "index": 94, + "char": "z", + "width": 22, + "height": 26, + "xoffset": 0, + "yoffset": 7.5, + "xadvance": 21.369141, + "chnl": 15, + "x": 315, + "y": 217, + "page": 0 + }, + { + "id": 60, + "index": 32, + "char": "<", + "width": 22, + "height": 24, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 21.369141, + "chnl": 15, + "x": 338, + "y": 217, + "page": 0 + }, + { + "id": 62, + "index": 34, + "char": ">", + "width": 22, + "height": 24, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 21.697266, + "chnl": 15, + "x": 341, + "y": 188, + "page": 0 + }, + { + "id": 95, + "index": 67, + "char": "_", + "width": 23, + "height": 9, + "xoffset": -2, + "yoffset": 29.5, + "xadvance": 18.744141, + "chnl": 15, + "x": 155, + "y": 206, + "page": 0 + }, + { + "id": 42, + "index": 14, + "char": "*", + "width": 22, + "height": 22, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 19.03125, + "chnl": 15, + "x": 291, + "y": 234, + "page": 0 + }, + { + "id": 61, + "index": 33, + "char": "=", + "width": 22, + "height": 18, + "xoffset": 1, + "yoffset": 9.5, + "xadvance": 24.035156, + "chnl": 15, + "x": 0, + "y": 237, + "page": 0 + }, + { + "id": 94, + "index": 66, + "char": "^", + "width": 21, + "height": 19, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 18.354492, + "chnl": 15, + "x": 23, + "y": 237, + "page": 0 + }, + { + "id": 8220, + "index": 392, + "char": "“", + "width": 18, + "height": 15, + "xoffset": 0, + "yoffset": -2.5, + "xadvance": 17.041992, + "chnl": 15, + "x": 293, + "y": 190, + "page": 0 + }, + { + "id": 8221, + "index": 393, + "char": "”", + "width": 18, + "height": 15, + "xoffset": -1, + "yoffset": -1.5, + "xadvance": 17.165039, + "chnl": 15, + "x": 266, + "y": 241, + "page": 0 + }, + { + "id": 44, + "index": 16, + "char": ",", + "width": 11, + "height": 17, + "xoffset": -1, + "yoffset": 24.5, + "xadvance": 10.253906, + "chnl": 15, + "x": 121, + "y": 154, + "page": 0 + }, + { + "id": 45, + "index": 17, + "char": "-", + "width": 16, + "height": 9, + "xoffset": 0, + "yoffset": 14.5, + "xadvance": 16.283203, + "chnl": 15, + "x": 151, + "y": 64, + "page": 0 + }, + { + "id": 34, + "index": 6, + "char": "\"", + "width": 15, + "height": 15, + "xoffset": -1, + "yoffset": -1.5, + "xadvance": 13.473633, + "chnl": 15, + "x": 277, + "y": 162, + "page": 0 + }, + { + "id": 39, + "index": 11, + "char": "'", + "width": 8, + "height": 15, + "xoffset": -1, + "yoffset": -1.5, + "xadvance": 6.788086, + "chnl": 15, + "x": 45, + "y": 237, + "page": 0 + }, + { + "id": 96, + "index": 68, + "char": "`", + "width": 15, + "height": 10, + "xoffset": -1, + "yoffset": -1.5, + "xadvance": 13.883789, + "chnl": 15, + "x": 314, + "y": 244, + "page": 0 + }, + { + "id": 8217, + "index": 389, + "char": "’", + "width": 11, + "height": 15, + "xoffset": -1, + "yoffset": -1.5, + "xadvance": 9.638672, + "chnl": 15, + "x": 112, + "y": 224, + "page": 0 + }, + { + "id": 46, + "index": 18, + "char": ".", + "width": 11, + "height": 10, + "xoffset": 1, + "yoffset": 23.5, + "xadvance": 12.202148, + "chnl": 15, + "x": 330, + "y": 244, + "page": 0 + }, + { + "id": 32, + "index": 4, + "char": " ", + "width": 0, + "height": 0, + "xoffset": -2, + "yoffset": 29.5, + "xadvance": 10.458984, + "chnl": 15, + "x": 25, + "y": 87, + "page": 0 + } + ], + "info": { + "face": "Roboto-Bold", + "size": 42, + "bold": 0, + "italic": 0, + "charset": [ + " ", + "!", + "\\", + "\"", + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "[", + "]", + "^", + "_", + "`", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "{", + "|", + "}", + "~", + "’", + "“", + "”" + ], + "unicode": 1, + "stretchH": 100, + "smooth": 1, + "aa": 1, + "padding": [ + 2, + 2, + 2, + 2 + ], + "spacing": [ + 0, + 0 + ], + "outline": 0 + }, + "common": { + "lineHeight": 44.091797, + "base": 31.5, + "scaleW": 512, + "scaleH": 256, + "pages": 1, + "packed": 0, + "alphaChnl": 0, + "redChnl": 0, + "greenChnl": 0, + "blueChnl": 0 + }, + "distanceField": { + "fieldType": "msdf", + "distanceRange": 4 + }, + "kernings": [ + { + "first": 32, + "second": 84, + "amount": -1.230469 + }, + { + "first": 34, + "second": 34, + "amount": -0.758789 + }, + { + "first": 34, + "second": 39, + "amount": -0.758789 + }, + { + "first": 34, + "second": 65, + "amount": -2.460937 + }, + { + "first": 34, + "second": 97, + "amount": -1.025391 + }, + { + "first": 34, + "second": 99, + "amount": -1.209961 + }, + { + "first": 34, + "second": 100, + "amount": -1.209961 + }, + { + "first": 34, + "second": 101, + "amount": -1.209961 + }, + { + "first": 34, + "second": 103, + "amount": -1.209961 + }, + { + "first": 34, + "second": 109, + "amount": -0.410156 + }, + { + "first": 34, + "second": 110, + "amount": -0.410156 + }, + { + "first": 34, + "second": 111, + "amount": -1.866211 + }, + { + "first": 34, + "second": 112, + "amount": -0.410156 + }, + { + "first": 34, + "second": 113, + "amount": -1.209961 + }, + { + "first": 34, + "second": 115, + "amount": -1.886719 + }, + { + "first": 34, + "second": 119, + "amount": 0.225586 + }, + { + "first": 34, + "second": 8217, + "amount": -0.758789 + }, + { + "first": 34, + "second": 8220, + "amount": -0.758789 + }, + { + "first": 34, + "second": 8221, + "amount": -0.758789 + }, + { + "first": 39, + "second": 34, + "amount": -0.758789 + }, + { + "first": 39, + "second": 39, + "amount": -0.758789 + }, + { + "first": 39, + "second": 65, + "amount": -2.460937 + }, + { + "first": 39, + "second": 97, + "amount": -1.025391 + }, + { + "first": 39, + "second": 99, + "amount": -1.209961 + }, + { + "first": 39, + "second": 100, + "amount": -1.209961 + }, + { + "first": 39, + "second": 101, + "amount": -1.209961 + }, + { + "first": 39, + "second": 103, + "amount": -1.209961 + }, + { + "first": 39, + "second": 109, + "amount": -0.410156 + }, + { + "first": 39, + "second": 110, + "amount": -0.410156 + }, + { + "first": 39, + "second": 111, + "amount": -1.866211 + }, + { + "first": 39, + "second": 112, + "amount": -0.410156 + }, + { + "first": 39, + "second": 113, + "amount": -1.209961 + }, + { + "first": 39, + "second": 115, + "amount": -1.886719 + }, + { + "first": 39, + "second": 119, + "amount": 0.225586 + }, + { + "first": 39, + "second": 8217, + "amount": -0.758789 + }, + { + "first": 39, + "second": 8220, + "amount": -0.758789 + }, + { + "first": 39, + "second": 8221, + "amount": -0.758789 + }, + { + "first": 40, + "second": 86, + "amount": 0.410156 + }, + { + "first": 40, + "second": 87, + "amount": 0.779297 + }, + { + "first": 40, + "second": 89, + "amount": 0.451172 + }, + { + "first": 44, + "second": 34, + "amount": -5.844727 + }, + { + "first": 44, + "second": 39, + "amount": -5.844727 + }, + { + "first": 44, + "second": 8217, + "amount": -5.844727 + }, + { + "first": 44, + "second": 8220, + "amount": -5.844727 + }, + { + "first": 44, + "second": 8221, + "amount": -5.844727 + }, + { + "first": 46, + "second": 34, + "amount": -5.844727 + }, + { + "first": 46, + "second": 39, + "amount": -5.844727 + }, + { + "first": 46, + "second": 8217, + "amount": -5.844727 + }, + { + "first": 46, + "second": 8220, + "amount": -5.844727 + }, + { + "first": 46, + "second": 8221, + "amount": -5.844727 + }, + { + "first": 47, + "second": 47, + "amount": -5.085937 + }, + { + "first": 65, + "second": 34, + "amount": -2.460937 + }, + { + "first": 65, + "second": 39, + "amount": -2.460937 + }, + { + "first": 65, + "second": 63, + "amount": -1.661133 + }, + { + "first": 65, + "second": 67, + "amount": -0.430664 + }, + { + "first": 65, + "second": 71, + "amount": -0.430664 + }, + { + "first": 65, + "second": 79, + "amount": -0.430664 + }, + { + "first": 65, + "second": 81, + "amount": -0.430664 + }, + { + "first": 65, + "second": 84, + "amount": -2.460937 + }, + { + "first": 65, + "second": 85, + "amount": -0.348633 + }, + { + "first": 65, + "second": 86, + "amount": -1.579102 + }, + { + "first": 65, + "second": 87, + "amount": -0.799805 + }, + { + "first": 65, + "second": 89, + "amount": -3.076172 + }, + { + "first": 65, + "second": 109, + "amount": -0.984375 + }, + { + "first": 65, + "second": 110, + "amount": -0.984375 + }, + { + "first": 65, + "second": 111, + "amount": -0.246094 + }, + { + "first": 65, + "second": 112, + "amount": -0.984375 + }, + { + "first": 65, + "second": 116, + "amount": -0.348633 + }, + { + "first": 65, + "second": 117, + "amount": -0.225586 + }, + { + "first": 65, + "second": 118, + "amount": -1.025391 + }, + { + "first": 65, + "second": 119, + "amount": -0.676758 + }, + { + "first": 65, + "second": 121, + "amount": -1.025391 + }, + { + "first": 65, + "second": 122, + "amount": 0.246094 + }, + { + "first": 65, + "second": 8217, + "amount": -2.460937 + }, + { + "first": 65, + "second": 8220, + "amount": -2.460937 + }, + { + "first": 65, + "second": 8221, + "amount": -2.460937 + }, + { + "first": 66, + "second": 84, + "amount": -0.553711 + }, + { + "first": 66, + "second": 86, + "amount": -0.492187 + }, + { + "first": 66, + "second": 89, + "amount": -1.12793 + }, + { + "first": 67, + "second": 41, + "amount": -0.533203 + }, + { + "first": 67, + "second": 84, + "amount": -0.594727 + }, + { + "first": 67, + "second": 93, + "amount": -0.246094 + }, + { + "first": 67, + "second": 125, + "amount": -0.348633 + }, + { + "first": 68, + "second": 44, + "amount": -2.501953 + }, + { + "first": 68, + "second": 46, + "amount": -2.501953 + }, + { + "first": 68, + "second": 65, + "amount": -0.430664 + }, + { + "first": 68, + "second": 84, + "amount": -1.743164 + }, + { + "first": 68, + "second": 86, + "amount": -0.451172 + }, + { + "first": 68, + "second": 88, + "amount": -0.451172 + }, + { + "first": 68, + "second": 89, + "amount": -0.881836 + }, + { + "first": 68, + "second": 90, + "amount": -0.47168 + }, + { + "first": 69, + "second": 84, + "amount": 0.410156 + }, + { + "first": 69, + "second": 99, + "amount": -0.389648 + }, + { + "first": 69, + "second": 100, + "amount": -0.389648 + }, + { + "first": 69, + "second": 101, + "amount": -0.389648 + }, + { + "first": 69, + "second": 102, + "amount": -0.369141 + }, + { + "first": 69, + "second": 103, + "amount": -0.389648 + }, + { + "first": 69, + "second": 111, + "amount": -0.389648 + }, + { + "first": 69, + "second": 113, + "amount": -0.389648 + }, + { + "first": 69, + "second": 117, + "amount": -0.348633 + }, + { + "first": 69, + "second": 118, + "amount": -0.533203 + }, + { + "first": 69, + "second": 119, + "amount": -0.451172 + }, + { + "first": 69, + "second": 121, + "amount": -0.533203 + }, + { + "first": 70, + "second": 44, + "amount": -5.619141 + }, + { + "first": 70, + "second": 46, + "amount": -5.619141 + }, + { + "first": 70, + "second": 65, + "amount": -3.9375 + }, + { + "first": 70, + "second": 74, + "amount": -4.265625 + }, + { + "first": 70, + "second": 84, + "amount": 0.410156 + }, + { + "first": 70, + "second": 97, + "amount": -0.697266 + }, + { + "first": 70, + "second": 99, + "amount": -0.430664 + }, + { + "first": 70, + "second": 100, + "amount": -0.430664 + }, + { + "first": 70, + "second": 101, + "amount": -0.430664 + }, + { + "first": 70, + "second": 103, + "amount": -0.430664 + }, + { + "first": 70, + "second": 111, + "amount": -0.430664 + }, + { + "first": 70, + "second": 113, + "amount": -0.430664 + }, + { + "first": 70, + "second": 114, + "amount": -0.533203 + }, + { + "first": 70, + "second": 117, + "amount": -0.451172 + }, + { + "first": 70, + "second": 118, + "amount": -0.492187 + }, + { + "first": 70, + "second": 121, + "amount": -0.492187 + }, + { + "first": 72, + "second": 65, + "amount": 0.369141 + }, + { + "first": 72, + "second": 84, + "amount": -0.594727 + }, + { + "first": 72, + "second": 88, + "amount": 0.348633 + }, + { + "first": 72, + "second": 89, + "amount": -0.574219 + }, + { + "first": 73, + "second": 65, + "amount": 0.369141 + }, + { + "first": 73, + "second": 84, + "amount": -0.594727 + }, + { + "first": 73, + "second": 88, + "amount": 0.348633 + }, + { + "first": 73, + "second": 89, + "amount": -0.574219 + }, + { + "first": 74, + "second": 65, + "amount": -0.451172 + }, + { + "first": 75, + "second": 45, + "amount": -3.363281 + }, + { + "first": 75, + "second": 67, + "amount": -0.635742 + }, + { + "first": 75, + "second": 71, + "amount": -0.635742 + }, + { + "first": 75, + "second": 79, + "amount": -0.635742 + }, + { + "first": 75, + "second": 81, + "amount": -0.635742 + }, + { + "first": 75, + "second": 99, + "amount": -0.533203 + }, + { + "first": 75, + "second": 100, + "amount": -0.533203 + }, + { + "first": 75, + "second": 101, + "amount": -0.533203 + }, + { + "first": 75, + "second": 103, + "amount": -0.533203 + }, + { + "first": 75, + "second": 111, + "amount": -0.553711 + }, + { + "first": 75, + "second": 113, + "amount": -0.533203 + }, + { + "first": 75, + "second": 117, + "amount": -0.47168 + }, + { + "first": 75, + "second": 118, + "amount": -0.820312 + }, + { + "first": 75, + "second": 119, + "amount": -1.291992 + }, + { + "first": 75, + "second": 121, + "amount": -0.820312 + }, + { + "first": 76, + "second": 34, + "amount": -5.90625 + }, + { + "first": 76, + "second": 39, + "amount": -5.90625 + }, + { + "first": 76, + "second": 65, + "amount": 0.389648 + }, + { + "first": 76, + "second": 67, + "amount": -1.3125 + }, + { + "first": 76, + "second": 71, + "amount": -1.3125 + }, + { + "first": 76, + "second": 79, + "amount": -1.3125 + }, + { + "first": 76, + "second": 81, + "amount": -1.3125 + }, + { + "first": 76, + "second": 84, + "amount": -4.204102 + }, + { + "first": 76, + "second": 85, + "amount": -0.492187 + }, + { + "first": 76, + "second": 86, + "amount": -4.224609 + }, + { + "first": 76, + "second": 87, + "amount": -1.907227 + }, + { + "first": 76, + "second": 89, + "amount": -5.72168 + }, + { + "first": 76, + "second": 117, + "amount": -0.287109 + }, + { + "first": 76, + "second": 118, + "amount": -2.522461 + }, + { + "first": 76, + "second": 119, + "amount": -1.066406 + }, + { + "first": 76, + "second": 121, + "amount": -2.522461 + }, + { + "first": 76, + "second": 8217, + "amount": -5.90625 + }, + { + "first": 76, + "second": 8220, + "amount": -5.90625 + }, + { + "first": 76, + "second": 8221, + "amount": -5.90625 + }, + { + "first": 77, + "second": 65, + "amount": 0.369141 + }, + { + "first": 77, + "second": 84, + "amount": -0.594727 + }, + { + "first": 77, + "second": 88, + "amount": 0.348633 + }, + { + "first": 77, + "second": 89, + "amount": -0.574219 + }, + { + "first": 78, + "second": 65, + "amount": 0.369141 + }, + { + "first": 78, + "second": 84, + "amount": -0.594727 + }, + { + "first": 78, + "second": 88, + "amount": 0.348633 + }, + { + "first": 78, + "second": 89, + "amount": -0.574219 + }, + { + "first": 79, + "second": 44, + "amount": -2.501953 + }, + { + "first": 79, + "second": 46, + "amount": -2.501953 + }, + { + "first": 79, + "second": 65, + "amount": -0.430664 + }, + { + "first": 79, + "second": 84, + "amount": -1.743164 + }, + { + "first": 79, + "second": 86, + "amount": -0.451172 + }, + { + "first": 79, + "second": 88, + "amount": -0.451172 + }, + { + "first": 79, + "second": 89, + "amount": -0.881836 + }, + { + "first": 79, + "second": 90, + "amount": -0.47168 + }, + { + "first": 80, + "second": 44, + "amount": -8.285156 + }, + { + "first": 80, + "second": 46, + "amount": -8.285156 + }, + { + "first": 80, + "second": 65, + "amount": -3.650391 + }, + { + "first": 80, + "second": 74, + "amount": -3.773437 + }, + { + "first": 80, + "second": 88, + "amount": -1.045898 + }, + { + "first": 80, + "second": 90, + "amount": -0.738281 + }, + { + "first": 80, + "second": 97, + "amount": -0.225586 + }, + { + "first": 80, + "second": 99, + "amount": -0.266602 + }, + { + "first": 80, + "second": 100, + "amount": -0.266602 + }, + { + "first": 80, + "second": 101, + "amount": -0.266602 + }, + { + "first": 80, + "second": 103, + "amount": -0.266602 + }, + { + "first": 80, + "second": 111, + "amount": -0.266602 + }, + { + "first": 80, + "second": 113, + "amount": -0.266602 + }, + { + "first": 80, + "second": 116, + "amount": 0.287109 + }, + { + "first": 80, + "second": 118, + "amount": 0.307617 + }, + { + "first": 80, + "second": 121, + "amount": 0.307617 + }, + { + "first": 81, + "second": 84, + "amount": -0.676758 + }, + { + "first": 81, + "second": 86, + "amount": -0.574219 + }, + { + "first": 81, + "second": 87, + "amount": -0.410156 + }, + { + "first": 81, + "second": 89, + "amount": -0.717773 + }, + { + "first": 82, + "second": 84, + "amount": -1.025391 + }, + { + "first": 82, + "second": 86, + "amount": -0.389648 + }, + { + "first": 82, + "second": 89, + "amount": -0.984375 + }, + { + "first": 84, + "second": 32, + "amount": -1.230469 + }, + { + "first": 84, + "second": 44, + "amount": -5.291016 + }, + { + "first": 84, + "second": 45, + "amount": -5.578125 + }, + { + "first": 84, + "second": 46, + "amount": -5.291016 + }, + { + "first": 84, + "second": 65, + "amount": -2.460937 + }, + { + "first": 84, + "second": 67, + "amount": -0.574219 + }, + { + "first": 84, + "second": 71, + "amount": -0.574219 + }, + { + "first": 84, + "second": 74, + "amount": -4.429687 + }, + { + "first": 84, + "second": 79, + "amount": -0.574219 + }, + { + "first": 84, + "second": 81, + "amount": -0.574219 + }, + { + "first": 84, + "second": 83, + "amount": -0.328125 + }, + { + "first": 84, + "second": 84, + "amount": 0.328125 + }, + { + "first": 84, + "second": 86, + "amount": 0.328125 + }, + { + "first": 84, + "second": 87, + "amount": 0.307617 + }, + { + "first": 84, + "second": 89, + "amount": 0.328125 + }, + { + "first": 84, + "second": 97, + "amount": -3.445312 + }, + { + "first": 84, + "second": 99, + "amount": -1.825195 + }, + { + "first": 84, + "second": 100, + "amount": -1.825195 + }, + { + "first": 84, + "second": 101, + "amount": -1.825195 + }, + { + "first": 84, + "second": 103, + "amount": -1.825195 + }, + { + "first": 84, + "second": 109, + "amount": -1.825195 + }, + { + "first": 84, + "second": 110, + "amount": -1.825195 + }, + { + "first": 84, + "second": 111, + "amount": -4.265625 + }, + { + "first": 84, + "second": 112, + "amount": -1.825195 + }, + { + "first": 84, + "second": 113, + "amount": -1.825195 + }, + { + "first": 84, + "second": 114, + "amount": -1.333008 + }, + { + "first": 84, + "second": 115, + "amount": -1.558594 + }, + { + "first": 84, + "second": 117, + "amount": -1.333008 + }, + { + "first": 84, + "second": 118, + "amount": -1.681641 + }, + { + "first": 84, + "second": 119, + "amount": -0.963867 + }, + { + "first": 84, + "second": 120, + "amount": -1.579102 + }, + { + "first": 84, + "second": 121, + "amount": -1.681641 + }, + { + "first": 84, + "second": 122, + "amount": -1.230469 + }, + { + "first": 85, + "second": 65, + "amount": -0.451172 + }, + { + "first": 86, + "second": 41, + "amount": 0.410156 + }, + { + "first": 86, + "second": 44, + "amount": -4.40918 + }, + { + "first": 86, + "second": 45, + "amount": -3.219727 + }, + { + "first": 86, + "second": 46, + "amount": -4.40918 + }, + { + "first": 86, + "second": 65, + "amount": -1.538086 + }, + { + "first": 86, + "second": 67, + "amount": -0.266602 + }, + { + "first": 86, + "second": 71, + "amount": -0.266602 + }, + { + "first": 86, + "second": 79, + "amount": -0.266602 + }, + { + "first": 86, + "second": 81, + "amount": -0.266602 + }, + { + "first": 86, + "second": 93, + "amount": 0.348633 + }, + { + "first": 86, + "second": 97, + "amount": -0.943359 + }, + { + "first": 86, + "second": 99, + "amount": -0.902344 + }, + { + "first": 86, + "second": 100, + "amount": -0.902344 + }, + { + "first": 86, + "second": 101, + "amount": -0.902344 + }, + { + "first": 86, + "second": 103, + "amount": -0.902344 + }, + { + "first": 86, + "second": 111, + "amount": -0.943359 + }, + { + "first": 86, + "second": 113, + "amount": -0.902344 + }, + { + "first": 86, + "second": 114, + "amount": -0.615234 + }, + { + "first": 86, + "second": 117, + "amount": -0.574219 + }, + { + "first": 86, + "second": 118, + "amount": -0.225586 + }, + { + "first": 86, + "second": 121, + "amount": -0.225586 + }, + { + "first": 86, + "second": 125, + "amount": 0.389648 + }, + { + "first": 87, + "second": 41, + "amount": 0.307617 + }, + { + "first": 87, + "second": 44, + "amount": -2.932617 + }, + { + "first": 87, + "second": 45, + "amount": -1.230469 + }, + { + "first": 87, + "second": 46, + "amount": -2.932617 + }, + { + "first": 87, + "second": 65, + "amount": -0.881836 + }, + { + "first": 87, + "second": 84, + "amount": 0.287109 + }, + { + "first": 87, + "second": 93, + "amount": 0.246094 + }, + { + "first": 87, + "second": 97, + "amount": -0.676758 + }, + { + "first": 87, + "second": 99, + "amount": -0.635742 + }, + { + "first": 87, + "second": 100, + "amount": -0.635742 + }, + { + "first": 87, + "second": 101, + "amount": -0.635742 + }, + { + "first": 87, + "second": 103, + "amount": -0.635742 + }, + { + "first": 87, + "second": 111, + "amount": -0.635742 + }, + { + "first": 87, + "second": 113, + "amount": -0.635742 + }, + { + "first": 87, + "second": 114, + "amount": -0.430664 + }, + { + "first": 87, + "second": 117, + "amount": -0.389648 + }, + { + "first": 87, + "second": 125, + "amount": 0.287109 + }, + { + "first": 88, + "second": 45, + "amount": -3.199219 + }, + { + "first": 88, + "second": 67, + "amount": -0.512695 + }, + { + "first": 88, + "second": 71, + "amount": -0.512695 + }, + { + "first": 88, + "second": 79, + "amount": -0.512695 + }, + { + "first": 88, + "second": 81, + "amount": -0.512695 + }, + { + "first": 88, + "second": 86, + "amount": 0.287109 + }, + { + "first": 88, + "second": 99, + "amount": -0.533203 + }, + { + "first": 88, + "second": 100, + "amount": -0.533203 + }, + { + "first": 88, + "second": 101, + "amount": -0.533203 + }, + { + "first": 88, + "second": 103, + "amount": -0.533203 + }, + { + "first": 88, + "second": 111, + "amount": -0.430664 + }, + { + "first": 88, + "second": 113, + "amount": -0.533203 + }, + { + "first": 88, + "second": 117, + "amount": -0.430664 + }, + { + "first": 88, + "second": 118, + "amount": -0.635742 + }, + { + "first": 88, + "second": 121, + "amount": -0.635742 + }, + { + "first": 89, + "second": 38, + "amount": -0.615234 + }, + { + "first": 89, + "second": 41, + "amount": 0.410156 + }, + { + "first": 89, + "second": 42, + "amount": -1.004883 + }, + { + "first": 89, + "second": 44, + "amount": -4.737305 + }, + { + "first": 89, + "second": 45, + "amount": -3.117187 + }, + { + "first": 89, + "second": 46, + "amount": -4.737305 + }, + { + "first": 89, + "second": 65, + "amount": -3.076172 + }, + { + "first": 89, + "second": 67, + "amount": -0.594727 + }, + { + "first": 89, + "second": 71, + "amount": -0.594727 + }, + { + "first": 89, + "second": 74, + "amount": -1.96875 + }, + { + "first": 89, + "second": 79, + "amount": -0.594727 + }, + { + "first": 89, + "second": 81, + "amount": -0.594727 + }, + { + "first": 89, + "second": 83, + "amount": -0.328125 + }, + { + "first": 89, + "second": 84, + "amount": 0.348633 + }, + { + "first": 89, + "second": 85, + "amount": -1.96875 + }, + { + "first": 89, + "second": 86, + "amount": 0.369141 + }, + { + "first": 89, + "second": 87, + "amount": 0.348633 + }, + { + "first": 89, + "second": 88, + "amount": 0.266602 + }, + { + "first": 89, + "second": 89, + "amount": 0.369141 + }, + { + "first": 89, + "second": 93, + "amount": 0.369141 + }, + { + "first": 89, + "second": 97, + "amount": -1.291992 + }, + { + "first": 89, + "second": 99, + "amount": -1.333008 + }, + { + "first": 89, + "second": 100, + "amount": -1.333008 + }, + { + "first": 89, + "second": 101, + "amount": -1.333008 + }, + { + "first": 89, + "second": 102, + "amount": -0.451172 + }, + { + "first": 89, + "second": 103, + "amount": -1.333008 + }, + { + "first": 89, + "second": 109, + "amount": -0.820312 + }, + { + "first": 89, + "second": 110, + "amount": -0.820312 + }, + { + "first": 89, + "second": 111, + "amount": -1.333008 + }, + { + "first": 89, + "second": 112, + "amount": -0.820312 + }, + { + "first": 89, + "second": 113, + "amount": -1.333008 + }, + { + "first": 89, + "second": 114, + "amount": -0.820312 + }, + { + "first": 89, + "second": 115, + "amount": -1.189453 + }, + { + "first": 89, + "second": 116, + "amount": -0.451172 + }, + { + "first": 89, + "second": 117, + "amount": -0.799805 + }, + { + "first": 89, + "second": 118, + "amount": -0.410156 + }, + { + "first": 89, + "second": 120, + "amount": -0.47168 + }, + { + "first": 89, + "second": 121, + "amount": -0.410156 + }, + { + "first": 89, + "second": 122, + "amount": -0.615234 + }, + { + "first": 89, + "second": 125, + "amount": 0.389648 + }, + { + "first": 90, + "second": 65, + "amount": 0.266602 + }, + { + "first": 90, + "second": 67, + "amount": -0.533203 + }, + { + "first": 90, + "second": 71, + "amount": -0.533203 + }, + { + "first": 90, + "second": 79, + "amount": -0.533203 + }, + { + "first": 90, + "second": 81, + "amount": -0.533203 + }, + { + "first": 90, + "second": 99, + "amount": -0.430664 + }, + { + "first": 90, + "second": 100, + "amount": -0.430664 + }, + { + "first": 90, + "second": 101, + "amount": -0.430664 + }, + { + "first": 90, + "second": 103, + "amount": -0.430664 + }, + { + "first": 90, + "second": 111, + "amount": -0.430664 + }, + { + "first": 90, + "second": 113, + "amount": -0.430664 + }, + { + "first": 90, + "second": 117, + "amount": -0.389648 + }, + { + "first": 90, + "second": 118, + "amount": -0.553711 + }, + { + "first": 90, + "second": 119, + "amount": -0.553711 + }, + { + "first": 90, + "second": 121, + "amount": -0.553711 + }, + { + "first": 91, + "second": 74, + "amount": -0.369141 + }, + { + "first": 91, + "second": 85, + "amount": -0.369141 + }, + { + "first": 97, + "second": 34, + "amount": -0.348633 + }, + { + "first": 97, + "second": 39, + "amount": -0.348633 + }, + { + "first": 97, + "second": 118, + "amount": -0.307617 + }, + { + "first": 97, + "second": 121, + "amount": -0.307617 + }, + { + "first": 97, + "second": 8217, + "amount": -0.348633 + }, + { + "first": 97, + "second": 8220, + "amount": -0.348633 + }, + { + "first": 97, + "second": 8221, + "amount": -0.348633 + }, + { + "first": 98, + "second": 34, + "amount": -0.594727 + }, + { + "first": 98, + "second": 39, + "amount": -0.594727 + }, + { + "first": 98, + "second": 118, + "amount": -0.225586 + }, + { + "first": 98, + "second": 120, + "amount": -0.307617 + }, + { + "first": 98, + "second": 121, + "amount": -0.225586 + }, + { + "first": 98, + "second": 122, + "amount": -0.307617 + }, + { + "first": 98, + "second": 8217, + "amount": -0.594727 + }, + { + "first": 98, + "second": 8220, + "amount": -0.594727 + }, + { + "first": 98, + "second": 8221, + "amount": -0.594727 + }, + { + "first": 99, + "second": 34, + "amount": -0.225586 + }, + { + "first": 99, + "second": 39, + "amount": -0.225586 + }, + { + "first": 99, + "second": 8217, + "amount": -0.225586 + }, + { + "first": 99, + "second": 8220, + "amount": -0.225586 + }, + { + "first": 99, + "second": 8221, + "amount": -0.225586 + }, + { + "first": 101, + "second": 34, + "amount": -0.287109 + }, + { + "first": 101, + "second": 39, + "amount": -0.287109 + }, + { + "first": 101, + "second": 118, + "amount": -0.266602 + }, + { + "first": 101, + "second": 121, + "amount": -0.266602 + }, + { + "first": 101, + "second": 8217, + "amount": -1.3125 + }, + { + "first": 101, + "second": 8220, + "amount": -0.287109 + }, + { + "first": 101, + "second": 8221, + "amount": -0.287109 + }, + { + "first": 102, + "second": 34, + "amount": 0.328125 + }, + { + "first": 102, + "second": 39, + "amount": 0.328125 + }, + { + "first": 102, + "second": 41, + "amount": 0.410156 + }, + { + "first": 102, + "second": 93, + "amount": 0.369141 + }, + { + "first": 102, + "second": 99, + "amount": -0.492187 + }, + { + "first": 102, + "second": 100, + "amount": -0.492187 + }, + { + "first": 102, + "second": 101, + "amount": -0.492187 + }, + { + "first": 102, + "second": 103, + "amount": -0.492187 + }, + { + "first": 102, + "second": 113, + "amount": -0.492187 + }, + { + "first": 102, + "second": 125, + "amount": 0.389648 + }, + { + "first": 102, + "second": 8217, + "amount": 0.328125 + }, + { + "first": 102, + "second": 8220, + "amount": 0.328125 + }, + { + "first": 102, + "second": 8221, + "amount": 0.328125 + }, + { + "first": 104, + "second": 34, + "amount": -1.640625 + }, + { + "first": 104, + "second": 39, + "amount": -1.640625 + }, + { + "first": 104, + "second": 8217, + "amount": -2.132812 + }, + { + "first": 104, + "second": 8220, + "amount": -1.640625 + }, + { + "first": 104, + "second": 8221, + "amount": -1.640625 + }, + { + "first": 107, + "second": 99, + "amount": -0.410156 + }, + { + "first": 107, + "second": 100, + "amount": -0.410156 + }, + { + "first": 107, + "second": 101, + "amount": -0.410156 + }, + { + "first": 107, + "second": 103, + "amount": -0.410156 + }, + { + "first": 107, + "second": 113, + "amount": -0.410156 + }, + { + "first": 109, + "second": 34, + "amount": -1.640625 + }, + { + "first": 109, + "second": 39, + "amount": -1.640625 + }, + { + "first": 109, + "second": 8217, + "amount": -2.460937 + }, + { + "first": 109, + "second": 8220, + "amount": -1.640625 + }, + { + "first": 109, + "second": 8221, + "amount": -1.640625 + }, + { + "first": 110, + "second": 34, + "amount": -1.640625 + }, + { + "first": 110, + "second": 39, + "amount": -1.640625 + }, + { + "first": 110, + "second": 8217, + "amount": -2.460937 + }, + { + "first": 110, + "second": 8220, + "amount": -1.640625 + }, + { + "first": 110, + "second": 8221, + "amount": -1.640625 + }, + { + "first": 111, + "second": 34, + "amount": -1.804687 + }, + { + "first": 111, + "second": 39, + "amount": -1.804687 + }, + { + "first": 111, + "second": 118, + "amount": -0.307617 + }, + { + "first": 111, + "second": 120, + "amount": -0.430664 + }, + { + "first": 111, + "second": 121, + "amount": -0.307617 + }, + { + "first": 111, + "second": 122, + "amount": -0.328125 + }, + { + "first": 111, + "second": 8217, + "amount": -2.296875 + }, + { + "first": 111, + "second": 8220, + "amount": -1.804687 + }, + { + "first": 111, + "second": 8221, + "amount": -1.804687 + }, + { + "first": 112, + "second": 34, + "amount": -0.594727 + }, + { + "first": 112, + "second": 39, + "amount": -0.594727 + }, + { + "first": 112, + "second": 118, + "amount": -0.225586 + }, + { + "first": 112, + "second": 120, + "amount": -0.307617 + }, + { + "first": 112, + "second": 121, + "amount": -0.225586 + }, + { + "first": 112, + "second": 122, + "amount": -0.307617 + }, + { + "first": 112, + "second": 8217, + "amount": -0.594727 + }, + { + "first": 112, + "second": 8220, + "amount": -0.594727 + }, + { + "first": 112, + "second": 8221, + "amount": -0.594727 + }, + { + "first": 114, + "second": 34, + "amount": 0.328125 + }, + { + "first": 114, + "second": 39, + "amount": 0.328125 + }, + { + "first": 114, + "second": 44, + "amount": -3.547852 + }, + { + "first": 114, + "second": 46, + "amount": -3.547852 + }, + { + "first": 114, + "second": 97, + "amount": -0.615234 + }, + { + "first": 114, + "second": 99, + "amount": -0.389648 + }, + { + "first": 114, + "second": 100, + "amount": -0.389648 + }, + { + "first": 114, + "second": 101, + "amount": -0.389648 + }, + { + "first": 114, + "second": 102, + "amount": 0.410156 + }, + { + "first": 114, + "second": 103, + "amount": -0.389648 + }, + { + "first": 114, + "second": 111, + "amount": -0.738281 + }, + { + "first": 114, + "second": 113, + "amount": -0.389648 + }, + { + "first": 114, + "second": 116, + "amount": 1.025391 + }, + { + "first": 114, + "second": 118, + "amount": 0.369141 + }, + { + "first": 114, + "second": 119, + "amount": 0.348633 + }, + { + "first": 114, + "second": 121, + "amount": 0.369141 + }, + { + "first": 114, + "second": 8217, + "amount": 0.328125 + }, + { + "first": 114, + "second": 8220, + "amount": 0.328125 + }, + { + "first": 114, + "second": 8221, + "amount": 0.328125 + }, + { + "first": 116, + "second": 111, + "amount": -0.615234 + }, + { + "first": 116, + "second": 8217, + "amount": 0.492188 + }, + { + "first": 118, + "second": 34, + "amount": 0.307617 + }, + { + "first": 118, + "second": 39, + "amount": 0.307617 + }, + { + "first": 118, + "second": 44, + "amount": -3.424805 + }, + { + "first": 118, + "second": 46, + "amount": -3.424805 + }, + { + "first": 118, + "second": 97, + "amount": -0.307617 + }, + { + "first": 118, + "second": 99, + "amount": -0.266602 + }, + { + "first": 118, + "second": 100, + "amount": -0.266602 + }, + { + "first": 118, + "second": 101, + "amount": -0.266602 + }, + { + "first": 118, + "second": 102, + "amount": 0.266602 + }, + { + "first": 118, + "second": 103, + "amount": -0.266602 + }, + { + "first": 118, + "second": 111, + "amount": -0.307617 + }, + { + "first": 118, + "second": 113, + "amount": -0.266602 + }, + { + "first": 118, + "second": 8217, + "amount": 0.307617 + }, + { + "first": 118, + "second": 8220, + "amount": 0.307617 + }, + { + "first": 118, + "second": 8221, + "amount": 0.307617 + }, + { + "first": 119, + "second": 44, + "amount": -2.542969 + }, + { + "first": 119, + "second": 46, + "amount": -2.542969 + }, + { + "first": 120, + "second": 99, + "amount": -0.410156 + }, + { + "first": 120, + "second": 100, + "amount": -0.410156 + }, + { + "first": 120, + "second": 101, + "amount": -0.410156 + }, + { + "first": 120, + "second": 103, + "amount": -0.410156 + }, + { + "first": 120, + "second": 111, + "amount": -0.820312 + }, + { + "first": 120, + "second": 113, + "amount": -0.410156 + }, + { + "first": 121, + "second": 34, + "amount": 0.307617 + }, + { + "first": 121, + "second": 39, + "amount": 0.307617 + }, + { + "first": 121, + "second": 44, + "amount": -3.424805 + }, + { + "first": 121, + "second": 46, + "amount": -3.424805 + }, + { + "first": 121, + "second": 97, + "amount": -0.307617 + }, + { + "first": 121, + "second": 99, + "amount": -0.266602 + }, + { + "first": 121, + "second": 100, + "amount": -0.266602 + }, + { + "first": 121, + "second": 101, + "amount": -0.266602 + }, + { + "first": 121, + "second": 102, + "amount": 0.266602 + }, + { + "first": 121, + "second": 103, + "amount": -0.266602 + }, + { + "first": 121, + "second": 111, + "amount": -0.307617 + }, + { + "first": 121, + "second": 113, + "amount": -0.266602 + }, + { + "first": 121, + "second": 8217, + "amount": 0.307617 + }, + { + "first": 121, + "second": 8220, + "amount": 0.307617 + }, + { + "first": 121, + "second": 8221, + "amount": 0.307617 + }, + { + "first": 122, + "second": 99, + "amount": -0.328125 + }, + { + "first": 122, + "second": 100, + "amount": -0.328125 + }, + { + "first": 122, + "second": 101, + "amount": -0.328125 + }, + { + "first": 122, + "second": 103, + "amount": -0.328125 + }, + { + "first": 122, + "second": 111, + "amount": -0.328125 + }, + { + "first": 122, + "second": 113, + "amount": -0.328125 + }, + { + "first": 123, + "second": 74, + "amount": -0.410156 + }, + { + "first": 123, + "second": 85, + "amount": -0.410156 + }, + { + "first": 8217, + "second": 34, + "amount": -0.758789 + }, + { + "first": 8217, + "second": 39, + "amount": -0.758789 + }, + { + "first": 8217, + "second": 65, + "amount": -2.460937 + }, + { + "first": 8217, + "second": 97, + "amount": -1.025391 + }, + { + "first": 8217, + "second": 99, + "amount": -2.132812 + }, + { + "first": 8217, + "second": 100, + "amount": -2.132812 + }, + { + "first": 8217, + "second": 101, + "amount": -2.132812 + }, + { + "first": 8217, + "second": 103, + "amount": -2.132812 + }, + { + "first": 8217, + "second": 109, + "amount": -0.410156 + }, + { + "first": 8217, + "second": 110, + "amount": -0.410156 + }, + { + "first": 8217, + "second": 111, + "amount": -2.953125 + }, + { + "first": 8217, + "second": 112, + "amount": -0.410156 + }, + { + "first": 8217, + "second": 113, + "amount": -2.132812 + }, + { + "first": 8217, + "second": 115, + "amount": -4.757812 + }, + { + "first": 8217, + "second": 119, + "amount": 0.225586 + }, + { + "first": 8217, + "second": 8217, + "amount": -0.758789 + }, + { + "first": 8217, + "second": 8220, + "amount": -0.758789 + }, + { + "first": 8217, + "second": 8221, + "amount": -0.758789 + }, + { + "first": 8220, + "second": 34, + "amount": -0.758789 + }, + { + "first": 8220, + "second": 39, + "amount": -0.758789 + }, + { + "first": 8220, + "second": 65, + "amount": -2.460937 + }, + { + "first": 8220, + "second": 97, + "amount": -1.025391 + }, + { + "first": 8220, + "second": 99, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 100, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 101, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 103, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 109, + "amount": -0.410156 + }, + { + "first": 8220, + "second": 110, + "amount": -0.410156 + }, + { + "first": 8220, + "second": 111, + "amount": -1.866211 + }, + { + "first": 8220, + "second": 112, + "amount": -0.410156 + }, + { + "first": 8220, + "second": 113, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 115, + "amount": -1.886719 + }, + { + "first": 8220, + "second": 119, + "amount": 0.225586 + }, + { + "first": 8220, + "second": 8217, + "amount": -0.758789 + }, + { + "first": 8220, + "second": 8220, + "amount": -0.758789 + }, + { + "first": 8220, + "second": 8221, + "amount": -0.758789 + }, + { + "first": 8221, + "second": 34, + "amount": -0.758789 + }, + { + "first": 8221, + "second": 39, + "amount": -0.758789 + }, + { + "first": 8221, + "second": 65, + "amount": -2.460937 + }, + { + "first": 8221, + "second": 97, + "amount": -1.025391 + }, + { + "first": 8221, + "second": 99, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 100, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 101, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 103, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 109, + "amount": -0.410156 + }, + { + "first": 8221, + "second": 110, + "amount": -0.410156 + }, + { + "first": 8221, + "second": 111, + "amount": -1.866211 + }, + { + "first": 8221, + "second": 112, + "amount": -0.410156 + }, + { + "first": 8221, + "second": 113, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 115, + "amount": -1.886719 + }, + { + "first": 8221, + "second": 119, + "amount": 0.225586 + }, + { + "first": 8221, + "second": 8217, + "amount": -0.758789 + }, + { + "first": 8221, + "second": 8220, + "amount": -0.758789 + }, + { + "first": 8221, + "second": 8221, + "amount": -0.758789 + } + ], + "lightningMetrics": { + "ascender": 1536, + "descender": -512, + "lineGap": 102, + "unitsPerEm": 2048 + } +} \ No newline at end of file diff --git a/bench/public/fonts/Roboto-Bold.msdf.png b/bench/public/fonts/Roboto-Bold.msdf.png new file mode 100644 index 0000000..0f1e284 Binary files /dev/null and b/bench/public/fonts/Roboto-Bold.msdf.png differ diff --git a/bench/public/fonts/Roboto-Regular.msdf.json b/bench/public/fonts/Roboto-Regular.msdf.json new file mode 100644 index 0000000..599193a --- /dev/null +++ b/bench/public/fonts/Roboto-Regular.msdf.json @@ -0,0 +1,4264 @@ +{ + "pages": [ + "Roboto-Regular.png" + ], + "chars": [ + { + "id": 40, + "index": 12, + "char": "(", + "width": 15, + "height": 47, + "xoffset": 1, + "yoffset": -4.5, + "xadvance": 14.355469, + "chnl": 15, + "x": 0, + "y": 0, + "page": 0 + }, + { + "id": 41, + "index": 13, + "char": ")", + "width": 15, + "height": 47, + "xoffset": -1, + "yoffset": -4.5, + "xadvance": 14.601563, + "chnl": 15, + "x": 16, + "y": 0, + "page": 0 + }, + { + "id": 91, + "index": 63, + "char": "[", + "width": 12, + "height": 45, + "xoffset": 1, + "yoffset": -4.5, + "xadvance": 11.135742, + "chnl": 15, + "x": 32, + "y": 0, + "page": 0 + }, + { + "id": 93, + "index": 65, + "char": "]", + "width": 12, + "height": 45, + "xoffset": -2, + "yoffset": -4.5, + "xadvance": 11.135742, + "chnl": 15, + "x": 45, + "y": 0, + "page": 0 + }, + { + "id": 123, + "index": 95, + "char": "{", + "width": 16, + "height": 44, + "xoffset": -1, + "yoffset": -3.5, + "xadvance": 14.211914, + "chnl": 15, + "x": 58, + "y": 0, + "page": 0 + }, + { + "id": 125, + "index": 97, + "char": "}", + "width": 16, + "height": 44, + "xoffset": -2, + "yoffset": -3.5, + "xadvance": 14.211914, + "chnl": 15, + "x": 75, + "y": 0, + "page": 0 + }, + { + "id": 36, + "index": 8, + "char": "$", + "width": 23, + "height": 43, + "xoffset": 0, + "yoffset": -5.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 92, + "y": 0, + "page": 0 + }, + { + "id": 64, + "index": 36, + "char": "@", + "width": 37, + "height": 43, + "xoffset": 0, + "yoffset": 0.5, + "xadvance": 37.713867, + "chnl": 15, + "x": 58, + "y": 45, + "page": 0 + }, + { + "id": 106, + "index": 78, + "char": "j", + "width": 12, + "height": 43, + "xoffset": -3, + "yoffset": -0.5, + "xadvance": 10.02832, + "chnl": 15, + "x": 116, + "y": 0, + "page": 0 + }, + { + "id": 81, + "index": 53, + "char": "Q", + "width": 28, + "height": 39, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 28.875, + "chnl": 15, + "x": 0, + "y": 89, + "page": 0 + }, + { + "id": 87, + "index": 59, + "char": "W", + "width": 39, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 37.262695, + "chnl": 15, + "x": 29, + "y": 89, + "page": 0 + }, + { + "id": 124, + "index": 96, + "char": "|", + "width": 7, + "height": 39, + "xoffset": 2, + "yoffset": -0.5, + "xadvance": 10.233398, + "chnl": 15, + "x": 69, + "y": 89, + "page": 0 + }, + { + "id": 92, + "index": 64, + "char": "\\", + "width": 20, + "height": 36, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 17.226563, + "chnl": 15, + "x": 77, + "y": 89, + "page": 0 + }, + { + "id": 47, + "index": 19, + "char": "/", + "width": 20, + "height": 36, + "xoffset": -2, + "yoffset": -0.5, + "xadvance": 17.308594, + "chnl": 15, + "x": 0, + "y": 48, + "page": 0 + }, + { + "id": 98, + "index": 70, + "char": "b", + "width": 23, + "height": 36, + "xoffset": 1, + "yoffset": -1.5, + "xadvance": 23.563477, + "chnl": 15, + "x": 32, + "y": 46, + "page": 0 + }, + { + "id": 100, + "index": 72, + "char": "d", + "width": 23, + "height": 36, + "xoffset": 0, + "yoffset": -1.5, + "xadvance": 23.686523, + "chnl": 15, + "x": 98, + "y": 44, + "page": 0 + }, + { + "id": 102, + "index": 74, + "char": "f", + "width": 17, + "height": 36, + "xoffset": -1, + "yoffset": -2.5, + "xadvance": 14.581055, + "chnl": 15, + "x": 98, + "y": 81, + "page": 0 + }, + { + "id": 104, + "index": 76, + "char": "h", + "width": 21, + "height": 36, + "xoffset": 1, + "yoffset": -1.5, + "xadvance": 23.132813, + "chnl": 15, + "x": 116, + "y": 81, + "page": 0 + }, + { + "id": 107, + "index": 79, + "char": "k", + "width": 22, + "height": 36, + "xoffset": 1, + "yoffset": -1.5, + "xadvance": 21.287109, + "chnl": 15, + "x": 122, + "y": 44, + "page": 0 + }, + { + "id": 108, + "index": 80, + "char": "l", + "width": 8, + "height": 36, + "xoffset": 1, + "yoffset": -1.5, + "xadvance": 10.192383, + "chnl": 15, + "x": 21, + "y": 48, + "page": 0 + }, + { + "id": 37, + "index": 9, + "char": "%", + "width": 31, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 30.761719, + "chnl": 15, + "x": 129, + "y": 0, + "page": 0 + }, + { + "id": 38, + "index": 10, + "char": "&", + "width": 28, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 26.106445, + "chnl": 15, + "x": 138, + "y": 81, + "page": 0 + }, + { + "id": 48, + "index": 20, + "char": "0", + "width": 23, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 145, + "y": 36, + "page": 0 + }, + { + "id": 51, + "index": 23, + "char": "3", + "width": 23, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 161, + "y": 0, + "page": 0 + }, + { + "id": 56, + "index": 28, + "char": "8", + "width": 23, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 167, + "y": 72, + "page": 0 + }, + { + "id": 67, + "index": 39, + "char": "C", + "width": 27, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 27.336914, + "chnl": 15, + "x": 169, + "y": 36, + "page": 0 + }, + { + "id": 71, + "index": 43, + "char": "G", + "width": 27, + "height": 35, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 28.608398, + "chnl": 15, + "x": 185, + "y": 0, + "page": 0 + }, + { + "id": 79, + "index": 51, + "char": "O", + "width": 28, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 28.875, + "chnl": 15, + "x": 213, + "y": 0, + "page": 0 + }, + { + "id": 83, + "index": 55, + "char": "S", + "width": 26, + "height": 35, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 24.916992, + "chnl": 15, + "x": 191, + "y": 72, + "page": 0 + }, + { + "id": 103, + "index": 75, + "char": "g", + "width": 23, + "height": 35, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 23.563477, + "chnl": 15, + "x": 197, + "y": 36, + "page": 0 + }, + { + "id": 109, + "index": 81, + "char": "m", + "width": 35, + "height": 27, + "xoffset": 1, + "yoffset": 6.5, + "xadvance": 36.811523, + "chnl": 15, + "x": 221, + "y": 36, + "page": 0 + }, + { + "id": 112, + "index": 84, + "char": "p", + "width": 23, + "height": 35, + "xoffset": 1, + "yoffset": 6.5, + "xadvance": 23.563477, + "chnl": 15, + "x": 221, + "y": 64, + "page": 0 + }, + { + "id": 113, + "index": 85, + "char": "q", + "width": 23, + "height": 35, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 23.871094, + "chnl": 15, + "x": 218, + "y": 100, + "page": 0 + }, + { + "id": 121, + "index": 93, + "char": "y", + "width": 23, + "height": 35, + "xoffset": -2, + "yoffset": 7.5, + "xadvance": 19.87207, + "chnl": 15, + "x": 29, + "y": 124, + "page": 0 + }, + { + "id": 33, + "index": 5, + "char": "!", + "width": 8, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 10.807617, + "chnl": 15, + "x": 242, + "y": 0, + "page": 0 + }, + { + "id": 35, + "index": 7, + "char": "#", + "width": 27, + "height": 34, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 25.860352, + "chnl": 15, + "x": 0, + "y": 129, + "page": 0 + }, + { + "id": 49, + "index": 21, + "char": "1", + "width": 15, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 53, + "y": 124, + "page": 0 + }, + { + "id": 50, + "index": 22, + "char": "2", + "width": 24, + "height": 34, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 167, + "y": 108, + "page": 0 + }, + { + "id": 52, + "index": 24, + "char": "4", + "width": 26, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 138, + "y": 117, + "page": 0 + }, + { + "id": 53, + "index": 25, + "char": "5", + "width": 23, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 192, + "y": 108, + "page": 0 + }, + { + "id": 54, + "index": 26, + "char": "6", + "width": 23, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 98, + "y": 118, + "page": 0 + }, + { + "id": 55, + "index": 27, + "char": "7", + "width": 24, + "height": 34, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 69, + "y": 129, + "page": 0 + }, + { + "id": 57, + "index": 29, + "char": "9", + "width": 23, + "height": 34, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 23.583984, + "chnl": 15, + "x": 216, + "y": 136, + "page": 0 + }, + { + "id": 63, + "index": 35, + "char": "?", + "width": 21, + "height": 34, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 19.831055, + "chnl": 15, + "x": 28, + "y": 160, + "page": 0 + }, + { + "id": 65, + "index": 37, + "char": "A", + "width": 30, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 27.398438, + "chnl": 15, + "x": 165, + "y": 143, + "page": 0 + }, + { + "id": 66, + "index": 38, + "char": "B", + "width": 24, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 26.147461, + "chnl": 15, + "x": 0, + "y": 164, + "page": 0 + }, + { + "id": 68, + "index": 40, + "char": "D", + "width": 26, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 27.541992, + "chnl": 15, + "x": 122, + "y": 152, + "page": 0 + }, + { + "id": 69, + "index": 41, + "char": "E", + "width": 23, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 23.871094, + "chnl": 15, + "x": 94, + "y": 153, + "page": 0 + }, + { + "id": 70, + "index": 42, + "char": "F", + "width": 22, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 23.214844, + "chnl": 15, + "x": 50, + "y": 164, + "page": 0 + }, + { + "id": 72, + "index": 44, + "char": "H", + "width": 27, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 29.941406, + "chnl": 15, + "x": 0, + "y": 199, + "page": 0 + }, + { + "id": 73, + "index": 45, + "char": "I", + "width": 8, + "height": 34, + "xoffset": 2, + "yoffset": -0.5, + "xadvance": 11.422852, + "chnl": 15, + "x": 245, + "y": 64, + "page": 0 + }, + { + "id": 74, + "index": 46, + "char": "J", + "width": 23, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 23.173828, + "chnl": 15, + "x": 28, + "y": 199, + "page": 0 + }, + { + "id": 75, + "index": 47, + "char": "K", + "width": 27, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 26.332031, + "chnl": 15, + "x": 52, + "y": 199, + "page": 0 + }, + { + "id": 76, + "index": 48, + "char": "L", + "width": 22, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 22.599609, + "chnl": 15, + "x": 80, + "y": 188, + "page": 0 + }, + { + "id": 77, + "index": 49, + "char": "M", + "width": 34, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 36.667969, + "chnl": 15, + "x": 196, + "y": 171, + "page": 0 + }, + { + "id": 78, + "index": 50, + "char": "N", + "width": 27, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 29.941406, + "chnl": 15, + "x": 103, + "y": 206, + "page": 0 + }, + { + "id": 80, + "index": 52, + "char": "P", + "width": 25, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 26.496094, + "chnl": 15, + "x": 231, + "y": 171, + "page": 0 + }, + { + "id": 82, + "index": 54, + "char": "R", + "width": 26, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 25.860352, + "chnl": 15, + "x": 131, + "y": 206, + "page": 0 + }, + { + "id": 84, + "index": 56, + "char": "T", + "width": 27, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 25.060547, + "chnl": 15, + "x": 158, + "y": 178, + "page": 0 + }, + { + "id": 85, + "index": 57, + "char": "U", + "width": 26, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 27.234375, + "chnl": 15, + "x": 158, + "y": 213, + "page": 0 + }, + { + "id": 86, + "index": 58, + "char": "V", + "width": 30, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 26.72168, + "chnl": 15, + "x": 185, + "y": 213, + "page": 0 + }, + { + "id": 88, + "index": 60, + "char": "X", + "width": 28, + "height": 34, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 26.332031, + "chnl": 15, + "x": 216, + "y": 206, + "page": 0 + }, + { + "id": 89, + "index": 61, + "char": "Y", + "width": 29, + "height": 34, + "xoffset": -2, + "yoffset": -0.5, + "xadvance": 25.224609, + "chnl": 15, + "x": 240, + "y": 136, + "page": 0 + }, + { + "id": 90, + "index": 62, + "char": "Z", + "width": 26, + "height": 34, + "xoffset": 0, + "yoffset": -0.5, + "xadvance": 25.142578, + "chnl": 15, + "x": 251, + "y": 0, + "page": 0 + }, + { + "id": 105, + "index": 77, + "char": "i", + "width": 8, + "height": 34, + "xoffset": 1, + "yoffset": -0.5, + "xadvance": 10.192383, + "chnl": 15, + "x": 149, + "y": 152, + "page": 0 + }, + { + "id": 119, + "index": 91, + "char": "w", + "width": 34, + "height": 26, + "xoffset": -1, + "yoffset": 7.5, + "xadvance": 31.561523, + "chnl": 15, + "x": 242, + "y": 100, + "page": 0 + }, + { + "id": 59, + "index": 31, + "char": ";", + "width": 10, + "height": 32, + "xoffset": -1, + "yoffset": 7.5, + "xadvance": 8.879883, + "chnl": 15, + "x": 122, + "y": 118, + "page": 0 + }, + { + "id": 116, + "index": 88, + "char": "t", + "width": 16, + "height": 32, + "xoffset": -2, + "yoffset": 1.5, + "xadvance": 13.719727, + "chnl": 15, + "x": 80, + "y": 223, + "page": 0 + }, + { + "id": 58, + "index": 30, + "char": ":", + "width": 9, + "height": 27, + "xoffset": 1, + "yoffset": 7.5, + "xadvance": 10.171875, + "chnl": 15, + "x": 196, + "y": 143, + "page": 0 + }, + { + "id": 97, + "index": 69, + "char": "a", + "width": 22, + "height": 27, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 22.845703, + "chnl": 15, + "x": 254, + "y": 64, + "page": 0 + }, + { + "id": 99, + "index": 71, + "char": "c", + "width": 23, + "height": 27, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 21.984375, + "chnl": 15, + "x": 257, + "y": 35, + "page": 0 + }, + { + "id": 101, + "index": 73, + "char": "e", + "width": 23, + "height": 27, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 22.250977, + "chnl": 15, + "x": 278, + "y": 0, + "page": 0 + }, + { + "id": 110, + "index": 82, + "char": "n", + "width": 21, + "height": 27, + "xoffset": 1, + "yoffset": 6.5, + "xadvance": 23.173828, + "chnl": 15, + "x": 245, + "y": 206, + "page": 0 + }, + { + "id": 111, + "index": 83, + "char": "o", + "width": 24, + "height": 27, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 23.953125, + "chnl": 15, + "x": 257, + "y": 171, + "page": 0 + }, + { + "id": 114, + "index": 86, + "char": "r", + "width": 15, + "height": 27, + "xoffset": 1, + "yoffset": 6.5, + "xadvance": 14.211914, + "chnl": 15, + "x": 270, + "y": 127, + "page": 0 + }, + { + "id": 115, + "index": 87, + "char": "s", + "width": 22, + "height": 27, + "xoffset": 0, + "yoffset": 6.5, + "xadvance": 21.65625, + "chnl": 15, + "x": 267, + "y": 199, + "page": 0 + }, + { + "id": 117, + "index": 89, + "char": "u", + "width": 21, + "height": 27, + "xoffset": 1, + "yoffset": 7.5, + "xadvance": 23.15332, + "chnl": 15, + "x": 267, + "y": 227, + "page": 0 + }, + { + "id": 126, + "index": 98, + "char": "~", + "width": 27, + "height": 12, + "xoffset": 1, + "yoffset": 13.5, + "xadvance": 28.567383, + "chnl": 15, + "x": 97, + "y": 241, + "page": 0 + }, + { + "id": 43, + "index": 15, + "char": "+", + "width": 24, + "height": 26, + "xoffset": 0, + "yoffset": 4.5, + "xadvance": 23.80957, + "chnl": 15, + "x": 289, + "y": 227, + "page": 0 + }, + { + "id": 118, + "index": 90, + "char": "v", + "width": 23, + "height": 26, + "xoffset": -1, + "yoffset": 7.5, + "xadvance": 20.34375, + "chnl": 15, + "x": 282, + "y": 155, + "page": 0 + }, + { + "id": 120, + "index": 92, + "char": "x", + "width": 23, + "height": 26, + "xoffset": -1, + "yoffset": 7.5, + "xadvance": 20.81543, + "chnl": 15, + "x": 290, + "y": 182, + "page": 0 + }, + { + "id": 122, + "index": 94, + "char": "z", + "width": 22, + "height": 26, + "xoffset": 0, + "yoffset": 7.5, + "xadvance": 20.81543, + "chnl": 15, + "x": 277, + "y": 63, + "page": 0 + }, + { + "id": 60, + "index": 32, + "char": "<", + "width": 21, + "height": 23, + "xoffset": -1, + "yoffset": 6.5, + "xadvance": 21.348633, + "chnl": 15, + "x": 281, + "y": 28, + "page": 0 + }, + { + "id": 62, + "index": 34, + "char": ">", + "width": 22, + "height": 23, + "xoffset": 1, + "yoffset": 6.5, + "xadvance": 21.943359, + "chnl": 15, + "x": 302, + "y": 0, + "page": 0 + }, + { + "id": 95, + "index": 67, + "char": "_", + "width": 23, + "height": 7, + "xoffset": -2, + "yoffset": 29.5, + "xadvance": 18.949219, + "chnl": 15, + "x": 254, + "y": 92, + "page": 0 + }, + { + "id": 42, + "index": 14, + "char": "*", + "width": 21, + "height": 21, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 18.087891, + "chnl": 15, + "x": 245, + "y": 234, + "page": 0 + }, + { + "id": 61, + "index": 33, + "char": "=", + "width": 21, + "height": 16, + "xoffset": 1, + "yoffset": 9.5, + "xadvance": 23.050781, + "chnl": 15, + "x": 103, + "y": 188, + "page": 0 + }, + { + "id": 94, + "index": 66, + "char": "^", + "width": 19, + "height": 19, + "xoffset": -1, + "yoffset": -0.5, + "xadvance": 17.554688, + "chnl": 15, + "x": 73, + "y": 164, + "page": 0 + }, + { + "id": 8220, + "index": 392, + "char": "“", + "width": 16, + "height": 14, + "xoffset": 0, + "yoffset": -2.5, + "xadvance": 14.847656, + "chnl": 15, + "x": 125, + "y": 241, + "page": 0 + }, + { + "id": 8221, + "index": 393, + "char": "”", + "width": 16, + "height": 14, + "xoffset": -1, + "yoffset": -1.5, + "xadvance": 14.991211, + "chnl": 15, + "x": 216, + "y": 241, + "page": 0 + }, + { + "id": 34, + "index": 6, + "char": "\"", + "width": 12, + "height": 14, + "xoffset": 1, + "yoffset": -1.5, + "xadvance": 13.432617, + "chnl": 15, + "x": 142, + "y": 241, + "page": 0 + }, + { + "id": 39, + "index": 11, + "char": "'", + "width": 7, + "height": 14, + "xoffset": 0, + "yoffset": -1.5, + "xadvance": 7.321289, + "chnl": 15, + "x": 282, + "y": 182, + "page": 0 + }, + { + "id": 44, + "index": 16, + "char": ",", + "width": 10, + "height": 14, + "xoffset": -1, + "yoffset": 25.5, + "xadvance": 8.244141, + "chnl": 15, + "x": 270, + "y": 155, + "page": 0 + }, + { + "id": 45, + "index": 17, + "char": "-", + "width": 14, + "height": 7, + "xoffset": -1, + "yoffset": 15.5, + "xadvance": 11.586914, + "chnl": 15, + "x": 129, + "y": 36, + "page": 0 + }, + { + "id": 8217, + "index": 389, + "char": "’", + "width": 10, + "height": 14, + "xoffset": -1, + "yoffset": -1.5, + "xadvance": 8.387695, + "chnl": 15, + "x": 233, + "y": 241, + "page": 0 + }, + { + "id": 96, + "index": 68, + "char": "`", + "width": 13, + "height": 10, + "xoffset": -1, + "yoffset": -1.5, + "xadvance": 12.981445, + "chnl": 15, + "x": 281, + "y": 52, + "page": 0 + }, + { + "id": 46, + "index": 18, + "char": ".", + "width": 9, + "height": 9, + "xoffset": 1, + "yoffset": 25.5, + "xadvance": 11.053711, + "chnl": 15, + "x": 186, + "y": 178, + "page": 0 + }, + { + "id": 32, + "index": 4, + "char": " ", + "width": 0, + "height": 0, + "xoffset": -2, + "yoffset": 29.5, + "xadvance": 10.397461, + "chnl": 15, + "x": 92, + "y": 44, + "page": 0 + } + ], + "info": { + "face": "Roboto-Regular", + "size": 42, + "bold": 0, + "italic": 0, + "charset": [ + " ", + "!", + "\\", + "\"", + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "[", + "]", + "^", + "_", + "`", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "{", + "|", + "}", + "~", + "’", + "“", + "”" + ], + "unicode": 1, + "stretchH": 100, + "smooth": 1, + "aa": 1, + "padding": [ + 2, + 2, + 2, + 2 + ], + "spacing": [ + 0, + 0 + ], + "outline": 0 + }, + "common": { + "lineHeight": 44.091797, + "base": 31.5, + "scaleW": 512, + "scaleH": 256, + "pages": 1, + "packed": 0, + "alphaChnl": 0, + "redChnl": 0, + "greenChnl": 0, + "blueChnl": 0 + }, + "distanceField": { + "fieldType": "msdf", + "distanceRange": 4 + }, + "kernings": [ + { + "first": 32, + "second": 84, + "amount": -0.820312 + }, + { + "first": 34, + "second": 34, + "amount": -2.194336 + }, + { + "first": 34, + "second": 39, + "amount": -2.194336 + }, + { + "first": 34, + "second": 65, + "amount": -2.460937 + }, + { + "first": 34, + "second": 97, + "amount": -1.025391 + }, + { + "first": 34, + "second": 99, + "amount": -1.209961 + }, + { + "first": 34, + "second": 100, + "amount": -1.209961 + }, + { + "first": 34, + "second": 101, + "amount": -1.209961 + }, + { + "first": 34, + "second": 103, + "amount": -1.209961 + }, + { + "first": 34, + "second": 109, + "amount": -0.410156 + }, + { + "first": 34, + "second": 110, + "amount": -0.410156 + }, + { + "first": 34, + "second": 111, + "amount": -1.250977 + }, + { + "first": 34, + "second": 112, + "amount": -0.410156 + }, + { + "first": 34, + "second": 113, + "amount": -1.209961 + }, + { + "first": 34, + "second": 115, + "amount": -1.640625 + }, + { + "first": 34, + "second": 119, + "amount": 0.225586 + }, + { + "first": 34, + "second": 8217, + "amount": -2.194336 + }, + { + "first": 34, + "second": 8220, + "amount": -2.194336 + }, + { + "first": 34, + "second": 8221, + "amount": -2.194336 + }, + { + "first": 39, + "second": 34, + "amount": -2.194336 + }, + { + "first": 39, + "second": 39, + "amount": -2.194336 + }, + { + "first": 39, + "second": 65, + "amount": -2.460937 + }, + { + "first": 39, + "second": 97, + "amount": -1.025391 + }, + { + "first": 39, + "second": 99, + "amount": -1.209961 + }, + { + "first": 39, + "second": 100, + "amount": -1.209961 + }, + { + "first": 39, + "second": 101, + "amount": -1.209961 + }, + { + "first": 39, + "second": 103, + "amount": -1.209961 + }, + { + "first": 39, + "second": 109, + "amount": -0.410156 + }, + { + "first": 39, + "second": 110, + "amount": -0.410156 + }, + { + "first": 39, + "second": 111, + "amount": -1.250977 + }, + { + "first": 39, + "second": 112, + "amount": -0.410156 + }, + { + "first": 39, + "second": 113, + "amount": -1.209961 + }, + { + "first": 39, + "second": 115, + "amount": -1.640625 + }, + { + "first": 39, + "second": 119, + "amount": 0.225586 + }, + { + "first": 39, + "second": 8217, + "amount": -2.194336 + }, + { + "first": 39, + "second": 8220, + "amount": -2.194336 + }, + { + "first": 39, + "second": 8221, + "amount": -2.194336 + }, + { + "first": 40, + "second": 86, + "amount": 0.410156 + }, + { + "first": 40, + "second": 87, + "amount": 0.369141 + }, + { + "first": 40, + "second": 89, + "amount": 0.451172 + }, + { + "first": 44, + "second": 34, + "amount": -3.486328 + }, + { + "first": 44, + "second": 39, + "amount": -3.486328 + }, + { + "first": 44, + "second": 8217, + "amount": -3.486328 + }, + { + "first": 44, + "second": 8220, + "amount": -3.486328 + }, + { + "first": 44, + "second": 8221, + "amount": -3.486328 + }, + { + "first": 46, + "second": 34, + "amount": -3.486328 + }, + { + "first": 46, + "second": 39, + "amount": -3.486328 + }, + { + "first": 46, + "second": 8217, + "amount": -3.486328 + }, + { + "first": 46, + "second": 8220, + "amount": -3.486328 + }, + { + "first": 46, + "second": 8221, + "amount": -3.486328 + }, + { + "first": 47, + "second": 47, + "amount": -4.59375 + }, + { + "first": 65, + "second": 34, + "amount": -2.460937 + }, + { + "first": 65, + "second": 39, + "amount": -2.460937 + }, + { + "first": 65, + "second": 63, + "amount": -1.250977 + }, + { + "first": 65, + "second": 67, + "amount": -0.225586 + }, + { + "first": 65, + "second": 71, + "amount": -0.225586 + }, + { + "first": 65, + "second": 79, + "amount": -0.225586 + }, + { + "first": 65, + "second": 81, + "amount": -0.225586 + }, + { + "first": 65, + "second": 84, + "amount": -2.645508 + }, + { + "first": 65, + "second": 85, + "amount": -0.348633 + }, + { + "first": 65, + "second": 86, + "amount": -1.78418 + }, + { + "first": 65, + "second": 87, + "amount": -1.415039 + }, + { + "first": 65, + "second": 89, + "amount": -1.927734 + }, + { + "first": 65, + "second": 111, + "amount": -0.246094 + }, + { + "first": 65, + "second": 116, + "amount": -0.348633 + }, + { + "first": 65, + "second": 117, + "amount": -0.225586 + }, + { + "first": 65, + "second": 118, + "amount": -1.025391 + }, + { + "first": 65, + "second": 119, + "amount": -0.676758 + }, + { + "first": 65, + "second": 121, + "amount": -1.025391 + }, + { + "first": 65, + "second": 122, + "amount": 0.246094 + }, + { + "first": 65, + "second": 8217, + "amount": -2.460937 + }, + { + "first": 65, + "second": 8220, + "amount": -2.460937 + }, + { + "first": 65, + "second": 8221, + "amount": -2.460937 + }, + { + "first": 66, + "second": 84, + "amount": -0.553711 + }, + { + "first": 66, + "second": 86, + "amount": -0.492187 + }, + { + "first": 66, + "second": 89, + "amount": -1.12793 + }, + { + "first": 67, + "second": 41, + "amount": -0.533203 + }, + { + "first": 67, + "second": 84, + "amount": -0.594727 + }, + { + "first": 67, + "second": 93, + "amount": -0.246094 + }, + { + "first": 67, + "second": 125, + "amount": -0.348633 + }, + { + "first": 68, + "second": 44, + "amount": -2.091797 + }, + { + "first": 68, + "second": 46, + "amount": -2.091797 + }, + { + "first": 68, + "second": 65, + "amount": -0.430664 + }, + { + "first": 68, + "second": 84, + "amount": -0.553711 + }, + { + "first": 68, + "second": 86, + "amount": -0.451172 + }, + { + "first": 68, + "second": 88, + "amount": -0.451172 + }, + { + "first": 68, + "second": 89, + "amount": -0.881836 + }, + { + "first": 68, + "second": 90, + "amount": -0.47168 + }, + { + "first": 69, + "second": 84, + "amount": 0.410156 + }, + { + "first": 69, + "second": 99, + "amount": -0.389648 + }, + { + "first": 69, + "second": 100, + "amount": -0.389648 + }, + { + "first": 69, + "second": 101, + "amount": -0.389648 + }, + { + "first": 69, + "second": 102, + "amount": -0.369141 + }, + { + "first": 69, + "second": 103, + "amount": -0.389648 + }, + { + "first": 69, + "second": 111, + "amount": -0.389648 + }, + { + "first": 69, + "second": 113, + "amount": -0.389648 + }, + { + "first": 69, + "second": 117, + "amount": -0.348633 + }, + { + "first": 69, + "second": 118, + "amount": -0.533203 + }, + { + "first": 69, + "second": 119, + "amount": -0.451172 + }, + { + "first": 69, + "second": 121, + "amount": -0.533203 + }, + { + "first": 70, + "second": 44, + "amount": -4.798828 + }, + { + "first": 70, + "second": 46, + "amount": -4.798828 + }, + { + "first": 70, + "second": 65, + "amount": -3.486328 + }, + { + "first": 70, + "second": 74, + "amount": -5.414062 + }, + { + "first": 70, + "second": 84, + "amount": 0.410156 + }, + { + "first": 70, + "second": 97, + "amount": -0.697266 + }, + { + "first": 70, + "second": 99, + "amount": -0.430664 + }, + { + "first": 70, + "second": 100, + "amount": -0.430664 + }, + { + "first": 70, + "second": 101, + "amount": -0.430664 + }, + { + "first": 70, + "second": 103, + "amount": -0.430664 + }, + { + "first": 70, + "second": 111, + "amount": -0.430664 + }, + { + "first": 70, + "second": 113, + "amount": -0.430664 + }, + { + "first": 70, + "second": 114, + "amount": -0.533203 + }, + { + "first": 70, + "second": 117, + "amount": -0.451172 + }, + { + "first": 70, + "second": 118, + "amount": -0.492187 + }, + { + "first": 70, + "second": 121, + "amount": -0.492187 + }, + { + "first": 72, + "second": 65, + "amount": 0.369141 + }, + { + "first": 72, + "second": 84, + "amount": -0.594727 + }, + { + "first": 72, + "second": 88, + "amount": 0.348633 + }, + { + "first": 72, + "second": 89, + "amount": -0.574219 + }, + { + "first": 73, + "second": 65, + "amount": 0.369141 + }, + { + "first": 73, + "second": 84, + "amount": -0.594727 + }, + { + "first": 73, + "second": 88, + "amount": 0.348633 + }, + { + "first": 73, + "second": 89, + "amount": -0.574219 + }, + { + "first": 74, + "second": 65, + "amount": -0.451172 + }, + { + "first": 75, + "second": 45, + "amount": -1.3125 + }, + { + "first": 75, + "second": 67, + "amount": -0.635742 + }, + { + "first": 75, + "second": 71, + "amount": -0.635742 + }, + { + "first": 75, + "second": 79, + "amount": -0.635742 + }, + { + "first": 75, + "second": 81, + "amount": -0.635742 + }, + { + "first": 75, + "second": 99, + "amount": -0.533203 + }, + { + "first": 75, + "second": 100, + "amount": -0.533203 + }, + { + "first": 75, + "second": 101, + "amount": -0.533203 + }, + { + "first": 75, + "second": 103, + "amount": -0.533203 + }, + { + "first": 75, + "second": 109, + "amount": -0.47168 + }, + { + "first": 75, + "second": 110, + "amount": -0.47168 + }, + { + "first": 75, + "second": 111, + "amount": -0.553711 + }, + { + "first": 75, + "second": 112, + "amount": -0.47168 + }, + { + "first": 75, + "second": 113, + "amount": -0.533203 + }, + { + "first": 75, + "second": 117, + "amount": -0.47168 + }, + { + "first": 75, + "second": 118, + "amount": -0.820312 + }, + { + "first": 75, + "second": 119, + "amount": -1.291992 + }, + { + "first": 75, + "second": 121, + "amount": -0.820312 + }, + { + "first": 76, + "second": 34, + "amount": -6.890625 + }, + { + "first": 76, + "second": 39, + "amount": -6.890625 + }, + { + "first": 76, + "second": 65, + "amount": 0.389648 + }, + { + "first": 76, + "second": 67, + "amount": -1.333008 + }, + { + "first": 76, + "second": 71, + "amount": -1.333008 + }, + { + "first": 76, + "second": 79, + "amount": -1.333008 + }, + { + "first": 76, + "second": 81, + "amount": -1.333008 + }, + { + "first": 76, + "second": 84, + "amount": -5.639648 + }, + { + "first": 76, + "second": 85, + "amount": -1.107422 + }, + { + "first": 76, + "second": 86, + "amount": -3.588867 + }, + { + "first": 76, + "second": 87, + "amount": -2.932617 + }, + { + "first": 76, + "second": 89, + "amount": -4.901367 + }, + { + "first": 76, + "second": 117, + "amount": -0.902344 + }, + { + "first": 76, + "second": 118, + "amount": -2.727539 + }, + { + "first": 76, + "second": 119, + "amount": -1.886719 + }, + { + "first": 76, + "second": 121, + "amount": -2.727539 + }, + { + "first": 76, + "second": 8217, + "amount": -6.890625 + }, + { + "first": 76, + "second": 8220, + "amount": -6.890625 + }, + { + "first": 76, + "second": 8221, + "amount": -6.890625 + }, + { + "first": 77, + "second": 65, + "amount": 0.369141 + }, + { + "first": 77, + "second": 84, + "amount": -0.594727 + }, + { + "first": 77, + "second": 88, + "amount": 0.348633 + }, + { + "first": 77, + "second": 89, + "amount": -0.574219 + }, + { + "first": 78, + "second": 65, + "amount": 0.369141 + }, + { + "first": 78, + "second": 84, + "amount": -0.594727 + }, + { + "first": 78, + "second": 88, + "amount": 0.348633 + }, + { + "first": 78, + "second": 89, + "amount": -0.574219 + }, + { + "first": 79, + "second": 44, + "amount": -2.091797 + }, + { + "first": 79, + "second": 46, + "amount": -2.091797 + }, + { + "first": 79, + "second": 65, + "amount": -0.430664 + }, + { + "first": 79, + "second": 84, + "amount": -0.553711 + }, + { + "first": 79, + "second": 86, + "amount": -0.451172 + }, + { + "first": 79, + "second": 88, + "amount": -0.451172 + }, + { + "first": 79, + "second": 89, + "amount": -0.881836 + }, + { + "first": 79, + "second": 90, + "amount": -0.47168 + }, + { + "first": 80, + "second": 44, + "amount": -6.644531 + }, + { + "first": 80, + "second": 46, + "amount": -6.644531 + }, + { + "first": 80, + "second": 65, + "amount": -2.830078 + }, + { + "first": 80, + "second": 74, + "amount": -4.101562 + }, + { + "first": 80, + "second": 88, + "amount": -0.635742 + }, + { + "first": 80, + "second": 90, + "amount": -0.533203 + }, + { + "first": 80, + "second": 97, + "amount": -0.225586 + }, + { + "first": 80, + "second": 99, + "amount": -0.266602 + }, + { + "first": 80, + "second": 100, + "amount": -0.266602 + }, + { + "first": 80, + "second": 101, + "amount": -0.266602 + }, + { + "first": 80, + "second": 103, + "amount": -0.266602 + }, + { + "first": 80, + "second": 111, + "amount": -0.266602 + }, + { + "first": 80, + "second": 113, + "amount": -0.266602 + }, + { + "first": 80, + "second": 116, + "amount": 0.287109 + }, + { + "first": 80, + "second": 118, + "amount": 0.307617 + }, + { + "first": 80, + "second": 121, + "amount": 0.307617 + }, + { + "first": 81, + "second": 84, + "amount": -0.881836 + }, + { + "first": 81, + "second": 86, + "amount": -0.574219 + }, + { + "first": 81, + "second": 87, + "amount": -0.410156 + }, + { + "first": 81, + "second": 89, + "amount": -0.717773 + }, + { + "first": 82, + "second": 84, + "amount": -1.640625 + }, + { + "first": 82, + "second": 86, + "amount": -0.389648 + }, + { + "first": 82, + "second": 89, + "amount": -0.984375 + }, + { + "first": 84, + "second": 32, + "amount": -0.820312 + }, + { + "first": 84, + "second": 44, + "amount": -4.470703 + }, + { + "first": 84, + "second": 45, + "amount": -4.757812 + }, + { + "first": 84, + "second": 46, + "amount": -4.470703 + }, + { + "first": 84, + "second": 65, + "amount": -1.620117 + }, + { + "first": 84, + "second": 67, + "amount": -0.574219 + }, + { + "first": 84, + "second": 71, + "amount": -0.574219 + }, + { + "first": 84, + "second": 74, + "amount": -4.921875 + }, + { + "first": 84, + "second": 79, + "amount": -0.574219 + }, + { + "first": 84, + "second": 81, + "amount": -0.574219 + }, + { + "first": 84, + "second": 83, + "amount": -0.328125 + }, + { + "first": 84, + "second": 84, + "amount": 0.328125 + }, + { + "first": 84, + "second": 86, + "amount": 0.328125 + }, + { + "first": 84, + "second": 87, + "amount": 0.307617 + }, + { + "first": 84, + "second": 89, + "amount": 0.328125 + }, + { + "first": 84, + "second": 97, + "amount": -2.317383 + }, + { + "first": 84, + "second": 99, + "amount": -2.030273 + }, + { + "first": 84, + "second": 100, + "amount": -2.030273 + }, + { + "first": 84, + "second": 101, + "amount": -2.030273 + }, + { + "first": 84, + "second": 103, + "amount": -2.030273 + }, + { + "first": 84, + "second": 109, + "amount": -2.235352 + }, + { + "first": 84, + "second": 110, + "amount": -2.235352 + }, + { + "first": 84, + "second": 111, + "amount": -2.030273 + }, + { + "first": 84, + "second": 112, + "amount": -2.235352 + }, + { + "first": 84, + "second": 113, + "amount": -2.030273 + }, + { + "first": 84, + "second": 114, + "amount": -1.538086 + }, + { + "first": 84, + "second": 115, + "amount": -2.378906 + }, + { + "first": 84, + "second": 117, + "amount": -1.948242 + }, + { + "first": 84, + "second": 118, + "amount": -1.476562 + }, + { + "first": 84, + "second": 119, + "amount": -1.168945 + }, + { + "first": 84, + "second": 120, + "amount": -1.579102 + }, + { + "first": 84, + "second": 121, + "amount": -1.476562 + }, + { + "first": 84, + "second": 122, + "amount": -1.230469 + }, + { + "first": 85, + "second": 65, + "amount": -0.451172 + }, + { + "first": 86, + "second": 41, + "amount": 0.410156 + }, + { + "first": 86, + "second": 44, + "amount": -4.614258 + }, + { + "first": 86, + "second": 45, + "amount": -0.758789 + }, + { + "first": 86, + "second": 46, + "amount": -4.614258 + }, + { + "first": 86, + "second": 65, + "amount": -1.538086 + }, + { + "first": 86, + "second": 67, + "amount": -0.266602 + }, + { + "first": 86, + "second": 71, + "amount": -0.266602 + }, + { + "first": 86, + "second": 79, + "amount": -0.266602 + }, + { + "first": 86, + "second": 81, + "amount": -0.266602 + }, + { + "first": 86, + "second": 93, + "amount": 0.348633 + }, + { + "first": 86, + "second": 97, + "amount": -0.943359 + }, + { + "first": 86, + "second": 99, + "amount": -0.902344 + }, + { + "first": 86, + "second": 100, + "amount": -0.902344 + }, + { + "first": 86, + "second": 101, + "amount": -0.902344 + }, + { + "first": 86, + "second": 103, + "amount": -0.902344 + }, + { + "first": 86, + "second": 111, + "amount": -0.943359 + }, + { + "first": 86, + "second": 113, + "amount": -0.902344 + }, + { + "first": 86, + "second": 114, + "amount": -0.615234 + }, + { + "first": 86, + "second": 117, + "amount": -0.574219 + }, + { + "first": 86, + "second": 118, + "amount": -0.225586 + }, + { + "first": 86, + "second": 121, + "amount": -0.225586 + }, + { + "first": 86, + "second": 125, + "amount": 0.389648 + }, + { + "first": 87, + "second": 41, + "amount": 0.307617 + }, + { + "first": 87, + "second": 44, + "amount": -2.522461 + }, + { + "first": 87, + "second": 45, + "amount": -1.230469 + }, + { + "first": 87, + "second": 46, + "amount": -2.522461 + }, + { + "first": 87, + "second": 65, + "amount": -0.881836 + }, + { + "first": 87, + "second": 84, + "amount": 0.287109 + }, + { + "first": 87, + "second": 93, + "amount": 0.246094 + }, + { + "first": 87, + "second": 97, + "amount": -0.676758 + }, + { + "first": 87, + "second": 99, + "amount": -0.635742 + }, + { + "first": 87, + "second": 100, + "amount": -0.635742 + }, + { + "first": 87, + "second": 101, + "amount": -0.635742 + }, + { + "first": 87, + "second": 103, + "amount": -0.635742 + }, + { + "first": 87, + "second": 111, + "amount": -0.635742 + }, + { + "first": 87, + "second": 113, + "amount": -0.635742 + }, + { + "first": 87, + "second": 114, + "amount": -0.430664 + }, + { + "first": 87, + "second": 117, + "amount": -0.389648 + }, + { + "first": 87, + "second": 125, + "amount": 0.287109 + }, + { + "first": 88, + "second": 45, + "amount": -0.943359 + }, + { + "first": 88, + "second": 67, + "amount": -0.512695 + }, + { + "first": 88, + "second": 71, + "amount": -0.512695 + }, + { + "first": 88, + "second": 79, + "amount": -0.512695 + }, + { + "first": 88, + "second": 81, + "amount": -0.512695 + }, + { + "first": 88, + "second": 86, + "amount": 0.287109 + }, + { + "first": 88, + "second": 99, + "amount": -0.533203 + }, + { + "first": 88, + "second": 100, + "amount": -0.533203 + }, + { + "first": 88, + "second": 101, + "amount": -0.533203 + }, + { + "first": 88, + "second": 103, + "amount": -0.533203 + }, + { + "first": 88, + "second": 111, + "amount": -0.430664 + }, + { + "first": 88, + "second": 113, + "amount": -0.533203 + }, + { + "first": 88, + "second": 117, + "amount": -0.430664 + }, + { + "first": 88, + "second": 118, + "amount": -0.635742 + }, + { + "first": 88, + "second": 121, + "amount": -0.635742 + }, + { + "first": 89, + "second": 38, + "amount": -0.615234 + }, + { + "first": 89, + "second": 41, + "amount": 0.410156 + }, + { + "first": 89, + "second": 42, + "amount": -1.004883 + }, + { + "first": 89, + "second": 44, + "amount": -4.327148 + }, + { + "first": 89, + "second": 45, + "amount": -1.066406 + }, + { + "first": 89, + "second": 46, + "amount": -4.327148 + }, + { + "first": 89, + "second": 65, + "amount": -1.927734 + }, + { + "first": 89, + "second": 67, + "amount": -0.594727 + }, + { + "first": 89, + "second": 71, + "amount": -0.594727 + }, + { + "first": 89, + "second": 74, + "amount": -1.96875 + }, + { + "first": 89, + "second": 79, + "amount": -0.594727 + }, + { + "first": 89, + "second": 81, + "amount": -0.594727 + }, + { + "first": 89, + "second": 83, + "amount": -0.328125 + }, + { + "first": 89, + "second": 84, + "amount": 0.348633 + }, + { + "first": 89, + "second": 85, + "amount": -1.96875 + }, + { + "first": 89, + "second": 86, + "amount": 0.369141 + }, + { + "first": 89, + "second": 87, + "amount": 0.348633 + }, + { + "first": 89, + "second": 88, + "amount": 0.266602 + }, + { + "first": 89, + "second": 89, + "amount": 0.369141 + }, + { + "first": 89, + "second": 93, + "amount": 0.369141 + }, + { + "first": 89, + "second": 97, + "amount": -1.49707 + }, + { + "first": 89, + "second": 99, + "amount": -1.333008 + }, + { + "first": 89, + "second": 100, + "amount": -1.333008 + }, + { + "first": 89, + "second": 101, + "amount": -1.333008 + }, + { + "first": 89, + "second": 102, + "amount": -0.451172 + }, + { + "first": 89, + "second": 103, + "amount": -1.333008 + }, + { + "first": 89, + "second": 109, + "amount": -0.820312 + }, + { + "first": 89, + "second": 110, + "amount": -0.820312 + }, + { + "first": 89, + "second": 111, + "amount": -1.333008 + }, + { + "first": 89, + "second": 112, + "amount": -0.820312 + }, + { + "first": 89, + "second": 113, + "amount": -1.333008 + }, + { + "first": 89, + "second": 114, + "amount": -0.820312 + }, + { + "first": 89, + "second": 115, + "amount": -1.189453 + }, + { + "first": 89, + "second": 116, + "amount": -0.451172 + }, + { + "first": 89, + "second": 117, + "amount": -0.799805 + }, + { + "first": 89, + "second": 118, + "amount": -0.410156 + }, + { + "first": 89, + "second": 120, + "amount": -0.47168 + }, + { + "first": 89, + "second": 121, + "amount": -0.410156 + }, + { + "first": 89, + "second": 122, + "amount": -0.615234 + }, + { + "first": 89, + "second": 125, + "amount": 0.389648 + }, + { + "first": 90, + "second": 65, + "amount": 0.266602 + }, + { + "first": 90, + "second": 67, + "amount": -0.533203 + }, + { + "first": 90, + "second": 71, + "amount": -0.533203 + }, + { + "first": 90, + "second": 79, + "amount": -0.533203 + }, + { + "first": 90, + "second": 81, + "amount": -0.533203 + }, + { + "first": 90, + "second": 99, + "amount": -0.430664 + }, + { + "first": 90, + "second": 100, + "amount": -0.430664 + }, + { + "first": 90, + "second": 101, + "amount": -0.430664 + }, + { + "first": 90, + "second": 103, + "amount": -0.430664 + }, + { + "first": 90, + "second": 111, + "amount": -0.430664 + }, + { + "first": 90, + "second": 113, + "amount": -0.430664 + }, + { + "first": 90, + "second": 117, + "amount": -0.389648 + }, + { + "first": 90, + "second": 118, + "amount": -0.553711 + }, + { + "first": 90, + "second": 119, + "amount": -0.553711 + }, + { + "first": 90, + "second": 121, + "amount": -0.553711 + }, + { + "first": 91, + "second": 74, + "amount": -0.369141 + }, + { + "first": 91, + "second": 85, + "amount": -0.369141 + }, + { + "first": 97, + "second": 34, + "amount": -1.374023 + }, + { + "first": 97, + "second": 39, + "amount": -1.374023 + }, + { + "first": 97, + "second": 118, + "amount": -0.307617 + }, + { + "first": 97, + "second": 121, + "amount": -0.307617 + }, + { + "first": 97, + "second": 8217, + "amount": -1.374023 + }, + { + "first": 97, + "second": 8220, + "amount": -1.374023 + }, + { + "first": 97, + "second": 8221, + "amount": -1.374023 + }, + { + "first": 98, + "second": 34, + "amount": -0.594727 + }, + { + "first": 98, + "second": 39, + "amount": -0.594727 + }, + { + "first": 98, + "second": 118, + "amount": -0.225586 + }, + { + "first": 98, + "second": 120, + "amount": -0.307617 + }, + { + "first": 98, + "second": 121, + "amount": -0.225586 + }, + { + "first": 98, + "second": 122, + "amount": -0.307617 + }, + { + "first": 98, + "second": 8217, + "amount": -0.594727 + }, + { + "first": 98, + "second": 8220, + "amount": -0.594727 + }, + { + "first": 98, + "second": 8221, + "amount": -0.594727 + }, + { + "first": 99, + "second": 34, + "amount": -0.225586 + }, + { + "first": 99, + "second": 39, + "amount": -0.225586 + }, + { + "first": 99, + "second": 8217, + "amount": -0.225586 + }, + { + "first": 99, + "second": 8220, + "amount": -0.225586 + }, + { + "first": 99, + "second": 8221, + "amount": -0.225586 + }, + { + "first": 101, + "second": 34, + "amount": -0.287109 + }, + { + "first": 101, + "second": 39, + "amount": -0.287109 + }, + { + "first": 101, + "second": 118, + "amount": -0.266602 + }, + { + "first": 101, + "second": 121, + "amount": -0.266602 + }, + { + "first": 101, + "second": 8217, + "amount": -1.640625 + }, + { + "first": 101, + "second": 8220, + "amount": -0.287109 + }, + { + "first": 101, + "second": 8221, + "amount": -0.287109 + }, + { + "first": 102, + "second": 34, + "amount": 0.328125 + }, + { + "first": 102, + "second": 39, + "amount": 0.328125 + }, + { + "first": 102, + "second": 41, + "amount": 0.410156 + }, + { + "first": 102, + "second": 93, + "amount": 0.369141 + }, + { + "first": 102, + "second": 99, + "amount": -0.492187 + }, + { + "first": 102, + "second": 100, + "amount": -0.492187 + }, + { + "first": 102, + "second": 101, + "amount": -0.492187 + }, + { + "first": 102, + "second": 103, + "amount": -0.492187 + }, + { + "first": 102, + "second": 113, + "amount": -0.492187 + }, + { + "first": 102, + "second": 125, + "amount": 0.389648 + }, + { + "first": 102, + "second": 8217, + "amount": 0.328125 + }, + { + "first": 102, + "second": 8220, + "amount": 0.328125 + }, + { + "first": 102, + "second": 8221, + "amount": 0.328125 + }, + { + "first": 104, + "second": 34, + "amount": -2.132812 + }, + { + "first": 104, + "second": 39, + "amount": -2.132812 + }, + { + "first": 104, + "second": 8217, + "amount": -1.640625 + }, + { + "first": 104, + "second": 8220, + "amount": -2.132812 + }, + { + "first": 104, + "second": 8221, + "amount": -2.132812 + }, + { + "first": 107, + "second": 99, + "amount": -0.410156 + }, + { + "first": 107, + "second": 100, + "amount": -0.410156 + }, + { + "first": 107, + "second": 101, + "amount": -0.410156 + }, + { + "first": 107, + "second": 103, + "amount": -0.410156 + }, + { + "first": 107, + "second": 113, + "amount": -0.410156 + }, + { + "first": 109, + "second": 34, + "amount": -2.132812 + }, + { + "first": 109, + "second": 39, + "amount": -2.132812 + }, + { + "first": 109, + "second": 8217, + "amount": -1.640625 + }, + { + "first": 109, + "second": 8220, + "amount": -2.132812 + }, + { + "first": 109, + "second": 8221, + "amount": -2.132812 + }, + { + "first": 110, + "second": 34, + "amount": -2.132812 + }, + { + "first": 110, + "second": 39, + "amount": -2.132812 + }, + { + "first": 110, + "second": 8217, + "amount": -1.640625 + }, + { + "first": 110, + "second": 8220, + "amount": -2.132812 + }, + { + "first": 110, + "second": 8221, + "amount": -2.132812 + }, + { + "first": 111, + "second": 34, + "amount": -2.789062 + }, + { + "first": 111, + "second": 39, + "amount": -2.789062 + }, + { + "first": 111, + "second": 118, + "amount": -0.307617 + }, + { + "first": 111, + "second": 120, + "amount": -0.430664 + }, + { + "first": 111, + "second": 121, + "amount": -0.307617 + }, + { + "first": 111, + "second": 122, + "amount": -0.328125 + }, + { + "first": 111, + "second": 8217, + "amount": -1.640625 + }, + { + "first": 111, + "second": 8220, + "amount": -2.789062 + }, + { + "first": 111, + "second": 8221, + "amount": -2.789062 + }, + { + "first": 112, + "second": 34, + "amount": -0.594727 + }, + { + "first": 112, + "second": 39, + "amount": -0.594727 + }, + { + "first": 112, + "second": 118, + "amount": -0.225586 + }, + { + "first": 112, + "second": 120, + "amount": -0.307617 + }, + { + "first": 112, + "second": 121, + "amount": -0.225586 + }, + { + "first": 112, + "second": 122, + "amount": -0.307617 + }, + { + "first": 112, + "second": 8217, + "amount": -0.594727 + }, + { + "first": 112, + "second": 8220, + "amount": -0.594727 + }, + { + "first": 112, + "second": 8221, + "amount": -0.594727 + }, + { + "first": 114, + "second": 34, + "amount": 0.328125 + }, + { + "first": 114, + "second": 39, + "amount": 0.328125 + }, + { + "first": 114, + "second": 44, + "amount": -2.522461 + }, + { + "first": 114, + "second": 46, + "amount": -2.522461 + }, + { + "first": 114, + "second": 97, + "amount": -0.820312 + }, + { + "first": 114, + "second": 99, + "amount": -0.389648 + }, + { + "first": 114, + "second": 100, + "amount": -0.389648 + }, + { + "first": 114, + "second": 101, + "amount": -0.389648 + }, + { + "first": 114, + "second": 102, + "amount": 0.307617 + }, + { + "first": 114, + "second": 103, + "amount": -0.389648 + }, + { + "first": 114, + "second": 111, + "amount": -0.410156 + }, + { + "first": 114, + "second": 113, + "amount": -0.389648 + }, + { + "first": 114, + "second": 116, + "amount": 1.025391 + }, + { + "first": 114, + "second": 118, + "amount": 0.369141 + }, + { + "first": 114, + "second": 119, + "amount": 0.348633 + }, + { + "first": 114, + "second": 121, + "amount": 0.369141 + }, + { + "first": 114, + "second": 8217, + "amount": 0.328125 + }, + { + "first": 114, + "second": 8220, + "amount": 0.328125 + }, + { + "first": 114, + "second": 8221, + "amount": 0.328125 + }, + { + "first": 116, + "second": 111, + "amount": -0.410156 + }, + { + "first": 118, + "second": 34, + "amount": 0.307617 + }, + { + "first": 118, + "second": 39, + "amount": 0.307617 + }, + { + "first": 118, + "second": 44, + "amount": -2.194336 + }, + { + "first": 118, + "second": 46, + "amount": -2.194336 + }, + { + "first": 118, + "second": 97, + "amount": -0.307617 + }, + { + "first": 118, + "second": 99, + "amount": -0.266602 + }, + { + "first": 118, + "second": 100, + "amount": -0.266602 + }, + { + "first": 118, + "second": 101, + "amount": -0.266602 + }, + { + "first": 118, + "second": 102, + "amount": 0.266602 + }, + { + "first": 118, + "second": 103, + "amount": -0.266602 + }, + { + "first": 118, + "second": 111, + "amount": -0.307617 + }, + { + "first": 118, + "second": 113, + "amount": -0.266602 + }, + { + "first": 118, + "second": 8217, + "amount": 0.307617 + }, + { + "first": 118, + "second": 8220, + "amount": 0.307617 + }, + { + "first": 118, + "second": 8221, + "amount": 0.307617 + }, + { + "first": 119, + "second": 44, + "amount": -2.542969 + }, + { + "first": 119, + "second": 46, + "amount": -2.542969 + }, + { + "first": 120, + "second": 99, + "amount": -0.410156 + }, + { + "first": 120, + "second": 100, + "amount": -0.410156 + }, + { + "first": 120, + "second": 101, + "amount": -0.410156 + }, + { + "first": 120, + "second": 103, + "amount": -0.410156 + }, + { + "first": 120, + "second": 111, + "amount": -0.410156 + }, + { + "first": 120, + "second": 113, + "amount": -0.410156 + }, + { + "first": 121, + "second": 34, + "amount": 0.307617 + }, + { + "first": 121, + "second": 39, + "amount": 0.307617 + }, + { + "first": 121, + "second": 44, + "amount": -2.194336 + }, + { + "first": 121, + "second": 46, + "amount": -2.194336 + }, + { + "first": 121, + "second": 97, + "amount": -0.307617 + }, + { + "first": 121, + "second": 99, + "amount": -0.266602 + }, + { + "first": 121, + "second": 100, + "amount": -0.266602 + }, + { + "first": 121, + "second": 101, + "amount": -0.266602 + }, + { + "first": 121, + "second": 102, + "amount": 0.266602 + }, + { + "first": 121, + "second": 103, + "amount": -0.266602 + }, + { + "first": 121, + "second": 111, + "amount": -0.307617 + }, + { + "first": 121, + "second": 113, + "amount": -0.266602 + }, + { + "first": 121, + "second": 8217, + "amount": 0.307617 + }, + { + "first": 121, + "second": 8220, + "amount": 0.307617 + }, + { + "first": 121, + "second": 8221, + "amount": 0.307617 + }, + { + "first": 122, + "second": 99, + "amount": -0.328125 + }, + { + "first": 122, + "second": 100, + "amount": -0.328125 + }, + { + "first": 122, + "second": 101, + "amount": -0.328125 + }, + { + "first": 122, + "second": 103, + "amount": -0.328125 + }, + { + "first": 122, + "second": 111, + "amount": -0.328125 + }, + { + "first": 122, + "second": 113, + "amount": -0.328125 + }, + { + "first": 123, + "second": 74, + "amount": -0.410156 + }, + { + "first": 123, + "second": 85, + "amount": -0.410156 + }, + { + "first": 8217, + "second": 34, + "amount": -2.194336 + }, + { + "first": 8217, + "second": 39, + "amount": -2.194336 + }, + { + "first": 8217, + "second": 65, + "amount": -2.460937 + }, + { + "first": 8217, + "second": 97, + "amount": -1.025391 + }, + { + "first": 8217, + "second": 99, + "amount": -1.209961 + }, + { + "first": 8217, + "second": 100, + "amount": -1.209961 + }, + { + "first": 8217, + "second": 101, + "amount": -1.209961 + }, + { + "first": 8217, + "second": 103, + "amount": -1.209961 + }, + { + "first": 8217, + "second": 104, + "amount": 0.65625 + }, + { + "first": 8217, + "second": 107, + "amount": 0.65625 + }, + { + "first": 8217, + "second": 108, + "amount": 0.65625 + }, + { + "first": 8217, + "second": 109, + "amount": -0.410156 + }, + { + "first": 8217, + "second": 110, + "amount": -0.410156 + }, + { + "first": 8217, + "second": 111, + "amount": -2.625 + }, + { + "first": 8217, + "second": 112, + "amount": -0.410156 + }, + { + "first": 8217, + "second": 113, + "amount": -1.209961 + }, + { + "first": 8217, + "second": 115, + "amount": -2.296875 + }, + { + "first": 8217, + "second": 119, + "amount": 0.225586 + }, + { + "first": 8217, + "second": 8217, + "amount": -2.194336 + }, + { + "first": 8217, + "second": 8220, + "amount": -2.194336 + }, + { + "first": 8217, + "second": 8221, + "amount": -2.194336 + }, + { + "first": 8220, + "second": 34, + "amount": -2.194336 + }, + { + "first": 8220, + "second": 39, + "amount": -2.194336 + }, + { + "first": 8220, + "second": 65, + "amount": -2.460937 + }, + { + "first": 8220, + "second": 97, + "amount": -1.025391 + }, + { + "first": 8220, + "second": 99, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 100, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 101, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 103, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 109, + "amount": -0.410156 + }, + { + "first": 8220, + "second": 110, + "amount": -0.410156 + }, + { + "first": 8220, + "second": 111, + "amount": -1.250977 + }, + { + "first": 8220, + "second": 112, + "amount": -0.410156 + }, + { + "first": 8220, + "second": 113, + "amount": -1.209961 + }, + { + "first": 8220, + "second": 115, + "amount": -1.640625 + }, + { + "first": 8220, + "second": 119, + "amount": 0.225586 + }, + { + "first": 8220, + "second": 8217, + "amount": -2.194336 + }, + { + "first": 8220, + "second": 8220, + "amount": -2.194336 + }, + { + "first": 8220, + "second": 8221, + "amount": -2.194336 + }, + { + "first": 8221, + "second": 34, + "amount": -2.194336 + }, + { + "first": 8221, + "second": 39, + "amount": -2.194336 + }, + { + "first": 8221, + "second": 65, + "amount": -2.460937 + }, + { + "first": 8221, + "second": 97, + "amount": -1.025391 + }, + { + "first": 8221, + "second": 99, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 100, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 101, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 103, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 109, + "amount": -0.410156 + }, + { + "first": 8221, + "second": 110, + "amount": -0.410156 + }, + { + "first": 8221, + "second": 111, + "amount": -1.250977 + }, + { + "first": 8221, + "second": 112, + "amount": -0.410156 + }, + { + "first": 8221, + "second": 113, + "amount": -1.209961 + }, + { + "first": 8221, + "second": 115, + "amount": -1.640625 + }, + { + "first": 8221, + "second": 119, + "amount": 0.225586 + }, + { + "first": 8221, + "second": 8217, + "amount": -2.194336 + }, + { + "first": 8221, + "second": 8220, + "amount": -2.194336 + }, + { + "first": 8221, + "second": 8221, + "amount": -2.194336 + } + ], + "lightningMetrics": { + "ascender": 1536, + "descender": -512, + "lineGap": 102, + "unitsPerEm": 2048 + } +} \ No newline at end of file diff --git a/bench/public/fonts/Roboto-Regular.msdf.png b/bench/public/fonts/Roboto-Regular.msdf.png new file mode 100644 index 0000000..84ba160 Binary files /dev/null and b/bench/public/fonts/Roboto-Regular.msdf.png differ diff --git a/bench/public/images/icon.png b/bench/public/images/icon.png new file mode 100644 index 0000000..e2ba54f Binary files /dev/null and b/bench/public/images/icon.png differ diff --git a/bench/public/images/poster-0.png b/bench/public/images/poster-0.png new file mode 100644 index 0000000..c136ab2 Binary files /dev/null and b/bench/public/images/poster-0.png differ diff --git a/bench/public/images/poster-1.png b/bench/public/images/poster-1.png new file mode 100644 index 0000000..90d45a0 Binary files /dev/null and b/bench/public/images/poster-1.png differ diff --git a/bench/public/images/poster-2.png b/bench/public/images/poster-2.png new file mode 100644 index 0000000..3fffc9e Binary files /dev/null and b/bench/public/images/poster-2.png differ diff --git a/bench/public/images/poster-3.png b/bench/public/images/poster-3.png new file mode 100644 index 0000000..386439a Binary files /dev/null and b/bench/public/images/poster-3.png differ diff --git a/bench/public/images/poster-4.png b/bench/public/images/poster-4.png new file mode 100644 index 0000000..2012df6 Binary files /dev/null and b/bench/public/images/poster-4.png differ diff --git a/bench/public/images/poster-5.png b/bench/public/images/poster-5.png new file mode 100644 index 0000000..4bef70b Binary files /dev/null and b/bench/public/images/poster-5.png differ diff --git a/bench/public/images/poster-6.png b/bench/public/images/poster-6.png new file mode 100644 index 0000000..bcaec02 Binary files /dev/null and b/bench/public/images/poster-6.png differ diff --git a/bench/public/images/poster-7.png b/bench/public/images/poster-7.png new file mode 100644 index 0000000..e59d1be Binary files /dev/null and b/bench/public/images/poster-7.png differ diff --git a/bench/public/images/poster-8.png b/bench/public/images/poster-8.png new file mode 100644 index 0000000..9549237 Binary files /dev/null and b/bench/public/images/poster-8.png differ diff --git a/bench/public/images/poster-9.png b/bench/public/images/poster-9.png new file mode 100644 index 0000000..6b6dbd2 Binary files /dev/null and b/bench/public/images/poster-9.png differ diff --git a/bench/public/images/sidenav.png b/bench/public/images/sidenav.png new file mode 100644 index 0000000..12809d4 Binary files /dev/null and b/bench/public/images/sidenav.png differ diff --git a/bench/run.mjs b/bench/run.mjs new file mode 100644 index 0000000..079ade5 --- /dev/null +++ b/bench/run.mjs @@ -0,0 +1,562 @@ +// The benchmark runner: `npm run bench` (node bench/run.mjs). Builds each arm, +// serves it with COOP/COEP (so performance.now() has a 5 µs clock), and +// drives one fresh Chromium per run with CDP CPU throttling. Methodology and +// the meaning of every number: docs/perf/README.md. +// +// node bench/run.mjs [--arms A,C] [--scenarios id,...] [--runs 3] +// [--modes time,alloc,profile,count] [--throttle 6] [--skip-build] +// [--quick] [--out dir] [--gpu metal|swiftshader|default] [--no-chunks] +// [--flex new|old] [--alloc-interval 1] [--profile-interval 50] +// [--idle-timeout 10000] [--summarize dir] +import { execFileSync, spawnSync } from 'node:child_process'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import http from 'node:http'; +import { createRequire } from 'node:module'; +import { cpus, loadavg } from 'node:os'; +import { dirname, extname, join, normalize, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; +import { chromium } from 'playwright'; +import { ARMS, PINS, prepareArms } from './prepare-arms.mjs'; +import { pageProbe } from './harness/probe.mjs'; +import { + BUCKETS, + chargeAllocations, + chargeProfile, + countStats, + timeStats, + writeSummary, +} from './harness/analyze.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const solidRoot = resolve(here, '..'); +const distRoot = join(here, 'dist'); +const require = createRequire(import.meta.url); + +const { values: args } = parseArgs({ + options: { + arms: { type: 'string', default: 'A,C' }, + scenarios: { type: 'string' }, + runs: { type: 'string', default: '3' }, + modes: { type: 'string', default: 'time,alloc,profile,count' }, + throttle: { type: 'string', default: '6' }, + 'skip-build': { type: 'boolean', default: false }, + quick: { type: 'boolean', default: false }, + out: { type: 'string' }, + gpu: { + type: 'string', + default: process.platform === 'darwin' ? 'metal' : 'default', + }, + 'no-chunks': { type: 'boolean', default: false }, + flex: { type: 'string', default: 'new' }, + 'alloc-interval': { type: 'string', default: '1' }, + 'profile-interval': { type: 'string', default: '50' }, + 'save-profiles': { type: 'boolean', default: false }, + 'idle-timeout': { type: 'string', default: '10000' }, + summarize: { type: 'string' }, + help: { type: 'boolean', default: false }, + }, +}); + +if (args.help) { + console.log( + readFileSync(fileURLToPath(import.meta.url), 'utf8') + .split('\n') + .slice(0, 10) + .join('\n'), + ); + process.exit(0); +} + +if (args.summarize !== undefined) { + console.log(`wrote ${writeSummary(resolve(args.summarize))}`); + process.exit(0); +} + +const arms = args.arms.split(',').map((a) => a.trim().toUpperCase()); +for (const a of arms) { + if (!(a in ARMS)) { + throw new Error(`unknown arm ${a}: one of ${Object.keys(ARMS).join(', ')}`); + } +} +const modes = args.modes.split(',').map((m) => m.trim()); +for (const m of modes) { + if (!['time', 'alloc', 'profile', 'count'].includes(m)) { + throw new Error(`unknown mode ${m}`); + } +} +const runs = Number(args.runs); +const throttle = Number(args.throttle); +const chunks = !args['no-chunks']; +if (args.flex !== 'new' && args.flex !== 'old') { + throw new Error( + '--flex: new (src/core/flexLayout.ts, the demo app) or old (flex.ts)', + ); +} +const flexOld = args.flex === 'old'; +const today = new Date().toISOString().slice(0, 10); +const outDir = resolve(args.out ?? join(solidRoot, 'docs/perf/results', today)); +mkdirSync(outDir, { recursive: true }); + +/** dist/[-count][-flexold][-nochunks], as bench/vite.config.ts names them. */ +const buildName = (arm, instrument) => + arm + + (instrument ? '-count' : '') + + (flexOld ? '-flexold' : '') + + (chunks ? '' : '-nochunks'); +/** The arm as results label it: C, or C-flexold / C-nochunks for a variant. */ +const label = (arm) => + arm + (flexOld ? '-flexold' : '') + (chunks ? '' : '-nochunks'); + +// --------------------------------------------------------------------------- +// Build + +function build(arm, instrument) { + const vite = join( + dirname(require.resolve('vite/package.json')), + 'bin/vite.js', + ); + console.log(`build ${buildName(arm, instrument)}`); + const r = spawnSync( + process.execPath, + [ + vite, + 'build', + '--config', + join(here, 'vite.config.ts'), + '--logLevel', + 'warn', + ], + { + cwd: solidRoot, + stdio: 'inherit', + env: { + ...process.env, + BENCH_ARM: arm, + BENCH_INSTRUMENT: instrument ? '1' : '0', + BENCH_CHUNKS: chunks ? '1' : '0', + BENCH_FLEX: args.flex, + }, + }, + ); + if (r.status !== 0) { + throw new Error(`build of arm ${arm} failed`); + } +} + +if (!args['skip-build']) { + prepareArms(); + for (const arm of arms) { + if (modes.some((m) => m !== 'count')) { + build(arm, false); + } + if (modes.includes('count')) { + build(arm, true); + } + } +} + +// --------------------------------------------------------------------------- +// Serve: COOP/COEP make the page cross-origin isolated (5 µs clock). + +const TYPES = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.webp': 'image/webp', + '.svg': 'image/svg+xml', +}; + +function serve(root) { + const server = http.createServer((req, res) => { + const path = decodeURIComponent( + new URL(req.url, 'http://localhost').pathname, + ); + let file = normalize(join(root, path)); + if (!file.startsWith(root + sep)) { + res.writeHead(403).end(); + return; + } + if (file.endsWith(sep)) { + file += 'index.html'; + } + let body; + try { + body = readFileSync(file); + } catch { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { + 'content-type': TYPES[extname(file)] ?? 'application/octet-stream', + 'cross-origin-opener-policy': 'same-origin', + 'cross-origin-embedder-policy': 'require-corp', + 'cross-origin-resource-policy': 'same-origin', + 'cache-control': 'no-store', + }); + res.end(body); + }); + return new Promise((resolveServer) => { + server.listen(0, '127.0.0.1', () => resolveServer(server)); + }); +} + +// --------------------------------------------------------------------------- +// Browser + +const GPU_ARGS = { + // The hardware GPU through ANGLE's Metal backend (headless new mode). + metal: ['--use-angle=metal', '--ignore-gpu-blocklist', '--enable-gpu'], + // Software GL: burns CPU in the GPU process; for comparison only. + swiftshader: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + default: [], +}; +if (!(args.gpu in GPU_ARGS)) { + throw new Error(`--gpu: one of ${Object.keys(GPU_ARGS).join(', ')}`); +} + +const launch = () => + chromium.launch({ + channel: 'chromium', + headless: true, + args: [ + ...GPU_ARGS[args.gpu], + '--disable-background-timer-throttling', + '--disable-renderer-backgrounding', + '--disable-backgrounding-occluded-windows', + ], + }); + +const probeOptions = (mode) => ({ + mode, + gapMs: 50, + quietMs: 50, + idleTimeoutMs: Number(args['idle-timeout']), +}); + +const git = (dir, ...a) => { + try { + return execFileSync('git', ['-C', dir, ...a], { encoding: 'utf8' }).trim(); + } catch { + return null; + } +}; + +/** What each arm is, for the record. */ +function armSource(arm) { + if (arm === 'A') { + return { solid: PINS.solidA, renderer: `npm ${PINS.rendererA}` }; + } + const dirty = + (git(solidRoot, 'status', '--porcelain', '--', 'src') ?? '') !== ''; + return { + solid: `${git(solidRoot, 'rev-parse', '--short', 'HEAD')}${dirty ? '+dirty' : ''}`, + renderer: `installed ${ARMS.C.rendererVersion}`, + }; +} + +/** + * Ops per page load. --quick takes at most 5 warmup and 10 measured ops, + * rounded up to whole cycles (Scenario.cycle) so that a warm-cache scenario + * stays warm. + */ +const opCounts = (info) => { + if (!args.quick) { + return { warmup: info.warmup, measured: info.measured }; + } + const cycle = info.cycle; + const whole = (n) => + cycle === null ? n : Math.max(cycle, Math.ceil(n / cycle) * cycle); + return { + warmup: whole(Math.min(info.warmup, 5)), + measured: whole(Math.min(info.measured, 10)), + }; +}; + +/** One page load: one scenario, one arm, one mode. */ +async function runOnce({ origin, arm, scenario, mode, run }) { + const instrument = mode === 'count'; + const url = `${origin}/${buildName(arm, instrument)}/index.html?scenario=${encodeURIComponent(scenario)}`; + const browser = await launch(); + const errors = []; + const record = { + scenario, + arm: label(arm), + mode, + run, + throttle, + quick: args.quick, + gpuFlag: args.gpu, + chunks, + flex: flexOld ? 'old (src/core/flex.ts)' : 'new (src/core/flexLayout.ts)', + loadAvg: loadavg()[0], + cpu: cpus()[0]?.model, + date: new Date().toISOString(), + source: armSource(arm), + }; + try { + const context = await browser.newContext({ + viewport: { width: 1920, height: 1080 }, + deviceScaleFactor: 1, + }); + const page = await context.newPage(); + page.on('pageerror', (e) => errors.push(`pageerror: ${e.message}`)); + page.on('console', (m) => { + // A failed load is reported below with its URL. + if ( + m.type() === 'error' && + !m.text().startsWith('Failed to load resource') + ) { + errors.push(`console: ${m.text()}`); + } + }); + page.on('response', (res) => { + if (res.status() >= 400 && !res.url().endsWith('/favicon.ico')) { + errors.push(`http ${res.status()}: ${res.url()}`); + } + }); + await page.addInitScript({ + content: `(${pageProbe.toString()})(${JSON.stringify(probeOptions(mode))});\n//# sourceURL=bench-probe.js`, + }); + const cdp = await context.newCDPSession(page); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: throttle }); + await page.goto(url); + await page.waitForFunction( + () => window.__bench !== undefined && window.__benchProbe !== undefined, + undefined, + { timeout: 30000 }, + ); + await page.evaluate(() => window.__benchProbe.ready(30000)); + const info = await page.evaluate(() => window.__benchProbe.info()); + const { warmup, measured } = opCounts(info); + record.title = info.title; + record.text = info.text; + record.warmup = warmup; + record.measured = measured; + record.cycle = info.cycle; + record.env = { + chrome: browser.version(), + gpu: info.gpu, + crossOriginIsolated: info.crossOriginIsolated, + clockResolutionUs: info.clockResolutionUs, + rendererMajor: info.rendererMajor, + instrumented: info.instrumented, + flex: info.flex, + }; + if (info.flex !== args.flex) { + throw new Error( + `the build runs ${info.flex} flex, not ${args.flex}: rebuild`, + ); + } + if (mode === 'count') { + record.hooks = await page.evaluate(() => + window.__benchProbe.installCounters(), + ); + } + const state = () => page.evaluate(() => window.__benchProbe.state()); + const states = { start: await state() }; + await page.evaluate((n) => window.__benchProbe.runOps(0, n, false), warmup); + states.afterWarmup = await state(); + if (mode === 'count') { + await page.evaluate(() => window.__benchProbe.resetCountDetails()); + } + if (mode === 'alloc') { + await cdp.send('HeapProfiler.enable'); + await cdp.send('HeapProfiler.startSampling', { + samplingInterval: Number(args['alloc-interval']), + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + } else if (mode === 'profile') { + await cdp.send('Profiler.enable'); + await cdp.send('Profiler.setSamplingInterval', { + interval: Number(args['profile-interval']), + }); + await cdp.send('Profiler.start'); + } + const ops = await page.evaluate( + ([first, n]) => window.__benchProbe.runOps(first, n, true), + [warmup, measured], + ); + record.ops = ops; + states.end = await state(); + if (states.start !== null) { + // With whole cycles of ops, the state after the measured ops is the + // state after the warmup. + states.cycleOk = + JSON.stringify(states.afterWarmup) === JSON.stringify(states.end); + record.state = states; + } + if (mode === 'time') { + record.stats = timeStats(ops); + } else if (mode === 'alloc') { + const { profile } = await cdp.send('HeapProfiler.stopSampling'); + const { buckets, sites } = chargeAllocations(profile.head); + const perOp = {}; + for (const b of BUCKETS) { + perOp[b] = buckets[b] / measured; + } + perOp.unattributed = buckets.unattributed / measured; + // The app's bytes: the four chunks. The harness (the op loop, the key + // events), other scripts and allocations with no script on the stack + // (Blink's own: the harness's MessageEvents, event dispatch) are + // reported beside it. + perOp.total = + perOp.framework + perOp.reactivity + perOp.renderer + perOp.user; + record.bytesPerOp = perOp; + record.allocInterval = Number(args['alloc-interval']); + record.topSites = [...sites.entries()] + .filter(([k]) => !k.includes('(harness:')) + .sort((a, b) => b[1] - a[1]) + .slice(0, 30) + .map(([k, v]) => [k, v / measured]); + } else if (mode === 'profile') { + const { profile } = await cdp.send('Profiler.stop'); + const { total, chunks: byChunk, fns } = chargeProfile(profile); + const perChunk = {}; + for (const [k, v] of Object.entries(byChunk)) { + perChunk[k] = v / measured; + } + record.profileInterval = Number(args['profile-interval']); + record.selfPerOp = { + total: total / measured, + chunks: perChunk, + top: [...fns.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 40) + .map(([k, v]) => [k, v / measured]), + }; + if (args['save-profiles']) { + // Written beside the result as .cpuprofile (Chrome DevTools + // format), for inclusive-time analysis; not kept in the JSON. + record.rawProfile = profile; + } + } else { + const details = await page.evaluate(() => + window.__benchProbe.countDetails(), + ); + // What the hooks learned over the run, beside what they found at + // install; a hook that threw, or could not install, fails the run. + record.hooks.shaderUnwrapped = details.shaderUnwrapped; + record.hooks.errors = details.hookErrors; + for (const [hook, count] of details.hookErrors) { + errors.push(`count hook ${hook} threw ${count} time(s)`); + } + record.stats = countStats(ops, record.hooks); + const perOp = (list) => + list + .sort((a, b) => b[1] - a[1]) + .slice(0, 20) + .map(([k, v]) => [k, v / measured]); + record.topWrites = { + node: perOp(details.nodeWrites), + shader: perOp(details.shaderWrites), + }; + } + } catch (e) { + errors.push(`runner: ${e.message}`); + } finally { + await browser.close(); + } + record.errors = errors; + return record; +} + +// --------------------------------------------------------------------------- +// Main + +const server = await serve(distRoot); +const origin = `http://127.0.0.1:${server.address().port}`; + +let scenarios; +if (args.scenarios !== undefined) { + scenarios = args.scenarios.split(',').map((s) => s.trim()); +} else { + // The build's own list (main.tsx), less the harness smoke test. + const browser = await launch(); + const page = await browser.newPage(); + await page.goto( + `${origin}/${buildName(arms[0], modes[0] === 'count')}/index.html?scenario=smoke`, + ); + await page.waitForFunction(() => window.__bench !== undefined); + const list = await page.evaluate(() => window.__bench.scenarios); + await browser.close(); + scenarios = list.map((s) => s.id).filter((id) => id !== 'smoke'); + if (scenarios.length === 0) { + throw new Error( + 'no scenarios registered besides smoke: pass --scenarios smoke', + ); + } +} + +console.log( + `arms ${arms.join(',')}; scenarios ${scenarios.join(',')}; modes ${modes.join(',')}; ${runs} run(s); throttle ${throttle}x; gpu ${args.gpu}; flex ${args.flex}${chunks ? '' : '; no chunks'}${args.quick ? '; quick' : ''}`, +); +console.log(`results: ${outDir}`); + +let failures = 0; +for (const mode of modes) { + for (const scenario of scenarios) { + for (let run = 1; run <= runs; run++) { + // Arms interleaved within a run set: A, C, A, C, ... + for (const arm of arms) { + const r = await runOnce({ origin, arm, scenario, mode, run }); + const file = join( + outDir, + `${scenario}.${label(arm)}.${mode}.${run}.json`, + ); + if (r.rawProfile !== undefined) { + writeFileSync( + file.replace(/\.json$/, '.cpuprofile'), + JSON.stringify(r.rawProfile), + ); + r.rawProfile = undefined; + } + writeFileSync(file, JSON.stringify(r, null, 1)); + let line = `${mode} ${scenario} ${label(arm)} #${run}:`; + if (r.errors.length > 0) { + failures++; + line += ` ERRORS ${r.errors.slice(0, 3).join(' | ')}`; + } + if (mode === 'time' && r.stats !== undefined) { + const s = r.stats; + line += ` handler ${s.handler.mean.toFixed(3)} tail ${s.tail.mean.toFixed(3)} frame ${s.frame?.mean.toFixed(3) ?? 'n/a'} total ${s.total.mean.toFixed(3)} cpu ${s.cpu.mean.toFixed(3)} ms (mean of ${r.measured})`; + if (s.timedOut > 0) { + line += ` ${s.timedOut} op(s) timed out`; + } + } else if (mode === 'alloc' && r.bytesPerOp !== undefined) { + const b = r.bytesPerOp; + line += ` ${(b.total / 1024).toFixed(2)} KiB/op (framework ${(b.framework / 1024).toFixed(2)}, reactivity ${(b.reactivity / 1024).toFixed(2)}, renderer ${(b.renderer / 1024).toFixed(2)}, user ${(b.user / 1024).toFixed(2)})`; + } else if (mode === 'profile' && r.selfPerOp !== undefined) { + line += ` ${r.selfPerOp.total.toFixed(3)} ms self/op; top ${r.selfPerOp.top[0]?.[0]}`; + } else if (mode === 'count' && r.stats !== undefined) { + const s = r.stats; + line += ` flex ${s.flexPasses?.toFixed(1) ?? 'n/a'} writes ${s.writes?.toFixed(1) ?? 'n/a'} shader ${s.shaderWrites?.toFixed(1) ?? 'n/a'} walks/frame ${s.walksPerFrame?.toFixed(2) ?? 'n/a'} loaded ${s.loadedText?.toFixed(1) ?? 'n/a'}/${s.loadedOther?.toFixed(1) ?? 'n/a'} per op`; + } + if (r.env !== undefined) { + if ( + r.env.crossOriginIsolated !== true || + r.env.clockResolutionUs > 20 + ) { + line += ` WARNING clock ${r.env.clockResolutionUs.toFixed(1)} µs, isolated ${r.env.crossOriginIsolated}`; + } + if ( + /SwiftShader/i.test(r.env.gpu ?? '') && + args.gpu !== 'swiftshader' + ) { + line += ' WARNING software GL'; + } + } + console.log(line); + } + } + } +} +server.close(); +console.log(`wrote ${writeSummary(outDir)}`); +if (failures > 0) { + console.log(`${failures} run(s) had errors (see "errors" in their JSON)`); + process.exitCode = 1; +} diff --git a/bench/src/arm-v1.ts b/bench/src/arm-v1.ts new file mode 100644 index 0000000..7eed423 --- /dev/null +++ b/bench/src/arm-v1.ts @@ -0,0 +1,43 @@ +// Renderer 1.x bootstrap (both arms): the engines are passed in, and shader +// types register on the stage's shader manager, as solid-demo-app does on 1.9. +import { SdfTextRenderer, WebGlCoreRenderer } from '@solidtv/renderer/webgl'; +import { + HolePunch, + LinearGradient, + RadialGradient, + Rounded, + RoundedWithBorder, + RoundedWithBorderAndShadow, + RoundedWithShadow, +} from '@solidtv/renderer/webgl/shaders'; + +export const rendererMajor = 1; + +export function rendererOptions(base: T): T { + return { + ...base, + renderEngine: WebGlCoreRenderer, + fontEngines: [SdfTextRenderer], + }; +} + +const shaderTypes = { + rounded: Rounded, + roundedWithBorder: RoundedWithBorder, + roundedWithShadow: RoundedWithShadow, + roundedWithBorderWithShadow: RoundedWithBorderAndShadow, + radialGradient: RadialGradient, + linearGradient: LinearGradient, + holePunch: HolePunch, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function registerShaders(renderer: any): void { + const shManager = renderer.stage.shManager; + for (const name in shaderTypes) { + shManager.registerShaderType( + name, + shaderTypes[name as keyof typeof shaderTypes], + ); + } +} diff --git a/bench/src/main.tsx b/bench/src/main.tsx new file mode 100644 index 0000000..57d2678 --- /dev/null +++ b/bench/src/main.tsx @@ -0,0 +1,78 @@ +// Benchmark entry: ?scenario= mounts one scenario. The runner drives it +// through window.__bench (bench/run.mjs, bench/harness/probe.mjs). +import { Config, createRenderer, loadFonts } from '@solidtv/solid'; +import { useFocusManager } from '@solidtv/solid/primitives'; +import { registerShaders, rendererOptions } from 'bench-arm-init'; +import { scenarios } from './scenarios/index.js'; + +declare const __BENCH_ARM__: string; +declare const __BENCH_RENDERER_MAJOR__: number; +declare const __BENCH_INSTRUMENT__: boolean; +declare const __BENCH_FLEX__: string; + +const params = new URLSearchParams(location.search); +const id = params.get('scenario') ?? 'smoke'; +const scenario = scenarios.find((s) => s.id === id); +if (scenario === undefined) { + throw new Error(`unknown scenario ${id}`); +} + +Config.animationsEnabled = params.get('animations') !== 'false'; +Config.fontSettings.fontFamily = 'Roboto'; +Config.fontSettings.fontSize = 32; +Config.fontSettings.color = '#f6f6f6ff'; + +Config.rendererOptions = rendererOptions({ + appWidth: 1920, + appHeight: 1080, + deviceLogicalPixelRatio: 1, + fpsUpdateInterval: 0, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +}) as any; + +const fontsLoaded = loadFonts([ + { + type: 'msdf', + fontFamily: 'Roboto', + atlasDataUrl: './fonts/Roboto-Regular.msdf.json', + atlasUrl: './fonts/Roboto-Regular.msdf.png', + metrics: { ascender: 1000, descender: 100, lineGap: 0, unitsPerEm: 1000 }, + }, + { + type: 'msdf', + fontFamily: 'Roboto700', + atlasDataUrl: './fonts/Roboto-Bold.msdf.json', + atlasUrl: './fonts/Roboto-Bold.msdf.png', + metrics: { ascender: 1000, descender: 100, lineGap: 0, unitsPerEm: 1000 }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +] as any); + +const { renderer, render } = createRenderer(undefined, 'app'); +registerShaders(renderer); + +const App = scenario.App; +render(() => { + useFocusManager(); + return ; +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(window as any).__bench = { + arm: __BENCH_ARM__, + rendererMajor: __BENCH_RENDERER_MAJOR__, + /** The count-mode build (BENCH_INSTRUMENT=1). */ + instrumented: __BENCH_INSTRUMENT__, + /** 'new' (src/core/flexLayout.ts, as the demo app) or 'old' (flex.ts). */ + flex: __BENCH_FLEX__, + scenario, + /** Every scenario in this build, for the runner's default list. */ + scenarios: scenarios.map((s) => ({ + id: s.id, + title: s.title, + text: s.text === true, + })), + renderer, + fontsLoaded, + Config, +}; diff --git a/bench/src/scenario.ts b/bench/src/scenario.ts new file mode 100644 index 0000000..971bcfc --- /dev/null +++ b/bench/src/scenario.ts @@ -0,0 +1,59 @@ +import type { JSX } from 'solid-js'; + +/** + * One benchmark scenario. A scenario is a scene plus a workload: the runner + * mounts `App` once per page load, waits for `ready`, then calls `step` for + * `warmup` operations and then for `measured` operations, timing each one. + * + * Scenarios must be arm-agnostic: they use only the public `@solidtv/solid` + * API that solid 1.6.4 (arm A) and the working tree (arm C) share, never the + * renderer directly. + */ +export interface Scenario { + /** Stable kebab-case id, used in URLs (`?scenario=`) and result files. */ + id: string; + /** One line for the report. */ + title: string; + /** A text scenario: the runner also records the text metrics. */ + text?: boolean; + /** The scene, rendered once per page load under the root node. */ + App: () => JSX.Element; + /** + * Resolves when the scene is mounted, its fonts are loaded and its first + * layout is done. Default: the runner waits for the renderer to go idle. + */ + ready?: () => Promise; + /** + * One operation of the workload, `i` counting from 0 across warmup and + * measured operations. A key-press workload returns the key name + * (`'ArrowRight'`, `'Enter'`, ...) and the runner dispatches it as a + * keydown/keyup pair on `document`. Any other workload (node creation, a + * state toggle, a text change) does its work synchronously inside `step` + * and returns null. + */ + step: (i: number) => string | null; + /** Operations before measuring. Default 30. */ + warmup?: number; + /** Operations measured. Default 60. */ + measured?: number; + /** + * Operations per cycle of the workload (one that returns the scene to its + * start state). `--quick` rounds its warmup and measured counts up to + * whole cycles, so that a warm-cache scenario stays warm. + */ + cycle?: number; + /** + * A JSON-serializable snapshot of the scene's state (focus path, list + * offsets, mounted counts). The runner records it after mount, after the + * warmup and after the measured operations, and flags a run whose two + * last snapshots differ (when `warmup` and `measured` are whole cycles, + * they must match) and a scenario whose final state differs between arms. + */ + probe?: () => unknown; + /** + * The kind of operation `i` (for example `'create'` or `'destroy'` in a + * workload that alternates them). The summary reports each kind on its + * own row as well as all of them together. + */ + opKind?: (i: number) => string; +} diff --git a/bench/src/scenarios/index.ts b/bench/src/scenarios/index.ts new file mode 100644 index 0000000..6c75476 --- /dev/null +++ b/bench/src/scenarios/index.ts @@ -0,0 +1,7 @@ +import type { Scenario } from '../scenario.js'; +import smoke from './smoke.js'; +import { navScenarios } from './nav/index.js'; +import { textScenarios } from './text/index.js'; + +/** Every scenario, in report order. `smoke` is for checking the harness only. */ +export const scenarios: Scenario[] = [smoke, ...navScenarios, ...textScenarios]; diff --git a/bench/src/scenarios/nav/index.ts b/bench/src/scenarios/nav/index.ts new file mode 100644 index 0000000..7653ce9 --- /dev/null +++ b/bench/src/scenarios/nav/index.ts @@ -0,0 +1,19 @@ +import type { Scenario } from '../../scenario.js'; +import navDrawerToggle from './navDrawer.js'; +import { pageScenarios } from './page.js'; +import portalFocusText from './portal.js'; +import { posterScenarios } from './poster.js'; +import { rowsScenarios } from './rows.js'; +import thumbnailFocus from './thumbnail.js'; +import { virtualScenarios } from './virtual.js'; + +/** Key-press, state-change and node-creation scenarios. */ +export const navScenarios: Scenario[] = [ + ...rowsScenarios, + thumbnailFocus, + ...virtualScenarios, + navDrawerToggle, + portalFocusText, + ...posterScenarios, + ...pageScenarios, +]; diff --git a/bench/src/scenarios/nav/navDrawer.tsx b/bench/src/scenarios/nav/navDrawer.tsx new file mode 100644 index 0000000..d18bf40 --- /dev/null +++ b/bench/src/scenarios/nav/navDrawer.tsx @@ -0,0 +1,182 @@ +// Scenario: a NavDrawer-style toggle: `states` add and remove on 10 +// children with `forwardStates`, and a width transition. +// +// Modelled on solid-demo-app's LeftNavWrapper + components/NavDrawer: Left +// from the first page tile bubbles to the wrapper's onLeft, which focuses the +// drawer; the drawer's onFocus adds `$focus` to the backdrop (width 200 -> 1600 +// with a transition) and `$active` to each button (width 100 -> 328, +// forwarded to its label: alpha 0 -> 1). Right from the drawer bubbles to the +// wrapper's onRight, which refocuses the page tile, and onBlur undoes it all. +import { + activeElement, + type ElementNode, + type NodeStyles, + type TextStyles, +} from '@solidtv/solid'; +import { Column, Row } from '@solidtv/solid/primitives'; +import { For } from 'solid-js'; +import { + focusPath, + plainTile, + pos, + theme, + type NavScenario, +} from './shared.js'; + +// NavDrawer.styles.ts +const drawerColumn = { + flexDirection: 'column', + display: 'flex', + width: 140, + height: 600, + y: 120, + gap: 20, + zIndex: 101, + transition: { + x: { duration: 250, easing: 'ease-in-out' }, + }, + x: 24, + $focus: { width: 500 }, +} satisfies NodeStyles; + +const backdropStyle = { + zIndex: 99, + color: '#000000ff', + src: './images/sidenav.png', + alpha: 0, + width: 200, + height: 1080, + $focus: { alpha: 1, width: 1600 }, + transition: { alpha: true, width: true }, +} satisfies NodeStyles; + +const navButton = { + zIndex: 102, + height: 70, + width: 100, + borderRadius: 0, + color: '#00000000', + $focus: { color: theme.primaryLight, borderRadius: 8 }, + $active: { width: 328 }, +} satisfies NodeStyles; + +const navButtonText = { + x: 112, + fontSize: 38, + lineHeight: 70, + alpha: 0, + color: theme.textPrimary, + $active: { alpha: 1 }, +} satisfies TextStyles; + +const LABELS = [ + 'Trending', + 'Movies', + 'TV', + 'Examples', + 'Benchmark', + 'Search', + 'Live', + 'Sports', + 'Kids', + 'Settings', +]; + +const TILES = Array.from({ length: 7 }, (_, i) => i); + +let wrapper: ElementNode | undefined; +let drawer: ElementNode | undefined; +let backdrop: ElementNode | undefined; +let pageRow: ElementNode | undefined; +let lastFocused: ElementNode | undefined; + +function NavButton(props: { children: string }) { + return ( + + + {props.children} + + ); +} + +function onDrawerFocus(this: ElementNode) { + backdrop!.states.add('$focus'); + this.children.forEach((c) => c.states!.add('$active')); + (this.children[this.selected || 0] as ElementNode).setFocus(); +} + +function onDrawerBlur(this: ElementNode) { + backdrop!.states.remove('$focus'); + this.selected = 0; + this.children.forEach((c) => c.states!.remove('$active')); +} + +function focusNavDrawer() { + if (drawer!.states.has('$focus')) return false; + lastFocused = activeElement(); + drawer!.setFocus(); + return true; +} + +function App() { + return ( + { + if (drawer!.states.has('$focus')) { + (lastFocused ?? pageRow!).setFocus(); + return true; + } + return false; + }} + > + + {(label) => {label}} + + + + + {() => } + + + + ); +} + +const navDrawerToggle: NavScenario = { + id: 'navdrawer-toggle', + title: + 'NavDrawer of 10 forwardStates buttons: Left opens it ($active on 10 buttons, backdrop $focus width 200->1600 transition), Right closes it', + App, + step: (i) => (i % 2 === 0 ? 'ArrowLeft' : 'ArrowRight'), + warmup: 30, + measured: 60, + probe: () => { + const buttons = (drawer?.children ?? []) as ElementNode[]; + return { + focus: focusPath(), + drawerStates: [...(drawer?.states ?? [])], + backdropStates: [...(backdrop?.states ?? [])], + backdropWidth: [backdrop?.width, Math.round(backdrop?.lng.w ?? 0)], + drawerWidth: drawer?.width, + buttonWidths: buttons.map((b) => b.width), + activeButtons: buttons.filter((b) => b.states.has('$active')).length, + labelAlpha: buttons.map( + (b) => (b.children[1] as ElementNode | undefined)?.alpha, + ), + page: pos(pageRow), + wrapper: pos(wrapper), + }; + }, +}; + +export default navDrawerToggle; diff --git a/bench/src/scenarios/nav/page.tsx b/bench/src/scenarios/nav/page.tsx new file mode 100644 index 0000000..2fc4e7e --- /dev/null +++ b/bench/src/scenarios/nav/page.tsx @@ -0,0 +1,195 @@ +// Scenarios: node creation for a whole page, the shape of a route +// change. A Browse-like page: a Column of 7 titled Rows x 20 Poster tiles. +// Each tile is poster.tsx's (an image, a title, `$focus`, a border and +// `forwardStates`) with a subtitle and a badge added, four nodes in all, so a +// page is about 600 nodes (582: the tiles' 560, the 7 Rows, their wrappers and +// titles, and the Column). +// +// Rows are 400px tall from y=40: rows 0-2 are on screen, rows 3-6 start below +// the bottom of the 1080px screen. Posters are 205px apart from x=160: about +// the first 10 of a Row are in bounds. Most of a page is therefore out of +// bounds, as most of a route's tree is (a swap lays out 63 of its 287 texts), +// and text under an out-of-bounds parent is where renderers differ. +import type { ElementNode, NodeStyles, TextStyles } from '@solidtv/solid'; +import { Column, Row } from '@solidtv/solid/primitives'; +import { createSignal, For, onCleanup, Show } from 'solid-js'; +import { posterStyle, posterTitle } from './poster.js'; +import { focusPath, posterImages, theme, type NavScenario } from './shared.js'; + +const ROWS = 7; +const TILES = 20; + +interface Tile { + src: string; + title: string; + meta: string; +} +interface PageRow { + title: string; + tiles: Tile[]; +} + +/** A page's data: `name` and `year` make every string differ between pages. */ +function makePage(name: string, year: number): PageRow[] { + return Array.from({ length: ROWS }, (_, r) => ({ + title: `${name} row ${r}`, + tiles: Array.from({ length: TILES }, (_, t) => ({ + src: posterImages[(r + t) % posterImages.length]!, + title: `${name} poster ${r}-${t}`, + meta: `${year + ((r * TILES + t) % 25)} | ${( + 6 + + ((r * 7 + t * 3) % 40) / 10 + ).toFixed(1)}`, + })), + })); +} + +const pageA = makePage('Alpha', 2000); +const pageB = makePage('Bravo', 2010); + +const rowTitle = { + fontSize: 28, + lineHeight: 28, + height: 28, + color: theme.textPrimary, +} satisfies TextStyles; + +const tileMeta = { + fontSize: 16, + lineHeight: 16, + height: 16, + x: 10, + y: 316, + contain: 'width', + width: 185, + color: theme.textPrimary, +} satisfies TextStyles; + +const tileBadge = { + x: 10, + y: 10, + width: 44, + height: 24, + color: '#000000cc', +} satisfies NodeStyles; + +function PageTile(props: { tile: Tile }) { + return ( + + {props.tile.title} + {props.tile.meta} + + + ); +} + +let column: ElementNode | undefined; + +function Page(props: { rows: PageRow[] }) { + let self: ElementNode | undefined; + onCleanup(() => { + if (column === self) { + column = undefined; + } + }); + return ( + (column = self = el)} + autofocus + x={160} + y={40} + width={1760} + > + + {(row) => ( + + + {row.title} + + + {(tile) => } + + + )} + + + ); +} + +/** Nodes under and including `node`; a ``'s string child is not a node. */ +function count(node: ElementNode): number { + let n = 1; + for (const child of node.children as ElementNode[]) { + if ((child as { _type?: string })._type !== 'text') { + n += count(child); + } + } + return n; +} + +function probe() { + const rows = (column?.children ?? []) as ElementNode[]; + return { + mounted: column !== undefined, + rows: rows.length, + nodes: column === undefined ? 0 : count(column), + firstTitle: (rows[0]?.children[0] as ElementNode | undefined)?.text, + focus: focusPath(), + }; +} + +// Even ops unmount the page, odd ops mount it again (new nodes, the same +// data). Split the samples by op kind to get destroy vs create. It starts +// mounted, so a cycle of 2 ops returns to the start state. +const [mounted, setMounted] = createSignal(true); +const pageMount: NavScenario = { + id: 'page-mount', + title: + 'Browse-like page (Column of 7 Rows x 20 Poster tiles, about 600 nodes): even ops unmount it, odd ops mount it', + text: true, + App: () => ( + + + + ), + step: (i) => { + setMounted(i % 2 !== 0); + return null; + }, + opKind: (i) => (i % 2 === 0 ? 'destroy' : 'create'), + warmup: 10, + measured: 20, + cycle: 2, + probe, +}; + +// One op replaces the page with a second page of the same shape and different +// strings, as a route change does: the old page's nodes are destroyed and the +// new page's created. Ops alternate between the two pages; it starts on A. +const [second, setSecond] = createSignal(false); +const pageSwap: NavScenario = { + id: 'page-swap', + title: + 'Browse-like page (Column of 7 Rows x 20 Poster tiles, about 600 nodes): each op replaces it with a page of the same shape and new strings', + text: true, + App: () => ( + }> + + + ), + step: (i) => { + setSecond(i % 2 === 0); + return null; + }, + warmup: 10, + measured: 20, + cycle: 2, + probe, +}; + +export const pageScenarios: NavScenario[] = [pageMount, pageSwap]; diff --git a/bench/src/scenarios/nav/portal.tsx b/bench/src/scenarios/nav/portal.tsx new file mode 100644 index 0000000..f828b8c --- /dev/null +++ b/bench/src/scenarios/nav/portal.tsx @@ -0,0 +1,200 @@ +// Scenario: `onFocusChanged` driving reactive text colours, modelled on +// solid-demo-app's pages/Portal.tsx (the Examples page): a Column of 4 Rows of +// DemoTiles, each tile tracking focus in a signal that recolours its 3 texts. +import type { ElementNode, NodeStyles, TextStyles } from '@solidtv/solid'; +import { Column, Row } from '@solidtv/solid/primitives'; +import { createSignal, For } from 'solid-js'; +import { backAndForth, focusPath, pos, type NavScenario } from './shared.js'; + +interface Demo { + title: string; + description: string; +} + +const d = (title: string, description: string): Demo => ({ + title, + description, +}); + +const rows: { title: string; demos: Demo[] }[] = [ + { + title: 'Core Concepts', + demos: [ + d('Focus Basics', 'Quick guide on Focus'), + d('Key Handling', 'Understanding Key Handling'), + d('Focus Handling', 'Dealing with Focus Handling'), + d('Loop Basics', 'Understanding For, Index, Lazy and List'), + d('Layout Basics', 'Quick guide on Layout'), + d('Positioning', 'Positioning Elements'), + d('Create Elements', 'Testing Show + children + inserting text'), + d('Destroy', 'Using onDestroy to animate destruction'), + d('Viewport', 'Events going in and out of viewport'), + ], + }, + { + title: 'Text', + demos: [ + d('Text', 'Text layout with flexbox'), + d('TextPoster', 'Text layout with flex and Poster'), + d( + 'Text Centering', + 'verticalAlign, textBaselineMode, and flex alignment', + ), + d('Custom Buttons', '6 custom buttons with text and icons'), + ], + }, + { + title: 'Flexbox & Styling', + demos: [ + d('Flex Row', 'Flex Row Implementation'), + d('Flex Column', 'Flex Column Implementation'), + d('Flex Grow', 'Flex Grow Examples'), + d('Flex Row Vertical Align', 'Flex Row Vertical Align Implementation'), + d( + 'Flex Column Vertical Align', + 'Flex Column Vertical Align Implementation', + ), + d('Flex Menu', 'Flex Menu On Right Implementation'), + d('Flex Layout Tests', 'Complicated flex layouts'), + d('Gradients', 'Basic Gradients'), + d('Transitions', 'Comparing different Transitions'), + d('Complex Flex', 'Complex Flex Layout with ~400 Nodes'), + ], + }, + { + title: 'Patterns & Examples', + demos: [ + d('TMDB', 'TMDB Example'), + d('Login and Forms', 'Login with forms Example'), + d('Small Image', 'Performance test for loading images'), + d('Large Image', 'Performance test for loading 4 large images'), + d('Nested Modal', 'Nested Right Modal Example'), + d('Components', 'Reusable Components'), + d('Grid', 'Infinite Scroll Grid'), + d('Virtual', 'Virtual Rows'), + ], + }, +]; + +// styles.RowTitle, with Portal's fontSize 42. +const rowTitle = { + height: 44, + width: 300, + marginBottom: -54, + fontSize: 42, + color: '#f0f0f0ff', + zIndex: 2, +} satisfies TextStyles; + +const container = { + width: 370, + height: 320, + borderRadius: 6, + color: '#182b44ff', + transition: { color: true }, + $focus: { color: '#ffffffff' }, +} satisfies NodeStyles; + +function DemoTile(props: { index: number } & Demo) { + const [hasFocus, setHasFocus] = createSignal(false); + return ( + + + + {String(props.index)} + + + {props.title} + + + {props.description} + + + + ); +} + +let column: ElementNode | undefined; + +function App() { + return ( + + + + + Examples + + + + + + + {(row) => ( + + {row.title} + + + {(demo, i) => } + + + + )} + + + + + ); +} + +function firstRow(): ElementNode | undefined { + return (column?.children[0] as ElementNode | undefined)?.children[1] as + | ElementNode + | undefined; +} + +const portalFocusText: NavScenario = { + id: 'portal-focus-text', + title: + 'Portal page: 4 Rows of DemoTiles, onFocusChanged recolours 3 texts per tile, Row scroll=always: Right x4 then Left x4 in row 0', + App, + step: backAndForth(4, 'ArrowRight', 'ArrowLeft'), + warmup: 24, + measured: 64, + probe: () => { + const tiles = (firstRow()?.children ?? []) as ElementNode[]; + return { + focus: focusPath(), + row: pos(firstRow()), + // Colour of each tile's title text in row 0: 0 = black (focused). + titleColors: tiles.map( + (t) => + ((t.children[0] as ElementNode).children[1] as ElementNode).color, + ), + }; + }, +}; + +export default portalFocusText; diff --git a/bench/src/scenarios/nav/poster.tsx b/bench/src/scenarios/nav/poster.tsx new file mode 100644 index 0000000..a8cc673 --- /dev/null +++ b/bench/src/scenarios/nav/poster.tsx @@ -0,0 +1,123 @@ +// Scenario: node creation for a Poster tile: an image, text, `$focus` +// and a border. Modelled on solid-demo-app's PosterTitle (image + title with +// forwardStates) with styles.Thumbnail's border and focus. +import type { ElementNode, NodeStyles, TextStyles } from '@solidtv/solid'; +import { Row } from '@solidtv/solid/primitives'; +import { createSignal, For } from 'solid-js'; +import { + placeholderColor, + theme, + type Item, + type NavScenario, +} from './shared.js'; + +export const posterStyle = { + width: 185, + height: 278, + scale: 1, + placeholderColor, + borderRadius: 8, + border: { width: 0, color: '#00000000' }, + transition: { + scale: { duration: 200, easing: 'linear' }, + }, + $focus: { + scale: 1.1, + border: { color: theme.primaryLight, width: 6, gap: 4, align: 'outside' }, + }, +} satisfies NodeStyles; + +// components/index.tsx posterTitleStyles, in Roboto. +export const posterTitle = { + fontSize: 22, + lineHeight: 22, + height: 22, + x: 10, + y: 288, + contain: 'width', + width: 185, + maxLines: 2, + alpha: 1, +} satisfies TextStyles; + +function Poster(props: { item: Item }) { + return ( + + {props.item.title} + + ); +} + +/** A batch of 10 posters; each call returns new objects, so `For` recreates them. */ +function batch(name: string): Item[] { + return Array.from({ length: 10 }, (_, i) => ({ + src: `./images/poster-${i}.png`, + title: `${name} poster ${i}`, + })); +} + +const batchA = batch('Alpha'); +const batchB = batch('Bravo'); +const empty: Item[] = []; + +let row: ElementNode | undefined; + +function posterScenario( + id: string, + title: string, + initial: Item[], + next: (i: number) => Item[], +): NavScenario { + const [items, setItems] = createSignal(initial); + return { + id, + title, + App: () => ( + + {(item) => } + + ), + step: (i) => { + setItems(next(i)); + return null; + }, + warmup: 30, + measured: 60, + probe: () => { + const posters = (row?.children ?? []) as ElementNode[]; + return { + mounted: posters.length, + titles: posters.map( + (p) => (p.children[0] as ElementNode | undefined)?.text, + ), + rowWidth: row?.width, + }; + }, + }; +} + +export const posterScenarios: NavScenario[] = [ + // One op = unmount 10 posters and mount 10 new ones (alternating between two + // batches of distinct item objects), as a list window replaces its contents. + posterScenario( + 'poster-swap', + 'Row of 10 Poster tiles (image, text, $focus, border): each op unmounts the 10 and mounts 10 new ones', + batchA, + (i) => (i % 2 === 0 ? batchB : batchA), + ), + // Ops alternate: even ops unmount the 10 posters, odd ops mount them again + // (new nodes for the same items). Split the samples by op parity to get + // destroy (even) vs create (odd). It starts mounted because an emptied Row + // keeps its flex width, so a cycle from mounted returns to the start state. + posterScenario( + 'poster-mount', + 'Row of 10 Poster tiles (image, text, $focus, border): even ops unmount the 10, odd ops mount them', + batchA, + (i) => (i % 2 === 0 ? empty : batchA), + ), +]; diff --git a/bench/src/scenarios/nav/rows.tsx b/bench/src/scenarios/nav/rows.tsx new file mode 100644 index 0000000..e42d83d --- /dev/null +++ b/bench/src/scenarios/nav/rows.tsx @@ -0,0 +1,122 @@ +// Scenario: a Column of 10 Rows x 20 tiles, with right/left and +// down/up presses, and `scroll` auto and always. +import type { ElementNode } from '@solidtv/solid'; +import { Column, Row } from '@solidtv/solid/primitives'; +import { For } from 'solid-js'; +import { + backAndForth, + focusPath, + plainTile, + pos, + type NavScenario, +} from './shared.js'; + +type Scroll = 'auto' | 'always' | 'edge'; + +const ROWS = Array.from({ length: 10 }, (_, i) => i); +const TILES = Array.from({ length: 20 }, (_, i) => i); + +let column: ElementNode | undefined; + +function RowsScene(props: { rowScroll: Scroll; columnScroll: Scroll }) { + return ( + + + {() => ( + + {() => } + + )} + + + ); +} + +function probe() { + const rows = (column?.children ?? []) as ElementNode[]; + return { + focus: focusPath(), + column: pos(column), + rowX: rows.map((r) => pos(r)[0]), + rowSelected: rows.map((r) => r.selected), + }; +} + +function rowsScenario( + id: string, + title: string, + rowScroll: Scroll, + columnScroll: Scroll, + step: (i: number) => string, + warmup: number, + measured: number, +): NavScenario { + return { + id, + title, + App: () => , + step, + warmup, + measured, + probe, + }; +} + +// Rows grow to their content (4080px), so `auto` and `always` both move the +// row on every press. `edge` moves it only when the next tile is off screen: +// tiles 0-8 are on screen, so Right x4 / Left x4 from tile 0 never shifts. +export const rowsScenarios: NavScenario[] = [ + rowsScenario( + 'rows-lr-noshift', + 'Column of 10 Rows x 20 tiles, Row scroll=edge: Right x4 then Left x4 in row 0, no list shift', + 'edge', + 'auto', + backAndForth(4, 'ArrowRight', 'ArrowLeft'), + 24, + 64, + ), + rowsScenario( + 'rows-lr-auto', + 'Column of 10 Rows x 20 tiles, Row scroll=auto: Right x10 then Left x10 in row 0, row shifts every press', + 'auto', + 'auto', + backAndForth(10, 'ArrowRight', 'ArrowLeft'), + 20, + 60, + ), + rowsScenario( + 'rows-lr-always', + 'Column of 10 Rows x 20 tiles, Row scroll=always: Right x10 then Left x10 in row 0, row shifts every press', + 'always', + 'always', + backAndForth(10, 'ArrowRight', 'ArrowLeft'), + 20, + 60, + ), + rowsScenario( + 'rows-ud-auto', + 'Column of 10 Rows x 20 tiles, Column scroll=auto: Down x9 then Up x9 across rows', + 'auto', + 'auto', + backAndForth(9, 'ArrowDown', 'ArrowUp'), + 18, + 72, + ), + rowsScenario( + 'rows-ud-always', + 'Column of 10 Rows x 20 tiles, Column scroll=always: Down x9 then Up x9 across rows', + 'always', + 'always', + backAndForth(9, 'ArrowDown', 'ArrowUp'), + 18, + 72, + ), +]; diff --git a/bench/src/scenarios/nav/shared.ts b/bench/src/scenarios/nav/shared.ts new file mode 100644 index 0000000..845da24 --- /dev/null +++ b/bench/src/scenarios/nav/shared.ts @@ -0,0 +1,104 @@ +// Shared pieces of the key-press, state-change and node-creation scenarios. +// Styles are modelled on solid-demo-app's styles.ts and theme.ts. +import { + activeElement, + type ElementNode, + type NodeStyles, +} from '@solidtv/solid'; +import type { Scenario } from '../../scenario.js'; + +/** + * A scenario plus `probe`, a debugging aid outside the Scenario contract: it + * returns a JSON snapshot of the scene's state (which element has focus, list + * offsets, mounted counts) so a check can confirm that the workload does what + * it says and that a full cycle returns to the start state. The runner does + * not call it. + */ +export type NavScenario = Scenario & { probe: () => unknown }; + +/** `forward` n times, then `back` n times: a cycle of 2n returns to the start. */ +export function backAndForth( + n: number, + forward: string, + back: string, +): (i: number) => string { + return (i) => (i % (2 * n) < n ? forward : back); +} + +/** Child indices from the root down to the focused element. */ +export function focusPath(): number[] { + const path: number[] = []; + let node: ElementNode | undefined = activeElement(); + while (node?.parent) { + path.unshift(node.parent.children.indexOf(node)); + node = node.parent; + } + return path; +} + +/** Target and live (renderer) position of a node, rounded. */ +export function pos(node: ElementNode | undefined): number[] { + if (!node) return []; + return [ + Math.round(node.x ?? 0), + Math.round(node.y ?? 0), + Math.round(node.lng.x ?? 0), + Math.round(node.lng.y ?? 0), + ]; +} + +/** Ten 185x278 poster images, generated into bench/public/images. */ +export const posterImages = Array.from( + { length: 10 }, + (_, i) => `./images/poster-${i}.png`, +); + +// placeholderColor is typed as a number (the renderer's), so the card colour +// has a numeric twin for it. +export const placeholderColor = 0x252c37ff; + +export const theme = { + primary: '#2c4f7cff', + primaryLight: '#4a7bd0ff', + card: '#252c37ff', + textPrimary: '#e6e8ebff', +} as const; + +/** A plain tile the size of the demo's Thumbnail, with a `$focus` colour. */ +export const plainTile = { + width: 185, + height: 278, + color: theme.card, + $focus: { color: theme.primaryLight }, +} satisfies NodeStyles; + +/** solid-demo-app styles.Thumbnail (roundPoster on, `$hover`/`$pressed` dropped). */ +export const thumbnail = { + width: 185, + height: 278, + scale: 1, + zIndex: 2, + placeholderColor, + transition: { + scale: { duration: 250, easing: 'linear' }, + }, + borderRadius: 16, + border: { width: 0, color: '#00000000' }, + $focus: { + scale: 1.1, + border: { color: theme.primaryLight, width: 6, gap: 4, align: 'outside' }, + }, +} satisfies NodeStyles; + +export interface Item { + src: string; + title: string; +} + +/** `count` deterministic items cycling through the poster images. */ +export function makeItems(count: number, prefix = 'Item'): Item[] { + return Array.from({ length: count }, (_, i) => ({ + src: posterImages[i % posterImages.length]!, + title: `${prefix} ${i}`, + })); +} diff --git a/bench/src/scenarios/nav/thumbnail.tsx b/bench/src/scenarios/nav/thumbnail.tsx new file mode 100644 index 0000000..03b3bf8 --- /dev/null +++ b/bench/src/scenarios/nav/thumbnail.tsx @@ -0,0 +1,43 @@ +// Scenario: a `$focus` with `scale`, a `border` object, `borderRadius` +// and a scale transition, modelled on solid-demo-app's styles.Thumbnail. +import type { ElementNode } from '@solidtv/solid'; +import { Row } from '@solidtv/solid/primitives'; +import { For } from 'solid-js'; +import { + backAndForth, + focusPath, + makeItems, + pos, + thumbnail, + type NavScenario, +} from './shared.js'; + +// One row of the Browse page's VirtualGrid: 7 columns, gap 50, at x 160. +const items = makeItems(7); + +let row: ElementNode | undefined; + +const thumbnailFocus: NavScenario = { + id: 'thumbnail-focus', + title: + 'Row of 7 Thumbnail tiles (image, $focus scale 1.1 + border, borderRadius 16, 250ms scale transition), scroll=none: Right x6 then Left x6', + App: () => ( + + + {(item) => } + + + ), + step: backAndForth(6, 'ArrowRight', 'ArrowLeft'), + warmup: 24, + measured: 72, + probe: () => ({ + focus: focusPath(), + row: pos(row), + scale: ((row?.children ?? []) as ElementNode[]).map( + (c) => Math.round((c.lng.scale ?? 1) * 100) / 100, + ), + }), +}; + +export default thumbnailFocus; diff --git a/bench/src/scenarios/nav/virtual.tsx b/bench/src/scenarios/nav/virtual.tsx new file mode 100644 index 0000000..fed5022 --- /dev/null +++ b/bench/src/scenarios/nav/virtual.tsx @@ -0,0 +1,125 @@ +// Scenario: a VirtualRow and a VirtualGrid, where presses move the window. +import type { ElementNode, NodeStyles } from '@solidtv/solid'; +import { VirtualGrid, VirtualRow } from '@solidtv/solid/primitives'; +import { + backAndForth, + focusPath, + makeItems, + placeholderColor, + pos, + thumbnail, + type Item, + type NavScenario, +} from './shared.js'; + +// solid-demo-app components/index.tsx posterStyles (the TitleRow's Poster). +const poster = { + width: 185, + height: 278, + scale: 1, + color: '#b0b0b0ff', + placeholderColor, + borderRadius: 8, + transition: { + scale: { duration: 200, easing: 'linear' }, + }, + $focus: { scale: 1.1, color: '#ffffffff' }, +} satisfies NodeStyles; + +function Poster(props: { item: Item }) { + return ; +} + +const rowItems = makeItems(60, 'Row item'); + +let virtualRow: ElementNode | undefined; + +function itemTitles(list: ElementNode | undefined): string { + const children = (list?.children ?? []) as (ElementNode & { item?: Item })[]; + return children.map((c) => c.item?.title.split(' ').pop()).join(','); +} + +// Modelled on the TMDB page's TitleRow: VirtualRow gap 20, displaySize 8, +// bufferSize 3. With scroll=auto the window moves on every press after the first. +const virtualRowScenario: NavScenario = { + id: 'virtual-row', + title: + 'VirtualRow of 60 Poster tiles (displaySize 8, bufferSize 3, scroll=auto): Right x10 then Left x10, the window moves', + App: () => ( + + + {(item) => } + + + ), + step: backAndForth(10, 'ArrowRight', 'ArrowLeft'), + warmup: 20, + measured: 60, + probe: () => ({ + focus: focusPath(), + row: pos(virtualRow), + selected: virtualRow?.selected, + items: itemTitles(virtualRow), + }), +}; + +const gridItems = makeItems(140, 'Grid item'); + +let virtualGrid: ElementNode | undefined; + +function Thumbnail(props: { item: Item }) { + return ; +} + +// Modelled on the Browse page: VirtualGrid of Thumbnails, 7 columns x 2 rows, +// buffer 2, gap 50, scroll=always, inside a clipping container. +const virtualGridScenario: NavScenario = { + id: 'virtual-grid', + title: + 'VirtualGrid of 140 Thumbnail tiles (7 columns, 2 rows, buffer 2, scroll=always): Down x8 then Up x8, the window moves', + App: () => ( + + + {(item) => } + + + ), + step: backAndForth(8, 'ArrowDown', 'ArrowUp'), + warmup: 32, + measured: 64, + probe: () => { + const children = (virtualGrid?.children ?? []) as ElementNode[]; + return { + focus: focusPath(), + grid: pos(virtualGrid), + selected: virtualGrid?.selected, + mounted: children.length, + first: itemTitles(virtualGrid).split(',')[0], + }; + }, +}; + +export const virtualScenarios: NavScenario[] = [ + virtualRowScenario, + virtualGridScenario, +]; diff --git a/bench/src/scenarios/smoke.tsx b/bench/src/scenarios/smoke.tsx new file mode 100644 index 0000000..6055d23 --- /dev/null +++ b/bench/src/scenarios/smoke.tsx @@ -0,0 +1,34 @@ +import { Row } from '@solidtv/solid/primitives'; +import { For } from 'solid-js'; +import type { Scenario } from '../scenario.js'; + +const tile = { + width: 200, + height: 120, + color: '#334455ff', + $focus: { color: '#ffffffff' }, +} as const; + +/** A tiny row to prove the build and runner end to end. Not a reported scenario. */ +const smoke: Scenario = { + id: 'smoke', + title: 'Smoke: one Row of 10 tiles, Right x9 then Left x9', + App: () => ( + + i)}> + {(i) => ( + + + Tile {i} + + + )} + + + ), + step: (i) => (i % 18 < 9 ? 'ArrowRight' : 'ArrowLeft'), + warmup: 18, + measured: 36, +}; + +export default smoke; diff --git a/bench/src/scenarios/text/TextTile.tsx b/bench/src/scenarios/text/TextTile.tsx new file mode 100644 index 0000000..ec2c9c4 --- /dev/null +++ b/bench/src/scenarios/text/TextTile.tsx @@ -0,0 +1,55 @@ +import type { NodeStyles, TextStyles } from '@solidtv/solid'; +import type { TileText } from './data.js'; + +export const TILE_WIDTH = 200; + +/** + * A flex column that sizes its height to its children: artwork, a title of up + * to two lines and a one-line subtitle. Text sits 12px in from the card edge. + */ +const tile = { + width: TILE_WIDTH, + display: 'flex', + flexDirection: 'column', + gap: 8, + color: '#1a1f27ff', + $focus: { color: '#2c4f7cff' }, +} satisfies NodeStyles; + +const art = { + width: TILE_WIDTH, + height: 112, + color: '#252c37ff', +} satisfies NodeStyles; + +// As the demo app's PosterTitle: a width, contain 'width' and maxLines. No +// height, so the title's height (one or two lines) comes from its layout and +// the column waits for its `loaded`. +const title = { + x: 12, + width: TILE_WIDTH - 24, + contain: 'width', + maxLines: 2, + fontSize: 24, + lineHeight: 30, + color: '#e6e8ebff', +} satisfies TextStyles; + +// No width: flex skips the column until this text has its size. +const subtitle = { + x: 12, + fontSize: 20, + lineHeight: 26, + marginBottom: 12, + color: '#9aa0a6ff', +} satisfies TextStyles; + +export function TextTile(props: { item: TileText }) { + return ( + + + {props.item.title} + {props.item.subtitle} + + ); +} diff --git a/bench/src/scenarios/text/data.ts b/bench/src/scenarios/text/data.ts new file mode 100644 index 0000000..6c8c3ee --- /dev/null +++ b/bench/src/scenarios/text/data.ts @@ -0,0 +1,207 @@ +// Deterministic strings for the text scenarios: invented titles, metadata and +// synopses of realistic length. No network and no randomness, so every arm +// lays out exactly the same text. ASCII only: the bench's Roboto MSDF atlases +// have printable ASCII and curly quotes, no bullet or accented letters. + +/** 50 base titles, 10 to 22 characters. */ +const BASE_TITLES = [ + 'The Silent Harbor', + 'Midnight in Lisbon', + 'Echoes of the Valley', + 'Paper Kingdoms', + 'The Last Lighthouse', + 'Northbound Express', + 'A Winter in Kyoto', + 'Glass Houses', + 'The Cartographer', + 'Saltwater Hearts', + 'Neon Prairie', + 'The Quiet Hours', + 'Iron and Ivy', + 'Beneath the Aurora', + 'The Weekend Detectives', + 'Copper Canyon', + 'Small Town Signals', + 'The Orchard Keeper', + 'Moonlight Bakery', + 'Fault Lines', + 'The Ninth Passenger', + 'Harbor Lights', + 'Summer of Static', + 'The Long Exposure', + 'Wild Coast Rescue', + 'The Velvet Kitchen', + 'Starfall Academy', + 'Under the Same Sky', + 'The Brass Compass', + 'Rivers of Gold', + 'City of Lanterns', + 'The Forgotten Shore', + 'Cold Case Chronicles', + 'Highland Rangers', + "The Tinker's Daughter", + 'Desert Bloom', + 'Signal and Noise', + 'The Glass Orchard', + 'Thunder Road Diaries', + 'Little Fires', + 'The Archivist', + 'Open Water', + 'Pacific Standard', + 'Shadows of Verona', + "The Clockmaker's Son", + 'Blue Ridge Nights', + 'Emerald Coast', + 'The Winter Garden', + 'Lost in Transit', + 'Kingdom of Salt', +]; + +/** Appended per block of 50, so titles stay unique up to 400 (at most 38 characters). */ +const TITLE_SUFFIXES = [ + '', + ': Season 2', + ': The Return', + " (Director's Cut)", + ': Origins', + ' Live', + ': Part Two', + ' Revisited', +]; + +const GENRES = [ + 'Drama', + 'Comedy', + 'Thriller', + 'Documentary', + 'Sci-Fi', + 'Romance', + 'Animation', + 'Crime', + 'Family', + 'Mystery', + 'Adventure', + 'Reality', +]; + +/** A tile's two lines: a title and a "year | genre" subtitle. */ +export interface TileText { + title: string; + subtitle: string; +} + +/** Number of distinct tiles {@link tileText} can make. */ +export const TILE_TEXT_COUNT = BASE_TITLES.length * TITLE_SUFFIXES.length; + +/** + * Tile `n`, for 0 <= n < TILE_TEXT_COUNT: every title and every subtitle is + * distinct (35 years and 12 genres are coprime, so the pair repeats only + * every 420). + */ +export function tileText(n: number): TileText { + return { + title: + BASE_TITLES[n % BASE_TITLES.length]! + + TITLE_SUFFIXES[Math.floor(n / BASE_TITLES.length)]!, + subtitle: `${1990 + (n % 35)} | ${GENRES[n % GENRES.length]!}`, + }; +} + +/** What a details panel shows for one item. */ +export interface Details { + title: string; + year: string; + runtime: string; + genres: string; + rating: string; + description: string; + cast: string; +} + +/** + * Eight items for the details panel. Synopses run from 150 to 400 + * characters, so at the panel's width they wrap to two or three lines and + * the longer ones are cut by maxLines. + */ +export const DETAILS: Details[] = [ + { + title: 'The Silent Harbor', + year: '2024', + runtime: '2h 15m', + genres: 'Drama, Mystery', + rating: 'PG-13', + description: + 'A retired lighthouse keeper returns to the fishing town she left thirty years ago and finds that the harbor has kept every secret she tried to forget.', + cast: 'Mara Ellison, Tom Achebe, Lucia Fernandez', + }, + { + title: 'Midnight in Lisbon', + year: '2019', + runtime: '1h 48m', + genres: 'Romance, Comedy', + rating: 'PG', + description: + 'Two strangers miss the last tram up the hill and spend one long night walking the city, trading stories in cafes, dodging a wedding party and arguing about whether a single evening is ever enough to change a life.', + cast: 'Daniel Okafor, Ines Moreira, Paul Brandt', + }, + { + title: 'Echoes of the Valley', + year: '2021', + runtime: '52m', + genres: 'Documentary', + rating: 'TV-G', + description: + 'Over four seasons in a remote mountain valley, a small team of naturalists follows the wolves, eagles and red deer that share one river, recording how a single hard winter reshapes the lives of every animal that depends on it, from the smallest vole to the oldest stag.', + cast: 'Narrated by Helen Marsh', + }, + { + title: 'The Weekend Detectives', + year: '2023', + runtime: '44m', + genres: 'Crime, Comedy', + rating: 'TV-14', + description: + 'Three retired schoolteachers who meet every Saturday for crosswords stumble onto a real case when a library book comes back with a ransom note tucked inside. Armed with reading glasses, a minivan and a suspicious amount of free time, they set out to solve it before the police notice.', + cast: 'Rosa Delgado, Martin Hale, Judith Kim, Owen Price', + }, + { + title: "The Tinker's Daughter (Director's Cut)", + year: '2016', + runtime: '2h 41m', + genres: 'Adventure, Family', + rating: 'PG', + description: + 'When her father vanishes on the road between two feuding kingdoms, a young apprentice mechanic sets out with his unfinished clockwork bird, a stolen map and a goat who will not stop following her. Along the way she learns that the machines her father built were never meant to be toys, and that both kingdoms want them back.', + cast: 'Ada Whitfield, Ravi Menon, Clara Svensson', + }, + { + title: 'Starfall Academy', + year: '2022', + runtime: '3 Seasons', + genres: 'Science Fiction', + rating: 'TV-PG', + description: + 'At a training school on the edge of the solar system, a class of first-year pilots discovers that the academy is not preparing them for exploration at all. As the final exams approach, rival cadets must decide whether to trust the instructors who raised them or the strange signal that has started repeating their names from somewhere beyond the outer beacons, growing a little louder every night.', + cast: 'Jonah Reyes, Priya Natarajan, Leo Fischer, Amara Osei', + }, + { + title: 'Copper Canyon', + year: '2012', + runtime: '1h 56m', + genres: 'Western, Thriller', + rating: 'R', + description: + 'A disgraced surveyor takes one last job mapping a canyon claimed by three families, and finds the old boundary stones have all been moved in the same night.', + cast: 'Wade Turner, Elena Ruiz, Sam Holloway', + }, + { + title: 'Moonlight Bakery', + year: '2020', + runtime: '1h 32m', + genres: 'Comedy, Family', + rating: 'G', + description: + "A night-shift baker who has never seen a sunrise inherits her grandmother's shop and its eccentric regulars, then has one month to win the town's pie contest or lose the building to a coffee chain.", + cast: 'Grace Liu, Bernard Cole, Nina Patel', + }, +]; diff --git a/bench/src/scenarios/text/detailsPanel.tsx b/bench/src/scenarios/text/detailsPanel.tsx new file mode 100644 index 0000000..ef4102b --- /dev/null +++ b/bench/src/scenarios/text/detailsPanel.tsx @@ -0,0 +1,151 @@ +// Scenario: a details panel whose title, description and metadata text +// change on every key press, laid out with flex, as on a hero or details +// screen (modelled on solid-demo-app's ContentBlock and Entity page). +import type { NodeStyles, TextStyles } from '@solidtv/solid'; +import { Row } from '@solidtv/solid/primitives'; +import { createSignal, For } from 'solid-js'; +import type { Scenario } from '../../scenario.js'; +import { DETAILS } from './data.js'; + +const PANEL_WIDTH = 1200; +const TEXT_WIDTH = 1100; + +// The panel sizes its height to its children; the cast line below the +// description moves when the description wraps to a different line count. +const panel = { + x: 120, + y: 60, + width: PANEL_WIDTH, + display: 'flex', + flexDirection: 'column', + gap: 16, +} satisfies NodeStyles; + +// One line, so no `loaded` listener: maxLines 1 gives it a maxHeight. +const title = { + width: PANEL_WIDTH, + contain: 'width', + maxLines: 1, + fontSize: 56, + lineHeight: 68, + fontWeight: 700, + color: '#e6e8ebff', +} satisfies TextStyles; + +// A flex row of text children with no width, so it lays out once they have +// their sizes, as the demo's Metadata row does. +const metadataRow = { + display: 'flex', + gap: 14, + height: 36, + alignItems: 'center', +} satisfies NodeStyles; + +const metaText = { + fontSize: 24, + lineHeight: 32, + color: '#e6e8ebff', +} satisfies TextStyles; + +const separator = { + ...metaText, + color: '#7f848dff', +} satisfies TextStyles; + +// A flex badge nested in the metadata row: its width follows its text. The +// inset is the text's margins, not `padding`: the two flex engines centre a +// padded container's children differently (flexLayout.ts offsets the centre +// by the padding), and margins lay out the same in both. +const badge = { + display: 'flex', + height: 32, + alignItems: 'center', + color: '#ffffff33', +} satisfies NodeStyles; + +const badgeText = { + marginLeft: 10, + marginRight: 10, + fontSize: 20, + lineHeight: 24, + color: '#ffffffff', +} satisfies TextStyles; + +const description = { + width: TEXT_WIDTH, + contain: 'width', + maxLines: 3, + fontSize: 26, + lineHeight: 38, + color: '#c8ccd2ff', +} satisfies TextStyles; + +const castLine = { + width: TEXT_WIDTH, + contain: 'width', + maxLines: 1, + fontSize: 22, + lineHeight: 30, + color: '#7f848dff', +} satisfies TextStyles; + +const item = { + width: 192, + height: 270, + color: '#252c37ff', + $focus: { color: '#4a7bd0ff' }, +} satisfies NodeStyles; + +/** Right x7 then Left x7 over the 8 items: a cycle of 14 returns to item 0. */ +const CYCLE = 2 * (DETAILS.length - 1); + +const [selected, setSelected] = createSignal(0); +const details = () => DETAILS[selected()]!; + +/** + * The Row's onSelectedChanged sets the selected item inside the key handler, + * so all of a press's text changes (title, four metadata texts, description, + * cast) happen within the op. The Row does not scroll: all 8 items fit. + * + * The 8 items repeat, so after the first pass every layout is a layout-cache + * hit on both renderers: this measures the update path (text set, walk, + * `loaded`, flex) rather than text measurement. + */ +export const detailsPanel: Scenario = { + id: 'text-details-panel', + title: + 'Details panel (flex column: title, metadata flex row with separators and a badge, 3-line description, cast) updated by Right x7 / Left x7 on a Row of 8', + text: true, + App: () => ( + <> + + {details().title} + + {details().year} + | + {details().runtime} + | + {details().genres} + + {details().rating} + + + {details().description} + Starring: {details().cast} + + setSelected(idx)} + > + {() => } + + + ), + step: (i) => (i % CYCLE < CYCLE / 2 ? 'ArrowRight' : 'ArrowLeft'), + warmup: 2 * CYCLE, + measured: 4 * CYCLE, +}; diff --git a/bench/src/scenarios/text/flexMount.tsx b/bench/src/scenarios/text/flexMount.tsx new file mode 100644 index 0000000..c11dc3c --- /dev/null +++ b/bench/src/scenarios/text/flexMount.tsx @@ -0,0 +1,66 @@ +// Scenario: a Row of 20 flex tiles, each a flex column with a title and +// a subtitle, at mount. +import { Row } from '@solidtv/solid/primitives'; +import { createSignal, For, Show } from 'solid-js'; +import type { Scenario } from '../../scenario.js'; +import { tileText, type TileText } from './data.js'; +import { TextTile } from './TextTile.js'; + +const TILES_PER_ROW = 20; + +/** + * One op replaces the row: a keyed Show disposes the 20 tiles on screen and + * mounts 20 new ones, so every op does the same work (an unmount and a mount) + * rather than alternating between the two. + * + * Tiles are 200px wide with a 24px gap, from x=60: tiles 0 to 9 start inside + * the renderers' 200px bounds margin, tiles 10 to 19 do not, and on both + * renderers text under an out-of-bounds parent is not laid out, so those + * tiles' columns stay unlaid-out (as in an app, until the row scrolls). An op + * therefore creates 80 nodes and lays out 20 texts (10 tiles). + * + * `sets` is the number of distinct string sets the ops cycle through. With 1, + * every mount lays out the same 20 strings, which after the first op are + * layout-cache hits on both renderers. With 20, an op's strings were last laid + * out 19 ops earlier, 380 layouts ago (more than the 250-entry layout cache + * holds on either renderer), so every layout is a miss. + */ +function flexMount(variant: 'same' | 'new', sets: number): Scenario { + const strings: TileText[][] = Array.from({ length: sets }, (_, s) => + Array.from({ length: TILES_PER_ROW }, (_, k) => + tileText(s * TILES_PER_ROW + k), + ), + ); + // Ops so far: the Show's key, so each op mounts a fresh row. + const [ops, setOps] = createSignal(0); + + return { + id: `text-flex-mount-${variant}`, + title: + variant === 'same' + ? 'Remount a Row of 20 flex-column text tiles (title + subtitle), same 40 strings every op (layout cache hits)' + : 'Remount a Row of 20 flex-column text tiles (title + subtitle), new strings every op (20 sets cycled, layout cache misses)', + text: true, + App: () => ( + + {(key) => ( + + + {(item) => } + + + )} + + ), + step: (i) => { + setOps(i + 1); + return null; + }, + // Multiples of `sets`, so measuring starts and ends on string set 0. + warmup: variant === 'same' ? 30 : 40, + measured: 60, + }; +} + +export const flexMountSame = flexMount('same', 1); +export const flexMountNew = flexMount('new', 20); diff --git a/bench/src/scenarios/text/index.ts b/bench/src/scenarios/text/index.ts new file mode 100644 index 0000000..67fe0b5 --- /dev/null +++ b/bench/src/scenarios/text/index.ts @@ -0,0 +1,12 @@ +import type { Scenario } from '../../scenario.js'; +import { detailsPanel } from './detailsPanel.js'; +import { flexMountNew, flexMountSame } from './flexMount.js'; +import { virtualTextRow } from './virtualRow.js'; + +/** Text-in-flex scenarios (the runner records text metrics for these). */ +export const textScenarios: Scenario[] = [ + flexMountSame, + flexMountNew, + detailsPanel, + virtualTextRow, +]; diff --git a/bench/src/scenarios/text/virtualRow.tsx b/bench/src/scenarios/text/virtualRow.tsx new file mode 100644 index 0000000..a1b65ab --- /dev/null +++ b/bench/src/scenarios/text/virtualRow.tsx @@ -0,0 +1,44 @@ +// Scenario: a VirtualRow of text tiles where presses move the window, +// so new text comes in on every press. +import { VirtualRow } from '@solidtv/solid/primitives'; +import type { Scenario } from '../../scenario.js'; +import { tileText } from './data.js'; +import { TextTile } from './TextTile.js'; + +const ITEMS = Array.from({ length: 40 }, (_, n) => tileText(n)); + +/** Right x15 then Left x15: a cycle of 30 returns to item 0 and the start offset. */ +const PRESSES = 15; + +/** + * scroll 'auto' with displaySize 7 and bufferSize 2 keeps 9 tiles mounted. + * The cycle's first Right only scrolls the row by one tile and its last Left + * scrolls it back; each of the other 28 presses shifts the window by one: the + * VirtualRow's List reuses the tile that leaves the window for the item that + * enters, rewriting its title and subtitle, and the row's flex pass moves + * every tile by one slot. Only items 0 to 22 are ever in the window, so after + * the first pass every layout is a layout-cache hit on both renderers. + */ +export const virtualTextRow: Scenario = { + id: 'text-virtual-row', + title: + 'VirtualRow of 40 flex-column text tiles (displaySize 7, bufferSize 2, scroll=auto): Right x15 then Left x15, each press brings in a tile with new text', + text: true, + App: () => ( + + {(item) => } + + ), + step: (i) => (i % (2 * PRESSES) < PRESSES ? 'ArrowRight' : 'ArrowLeft'), + warmup: 2 * PRESSES, + measured: 4 * PRESSES, +}; diff --git a/bench/vite.config.ts b/bench/vite.config.ts new file mode 100644 index 0000000..143359b --- /dev/null +++ b/bench/vite.config.ts @@ -0,0 +1,249 @@ +// Builds the benchmark app for one arm: BENCH_ARM=A|C (default C). +// +// Every arm compiles its Solid from source (the `src/` of the arm's tree) and +// consumes its renderer through the renderer's built `dist`, as apps do. The +// build settings mirror solid-demo-app's modern bundle (chrome>=64, class +// fields lowered to plain assignment, terser with mangling off) so the numbers +// carry over to the demo app and to devices. +// +// The bundle is split into four chunks with fixed names, so that a profiler +// sample or an allocation can be charged to its owner by script URL +// (bench/harness/analyze.mjs): +// assets/reactivity.js solid-js +// assets/framework.js the arm's @solidtv/solid source and its @solid-primitives +// assets/renderer.js the arm's @solidtv/renderer dist +// assets/user.js the entry: bench/src (main, arm bootstrap, scenarios) +// BENCH_CHUNKS=0 builds a single chunk instead (into dist/-nochunks), to +// check that the split does not move the timings. +// +// BENCH_INSTRUMENT=1 builds the count-mode variant into dist/-count: the +// arm's flex layout function (the default export of src/core/flex.ts and +// flexLayout.ts) is wrapped to count its calls in `window.__benchCount.flex`. +// Every other count is patched in at runtime by bench/harness/probe.mjs. +// Timing, allocation and profile runs never load this build. +// +// Flex: solid-demo-app's .env sets VITE_USE_NEW_FLEX=true, so apps run +// src/core/flexLayout.ts (elementNode.ts picks it when +// `import.meta.env.VITE_USE_NEW_FLEX` is non-empty). Every arm is built the +// same way by default; BENCH_FLEX=old builds src/core/flex.ts instead, into +// dist/[-count]-flexold. +import { existsSync, readFileSync, realpathSync } from 'node:fs'; +import { dirname, join, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { defineConfig, type Alias, type Plugin } from 'vite'; +import solidPlugin from 'vite-plugin-solid'; +import hexColorTransform from '@lightningtv/vite-hex-transform'; +import { ARMS } from './prepare-arms.mjs'; + +const here = dirname(fileURLToPath(import.meta.url)); +const arm = (process.env.BENCH_ARM ?? 'C') as keyof typeof ARMS; +if (!(arm in ARMS)) { + throw new Error(`BENCH_ARM must be one of ${Object.keys(ARMS).join(', ')}`); +} +const { rendererMajor } = ARMS[arm]; +// The renderer bootstrap (engines, shader registration) for the arm's +// renderer major: src/arm-v1.ts. Renderer 1.x is the only one this branch's +// Solid runs on. +const armInit = resolve(here, `src/arm-v${rendererMajor}.ts`); +if (!existsSync(armInit)) { + throw new Error( + `arm ${arm}: no bench bootstrap for @solidtv/renderer ${rendererMajor}.x (${armInit})`, + ); +} +// The arms' real paths: the bundler's module ids are real paths, and +// `bench/.arms` can be a symlink (a worktree sharing another's arms), so a +// path joined from it would match no id (no chunks, no flex hook). +const solid = realpathSync(ARMS[arm].solid); +const renderer = realpathSync(ARMS[arm].renderer); +const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); +const instrument = process.env.BENCH_INSTRUMENT === '1'; +const chunks = process.env.BENCH_CHUNKS !== '0'; +const flex = process.env.BENCH_FLEX === 'old' ? 'old' : 'new'; +const outName = + arm + + (instrument ? '-count' : '') + + (flex === 'old' ? '-flexold' : '') + + (chunks ? '' : '-nochunks'); +const solidSrc = join(solid, 'src') + sep; +const rendererRoot = renderer + sep; + +/** `@solidtv/renderer` and each of its subpath exports, from the arm's package.json. */ +function rendererAliases(): Alias[] { + const pkg = JSON.parse(readFileSync(join(renderer, 'package.json'), 'utf8')); + const aliases: Alias[] = []; + for (const [subpath, target] of Object.entries( + pkg.exports as Record, + )) { + const name = '@solidtv/renderer' + subpath.slice(1); + aliases.push({ + find: new RegExp(`^${escape(name)}$`), + replacement: join(renderer, target), + }); + } + return aliases; +} + +const solidAliases: Alias[] = [ + ['@solidtv/solid/primitives/router', 'src/primitives/routerIndex.ts'], + ['@solidtv/solid/primitives', 'src/primitives/index.ts'], + ['@solidtv/solid/devtools', 'src/devtools/index.ts'], + ['@solidtv/solid', 'src/index.ts'], +].map(([find, target]) => ({ + find: new RegExp(`^${escape(find)}$`), + replacement: join(solid, target), +})); + +/** + * Count mode only: wraps the default export of the arm's flex layout modules + * so that each call (one flex pass over one container) counts. It matches + * `export default function (` in src/core/flex.ts and flexLayout.ts, as in + * both arms. A module without it is left alone with a warning, as is a + * build in which neither module was transformed (a path that matches no + * module id), and the runner reports flex passes as n/a (`flexHooks` stays 0). + */ +function countFlexPasses(): Plugin { + const files = new Set( + ['flex.ts', 'flexLayout.ts'].map((f) => join(solid, 'src', 'core', f)), + ); + const head = /export default function\s*\(/; + let hooked = 0; + return { + name: 'bench-count-flex', + enforce: 'pre', + buildEnd() { + if (hooked === 0) { + this.warn( + `flex pass hook not installed: none of ${[...files].join(', ')} was transformed`, + ); + } + }, + transform(code, id) { + const file = id.split('?')[0]!; + if (!files.has(file)) { + return null; + } + if (!head.test(code)) { + this.warn( + `flex pass hook not installed: no default function in ${file}`, + ); + return null; + } + hooked++; + return ( + code.replace(head, 'function __benchFlexPass(') + + ` +if (typeof window !== 'undefined' && window.__benchCount !== undefined) { + window.__benchCount.flexHooks++; +} +export default function (node) { + const count = window.__benchCount; + if (count !== undefined) count.flex++; + return __benchFlexPass(node); +} +` + ); + }, + }; +} + +export default defineConfig({ + root: here, + base: './', + define: { + __DEV__: false, + __enableInspector__: false, + __emitBoundsEvents__: false, + __enableCompressedTextures__: false, + __renderTextBatching__: true, + SOLIDTV_DOM_RENDERING: false, + __BENCH_ARM__: JSON.stringify(arm), + __BENCH_RENDERER_MAJOR__: rendererMajor, + __BENCH_INSTRUMENT__: instrument, + __BENCH_FLEX__: JSON.stringify(flex), + 'import.meta.env.VITE_USE_NEW_FLEX': JSON.stringify( + flex === 'new' ? 'true' : '', + ), + }, + plugins: [ + ...(instrument ? [countFlexPasses()] : []), + // '#rrggbbaa' literals become numbers at build time, as in solid-demo-app. + hexColorTransform({ include: [resolve(here, 'src/**/*.{ts,tsx}')] }), + solidPlugin({ + hot: false, + solid: { + moduleName: '@solidtv/solid', + generate: 'universal', + builtIns: [], + }, + }), + ], + resolve: { + alias: [ + ...solidAliases, + ...rendererAliases(), + { + find: /^bench-arm-init$/, + replacement: armInit, + }, + ], + conditions: ['@solidtv/source', 'browser'], + dedupe: ['solid-js', 'solid-js/universal', 'solid-js/store'], + }, + build: { + outDir: resolve(here, 'dist', outName), + emptyOutDir: true, + target: 'chrome64', + minify: 'terser', + sourcemap: false, + modulePreload: false, + rolldownOptions: { + transform: { + assumptions: { + setPublicClassFields: true, + noDocumentAll: true, + }, + }, + output: { + // Fixed names: the harness charges samples to chunks by URL. + entryFileNames: 'assets/user.js', + chunkFileNames: 'assets/[name].js', + codeSplitting: chunks + ? { + // Otherwise a group takes its modules' dependencies with it: + // the framework chunk would swallow the renderer. + includeDependenciesRecursively: false, + groups: [ + { + name: 'reactivity', + test: /[\\/]node_modules[\\/]solid-js[\\/]/, + priority: 4, + }, + { + name: 'framework', + test: (id: string) => + id.startsWith(solidSrc) || + /[\\/]node_modules[\\/]@solid-primitives[\\/]/.test(id), + priority: 3, + }, + { + name: 'renderer', + test: (id: string) => id.startsWith(rendererRoot), + priority: 2, + }, + ], + } + : false, + }, + }, + terserOptions: { + mangle: false, + // BENCH_TERSER_COMPRESS='{"reduce_funcs":false}' tries other compress + // options against the demo app's defaults. + compress: + process.env.BENCH_TERSER_COMPRESS !== undefined + ? JSON.parse(process.env.BENCH_TERSER_COMPRESS) + : undefined, + format: { comments: false, beautify: true }, + }, + }, +}); diff --git a/docs/perf/README.md b/docs/perf/README.md new file mode 100644 index 0000000..1fb4939 --- /dev/null +++ b/docs/perf/README.md @@ -0,0 +1,364 @@ +# Performance measurement: methodology + +How `npm run bench` measures the CPU cost of SolidTV on its two benchmark +arms, and what each number means. + +## Running it + +```sh +npm run bench # every scenario, arms A,C, 3 runs, all modes +npm run bench -- --scenarios thumbnail-focus --runs 5 --modes time +npm run bench -- --scenarios smoke --runs 1 --quick # harness smoke test +node bench/run.mjs --summarize docs/perf/results/ # rebuild summary.md only +node bench/harness/inclusive.mjs docs/perf/results/ # inclusive time (needs --save-profiles) +``` + +| Option | Default | Meaning | +| -------------------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `--arms` | `A,C` | Arms to run, interleaved within each run set (A, C, A, C, …) | +| `--scenarios` | every scenario but `smoke` | Scenario ids (`bench/src/scenarios`) | +| `--runs` | 3 | Page loads per scenario, arm and mode | +| `--modes` | `time,alloc,profile,count` | Measurement modes; each is its own page load | +| `--throttle` | 6 | CDP `Emulation.setCPUThrottlingRate` | +| `--quick` | off | At most 5 warmup and 10 measured ops: for checking the harness, not for numbers | +| `--skip-build` | off | Use the existing `bench/dist`. Count mode reads `dist/-count`, which only a count-mode run builds: after a source change, run count without `--skip-build` once | +| `--out` | `docs/perf/results/` | Result directory | +| `--gpu` | `metal` on macOS | `metal`, `swiftshader` or `default` (ANGLE's choice) | +| `--flex` | `new` | `new`: `src/core/flexLayout.ts`, as the demo app; `old`: `src/core/flex.ts` (labelled `-flexold`) | +| `--no-chunks` | off | Single-chunk build (labelled `-nochunks`): to check that chunking does not move timings | +| `--alloc-interval` | 1 | Heap sampling interval in bytes (1: every allocation) | +| `--profile-interval` | 50 | CPU profiler sampling interval in µs | +| `--save-profiles` | off | Also write each profile run's raw CPU profile as `.cpuprofile` (DevTools format) | +| `--idle-timeout` | 10000 | ms to wait for an op to settle before marking it `timedOut` | + +Each run writes `/....json` (every op's raw +record plus the run's statistics and environment), and the runner then +regenerates `/summary.md` from **every** result file in ``. The raw +JSON is large and gitignored, as are the `.cpuprofile` files; `summary.md` is +the part of a series to commit. Runs whose page threw are kept as JSON +(`errors`) and left out of the summary, and the runner exits 1. + +`node bench/harness/inclusive.mjs [scenario …]` reads the raw profiles +that `--modes profile --save-profiles` writes and prints, per scenario and +arm, the inclusive time per op of a list of named Solid and renderer +functions (key dispatch, Row/Column navigation, flex, focus change, state +styles, node creation, the renderer frame). + +Run the measured series on a quiet machine: the summary records the +1-minute load average at the start of each run. + +## Arms and builds + +| Arm | Solid | Renderer | +| --- | -------------------------- | -------------------------------------------------------------- | +| A | 1.6.4 (`71c170f`) | npm 1.9.3 | +| C | this working tree (`src/`) | the installed `@solidtv/renderer` (`node_modules`), its `dist` | + +`bench/prepare-arms.mjs` prepares arm A under `bench/.arms` (a `git archive` +of the pinned commit, and `npm pack` of the pinned renderer); arm C is the +working tree and whatever `node_modules/@solidtv/renderer` resolves to (its +version is read from its `package.json` and recorded in each result). +`bench/vite.config.ts` builds one arm (`BENCH_ARM=A|C`): Solid compiled from +the arm's source, the renderer from its `dist`, terser without mangling, +`chrome64` target, as the demo app's modern bundle. The renderer bootstrap +(engines, shader registration) is `bench/src/arm-v1.ts`, picked by the +renderer's major version. The build uses the arms' real paths: the bundler's +module ids are real paths, so through a symlinked `bench/.arms` nothing would +match a chunk or the flex hook. The runner rebuilds every arm it runs unless +`--skip-build`, and records each arm's source (`source` in each JSON; C is +marked `+dirty` when `src/` has uncommitted changes). + +While `src/` is unchanged from 1.6.4, **A and C run the same Solid code**, so +A/C (the ratios in the summary) is the in-session noise floor, plus whatever +differs between the two renderer versions (the summary lists each arm's +Solid and renderer; today's lockfile installs 1.9.0). After a change to `src/`, +A/C is the change's effect (above 1: C is faster or allocates less). + +**Flex.** solid-demo-app sets `VITE_USE_NEW_FLEX=true`, so apps run +`src/core/flexLayout.ts`. Every arm is built that way by default (the define +`import.meta.env.VITE_USE_NEW_FLEX = 'true'` folds the choice at build time; +the bundle contains only `flexLayout`'s code). `--flex old` builds `flex.ts` +into `dist/-flexold`. Each result records `flex`, and the page reports +the flex it was built with; the runner refuses a mismatch. + +**Chunks.** The bundle is split into four fixed-name chunks, so that a +profiler sample or an allocation is charged to its owner by script URL: + +| Chunk | Contents | +| --------------- | ------------------------------------------------------------- | +| `framework.js` | the arm's `@solidtv/solid` source and its `@solid-primitives` | +| `reactivity.js` | `solid-js` | +| `renderer.js` | the arm's `@solidtv/renderer` | +| `user.js` | the entry: `bench/src` (main, arm bootstrap, scenarios) | + +The harness's own in-page code is injected as `bench-probe.js` and is +reported as `harness`, never as app cost. Cross-chunk calls go through ES +module imports instead of scope-hoisted locals; `--no-chunks` builds the +single-chunk variant to check that this does not move timings (see "Noise" +below). + +## The browser + +- **Chromium** from Playwright (1.56, `channel: 'chromium'`: the full browser + in new headless mode; `npx playwright install chromium` once), one fresh + browser per run, viewport 1920×1080 at device scale 1 (the app's size, so + the canvas is 1:1). +- **Hardware GPU.** On macOS, headless Chromium can use the GPU through + ANGLE's Metal backend (`--use-angle=metal`, the default there): the WebGL + renderer string then names the GPU (`ANGLE Metal Renderer: Apple M4 Pro`). + The default headless shell uses SwiftShader, software GL that burns + CPU in the GPU process and competes with the page for cores. Each result + records the renderer string, and the runner warns on SwiftShader. On a TV + the GL calls are what costs CPU: those are serialized into the command + buffer on the page's main thread, inside the timed frame, whatever the GPU. +- **Cross-origin isolation.** The runner's static server sends + `Cross-Origin-Opener-Policy: same-origin` and + `Cross-Origin-Embedder-Policy: require-corp`, so `crossOriginIsolated` is + true and `performance.now()` has a 5 µs resolution (100 µs otherwise). Each + result records the measured resolution (`env.clockResolutionUs`); the runner + warns when it is coarser than 20 µs. +- `--disable-background-timer-throttling`, `--disable-renderer-backgrounding` + and `--disable-backgrounding-occluded-windows` keep headless timers honest. + +### CPU throttling is a duty cycle + +`Emulation.setCPUThrottlingRate` (6x by default, a TV proxy) does not slow the +main thread evenly: it lets it run for about 0.17 ms, then suspends it for +about 0.85 ms (measured at 6x: 16% of wall time running). An interval much +shorter than a millisecond therefore reads either unthrottled or about one +suspension longer, depending on where it fell. Two consequences: + +- the **median** of sub-millisecond intervals is close to the unthrottled + time, and says little about the throttled cost; +- the **mean** is an unbiased estimate of rate × CPU time, with a per-op + spread of up to ±0.43 ms that averages down over many ops. + +So every time in the summary is a per-run **mean over ops** (means also add +up: `total` = handler + tail + frame). The JSON keeps median, p95, min and +max per field. Report throttled means as the result. + +**Do not use `--throttle 1` for comparisons on a Mac.** Unthrottled, the +page's main thread is idle most of the time, and macOS runs it on an +efficiency core or at a low clock: on an M4 Pro every op read about 5x +_slower_ at 1x than the median of the same op at 6x, and the factor moved +with the machine's load. At 6x the throttler keeps a core busy, so the slices +the page runs in are at full speed. Consequences: + +- the 6x **mean** is the TV proxy (6 × full-speed CPU, tails included: GC + pauses, deoptimisations); +- the 6x per-op **median**, pooled over runs, approximates the full-speed CPU + of a typical op shorter than about 0.17 ms (at the clock's 5 µs + resolution). + +## The scenario contract + +`bench/src/scenario.ts`. A scenario is an `App` plus `step(i)`: a key name +(dispatched by the runner as a keydown, keyup pair on `document`) or `null` +(the step did its work itself: node creation, a state toggle, a text change). +`warmup` (default 30) and `measured` (default 60) ops per page load. Optional: +`ready()` (default: the scene is mounted when no frame has been drawn for +300 ms after the fonts load), `cycle` (ops per cycle of the workload: +`--quick` rounds its op counts up to whole cycles, so warm-cache scenarios +stay warm), `probe()` (a JSON state snapshot: recorded after mount, after the +warmup and after the measured ops; the summary flags a run whose last two +snapshots differ, and a scenario whose final state differs between arms) and +`opKind(i)` (a label per op; the summary adds one row per kind, so an +alternating create/destroy workload is not reported as one bimodal number). +Scenarios use only the public `@solidtv/solid` API both arms share, never the +renderer directly. + +The scenarios (`bench/src/scenarios`): `nav` (Row/Column presses with every +`scroll` mode, a `$focus` thumbnail row, VirtualRow and VirtualGrid windows, +a NavDrawer `states` toggle, `onFocusChanged` driving text colours, Poster +and whole-page mount and swap) and `text` (text in flex containers: mount, +text changes in a details panel, a VirtualRow of text tiles). `smoke` only +checks the harness. + +## One operation (all modes) + +`bench/harness/probe.mjs` is injected before the app's first script. It wraps +`requestAnimationFrame` (each callback timed with `performance.now()` into +preallocated typed arrays; a callback that ran `gl.clear` drew a frame: the +renderer clears once per drawn frame) and listens for the renderer's `idle` +event. For each op: + +1. **Alignment.** The op starts in a task right after a rendering step (a rAF, + then a `MessageChannel` message), so the next frame is a full vsync away. +2. **`handler`.** `step(i)` runs; a key name returned is dispatched as a + `KeyboardEvent` on `document`, and only `dispatchEvent` is timed. A non-key + step is timed as a whole. +3. **`tail`.** From the handler's end to the start of the first task after + the microtask checkpoint: a `MessageChannel` message posted at the handler's + end. This catches microtasks that queue more microtasks (Solid's + post-mutation pass, queued renderer work). If a frame runs before the + message, the tail ends where that frame starts (`tailCut` in the op + record). +4. **`frame`.** The CPU time (callback duration) of the first drawn renderer + frame after the op: the frame that shows the result. It includes what the + framework does inside the frame (Solid's flex on a text's `loaded` event). + `total` = handler + tail + frame: keydown dispatch to the end of the + renderer frame that shows the result. +5. **Frames until idle.** Every later callback until the renderer's `idle` + event: `frames` (drawn), `animCpu` (drawn frames after the first: + transitions), `undrawnCpu` (renderer callbacks that drew nothing: the + renderer's idle polls), `otherRaf` (callbacks that are not the + renderer's). `cpu` = handler + tail + all of those. `settle` = wall time + from the op's start to the end of its last drawn frame. `frameDelay` = from + the tail's end to the first frame's start. +6. **Idle.** The op is over at the renderer's `idle` event followed by 50 ms + with no drawn frame (Solid's idle tasks and late image uploads can draw + again: then it waits for the next `idle`), or after 50 ms with no drawn + frame at all (an op that changes nothing draws nothing, and no `idle` + follows), or after `--idle-timeout` (`timedOut`). +7. **Keyup.** Sent only once the op is idle, so it never lands in the op's + frames; its handler is timed as `keyup`, and the probe waits for idle again + (`keyupFrames`) before the next op. + +Work outside the handler, the microtask tail and rAF callbacks (timers, image +decode callbacks, the renderer's idle maintenance) is not in the time-mode +numbers; the profile mode sees it. + +## Modes + +Each mode is a separate page load, so instrumentation never touches the +timings. The warmup ops run first in every mode; only the measured ops are +recorded. + +### `time` + +The op records above. No instrumentation beyond the rAF wrap and the +`gl.clear` counter (one wrapped call per drawn frame), the same in every arm. + +### `alloc`: bytes per op + +CDP `HeapProfiler.startSampling` around the measured ops with +`includeObjectsCollectedByMajorGC` and `…MinorGC` (objects that die young +count), sampling interval 1 byte by default (every allocation). Each node's +bytes are charged to the innermost frame with a script URL on its stack +(builtins have none), and that frame's chunk names the owner: + +- `framework`, `reactivity`, `renderer`, `user`: the app; `total` is their sum; +- `harness`: the probe (op loop, `KeyboardEvent`s), reported, not counted; +- `no script`: allocations with no script on the stack (Blink's own objects, + mostly the harness's `MessageEvent`s), reported, not counted. + +V8 charges a callee it inlined to the function it was inlined into, so a +renderer function inlined into a framework function counts as framework. The +JSON lists the top 30 allocating functions (`function (chunk:line)`, bytes +per op; line numbers are of the beautified bundle in +`bench/dist//assets`). Zero framework allocations in the steady state +reads off the `framework` column. + +### `profile`: self time + +CDP `Profiler` at a 50 µs sampling interval over the measured ops. Self time +per function (`function (chunk:line)`) and per chunk, per op; a native +function (a WebGL call, a builtin) is charged to its nearest caller's chunk +and named `fn [native] (chunk)`. `(program)` and `(garbage collector)` are +kept as their own buckets, `(idle)` is dropped. Under throttling the +suspended time lands on whatever was on the stack, so self times are in +throttled ms, like the time mode. The window includes everything between the +ops too: for example the renderer's idle-maintenance `gl.getError()` (the +out-of-memory probe), which shows up as `getError [native] (renderer)`. +`--save-profiles` keeps the raw profile for `bench/harness/inclusive.mjs`. + +### `count` (instrumented build, its timings are not reported) + +Built separately (`BENCH_INSTRUMENT=1` into `dist/-count`). Per op: + +| Count | Hook | +| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| flex passes | Build time: the default export of the arm's `src/core/flex.ts` and `flexLayout.ts` is wrapped (`bench/vite.config.ts`, `countFlexPasses`); one call is one container laid out. `n/a` if the hook did not install (the build warns). | +| node writes | Runtime: every configurable setter on the renderer node prototypes (from `renderer.root`, and every node `createNode`/`createTextNode` returns) is wrapped. Only **app writes** count: a setter call made while renderer code is on the stack is skipped (a renderer frame's callback, `createNode`/`createTextNode`/`createShader`/`animate`, another setter, a non-node emitter's dispatch such as a texture's), while the app's listeners the renderer calls (`loaded` and other node events, the renderer's `idle`) run as app code. Per-prop counts in `topWrites.node`. | +| shader writes | Runtime: each shader node's `props` (the node's own `definedProps`: one non-enumerable accessor per prop) is swapped for a counting forwarder, at `createShader` and for existing nodes. App writes only, as above. | +| in frames | The app writes (both kinds) made during a renderer frame: Solid's work in listeners the frame calls, such as flex on a text's `loaded`. The renderer steps animations through its setters; that does not count. | +| created, creation props | Nodes created through `createNode`/`createTextNode`, and the keys with a defined value in the props bag passed to them (the renderer applies the bag in its constructor; never counted as writes). | +| animations | App calls of `animate`/`animateProp` on node prototypes. | +| walks per drawn frame | Iterations of `Stage.drawFrame`'s update loop (counted through reads of `stage.reprocessFrame`). | +| loaded (emitted / heard) | `emit('loaded', …)` on nodes, text when the payload's `type` is `'text'`: every emit, and the ones a listener hears (listeners are in `eventListeners`). Renderer 1.x emits on every text layout and texture load whether or not anything listens. | +| text layout calls and ms | `stage.textRenderers.sdf.renderText` (cache lookup and layout), timed in the instrumented build. | +| layout cache hit rate | `renderText` returning a layout object it returned before (a hit) or a new one (a miss); layouts made before the counters were installed count as a miss on first sight, which the warmup absorbs. | +| frames to final layout | The index (1 = the first drawn frame, 0 = during the handler or tail) of the last renderer frame in which a flex pass, a text layout or a text `loaded` happened; `ms to final layout` is from the op's start to that frame's end (instrumented timing). | + +Hooks. Every one is looked up by name at runtime (the flex wrap at build +time) and the page reports what it found (`hooks` in the count JSON): + +| Hook (`hooks.`) | Feeds | Renderer 1.x | +| --------------------------------------- | ------------------------------------------------ | -------------------------------------------- | +| `flexHooks` (build time) | flex passes, frames to final layout | default export of `flex.ts`, `flexLayout.ts` | +| `nodeAccessors` | node writes, in frames | node prototype setters | +| shader `props` swap (`shaderUnwrapped`) | shader writes | `definedProps` accessors | +| `createHook` | created, creation props | `createNode`, `createTextNode` | +| `animateHooks` | animations | `animate`, `animateProp` | +| `walkHook` | walks, walks/drawn frame | `Stage.drawFrame` loop iterations | +| `drawHook` | drawn frames, walks/drawn frame | `gl.clear` | +| `loadedHook` | loaded (emitted / heard), frames to final layout | `emit` on the node prototype chain | +| `textHook` | text layout calls and ms, frames to final layout | `SdfTextRenderer.renderText` | +| `cacheHook` | layout cache hit rate | `renderText` layout identity | +| `loopHook` | (errors only, below) | `stage.options.handleLoopError` | + +The probe also carries the hooks for `@solidtv/renderer` 2.x (`ScenePass.run`, +`TextNodes.lay`, `LayoutCache.get`, `insertBefore`, `settings.handleLoopError`), +each labelled "v2" in `probe.mjs`: they install only when the renderer has +those names, so on 1.x they never run. + +A count whose hook found nothing is **`n/a`, never 0**: the hook stays `null` +in the page (for node writes, animations and shader writes: no setter or +method wrapped, or a shader whose `props` could not be swapped), and +`countStats` (`bench/harness/analyze.mjs`) reports what it feeds as `n/a`. +A `n/a` in a cell means the hook needs updating for that renderer: look for +its renamed function and add it next to the others in `installCounters` +(`bench/harness/probe.mjs`). Frames to final layout is `n/a` unless the flex, +text and `loaded` hooks are all there, since it reads their sum. + +A hook that **throws** is an error, not a zero. Each hook forwards every +argument it is called with, and an exception that passes through one is +counted per hook (`hooks.errors`: name, times, first stack), logged once with +`console.error` and added to the run's `errors`; so is a hook whose install +threw (` (install)`). The run is kept as JSON, left out of the summary +and the runner exits 1. The renderer's frame loop swallows what a frame or a +listener it calls throws and hands it to `handleLoopError`, a no-op by +default (`stage.options.handleLoopError`, a copy of the setting made when the +Stage is built, so the setting itself is not read again): the frame is not +drawn and is tried again, which reads as zero drawn frames and zero counts. +So the probe also counts what reaches it (`renderer frame loop`). An +exception is charged once, to the innermost hook that saw it. + +## Noise + +- **In-session control:** while `src/` is unchanged, A and C are the same + Solid code; their ratio in the summary is the noise floor of that series + (with the renderer-version caveat above). An effect smaller than that + spread is not a result. +- **Runs:** a "faster" claim needs n ≥ 3 runs that beat the noise floor. Use + more runs (`--runs 5`) for effects near it. +- **Throttling** adds up to ±0.43 ms per op (above); means over 60 ops reduce + it to roughly ±0.05 ms per run. +- **Machine load** shows directly in the numbers: other processes take the + cores the GPU process and the compositor need. The summary prints the load + average range; measure on a quiet machine. +- **Chunking:** the chunked build's calls between chunks are real module + imports. Compare it with `--no-chunks` on a quiet machine before relying on + sub-10% effects. +- **Machine cost:** a throttled page keeps about one core busy in the + Chromium renderer process even when the page is idle. Do not run two + series at once. + +## Where each metric is + +| Metric | Where | +| ------------------------------------------ | --------------------------------------------------------------------- | +| JS time per press: handler, tail, frame | time mode: `handler`, `tail`, `frame`, `total` (and `cpu` until idle) | +| bytes allocated per press | alloc mode: `total`, split framework / reactivity / renderer / user | +| flex passes per press | count mode: `flex passes` | +| renderer writes per press | count mode: `node writes`, `shader writes` (`in frames` part) | +| walks per frame (text) | count mode: `walks/drawn frame` | +| `loaded` events per press (text) | count mode: `loaded (text)` | +| time spent measuring text | count mode: `text layout ms` (instrumented) | +| layout cache hit rate | count mode: `layout cache hit rate` | +| time from setting the text to final layout | count mode: `frames to final layout`, `ms to final layout` | +| where the time goes | profile mode: self time per chunk, top 20 functions | +| inclusive time of named functions | `bench/harness/inclusive.mjs` on `--save-profiles` output | + +This harness is the in-browser, per-scenario measurement on a desktop CPU +under throttling; it is not a substitute for measuring on a device. diff --git a/eslint.config.js b/eslint.config.js index dcff811..1c457d5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -30,7 +30,7 @@ const relaxedTypedRules = { export default tseslint.config( { - ignores: ['dist/**', 'node_modules/**'], + ignores: ['dist/**', 'node_modules/**', 'bench/.arms/**', 'bench/dist/**'], }, eslint.configs.recommended, { @@ -59,5 +59,16 @@ export default tseslint.config( ], }, }, + { + // The benchmark harness's Node scripts, which also hold functions that + // run in the page (Playwright's evaluate). + files: ['bench/**/*.mjs'], + languageOptions: { + globals: { + ...globals.node, + ...globals.browser, + }, + }, + }, prettierConfig, ); diff --git a/package.json b/package.json index 2fbeed3..6686289 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "sideEffects": false, "scripts": { "start": "pnpm run watch", + "bench": "node bench/run.mjs", "test": "vitest", "lint": "pnpm run lint:prettier && pnpm run lint:eslint", "lint:fix": "pnpm run lint:fix:prettier && pnpm run lint:fix:eslint", @@ -56,12 +57,13 @@ "lint:eslint": "eslint .", "lint:fix:eslint": "eslint --fix .", "build": "rm -rf dist && pnpm run tsc && cp jsx-runtime.d.ts dist/", - "tsc": "tsc", + "tsc": "tsc && tsc -p tests/tsconfig.contract.json", "watch": "tsc -w", "prepare": "husky", "prepack": "pnpm run build", "release": "release-it", - "test:browser": "vitest --config=vitest.browser.config.ts" + "test:browser": "vitest --config=vitest.browser.config.ts", + "test:webgl": "vitest run --config=vitest.webgl.config.ts" }, "keywords": [ "lightning", @@ -80,6 +82,7 @@ }, "devDependencies": { "@eslint/js": "^9.39.4", + "@lightningtv/vite-hex-transform": "^1.1.0", "@solid-devtools/debugger": "^0.28.1", "@solidjs/testing-library": "^0.8.10", "@typescript-eslint/eslint-plugin": "^8.59.2", @@ -92,8 +95,10 @@ "husky": "^9.1.7", "jsdom": "^26.1.0", "lint-staged": "^17.0.4", + "playwright": "1.56.1", "prettier": "^3.8.3", "release-it": "^20.0.1", + "terser": "^5.51.2", "typescript": "^6.0.3", "typescript-eslint": "^8.59.2", "vite": "^8.0.13", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 17e6c08..e1f548d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,6 +33,9 @@ importers: '@eslint/js': specifier: ^9.39.4 version: 9.39.4 + '@lightningtv/vite-hex-transform': + specifier: ^1.1.0 + version: 1.1.0(@types/node@22.14.1)(lightningcss@1.32.0)(terser@5.51.2) '@solid-devtools/debugger': specifier: ^0.28.1 version: 0.28.1(solid-js@1.9.3) @@ -47,7 +50,7 @@ importers: version: 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) '@vitest/browser-playwright': specifier: ^4.1.6 - version: 4.1.6(playwright@1.56.1)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4))(vitest@4.1.6) + version: 4.1.6(playwright@1.56.1)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4))(vitest@4.1.6) eslint: specifier: ^9.39.4 version: 9.39.4(jiti@2.7.0) @@ -69,12 +72,18 @@ importers: lint-staged: specifier: ^17.0.4 version: 17.0.4 + playwright: + specifier: 1.56.1 + version: 1.56.1 prettier: specifier: ^3.8.3 version: 3.8.3 release-it: specifier: ^20.0.1 version: 20.0.1(@types/node@22.14.1) + terser: + specifier: ^5.51.2 + version: 5.51.2 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -83,13 +92,13 @@ importers: version: 8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3) vite: specifier: ^8.0.13 - version: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) + version: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4) vite-plugin-solid: specifier: ^2.11.12 - version: 2.11.12(solid-js@1.9.3)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) + version: 2.11.12(solid-js@1.9.3)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)) vitest: specifier: ^4.1.6 - version: 4.1.6(@types/node@22.14.1)(@vitest/browser-playwright@4.1.6)(jsdom@26.1.0)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) + version: 4.1.6(@types/node@22.14.1)(@vitest/browser-playwright@4.1.6)(jsdom@26.1.0)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)) packages: @@ -221,102 +230,204 @@ packages: '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.27.7': resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.27.7': resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.27.7': resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.27.7': resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.27.7': resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.27.7': resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.27.7': resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.27.7': resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.27.7': resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.27.7': resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.27.7': resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.27.7': resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.27.7': resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.27.7': resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.27.7': resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.27.7': resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.27.7': resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} @@ -329,6 +440,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.27.7': resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} @@ -341,6 +458,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.27.7': resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} @@ -353,24 +476,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.27.7': resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.27.7': resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.27.7': resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.27.7': resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} @@ -585,12 +732,25 @@ packages: resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@lightningtv/vite-hex-transform@1.1.0': + resolution: {integrity: sha512-rz//Xessfta3rRISPSW5IpMySdQYlXDJ7N1sVNrQVnsT6Ns+z9LYnj5JAxoc1qCLqj56eLkUXLYQhdLjIwQGLg==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -764,6 +924,144 @@ packages: '@rolldown/pluginutils@1.0.0': resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} + '@rollup/rollup-android-arm-eabi@4.64.0': + resolution: {integrity: sha512-FLLpX941CD/7aBvX3GqLGBM9OvY5PGXTryPJkG5r5/OuBpZpV5k45Uo3RxAvqd35tsyc3hH1bhtz921IKzoIzg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.64.0': + resolution: {integrity: sha512-iI32XM/jnDYJsvx5ai/za/7gWO9XoSeFFtU06/ZCaRgyilfJOjaopD3nOpk2WrTCGOyeDt4hleUiGOAnd5tHNg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.64.0': + resolution: {integrity: sha512-dRBZlbt2sEtF8Mn4MODThze9fIPdDoOhVSlXMAKy5dQbOYK9pOOoPY4Fat3OTtr0+8zbJo5TTZwlsfxY/kDNJg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.64.0': + resolution: {integrity: sha512-Y3whrJOMucfkhgNkvcM93M6j8C53iEAMmPMYD99C6G1kUy4ELigLWk9ar2/4aCBSRgsql0zdlOemLgW2m5GZgQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.64.0': + resolution: {integrity: sha512-S6S1GEzPjFF6IONYJX3AKt/799+8RgBgCcwRnUo+Ep1IvBxUbw5M51iU0TDdK2NWWVnSvCQ8yi6Em0fGASMOng==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.64.0': + resolution: {integrity: sha512-Ed1DPoFENkXj4IGzSB12SesV40iP6LxCCrbTIkSGIQBpOOxpBM+0kj62lmaeIHfMWTDoddqTOtqc7s8LYfn+Pw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.64.0': + resolution: {integrity: sha512-J/6xhutJPtFyIkmHdf4olOdHHKH5kOJNumojdWn+Elv5ICEYTVOa362Vw27TxsK0BLzb1le3EDlO3UbvFK1lnQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.64.0': + resolution: {integrity: sha512-G4L/Nnx59d2LvDoV/P4S5lZ7oVLT6hodOHMacqjiq2LL2gts86KbkEBTYL53+fBt0odhf7XPIbIATGoYiJctxg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.64.0': + resolution: {integrity: sha512-zF16pvJDEUksGYRbfnXne7pdklAyj2XDmJeYKgP93oq0cUyX9Y8O7lUo1R+IY1GagP/YBvCEDCe7eEcAKpw//g==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.64.0': + resolution: {integrity: sha512-nIggWn8n+w12+R5lsV+PYYoUY8vuObxPAQqJI7WES+9v0qspYuAHoZMfYM0yLxlipasT5BY9ht5W8t5c/rSCkg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.64.0': + resolution: {integrity: sha512-WLOTiHPzszP0dxIMsHpFeDSOSB5I49Mj8K4+yKE3H1X/D0JRG5XZ5jrctJLMjnUc/LUzaKiwq6EHWNJ8pup8PA==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.64.0': + resolution: {integrity: sha512-35RK9aRhh7g7Pmeu5gQ01aCvrhXzj9LoBE6xtUcEH1ns+nyTaSzxSpADmBQINu4QiTDGvmQYwo1j5hlg1rRXFw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.64.0': + resolution: {integrity: sha512-RqTfnpoTjuWfPAmqK5p2edt2AMCDJWm6oTn7sKfmyx+6tRga0ip05SJ977o6udTcIiDeF/CJMUfkaN4gtWt6Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.64.0': + resolution: {integrity: sha512-RQjZPigjxFvFZDijG6rjlSXxhRaTf9zLYVpGK+lVcga64oMqP1GbgVqCb46eHTTceSIgp89Ja0y5aPe3GFBYHg==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.64.0': + resolution: {integrity: sha512-tjL58mgQcTRnDRpeky+Sth1MKQeYi2Ct6r3Luq9E5aTSjnM/i/JbozqZkKqp9thM5+oDSZxo+05f9kdQedfTGA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.64.0': + resolution: {integrity: sha512-ictoRyVaoZ8C67myHef+8hhCa6IplXJJ/xUCkVCxziV4zxMZh+jvhkhGsPBLqj1S72fnqD0QH5zL4lcaa4X7DQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.64.0': + resolution: {integrity: sha512-8aVvZ2hfr9RLFzBQkQNmYeHLDja8fLeQAmLt757zdKN/B+8L809KlLJKctQjS22wZotptV8G6o7fVPuAs8S1tQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.64.0': + resolution: {integrity: sha512-2dEF8GAcDKwshUfydD+GhosppNyBzhVUHkdFF3CXd3FpOkzj/RZzF5aoEtzklujklT5qFVRU2GK/cFeLaTNKVg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.64.0': + resolution: {integrity: sha512-3XHTvoo2hKkh0MiOfS7SkhHjMaaWF7P4F3drocZZaho/Z8w7h/jpnuBHm91BOQLfvrKHs1yR+zir4E8RI3XzTQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.64.0': + resolution: {integrity: sha512-HKtkzodL4a3KFWDXDoO61cRynAWTR3LnAE0oGtfG9lcNFpNuVYUFOByxDgNqEvic9rvaQbAj+SFwTEWDptRaIw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.64.0': + resolution: {integrity: sha512-JBsxqiY16uyyCujtoC3s7zL+SrYyNElptD7K4V+KUoJ2V8/XG3hPWpheK+SuPwNh1cikJvIZmIdsrYXvD6PLJw==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.64.0': + resolution: {integrity: sha512-S5Qrijh37qhnUYQ0ay8WN3LuWeKjow7wTUyCOPMZtnXiBXYXdsZETI6ZLfYnQAKovltckPLBFdNeD61P2rJc7g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.64.0': + resolution: {integrity: sha512-zbZ6iMmLFEsCtTdc2/y1F/cDFVNNCPF7Ae8PCT/wdPE//hGwYp6r+MyCLMFX+kOeIe09Pz6ASfnnfpSTN1FJQQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.64.0': + resolution: {integrity: sha512-L6Hw69oNpuYaOr1zCG30v0/DqKYCSRwodPGwIcVZ/6f8SOzq9rxtL/xNVI5MO4wjli+Ko0ErK9uHJL3mNPt8wQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.64.0': + resolution: {integrity: sha512-nl+CTWIgOGszQmUz/kanfgrfFZ4qiwxKIfT/ryl3N3Ewk5tqizmYtNXiqb7Bu/fDtNgu1kyxdFiAUJNur3iysg==} + cpu: [x64] + os: [win32] + '@solid-devtools/debugger@0.28.1': resolution: {integrity: sha512-6qIUI6VYkXoRnL8oF5bvh2KgH71qlJ18hNw/mwSyY6v48eb80ZR48/5PDXufUa3q+MBSuYa1uqTMwLewpay9eg==} peerDependencies: @@ -907,6 +1205,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1123,6 +1424,9 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-from@1.1.2: + resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -1194,6 +1498,9 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -1315,6 +1622,11 @@ packages: es-module-lexer@2.1.0: resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.27.7: resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} @@ -2063,6 +2375,11 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + rollup@4.64.0: + resolution: {integrity: sha512-gjA/kFeDfffII8COoPsvLPvE16hWDTEc1+0QKQNKUkuV8iJjqsC4xXs8/FzuL0v9EV39kuxTbAgcFoTRyy2TGQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + rrweb-cssom@0.8.0: resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} @@ -2152,6 +2469,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-support@0.5.21: + resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} + source-map@0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -2200,6 +2520,11 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} + terser@5.51.2: + resolution: {integrity: sha512-bWnjSNscmuI+GJze6ZupnHP8G/cTcsJF+bXCeQknk2SHQsgbNJnLrqiH9jZ2W4STPVXH2mDKKRX3iwPhc9Cn/Q==} + engines: {node: '>=10'} + hasBin: true + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -2303,6 +2628,37 @@ packages: '@testing-library/jest-dom': optional: true + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + vite@8.0.13: resolution: {integrity: sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2663,81 +3019,150 @@ snapshots: tslib: 2.8.1 optional: true + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.27.7': optional: true + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.27.7': optional: true + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.27.7': optional: true + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.27.7': optional: true + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.27.7': optional: true + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.27.7': optional: true + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.27.7': optional: true + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.27.7': optional: true + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.27.7': optional: true + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.27.7': optional: true + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.27.7': optional: true + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.27.7': optional: true + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.27.7': optional: true + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.27.7': optional: true + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.27.7': optional: true + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.27.7': optional: true + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.27.7': optional: true '@esbuild/netbsd-arm64@0.27.7': optional: true + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.27.7': optional: true '@esbuild/openbsd-arm64@0.27.7': optional: true + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.27.7': optional: true '@esbuild/openharmony-arm64@0.27.7': optional: true + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.27.7': optional: true + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.27.7': optional: true + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.27.7': optional: true + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.27.7': optional: true @@ -2931,6 +3356,11 @@ snapshots: '@jridgewell/set-array@1.2.1': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.25': @@ -2938,6 +3368,22 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lightningtv/vite-hex-transform@1.1.0(@types/node@22.14.1)(lightningcss@1.32.0)(terser@5.51.2)': + dependencies: + vite: 5.4.21(@types/node@22.14.1)(lightningcss@1.32.0)(terser@5.51.2) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - terser + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -3069,6 +3515,81 @@ snapshots: '@rolldown/pluginutils@1.0.0': {} + '@rollup/rollup-android-arm-eabi@4.64.0': + optional: true + + '@rollup/rollup-android-arm64@4.64.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.64.0': + optional: true + + '@rollup/rollup-darwin-x64@4.64.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.64.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.64.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.64.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.64.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.64.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.64.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.64.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.64.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.64.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.64.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.64.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.64.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.64.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.64.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.64.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.64.0': + optional: true + '@solid-devtools/debugger@0.28.1(solid-js@1.9.3)': dependencies: '@nothing-but/utils': 0.17.0 @@ -3249,6 +3770,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} '@types/node@22.14.1': @@ -3351,29 +3874,29 @@ snapshots: '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 - '@vitest/browser-playwright@4.1.6(playwright@1.56.1)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4))(vitest@4.1.6)': + '@vitest/browser-playwright@4.1.6(playwright@1.56.1)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4))(vitest@4.1.6)': dependencies: - '@vitest/browser': 4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4))(vitest@4.1.6) - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) + '@vitest/browser': 4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4))(vitest@4.1.6) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)) playwright: 1.56.1 tinyrainbow: 3.1.0 - vitest: 4.1.6(@types/node@22.14.1)(@vitest/browser-playwright@4.1.6)(jsdom@26.1.0)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) + vitest: 4.1.6(@types/node@22.14.1)(@vitest/browser-playwright@4.1.6)(jsdom@26.1.0)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4))(vitest@4.1.6)': + '@vitest/browser@4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4))(vitest@4.1.6)': dependencies: '@blazediff/core': 1.9.1 - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)) '@vitest/utils': 4.1.6 magic-string: 0.30.21 pngjs: 7.0.0 sirv: 3.0.2 tinyrainbow: 3.1.0 - vitest: 4.1.6(@types/node@22.14.1)(@vitest/browser-playwright@4.1.6)(jsdom@26.1.0)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) + vitest: 4.1.6(@types/node@22.14.1)(@vitest/browser-playwright@4.1.6)(jsdom@26.1.0)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)) ws: 8.20.0 transitivePeerDependencies: - bufferutil @@ -3390,13 +3913,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4))': + '@vitest/mocker@4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) + vite: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4) '@vitest/pretty-format@4.1.6': dependencies: @@ -3518,6 +4041,8 @@ snapshots: node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.24.4) + buffer-from@1.1.2: {} + bundle-name@4.1.0: dependencies: run-applescript: 7.0.0 @@ -3583,6 +4108,8 @@ snapshots: color-name@1.1.4: {} + commander@2.20.3: {} + concat-map@0.0.1: {} confbox@0.2.4: {} @@ -3665,6 +4192,32 @@ snapshots: es-module-lexer@2.1.0: {} + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.27.7: optionalDependencies: '@esbuild/aix-ppc64': 0.27.7 @@ -4447,6 +5000,38 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.0.1 '@rolldown/binding-win32-x64-msvc': 1.0.1 + rollup@4.64.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.64.0 + '@rollup/rollup-android-arm64': 4.64.0 + '@rollup/rollup-darwin-arm64': 4.64.0 + '@rollup/rollup-darwin-x64': 4.64.0 + '@rollup/rollup-freebsd-arm64': 4.64.0 + '@rollup/rollup-freebsd-x64': 4.64.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.64.0 + '@rollup/rollup-linux-arm-musleabihf': 4.64.0 + '@rollup/rollup-linux-arm64-gnu': 4.64.0 + '@rollup/rollup-linux-arm64-musl': 4.64.0 + '@rollup/rollup-linux-loong64-gnu': 4.64.0 + '@rollup/rollup-linux-loong64-musl': 4.64.0 + '@rollup/rollup-linux-ppc64-gnu': 4.64.0 + '@rollup/rollup-linux-ppc64-musl': 4.64.0 + '@rollup/rollup-linux-riscv64-gnu': 4.64.0 + '@rollup/rollup-linux-riscv64-musl': 4.64.0 + '@rollup/rollup-linux-s390x-gnu': 4.64.0 + '@rollup/rollup-linux-x64-gnu': 4.64.0 + '@rollup/rollup-linux-x64-musl': 4.64.0 + '@rollup/rollup-openbsd-x64': 4.64.0 + '@rollup/rollup-openharmony-arm64': 4.64.0 + '@rollup/rollup-win32-arm64-msvc': 4.64.0 + '@rollup/rollup-win32-ia32-msvc': 4.64.0 + '@rollup/rollup-win32-x64-gnu': 4.64.0 + '@rollup/rollup-win32-x64-msvc': 4.64.0 + fsevents: 2.3.3 + rrweb-cssom@0.8.0: {} run-applescript@7.0.0: {} @@ -4527,8 +5112,12 @@ snapshots: source-map-js@1.2.1: {} - source-map@0.6.1: - optional: true + source-map-support@0.5.21: + dependencies: + buffer-from: 1.1.2 + source-map: 0.6.1 + + source-map@0.6.1: {} sprintf-js@1.1.3: {} @@ -4567,6 +5156,13 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 + terser@5.51.2: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + tinybench@2.9.0: {} tinyexec@1.1.2: {} @@ -4645,7 +5241,7 @@ snapshots: validate-html-nesting@1.2.2: {} - vite-plugin-solid@2.11.12(solid-js@1.9.3)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)): + vite-plugin-solid@2.11.12(solid-js@1.9.3)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)): dependencies: '@babel/core': 7.26.10 '@types/babel__core': 7.20.5 @@ -4653,12 +5249,23 @@ snapshots: merge-anything: 5.1.7 solid-js: 1.9.3 solid-refresh: 0.6.3(solid-js@1.9.3) - vite: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) - vitefu: 1.0.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) + vite: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4) + vitefu: 1.0.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)) transitivePeerDependencies: - supports-color - vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4): + vite@5.4.21(@types/node@22.14.1)(lightningcss@1.32.0)(terser@5.51.2): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.14 + rollup: 4.64.0 + optionalDependencies: + '@types/node': 22.14.1 + fsevents: 2.3.3 + lightningcss: 1.32.0 + terser: 5.51.2 + + vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -4670,16 +5277,17 @@ snapshots: esbuild: 0.27.7 fsevents: 2.3.3 jiti: 2.7.0 + terser: 5.51.2 yaml: 2.8.4 - vitefu@1.0.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)): + vitefu@1.0.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)): optionalDependencies: - vite: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) + vite: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4) - vitest@4.1.6(@types/node@22.14.1)(@vitest/browser-playwright@4.1.6)(jsdom@26.1.0)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)): + vitest@4.1.6(@types/node@22.14.1)(@vitest/browser-playwright@4.1.6)(jsdom@26.1.0)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.6 - '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4)) + '@vitest/mocker': 4.1.6(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.6 '@vitest/runner': 4.1.6 '@vitest/snapshot': 4.1.6 @@ -4696,11 +5304,11 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4) + vite: 8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 22.14.1 - '@vitest/browser-playwright': 4.1.6(playwright@1.56.1)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(yaml@2.8.4))(vitest@4.1.6) + '@vitest/browser-playwright': 4.1.6(playwright@1.56.1)(vite@8.0.13(@types/node@22.14.1)(esbuild@0.27.7)(jiti@2.7.0)(terser@5.51.2)(yaml@2.8.4))(vitest@4.1.6) jsdom: 26.1.0 transitivePeerDependencies: - msw diff --git a/tests/contract-flex.test.tsx b/tests/contract-flex.test.tsx new file mode 100644 index 0000000..db19cd7 --- /dev/null +++ b/tests/contract-flex.test.tsx @@ -0,0 +1,1555 @@ +/** + * Contract tests: flex layout. + * + * Part 1 runs ONE table of flex cases against BOTH engines by calling them + * directly on ElementNode trees: + * - src/core/flex.ts (default) + * - src/core/flexLayout.ts (selected in src/core/elementNode.ts:22 by + * `import.meta.env?.VITE_USE_NEW_FLEX` being truthy — note any non-empty + * string, including "false", selects it) + * Where the engines differ today, a case carries `expectedNew` and a + * `newDiffers` note, so the cases show what each engine does and where they + * differ. + * + * Part 2 pins the end-to-end behaviour through JSX, the renderer and the + * post-mutation layout queue, on whichever engine elementNode.ts selected. + * Run it on both: + * npx vitest run tests/contract-flex.test.tsx + * VITE_USE_NEW_FLEX=true npx vitest run tests/contract-flex.test.tsx + */ +import { describe, it, expect, vi } from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { ElementNode } from '../src/core/elementNode.ts'; +import { TextNode } from '../src/core/nodeTypes.ts'; +import flexOld from '../src/core/flex.ts'; +import flexNew from '../src/core/flexLayout.ts'; +import { renderer, waitForUpdate } from './setup.js'; + +const NEW_FLEX_ACTIVE = !!import.meta.env?.VITE_USE_NEW_FLEX; + +// --------------------------------------------------------------------------- +// Part 1: table of flex cases, run on both engines +// --------------------------------------------------------------------------- + +type Props = Record; +type KidSpec = + | { kind?: 'view'; props: Props } + | { kind: 'text'; props: Props } + | { kind: 'textNode' }; + +interface Box { + x?: number; + y?: number; + w?: number; + h?: number; +} +interface FlexResult { + returned: boolean; + container: { w?: number; h?: number }; + children: Box[]; +} +interface FlexCase { + /** The contract bullet the case pins, then what it checks. */ + name: string; + container: Props; + kids: KidSpec[]; + expected: FlexResult; + /** flexLayout.ts result, when it differs from flex.ts today. */ + expectedNew?: FlexResult; + /** Why the engines differ. */ + newDiffers?: string; +} + +const view = (width: number, height: number, extra: Props = {}): KidSpec => ({ + props: { width, height, ...extra }, +}); +const A = (extra: Props = {}) => view(50, 50, extra); +const B = (extra: Props = {}) => view(60, 40, extra); + +// "untouched" = the engine never wrote the prop (unrendered nodes start +// undefined), so a case can tell "set to 0" from "not positioned". +const U = undefined; + +function build(container: Props, kids: KidSpec[]) { + const node = new ElementNode('view'); + Object.assign(node, { display: 'flex' }, container); + const children: Array = []; + for (const k of kids) { + let c: ElementNode | TextNode; + if (k.kind === 'textNode') { + c = new TextNode('raw string child'); + (c as TextNode).parent = node as never; + } else { + c = new ElementNode(k.kind === 'text' ? 'text' : 'view'); + Object.assign(c, k.props); + c.parent = node; + } + node.children.push(c as ElementNode); + children.push(c); + } + return { node, children }; +} + +function snapshot( + node: ElementNode, + children: Array, + returned: boolean, +): FlexResult { + return { + returned, + container: { w: node.width, h: node.height }, + children: children + .filter((c): c is ElementNode => c instanceof ElementNode) + .map((c) => ({ x: c.x, y: c.y, w: c.width, h: c.height })), + }; +} + +function run(engine: typeof flexOld, c: Pick) { + const { node, children } = build(c.container, c.kids); + const returned = engine(node); + return snapshot(node, children, returned); +} + +const res = ( + returned: boolean, + container: FlexResult['container'], + children: Box[], +): FlexResult => ({ returned, container, children }); + +const cases: FlexCase[] = [ + // --- display: "flex" + container auto-size (flexBoundary) --------------- + { + name: 'display flex / auto-size: row flexStart shrinks an explicit width to the content (no flexBoundary)', + container: { width: 300, height: 100, gap: 10 }, + kids: [A(), B()], + // 50 + 10 + 60 = 120; the explicit 300 is overwritten. + expected: res(true, { w: 120, h: 100 }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 60, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'display flex / auto-size: row flexStart with no width takes the content width', + container: { gap: 10 }, + kids: [A(), B()], + expected: res(true, { w: 120, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 60, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'flexBoundary: "fixed" keeps the container width with flexStart', + container: { width: 300, height: 100, gap: 10, flexBoundary: 'fixed' }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 60, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'flexBoundary: "contain" with flexEnd does NOT resize the container (only flexStart resizes, despite the docs)', + container: { + width: 300, + height: 100, + justifyContent: 'flexEnd', + flexBoundary: 'contain', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 190, y: U, w: 50, h: 50 }, + { x: 240, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'auto-size: a container minWidth is the floor for the content size', + container: { minWidth: 400 }, + kids: [A(), B()], + expected: res(true, { w: 400, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'display flex: a container with no children is not laid out', + container: { width: 300, height: 100 }, + kids: [], + expected: res(false, { w: 300, h: 100 }, []), + }, + + // --- justifyContent, row ------------------------------------------------- + { + name: 'justifyContent flexEnd (row)', + container: { width: 300, height: 100, gap: 10, justifyContent: 'flexEnd' }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 180, y: U, w: 50, h: 50 }, + { x: 240, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'justifyContent center (row, gap counts)', + container: { width: 300, height: 100, gap: 10, justifyContent: 'center' }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 90, y: U, w: 50, h: 50 }, + { x: 150, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'justifyContent spaceBetween (row, gap ignored)', + container: { + width: 300, + height: 100, + gap: 10, + justifyContent: 'spaceBetween', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 240, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'justifyContent spaceBetween with a single child puts it at the start', + container: { width: 300, height: 100, justifyContent: 'spaceBetween' }, + kids: [A()], + expected: res(false, { w: 300, h: 100 }, [{ x: 0, y: U, w: 50, h: 50 }]), + }, + { + name: 'justifyContent spaceAround (row)', + container: { width: 300, height: 100, justifyContent: 'spaceAround' }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 47.5, y: U, w: 50, h: 50 }, + { x: 192.5, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'justifyContent spaceEvenly (row)', + container: { width: 290, height: 100, justifyContent: 'spaceEvenly' }, + kids: [A(), B()], + expected: res(false, { w: 290, h: 100 }, [ + { x: 60, y: U, w: 50, h: 50 }, + { x: 170, y: U, w: 60, h: 40 }, + ]), + }, + + // --- flexDirection column + justifyContent ------------------------------- + { + name: 'flexDirection column / auto-size: flexStart sets the container height to the content', + container: { width: 200, height: 500, gap: 5, flexDirection: 'column' }, + kids: [A(), B()], + expected: res(true, { w: 200, h: 95 }, [ + { x: U, y: 0, w: 50, h: 50 }, + { x: U, y: 55, w: 60, h: 40 }, + ]), + }, + { + name: 'flexDirection column + justifyContent flexEnd', + container: { + width: 200, + height: 300, + gap: 5, + flexDirection: 'column', + justifyContent: 'flexEnd', + }, + kids: [A(), B()], + expected: res(false, { w: 200, h: 300 }, [ + { x: U, y: 205, w: 50, h: 50 }, + { x: U, y: 260, w: 60, h: 40 }, + ]), + }, + { + name: 'flexDirection column + justifyContent center', + container: { + width: 200, + height: 300, + flexDirection: 'column', + justifyContent: 'center', + }, + kids: [A(), B()], + expected: res(false, { w: 200, h: 300 }, [ + { x: U, y: 105, w: 50, h: 50 }, + { x: U, y: 155, w: 60, h: 40 }, + ]), + }, + { + name: 'flexDirection column + justifyContent spaceBetween', + container: { + width: 200, + height: 300, + flexDirection: 'column', + justifyContent: 'spaceBetween', + }, + kids: [A(), B()], + expected: res(false, { w: 200, h: 300 }, [ + { x: U, y: 0, w: 50, h: 50 }, + { x: U, y: 260, w: 60, h: 40 }, + ]), + }, + { + name: 'flexDirection column + justifyContent spaceEvenly', + container: { + width: 200, + height: 300, + flexDirection: 'column', + justifyContent: 'spaceEvenly', + }, + kids: [A(), B()], + expected: res(false, { w: 200, h: 300 }, [ + { x: U, y: 70, w: 50, h: 50 }, + { x: U, y: 190, w: 60, h: 40 }, + ]), + }, + { + name: 'flexDirection column + justifyContent spaceAround', + container: { + width: 200, + height: 300, + flexDirection: 'column', + justifyContent: 'spaceAround', + }, + kids: [A(), B()], + expected: res(false, { w: 200, h: 300 }, [ + { x: U, y: 52.5, w: 50, h: 50 }, + { x: U, y: 207.5, w: 60, h: 40 }, + ]), + }, + + // --- reverse directions --------------------------------------------------- + { + name: 'flexDirection row-reverse: reverses the order, still packed from the left with flexStart', + container: { width: 300, gap: 10, flexDirection: 'row-reverse' }, + kids: [A(), B()], + expected: res(true, { w: 120, h: U }, [ + { x: 70, y: U, w: 50, h: 50 }, + { x: 0, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'flexDirection row-reverse + flexEnd: first child ends up at the right edge', + container: { + width: 300, + gap: 10, + flexDirection: 'row-reverse', + justifyContent: 'flexEnd', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: U }, [ + { x: 250, y: U, w: 50, h: 50 }, + { x: 180, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'flexDirection column-reverse: reverses the order on the y axis', + container: { width: 200, height: 300, flexDirection: 'column-reverse' }, + kids: [A(), B()], + expected: res(true, { w: 200, h: 90 }, [ + { x: U, y: 40, w: 50, h: 50 }, + { x: U, y: 0, w: 60, h: 40 }, + ]), + }, + { + name: 'direction rtl: reverses the order like row-reverse', + container: { width: 300, gap: 10, direction: 'rtl' }, + kids: [A(), B()], + expected: res(true, { w: 120, h: U }, [ + { x: 70, y: U, w: 50, h: 50 }, + { x: 0, y: U, w: 60, h: 40 }, + ]), + }, + + // --- alignItems / alignSelf ---------------------------------------------- + { + name: 'alignItems flexStart (row)', + container: { + width: 300, + height: 100, + flexBoundary: 'fixed', + alignItems: 'flexStart', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: 0, w: 50, h: 50 }, + { x: 50, y: 0, w: 60, h: 40 }, + ]), + }, + { + name: 'alignItems center (row)', + container: { + width: 300, + height: 100, + flexBoundary: 'fixed', + alignItems: 'center', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: 25, w: 50, h: 50 }, + { x: 50, y: 30, w: 60, h: 40 }, + ]), + }, + { + name: 'alignItems flexEnd (row)', + container: { + width: 300, + height: 100, + flexBoundary: 'fixed', + alignItems: 'flexEnd', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: 50, w: 50, h: 50 }, + { x: 50, y: 60, w: 60, h: 40 }, + ]), + }, + { + name: 'alignItems center (column aligns on x)', + container: { + width: 200, + height: 300, + flexDirection: 'column', + flexBoundary: 'fixed', + alignItems: 'center', + }, + kids: [A(), B()], + expected: res(false, { w: 200, h: 300 }, [ + { x: 75, y: 0, w: 50, h: 50 }, + { x: 70, y: 50, w: 60, h: 40 }, + ]), + }, + { + name: 'alignItems: no-op when the container has no cross size', + container: { width: 300, flexBoundary: 'fixed', alignItems: 'center' }, + kids: [A(), B()], + expected: res(false, { w: 300, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'alignSelf overrides alignItems', + container: { + width: 300, + height: 100, + flexBoundary: 'fixed', + alignItems: 'center', + }, + kids: [A(), B({ alignSelf: 'flexEnd' })], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: 25, w: 50, h: 50 }, + { x: 50, y: 60, w: 60, h: 40 }, + ]), + }, + { + name: 'alignSelf works without alignItems; siblings keep their y', + container: { width: 300, height: 100, flexBoundary: 'fixed' }, + kids: [A(), B({ alignSelf: 'center' })], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: 30, w: 60, h: 40 }, + ]), + }, + + // --- gap ------------------------------------------------------------------ + { + name: 'gap (column)', + container: { width: 200, height: 500, gap: 20, flexDirection: 'column' }, + kids: [A(), B(), A()], + expected: res(true, { w: 200, h: 180 }, [ + { x: U, y: 0, w: 50, h: 50 }, + { x: U, y: 70, w: 60, h: 40 }, + { x: U, y: 130, w: 50, h: 50 }, + ]), + }, + { + name: 'rowGap/columnGap are ignored on the main axis without wrap (only gap counts)', + container: { width: 300, rowGap: 7, columnGap: 9 }, + kids: [A(), B()], + expected: res(true, { w: 110, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: 60, h: 40 }, + ]), + }, + + // --- padding (a number) --------------------------------------------------- + { + name: 'padding number + flexStart: main-axis start and auto-size include padding; cross-axis padding only in flexLayout.ts', + container: { + width: 300, + height: 100, + padding: 10, + alignItems: 'flexStart', + }, + kids: [A(), B()], + expected: res(true, { w: 130, h: 100 }, [ + { x: 10, y: 0, w: 50, h: 50 }, + { x: 60, y: 0, w: 60, h: 40 }, + ]), + expectedNew: res(true, { w: 130, h: 100 }, [ + { x: 10, y: 10, w: 50, h: 50 }, + { x: 60, y: 10, w: 60, h: 40 }, + ]), + newDiffers: + 'flex.ts applies a number padding on the main axis only; flexLayout.ts also offsets the cross axis by paddingTop', + }, + { + name: 'padding number + alignItems flexEnd: flexLayout.ts adds the top padding instead of subtracting the bottom one', + container: { + width: 300, + height: 100, + padding: 10, + flexBoundary: 'fixed', + alignItems: 'flexEnd', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 10, y: 50, w: 50, h: 50 }, + { x: 60, y: 60, w: 60, h: 40 }, + ]), + // BUG (flexLayout.ts:271-276): flexEnd adds paddingCrossStart, pushing the + // child 10px past the container's bottom edge (60 + 50 = 110 > 100). + expectedNew: res(false, { w: 300, h: 100 }, [ + { x: 10, y: 60, w: 50, h: 50 }, + { x: 60, y: 70, w: 60, h: 40 }, + ]), + newDiffers: + 'cross-axis flexEnd/center with padding: flexLayout.ts offsets by paddingTop (child overflows the bottom); flex.ts ignores cross padding', + }, + { + name: 'padding number + alignItems center', + container: { + width: 300, + height: 100, + padding: 10, + flexBoundary: 'fixed', + alignItems: 'center', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 10, y: 25, w: 50, h: 50 }, + { x: 60, y: 30, w: 60, h: 40 }, + ]), + expectedNew: res(false, { w: 300, h: 100 }, [ + { x: 10, y: 35, w: 50, h: 50 }, + { x: 60, y: 40, w: 60, h: 40 }, + ]), + newDiffers: + 'cross-axis center with padding: flexLayout.ts shifts by paddingTop instead of centering in the padded box', + }, + { + name: 'padding number + flexEnd (row)', + container: { + width: 300, + height: 100, + padding: 10, + justifyContent: 'flexEnd', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 180, y: U, w: 50, h: 50 }, + { x: 230, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'padding number + center (row): content is shifted right by the padding (see BUG test below)', + container: { + width: 300, + height: 100, + padding: 10, + justifyContent: 'center', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 105, y: U, w: 50, h: 50 }, + { x: 155, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'padding number + spaceBetween (row)', + container: { + width: 300, + height: 100, + padding: 10, + justifyContent: 'spaceBetween', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 10, y: U, w: 50, h: 50 }, + { x: 230, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'padding number + spaceEvenly (row)', + container: { + width: 310, + height: 100, + padding: 10, + justifyContent: 'spaceEvenly', + }, + kids: [A(), B()], + expected: res(false, { w: 310, h: 100 }, [ + { x: 70, y: U, w: 50, h: 50 }, + { x: 180, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'padding number + spaceAround (row)', + container: { + width: 300, + height: 100, + padding: 10, + justifyContent: 'spaceAround', + }, + kids: [A(), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 52.5, y: U, w: 50, h: 50 }, + { x: 187.5, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'padding number (column): start and auto-size height include padding', + container: { + width: 200, + height: 500, + padding: 10, + flexDirection: 'column', + }, + kids: [A(), B()], + expected: res(true, { w: 200, h: 110 }, [ + { x: U, y: 10, w: 50, h: 50 }, + { x: U, y: 60, w: 60, h: 40 }, + ]), + }, + + // --- margins -------------------------------------------------------------- + { + name: 'marginLeft/marginRight (row) count in positions and in the auto-size', + container: { width: 300 }, + kids: [A({ marginLeft: 5, marginRight: 10 }), B({ marginLeft: 15 })], + expected: res(true, { w: 140, h: U }, [ + { x: 5, y: U, w: 50, h: 50 }, + { x: 80, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'marginTop/marginBottom (column) count in positions and in the auto-size', + container: { width: 200, height: 500, flexDirection: 'column' }, + kids: [A({ marginTop: 5, marginBottom: 10 }), B({ marginTop: 15 })], + expected: res(true, { w: 200, h: 120 }, [ + { x: U, y: 5, w: 50, h: 50 }, + { x: U, y: 80, w: 60, h: 40 }, + ]), + }, + { + name: 'cross-axis margins: alignItems flexEnd uses marginBottom only', + container: { + width: 300, + height: 100, + flexBoundary: 'fixed', + alignItems: 'flexEnd', + }, + kids: [A({ marginBottom: 4 }), B({ marginTop: 3 })], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: 46, w: 50, h: 50 }, + { x: 50, y: 60, w: 60, h: 40 }, + ]), + }, + { + name: 'cross-axis margins: alignItems center adds marginTop only', + container: { + width: 300, + height: 100, + flexBoundary: 'fixed', + alignItems: 'center', + }, + kids: [A({ marginTop: 10 }), B({ marginBottom: 10 })], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: 35, w: 50, h: 50 }, + { x: 50, y: 30, w: 60, h: 40 }, + ]), + }, + { + name: 'margin array [t, r, b, l]: only flexLayout.ts reads it', + container: { width: 300, height: 100, alignItems: 'flexStart' }, + kids: [A({ margin: [1, 2, 3, 4] }), B()], + expected: res(true, { w: 110, h: 100 }, [ + { x: 0, y: 0, w: 50, h: 50 }, + { x: 50, y: 0, w: 60, h: 40 }, + ]), + expectedNew: res(true, { w: 116, h: 100 }, [ + { x: 4, y: 1, w: 50, h: 50 }, + { x: 56, y: 0, w: 60, h: 40 }, + ]), + newDiffers: + '`margin` array is read by flexLayout.ts only; flex.ts ignores it', + }, + + // --- flexGrow ------------------------------------------------------------- + { + name: 'flexGrow: one growing child takes the free space; the container width is kept', + container: { width: 300, height: 100, gap: 10 }, + kids: [A({ flexGrow: 1 }), B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: U, w: 230, h: 50 }, + { x: 240, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'flexGrow: free space is split by the grow ratio', + container: { width: 300, height: 100 }, + kids: [A({ flexGrow: 1 }), B({ flexGrow: 3 })], + expected: res(false, { w: 300, h: 100 }, [ + { x: 0, y: U, w: 97.5, h: 50 }, + { x: 97.5, y: U, w: 202.5, h: 40 }, + ]), + }, + { + name: 'flexGrow: a single child does not grow (needs 2+ items); container auto-sizes', + container: { width: 300, height: 100 }, + kids: [A({ flexGrow: 1 })], + expected: res(true, { w: 50, h: 100 }, [{ x: 0, y: U, w: 50, h: 50 }]), + }, + { + name: 'flexGrow: no free space means no growth (flex.ts also console.warns)', + container: { width: 100, height: 100 }, + kids: [A({ flexGrow: 1 }), B()], + expected: res(false, { w: 100, h: 100 }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'flexGrow: a negative value is ignored (container auto-sizes as without grow)', + container: { width: 300, height: 100 }, + kids: [A({ flexGrow: -1 }), B()], + expected: res(true, { w: 110, h: 100 }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: 60, h: 40 }, + ]), + }, + + // --- flexWrap ------------------------------------------------------------- + { + name: 'flexWrap wrap (row): overflow goes to a new line; line gap = gap; container height grows', + container: { width: 250, height: 50, gap: 10, flexWrap: 'wrap' }, + kids: [view(100, 50), view(100, 50), view(100, 50)], + expected: res(true, { w: 250, h: 110 }, [ + { x: 0, y: 0, w: 100, h: 50 }, + { x: 110, y: 0, w: 100, h: 50 }, + { x: 0, y: 60, w: 100, h: 50 }, + ]), + }, + { + name: 'flexWrap + columnGap/rowGap (row): the line gap is columnGap (CSS would use rowGap); items use gap', + container: { + width: 250, + height: 50, + rowGap: 20, + columnGap: 5, + flexWrap: 'wrap', + }, + kids: [view(100, 50), view(100, 50), view(100, 50)], + expected: res(true, { w: 250, h: 105 }, [ + { x: 0, y: 0, w: 100, h: 50 }, + { x: 100, y: 0, w: 100, h: 50 }, + { x: 0, y: 55, w: 100, h: 50 }, + ]), + }, + { + name: 'flexWrap wrap (column): the line gap is rowGap; container width grows', + container: { + width: 50, + height: 250, + gap: 10, + rowGap: 4, + flexDirection: 'column', + flexWrap: 'wrap', + }, + kids: [view(50, 100), view(50, 100), view(50, 100)], + expected: res(true, { w: 104, h: 250 }, [ + { x: 0, y: 0, w: 50, h: 100 }, + { x: 0, y: 110, w: 50, h: 100 }, + { x: 54, y: 0, w: 50, h: 100 }, + ]), + }, + { + name: 'flexWrap: every line is as tall as the FIRST child', + container: { width: 250, height: 50, flexWrap: 'wrap' }, + kids: [view(100, 30), view(100, 80), view(100, 50)], + expected: res(true, { w: 250, h: 60 }, [ + { x: 0, y: 0, w: 100, h: 30 }, + { x: 100, y: 0, w: 100, h: 80 }, + { x: 0, y: 30, w: 100, h: 50 }, + ]), + }, + { + name: 'flexWrap + padding: cross-axis padding only in flexLayout.ts', + container: { + width: 250, + height: 50, + gap: 10, + padding: 10, + flexWrap: 'wrap', + }, + kids: [view(100, 50), view(100, 50), view(100, 50)], + expected: res(true, { w: 250, h: 110 }, [ + { x: 10, y: 0, w: 100, h: 50 }, + { x: 120, y: 0, w: 100, h: 50 }, + { x: 10, y: 60, w: 100, h: 50 }, + ]), + expectedNew: res(true, { w: 250, h: 130 }, [ + { x: 10, y: 10, w: 100, h: 50 }, + { x: 120, y: 10, w: 100, h: 50 }, + { x: 10, y: 70, w: 100, h: 50 }, + ]), + newDiffers: + 'wrap + padding: flexLayout.ts starts lines at paddingTop and adds paddingBottom to the height; flex.ts ignores cross padding', + }, + { + name: 'flexWrap with no container height: lines are counted but children are never moved on the cross axis (see BUG test)', + container: { width: 250, gap: 10, flexWrap: 'wrap' }, + kids: [view(100, 50), view(100, 50), view(100, 50)], + expected: res(true, { w: 250, h: 110 }, [ + { x: 0, y: U, w: 100, h: 50 }, + { x: 110, y: U, w: 100, h: 50 }, + { x: 0, y: U, w: 100, h: 50 }, + ]), + }, + { + name: 'flexWrap wrap-reverse: flex.ts stacks lines upward to negative y and shrinks the width; flexLayout.ts does not wrap', + container: { width: 250, height: 50, gap: 10, flexWrap: 'wrap-reverse' }, + kids: [view(100, 50), view(100, 50), view(100, 50)], + expected: res(true, { w: 100, h: 110 }, [ + { x: 0, y: 0, w: 100, h: 50 }, + { x: 110, y: 0, w: 100, h: 50 }, + { x: 0, y: -60, w: 100, h: 50 }, + ]), + expectedNew: res(true, { w: 320, h: 50 }, [ + { x: 0, y: 0, w: 100, h: 50 }, + { x: 110, y: 0, w: 100, h: 50 }, + { x: 220, y: 0, w: 100, h: 50 }, + ]), + newDiffers: + 'wrap-reverse: flexLayout.ts only wraps for "wrap" (flexLayout.ts:297), so it lays out one line and auto-sizes the width', + }, + + // --- flexOrder ------------------------------------------------------------ + { + name: 'flexOrder sorts items; a missing flexOrder counts as 0', + container: { width: 300 }, + kids: [A({ flexOrder: 2 }), B({ flexOrder: 1 }), view(30, 30)], + expected: res(true, { w: 140, h: U }, [ + { x: 90, y: U, w: 50, h: 50 }, + { x: 30, y: U, w: 60, h: 40 }, + { x: 0, y: U, w: 30, h: 30 }, + ]), + }, + { + name: 'flexOrder then row-reverse: sorted first, then reversed', + container: { width: 300, flexDirection: 'row-reverse' }, + kids: [A({ flexOrder: 2 }), B({ flexOrder: 1 }), view(30, 30)], + expected: res(true, { w: 140, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: 60, h: 40 }, + { x: 110, y: U, w: 30, h: 30 }, + ]), + }, + + // --- flexItem={false}, raw text, children, min sizes --------------- + { + name: 'flexItem={false}: the child is skipped and keeps its own x; siblings close the gap', + container: { width: 300 }, + kids: [A(), view(999, 10, { flexItem: false, x: 5 }), B()], + expected: res(true, { w: 110, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 5, y: U, w: 999, h: 10 }, + { x: 50, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'flexItem={false} on every child: nothing to lay out', + container: { width: 300, height: 100 }, + kids: [A({ flexItem: false }), B({ flexItem: false })], + expected: res(false, { w: 300, h: 100 }, [ + { x: U, y: U, w: 50, h: 50 }, + { x: U, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'a raw string child (TextNode) is skipped', + container: { width: 300 }, + kids: [A(), { kind: 'textNode' }, B()], + expected: res(true, { w: 110, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'text in flex: a child with text and no size aborts the WHOLE pass', + container: { width: 300, height: 100 }, + kids: [A(), { kind: 'text', props: { text: 'Hello' } }, B()], + expected: res(false, { w: 300, h: 100 }, [ + { x: U, y: U, w: 50, h: 50 }, + { x: U, y: U, w: U, h: U }, + { x: U, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'text in flex: a sized child is laid out like a view', + container: { width: 300 }, + kids: [ + A(), + { kind: 'text', props: { text: 'Hello', width: 80, height: 20 } }, + B(), + ], + expected: res(true, { w: 190, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: 80, h: 20 }, + { x: 130, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'text in flex: maxWidth/maxHeight stand in for width/height (ElementNode.width = maxWidth || w)', + container: { width: 300 }, + kids: [ + A(), + { kind: 'text', props: { text: 'Hello', maxWidth: 70, maxHeight: 25 } }, + B(), + ], + expected: res(true, { w: 180, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: 70, h: 25 }, + { x: 120, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'text in flex: an empty with no size does not block the pass (size 0)', + container: { width: 300 }, + kids: [A(), { kind: 'text', props: { text: '' } }, B()], + expected: res(true, { w: 110, h: U }, [ + { x: 0, y: U, w: 50, h: 50 }, + { x: 50, y: U, w: U, h: U }, + { x: 50, y: U, w: 60, h: 40 }, + ]), + }, + { + name: 'child minWidth/minHeight raise the child size before layout', + container: { width: 300 }, + kids: [A({ minWidth: 80, minHeight: 70 }), B()], + expected: res(true, { w: 140, h: U }, [ + { x: 0, y: U, w: 80, h: 70 }, + { x: 80, y: U, w: 60, h: 40 }, + ]), + }, +]; + +describe.each([ + ['flex.ts', flexOld, false], + ['flexLayout.ts', flexNew, true], +] as const)( + 'contract: flex props, table run on %s', + (_label, engine, isNew) => { + it.each(cases.map((c) => [c.name, c] as const))('%s', (_name, c) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const expected = isNew && c.expectedNew ? c.expectedNew : c.expected; + expect(run(engine, c)).toEqual(expected); + } finally { + warn.mockRestore(); + } + }); + }, +); + +describe('contract: flex props, engine-specific details', () => { + const both = [ + ['flex.ts', flexOld], + ['flexLayout.ts', flexNew], + ] as const; + + it.each(both)( + 'flexGrow: a second pass on the same tree gives the same result (%s)', + (_label, engine) => { + const { node, children } = build({ width: 300, gap: 10 }, [ + A({ flexGrow: 1 }), + B(), + ]); + const first = snapshot(node, children, engine(node)); + const second = snapshot(node, children, engine(node)); + expect(second).toEqual(first); + expect(first.children[0]).toEqual({ x: 0, y: U, w: 230, h: 50 }); + }, + ); + + it.each(both)( + 'flexGrow: on a relayout the grown width is the new base, so the item never shrinks back (%s)', + (_label, engine) => { + const { node, children } = build({ width: 300, gap: 10 }, [ + A({ flexGrow: 1 }), + B(), + ]); + engine(node); + const [grow, sibling] = children as ElementNode[]; + expect(grow!.width).toBe(230); + sibling!.width = 100; + engine(node); + // 230 + 10 + 100 = 340 > 300: the row overflows (see BUG test below). + expect(grow!.width).toBe(230); + expect(sibling!.x).toBe(240); + }, + ); + + // BUG: flexGrow should start from the item's own size on every pass; after + // the sibling grows to 100 the grow item should be 300 - 100 - 10 = 190. + // flex.ts:146 / flexLayout.ts:199 add the free space to the current, already + // grown, width. + it.skip.each(both)( + 'BUG: flexGrow: a grown item shrinks back when a sibling grows (%s)', + (_label, engine) => { + const { node, children } = build({ width: 300, gap: 10 }, [ + A({ flexGrow: 1 }), + B(), + ]); + engine(node); + const [grow, sibling] = children as ElementNode[]; + sibling!.width = 100; + engine(node); + expect(grow!.width).toBe(190); + expect(sibling!.x).toBe(200); + }, + ); + + it.each(both)( + 'flexGrow: the container gets flexBoundary "fixed" written onto it (%s)', + (_label, engine) => { + const { node } = build({ width: 300 }, [A({ flexGrow: 1 }), B()]); + expect(node.flexBoundary).toBeUndefined(); + engine(node); + expect(node.flexBoundary).toBe('fixed'); + }, + ); + + it('flexGrow with no free space: flex.ts warns, flexLayout.ts is silent', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + flexOld(build({ width: 100 }, [A({ flexGrow: 1 }), B()]).node); + expect(warn).toHaveBeenCalledTimes(1); + warn.mockClear(); + flexNew(build({ width: 100 }, [A({ flexGrow: 1 }), B()]).node); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('padding array: flex.ts does not support it (docs: number only); positions become non-numeric', () => { + // flex.ts:95 casts `padding` to a number; an array then string-concatenates. + const { node, children } = build( + { width: 300, height: 100, padding: [5, 20, 15, 10] }, + [A(), B()], + ); + flexOld(node); + const [a, b] = children as ElementNode[]; + expect(Number.isFinite(a!.x)).toBe(false); + expect(Number.isFinite(b!.x)).toBe(false); + expect(Number.isFinite(node.width)).toBe(false); + }); + + it('padding array [t, r, b, l] (flexLayout.ts)', () => { + const r = run(flexNew, { + container: { + width: 300, + height: 100, + padding: [5, 20, 15, 10], + alignItems: 'flexStart', + }, + kids: [A(), B()], + }); + expect(r).toEqual( + res(true, { w: 140, h: 100 }, [ + { x: 10, y: 5, w: 50, h: 50 }, + { x: 60, y: 5, w: 60, h: 40 }, + ]), + ); + }); + + it('padding array [v, h] and [t, h, b] (flexLayout.ts)', () => { + const two = run(flexNew, { + container: { + width: 300, + height: 100, + padding: [5, 10], + alignItems: 'flexStart', + }, + kids: [A(), B()], + }); + expect(two).toEqual( + res(true, { w: 130, h: 100 }, [ + { x: 10, y: 5, w: 50, h: 50 }, + { x: 60, y: 5, w: 60, h: 40 }, + ]), + ); + const three = run(flexNew, { + container: { + width: 200, + height: 500, + flexDirection: 'column', + padding: [5, 10, 15], + alignItems: 'flexStart', + }, + kids: [A(), B()], + }); + expect(three).toEqual( + res(true, { w: 200, h: 110 }, [ + { x: 10, y: 5, w: 50, h: 50 }, + { x: 10, y: 55, w: 60, h: 40 }, + ]), + ); + }); + + it('paddingLeft/paddingTop override the padding value (flexLayout.ts only)', () => { + const container = { + width: 300, + height: 100, + padding: 10, + paddingLeft: 30, + paddingTop: 2, + alignItems: 'flexStart', + }; + expect(run(flexNew, { container, kids: [A(), B()] })).toEqual( + res(true, { w: 150, h: 100 }, [ + { x: 30, y: 2, w: 50, h: 50 }, + { x: 80, y: 2, w: 60, h: 40 }, + ]), + ); + // flex.ts ignores paddingLeft/paddingTop. + expect(run(flexOld, { container, kids: [A(), B()] })).toEqual( + res(true, { w: 130, h: 100 }, [ + { x: 10, y: 0, w: 50, h: 50 }, + { x: 60, y: 0, w: 60, h: 40 }, + ]), + ); + }); + + // BUG: with a symmetric padding, justifyContent center should center the + // items in the container: (300 - 110) / 2 = 95. Both engines add the padding + // on top of an already-centered start (flex.ts:282-284, + // flexLayout.ts:361-363), giving 105. + it.skip.each(both)( + 'BUG: padding + justifyContent center centers the items (%s)', + (_label, engine) => { + const r = run(engine, { + container: { + width: 300, + height: 100, + padding: 10, + justifyContent: 'center', + }, + kids: [A(), B()], + }); + expect(r.children.map((c) => c.x)).toEqual([95, 145]); + }, + ); + + // BUG: a wrapped row with no height never writes y, so the wrapped item + // overlaps the first line (doCrossAlign is a no-op when the container cross + // size is 0: flex.ts:179, flexLayout.ts:258). + it.skip.each(both)( + 'BUG: flexWrap moves wrapped items to the next line even when the container has no height (%s)', + (_label, engine) => { + const r = run(engine, { + container: { width: 250, gap: 10, flexWrap: 'wrap' }, + kids: [view(100, 50), view(100, 50), view(100, 50)], + }); + expect(r.children.map((c) => c.y)).toEqual([0, 0, 60]); + }, + ); + + // BUG: wrap-reverse should put the first line at the bottom of the grown + // container (CSS): lines at y 60 and 0 in a 110-high container, width kept. + // flex.ts stacks lines upward to y = -60 and shrinks the width to the last + // line; flexLayout.ts does not wrap at all. + it.skip.each(both)( + 'BUG: flexWrap wrap-reverse stacks lines from the bottom (%s)', + (_label, engine) => { + const r = run(engine, { + container: { + width: 250, + height: 50, + gap: 10, + flexWrap: 'wrap-reverse', + }, + kids: [view(100, 50), view(100, 50), view(100, 50)], + }); + expect(r).toEqual( + res(true, { w: 250, h: 110 }, [ + { x: 0, y: 60, w: 100, h: 50 }, + { x: 110, y: 60, w: 100, h: 50 }, + { x: 0, y: 0, w: 100, h: 50 }, + ]), + ); + }, + ); + + // BUG (flexLayout.ts only): cross-axis flexEnd with padding should keep the + // child inside the padded box: y = 100 - 10 - 50 = 40 for a 50-high child. + it.skip('BUG: padding + alignItems flexEnd keeps children inside the padding (flexLayout.ts)', () => { + const r = run(flexNew, { + container: { + width: 300, + height: 100, + padding: 10, + flexBoundary: 'fixed', + alignItems: 'flexEnd', + }, + kids: [A(), B()], + }); + expect(r.children.map((c) => c.y)).toEqual([40, 50]); + }); +}); + +// --------------------------------------------------------------------------- +// Part 2: end to end through JSX and the layout queue (active engine) +// --------------------------------------------------------------------------- + +describe(`contract: flex through the renderer (active engine: ${ + NEW_FLEX_ACTIVE ? 'flexLayout.ts' : 'flex.ts' +})`, () => { + it('engine selection: a truthy VITE_USE_NEW_FLEX string selects flexLayout.ts (margin arrays only work there)', async () => { + let item!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + )); + await waitForUpdate(); + expect(item.x).toBe(NEW_FLEX_ACTIVE ? 10 : 0); + dispose(); + }); + + it('auto-size: a row with no width/height takes its children width and the tallest child height', async () => { + let row!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + await waitForUpdate(); + expect(row.width).toBe(120); + expect(row.height).toBe(50); + dispose(); + }); + + it('auto-size: a column with no size takes the content height and keeps the parent width', async () => { + let col!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + + + )); + await waitForUpdate(); + expect(col.height).toBe(95); + // Width is the parent width less x; a column never fits its width. + expect(col.width).toBe(700); + dispose(); + }); + + it('auto-size: an explicit height on a row is kept (cross size is only fitted when height was not set)', async () => { + let row!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + )); + await waitForUpdate(); + expect(row.height).toBe(300); + expect(row.width).toBe(50); + dispose(); + }); + + it('flexCrossBoundary "fixed": a row with no height keeps the parent-derived height', async () => { + let row!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + + )); + await waitForUpdate(); + expect(row.height).toBe(500); + expect(row.width).toBe(50); + dispose(); + }); + + it('flexBoundary "fixed": a row with no width fills the parent width', async () => { + let row!: lng.ElementNode; + let second!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + + + )); + await waitForUpdate(); + expect(row.width).toBe(700); + expect(second.x).toBe(50); + dispose(); + }); + + it('nested flex containers: inner rows size first, then the outer row places them; adding a child relays out both', async () => { + const [extra, setExtra] = s.createSignal(false); + let outer!: lng.ElementNode; + let innerA!: lng.ElementNode; + let innerB!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + + + + + + + + + )); + await waitForUpdate(); + expect(innerA.width).toBe(100); + expect(innerA.height).toBe(50); + expect(innerB.x).toBe(110); + expect(outer.width).toBe(140); + expect(outer.height).toBe(50); + + setExtra(true); + await waitForUpdate(); + expect(innerA.width).toBe(150); + expect(innerA.height).toBe(80); + expect(innerB.x).toBe(160); + expect(outer.width).toBe(190); + expect(outer.height).toBe(80); + + setExtra(false); + await waitForUpdate(); + expect(innerA.width).toBe(100); + expect(innerB.x).toBe(110); + expect(outer.width).toBe(140); + dispose(); + }); + + it('flexGrow on a nested flex container: it grows inside its parent, then lays out its own children', async () => { + let inner!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + + + + )); + await waitForUpdate(); + expect(inner.x).toBe(100); + expect(inner.width).toBe(300); + expect(last.x).toBe(280); + dispose(); + }); + + it('onLayout: called once after the first flex pass, with this = node and (node) as the only argument, after children are placed', async () => { + const calls: Array<{ self: unknown; args: unknown[]; secondX: unknown }> = + []; + let row!: lng.ElementNode; + let second!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + await waitForUpdate(); + expect(calls).toHaveLength(1); + expect(calls[0]!.self).toBe(row); + expect(calls[0]!.args).toEqual([row]); + expect(calls[0]!.secondX).toBe(60); + dispose(); + }); + + it('onLayout: fires again when a child is added or removed, and on updateLayout() (synchronously)', async () => { + const [show, setShow] = s.createSignal(true); + let count = 0; + let row!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + void count++}> + + + + + + )); + await waitForUpdate(); + expect(count).toBe(1); + expect(last.x).toBe(50); + + setShow(false); + await waitForUpdate(); + expect(count).toBe(2); + expect(last.x).toBe(0); + expect(row.width).toBe(60); + + row.updateLayout(); + expect(count).toBe(3); + dispose(); + }); + + it('onLayout: fires on a non-flex view with children; never on a view without children', async () => { + let withKids = 0; + let withoutKids = 0; + const dispose = renderer.render(() => ( + + void withKids++}> + + + void withoutKids++} /> + + )); + await waitForUpdate(); + expect(withKids).toBe(1); + expect(withoutKids).toBe(0); + dispose(); + }); + + it('onLayout: a truthy return value queues the parent layout; a falsy one does not', async () => { + let parentCount = 0; + let returnValue = false; + let child!: lng.ElementNode; + const dispose = renderer.render(() => ( + void parentCount++}> + returnValue}> + + + + )); + await waitForUpdate(); + const afterRender = parentCount; + expect(afterRender).toBeGreaterThanOrEqual(1); + + child.updateLayout(); + await waitForUpdate(); + expect(parentCount).toBe(afterRender); + + returnValue = true; + child.updateLayout(); + await waitForUpdate(); + expect(parentCount).toBe(afterRender + 1); + dispose(); + }); + + it('relayout: a reactive width change on a flex child does NOT reflow the parent; updateLayout() does', async () => { + const [w, setW] = s.createSignal(50); + let row!: lng.ElementNode; + let first!: lng.ElementNode; + let second!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + await waitForUpdate(); + expect(second.x).toBe(50); + expect(row.width).toBe(110); + + setW(100); + await waitForUpdate(); + expect(first.width).toBe(100); + // Today: no automatic relayout on a child size change. + expect(second.x).toBe(50); + expect(row.width).toBe(110); + + row.updateLayout(); + expect(second.x).toBe(100); + expect(row.width).toBe(160); + dispose(); + }); + + it('relayout: updateLayoutOn={signal} relays out when the signal changes', async () => { + const [w, setW] = s.createSignal(50); + const [tick, setTick] = s.createSignal(0); + let row!: lng.ElementNode; + let second!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + await waitForUpdate(); + expect(second.x).toBe(50); + + setW(100); + setTick(1); + expect(second.x).toBe(100); + expect(row.width).toBe(160); + dispose(); + }); + + it('$focus: a focus-state width change on a flex child does NOT reflow its flex parent', async () => { + let row!: lng.ElementNode; + let first!: lng.ElementNode; + let second!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + await waitForUpdate(); + expect(second.x).toBe(50); + expect(row.width).toBe(110); + expect(row.height).toBe(50); + + first.setFocus(); + await waitForUpdate(); + expect(first.states.has('$focus')).toBe(true); + expect(first.width).toBe(100); + expect(first.height).toBe(70); + // Today: siblings and the parent keep the pre-focus layout. + expect(second.x).toBe(50); + expect(row.width).toBe(110); + expect(row.height).toBe(50); + + second.setFocus(); + await waitForUpdate(); + expect(first.states.has('$focus')).toBe(false); + expect(first.width).toBe(50); + expect(second.x).toBe(50); + expect(row.width).toBe(110); + dispose(); + }); +}); diff --git a/tests/contract-focus-helpers.ts b/tests/contract-focus-helpers.ts new file mode 100644 index 0000000..2154a6c --- /dev/null +++ b/tests/contract-focus-helpers.ts @@ -0,0 +1,111 @@ +// Shared fixtures for the contract-focus and contract-keys tests. +// +// Vitest runs with `isolate: false`, so the focus manager's module state +// (focus path, active element, key map, last key) is shared with every other +// test file in the worker, and some of those leave listeners on `document`. +// These helpers keep a test's key events off `document`: each mount gets its +// own KeyTarget, and only the focus manager mounted with it hears its events. +import * as v from 'vitest'; +import type { JSX } from 'solid-js'; +import type { ElementNode } from '@solidtv/solid'; +import { + useFocusManager, + type KeyEventLike, + type KeyEventTarget, + type KeyMap, +} from '@solidtv/solid/primitives'; +import { renderer } from './setup.js'; + +type KeyEventType = 'keydown' | 'keyup'; +type Listener = (event: KeyEventLike) => void; + +export interface TestKeyEvent extends KeyEventLike { + preventDefault: v.Mock<() => void>; +} + +export interface KeyOptions { + keyCode?: number; + repeat?: boolean; +} + +/** A key-event host only one focus manager listens on. */ +export class KeyTarget implements KeyEventTarget { + listeners: Record = { keydown: [], keyup: [] }; + + addEventListener(type: KeyEventType, listener: Listener) { + this.listeners[type].push(listener); + } + + removeEventListener(type: KeyEventType, listener: Listener) { + const list = this.listeners[type]; + const idx = list.indexOf(listener); + if (idx !== -1) list.splice(idx, 1); + } + + fire(type: KeyEventType, key: string, opts: KeyOptions = {}): TestKeyEvent { + const event: TestKeyEvent = { + key, + keyCode: opts.keyCode ?? 0, + repeat: opts.repeat ?? false, + preventDefault: v.vi.fn<() => void>(), + }; + for (const listener of this.listeners[type].slice()) listener(event); + return event; + } + + down(key: string, opts?: KeyOptions) { + return this.fire('keydown', key, opts); + } + + up(key: string, opts?: KeyOptions) { + return this.fire('keyup', key, opts); + } +} + +/** Lets queued post-mutation work (layout, then focus) run. */ +export const flush = () => + new Promise((resolve) => setTimeout(resolve, 0)); + +/** Renders `view` under a focus manager bound to a fresh KeyTarget. */ +export async function mount( + view: () => JSX.Element, + keyMap?: Partial, +): Promise<{ target: KeyTarget; dispose: () => void }> { + const target = new KeyTarget(); + const dispose = renderer.render(() => { + useFocusManager(keyMap, target); + return view(); + }) as unknown as () => void; + await flush(); + return { target, dispose }; +} + +export interface HandlerCall { + /** `id` of the node the handler was called on (read from `this`). */ + node: string | undefined; + /** `id` of the node the handler was set on, when given to `handler`. */ + owner: string | undefined; + handler: string; + self: ElementNode; + args: unknown[]; +} + +/** + * Records handler calls in order. `handler(name, ret, owner)` returns a plain + * function (not an arrow, so `this` is the node) that logs and returns `ret`. + * Pass `owner` (the id of the node it is set on) to check the `this` binding. + */ +export function recorder() { + const calls: HandlerCall[] = []; + // Typed loosely (any) so one recorder fits every handler prop's signature. + const handler = (name: string, ret?: unknown, owner?: string) => + function (this: ElementNode, ...args: any[]): any { + calls.push({ node: this.id, owner, handler: name, self: this, args }); + return ret; + }; + const order = () => calls.map((c) => `${c.node}.${c.handler}`); + const clear = () => { + calls.length = 0; + }; + return { calls, handler, order, clear }; +} diff --git a/tests/contract-focus.test.tsx b/tests/contract-focus.test.tsx new file mode 100644 index 0000000..9c1db7d --- /dev/null +++ b/tests/contract-focus.test.tsx @@ -0,0 +1,696 @@ +// Contract tests: focus. Pins the behaviour of solid 1.6.4 (renderer 1.9) +// that apps depend on: activeElement, focusPath, setFocus(), +// forwardFocus, skipFocus, autofocus, the focus state, and the order in which +// onFocus / onBlur / onFocusChanged fire. Behavioural only: handler call +// order, return values and public props/state, never underscore fields. +import * as v from 'vitest'; +import { createEffect, createRoot, createSignal, on } from 'solid-js'; +import { + Config, + type ElementNode, + activeElement as coreActiveElement, + setActiveElement, +} from '@solidtv/solid'; +import { activeElement, focusPath, Row } from '@solidtv/solid/primitives'; +import { flush, mount, recorder } from './contract-focus-helpers.js'; +import { renderer } from './setup.js'; + +// useFocusManager rebinds Config.setActiveElement on every mount; put back +// whatever the worker had before this file ran. +const savedSetActiveElement = Config.setActiveElement; +v.afterAll(() => { + Config.setActiveElement = savedSetActiveElement; +}); + +type TreeIds = 'G' | 'P1' | 'A' | 'B' | 'P2' | 'C'; + +/** Records onFocus / onBlur / onFocusChanged(hasFocus) per node id. */ +function focusLog() { + const r = recorder(); + const spies = (owner: TreeIds) => ({ + onFocus: r.handler('onFocus', undefined, owner), + onBlur: r.handler('onBlur', undefined, owner), + onFocusChanged: r.handler('onFocusChanged', undefined, owner), + }); + const order = () => + r.calls.map((c) => + c.handler === 'onFocusChanged' + ? `${c.node}.onFocusChanged(${String(c.args[0])})` + : `${c.node}.${c.handler}`, + ); + return { ...r, spies, order }; +} + +// G +// / \ +// P1 P2 +// / \ \ +// A B C (A autofocused) +async function mountTree() { + const log = focusLog(); + const n = {} as Record; + const { target, dispose } = await mount(() => ( + + + + + + + + + + )); + return { log, n, target, dispose }; +} + +// states.has() is typed to take `$`-prefixed names only, but app code (and +// the contract) also calls it without the `$`: has('focus') matches '$focus'. +const hasFocusState = (el: ElementNode) => + el.states.has('focus' as `$${string}`); + +async function focus(elm: ElementNode) { + elm.setFocus(); + await flush(); +} + +v.describe('contract: onFocus / onBlur / onFocusChanged order', () => { + // Contract: "Keep the order in which onFocus, onBlur and onFocusChanged fire." + v.it( + 'initial focus fires onFocus then onFocusChanged(true), leaf to root', + async () => { + const { log, n, dispose } = await mountTree(); + v.expect(activeElement()).toBe(n.A); + v.expect(log.order()).toEqual([ + 'A.onFocus', + 'A.onFocusChanged(true)', + 'P1.onFocus', + 'P1.onFocusChanged(true)', + 'G.onFocus', + 'G.onFocusChanged(true)', + ]); + dispose(); + }, + ); + + v.it( + 'siblings: new leaf gains focus first, then the old leaf blurs; shared parent gets nothing', + async () => { + const { log, n, dispose } = await mountTree(); + log.clear(); + await focus(n.B); + // Current behaviour: every gain fires before any loss. + v.expect(log.order()).toEqual([ + 'B.onFocus', + 'B.onFocusChanged(true)', + 'A.onBlur', + 'A.onFocusChanged(false)', + ]); + dispose(); + }, + ); + + v.it( + 'cousins: gains leaf→root up to the common ancestor, then losses old-leaf→root; common ancestor gets nothing', + async () => { + const { log, n, dispose } = await mountTree(); + await focus(n.B); + log.clear(); + await focus(n.C); + v.expect(log.order()).toEqual([ + 'C.onFocus', + 'C.onFocusChanged(true)', + 'P2.onFocus', + 'P2.onFocusChanged(true)', + 'B.onBlur', + 'B.onFocusChanged(false)', + 'P1.onBlur', + 'P1.onFocusChanged(false)', + ]); + dispose(); + }, + ); + + v.it( + 'parent → child: only the child fires (onFocus, onFocusChanged(true)); the parent keeps focus-within silently', + async () => { + const { log, n, dispose } = await mountTree(); + await focus(n.P2); + log.clear(); + await focus(n.C); + v.expect(log.order()).toEqual(['C.onFocus', 'C.onFocusChanged(true)']); + dispose(); + }, + ); + + v.it( + 'child → parent: the parent fires onFocus + onFocusChanged(true) again although it already had focus-within, then the child blurs', + async () => { + const { log, n, dispose } = await mountTree(); + await focus(n.C); + log.clear(); + await focus(n.P2); + // Current behaviour: the new leaf always gets onFocus/onFocusChanged(true), + // even when its focus-within state did not change. + v.expect(log.order()).toEqual([ + 'P2.onFocus', + 'P2.onFocusChanged(true)', + 'C.onBlur', + 'C.onFocusChanged(false)', + ]); + dispose(); + }, + ); + + v.it( + 'descendant → ancestor two levels up: ancestor re-fires as the leaf, then the old path blurs old-leaf→root', + async () => { + const { log, n, dispose } = await mountTree(); + log.clear(); + await focus(n.G); + v.expect(log.order()).toEqual([ + 'G.onFocus', + 'G.onFocusChanged(true)', + 'A.onBlur', + 'A.onFocusChanged(false)', + 'P1.onBlur', + 'P1.onFocusChanged(false)', + ]); + dispose(); + }, + ); + + v.it( + 'passes (currentFocusedElm, prevFocusedElm, node) with this = node; onFocusChanged gets hasFocus first', + async () => { + const { log, n, dispose } = await mountTree(); + await focus(n.B); + log.clear(); + await focus(n.C); + const byKey = new Map( + log.calls.map((c) => [`${c.node}.${c.handler}`, c] as const), + ); + v.expect(byKey.get('C.onFocus')!.args).toEqual([n.C, n.B, n.C]); + v.expect(byKey.get('P2.onFocus')!.args).toEqual([n.C, n.B, n.P2]); + v.expect(byKey.get('P2.onFocusChanged')!.args).toEqual([ + true, + n.C, + n.B, + n.P2, + ]); + v.expect(byKey.get('B.onBlur')!.args).toEqual([n.C, n.B, n.B]); + v.expect(byKey.get('P1.onBlur')!.args).toEqual([n.C, n.B, n.P1]); + v.expect(byKey.get('P1.onFocusChanged')!.args).toEqual([ + false, + n.C, + n.B, + n.P1, + ]); + // this = the node the callback is set on, for every call. + v.expect(log.calls.length).toBe(8); + for (const c of log.calls) v.expect(c.self).toBe(n[c.owner as TreeIds]); + dispose(); + }, + ); + + v.it( + 'callbacks run before activeElement / focusPath publish the change (current behaviour)', + async () => { + const { n, dispose } = await mountTree(); + const seen: unknown[] = []; + n.B.onFocus = function () { + seen.push([ + 'B.onFocus', + activeElement() === n.A, + focusPath()[0] === n.A, + ]); + }; + n.A.onBlur = function () { + seen.push([ + 'A.onBlur', + activeElement() === n.A, + focusPath()[0] === n.A, + ]); + }; + const publish = Config.setActiveElement; + Config.setActiveElement = (elm) => { + seen.push(['Config.setActiveElement', elm === n.B]); + publish(elm); + }; + await focus(n.B); + Config.setActiveElement = publish; + // Inside onFocus/onBlur, activeElement() and focusPath() still describe + // the previous focus; Config.setActiveElement is called last. + v.expect(seen).toEqual([ + ['B.onFocus', true, true], + ['A.onBlur', true, true], + ['Config.setActiveElement', true], + ]); + v.expect(activeElement()).toBe(n.B); + dispose(); + }, + ); +}); + +v.describe('contract: focus state', () => { + // Contract: the focused node (and its ancestors) carry the focus state; + // states.has('focus') matches '$focus'. + v.it( + 'the focused node and every ancestor have the focus state; nodes off the path do not', + async () => { + const { n, dispose } = await mountTree(); + for (const id of ['A', 'P1', 'G'] as const) { + v.expect(hasFocusState(n[id]), id).toBe(true); + v.expect(n[id].states.has('$focus'), id).toBe(true); + } + for (const id of ['B', 'P2', 'C'] as const) { + v.expect(hasFocusState(n[id]), id).toBe(false); + } + v.expect(Config.focusStateKey).toBe('$focus'); + dispose(); + }, + ); + + v.it( + 'moving focus removes the state from nodes that left the path and keeps it on the common ancestor', + async () => { + const { n, dispose } = await mountTree(); + await focus(n.C); + v.expect(hasFocusState(n.C)).toBe(true); + v.expect(hasFocusState(n.P2)).toBe(true); + v.expect(hasFocusState(n.G)).toBe(true); + v.expect(hasFocusState(n.A)).toBe(false); + v.expect(hasFocusState(n.P1)).toBe(false); + dispose(); + }, + ); + + v.it( + 'the state is added before onFocus runs and removed before onBlur runs', + async () => { + const { n, dispose } = await mountTree(); + const seen: unknown[] = []; + n.B.onFocus = function () { + seen.push(['B.onFocus', hasFocusState(this)]); + }; + n.A.onBlur = function () { + seen.push(['A.onBlur', hasFocusState(this)]); + }; + await focus(n.B); + v.expect(seen).toEqual([ + ['B.onFocus', true], + ['A.onBlur', false], + ]); + dispose(); + }, + ); +}); + +v.describe('contract: activeElement', () => { + // Contract: "activeElement works both as a signal and as a function call." + v.it('a plain call returns the focused node', async () => { + const { n, dispose } = await mountTree(); + v.expect(activeElement()).toBe(n.A); + await focus(n.C); + v.expect(activeElement()).toBe(n.C); + dispose(); + }); + + v.it( + 'it is a Solid signal: effects tracking it (createEffect, on()) re-run on each change', + async () => { + const { n, dispose } = await mountTree(); + const viaOn: unknown[] = []; + const viaEffect: unknown[] = []; + const disposeRoot = createRoot((d) => { + createEffect( + on(activeElement, (elm) => viaOn.push(elm), { defer: true }), + ); + createEffect(() => viaEffect.push(activeElement())); + return d; + }); + await flush(); + await focus(n.B); + await focus(n.C); + v.expect(viaOn).toEqual([n.B, n.C]); + v.expect(viaEffect).toEqual([n.A, n.B, n.C]); + disposeRoot(); + dispose(); + }, + ); + + v.it( + '@solidtv/solid and @solidtv/solid/primitives export the same accessor', + () => { + v.expect(coreActiveElement).toBe(activeElement); + }, + ); + + v.it( + 'setFocus() on the active element does nothing (no callbacks)', + async () => { + const { log, n, dispose } = await mountTree(); + log.clear(); + await focus(n.A); + v.expect(log.order()).toEqual([]); + v.expect(activeElement()).toBe(n.A); + dispose(); + }, + ); + + v.it( + 'the exported setActiveElement is the raw signal setter: it does not move focus state, fire callbacks or change focusPath (current behaviour)', + async () => { + const { log, n, dispose } = await mountTree(); + const before = focusPath(); + log.clear(); + setActiveElement(n.C); + await flush(); + v.expect(activeElement()).toBe(n.C); + v.expect(hasFocusState(n.C)).toBe(false); + v.expect(hasFocusState(n.A)).toBe(true); + v.expect(focusPath()).toBe(before); + v.expect(log.order()).toEqual([]); + setActiveElement(n.A); + dispose(); + }, + ); +}); + +v.describe('contract: focusPath', () => { + v.it( + 'focusPath() lists the focused leaf first, then each ancestor, ending at the root node', + async () => { + const { n, dispose } = await mountTree(); + v.expect(focusPath()).toEqual([n.A, n.P1, n.G, renderer.rootNode]); + await focus(n.C); + v.expect(focusPath()).toEqual([n.C, n.P2, n.G, renderer.rootNode]); + dispose(); + }, + ); + + v.it('focusPath is a signal: effects re-run with the new path', async () => { + const { n, dispose } = await mountTree(); + const leaves: unknown[] = []; + const disposeRoot = createRoot((d) => { + createEffect(on(focusPath, (fp) => leaves.push(fp[0]), { defer: true })); + return d; + }); + await focus(n.B); + await focus(n.P2); + v.expect(leaves).toEqual([n.B, n.P2]); + disposeRoot(); + dispose(); + }); +}); + +v.describe('contract: setFocus()', () => { + v.it( + 'applies focus asynchronously: activeElement changes after a microtask, not during the call (current behaviour)', + async () => { + const { log, n, dispose } = await mountTree(); + log.clear(); + n.B.setFocus(); + v.expect(activeElement()).toBe(n.A); + v.expect(log.order()).toEqual([]); + await flush(); + v.expect(activeElement()).toBe(n.B); + dispose(); + }, + ); + + v.it( + 'several setFocus() calls in one tick: only the last one takes effect', + async () => { + const { log, n, dispose } = await mountTree(); + log.clear(); + n.B.setFocus(); + n.C.setFocus(); + await flush(); + v.expect(activeElement()).toBe(n.C); + v.expect(log.order()).not.toContain('B.onFocus'); + v.expect(log.order()).toContain('C.onFocus'); + dispose(); + }, + ); +}); + +v.describe('contract: forwardFocus', () => { + v.it( + 'a number forwards focus to that child index; the forwarding node stays on the path', + async () => { + let p!: ElementNode, + a!: ElementNode, + b!: ElementNode, + other!: ElementNode; + const { dispose } = await mount(() => ( + + + + + + + + )); + v.expect(activeElement()).toBe(other); + await focus(p); + v.expect(activeElement()).toBe(b); + v.expect(focusPath()[1]).toBe(p); + v.expect(hasFocusState(p)).toBe(true); + v.expect(hasFocusState(a)).toBe(false); + dispose(); + }, + ); + + v.it('forwardFocus={0} works (0 is not treated as unset)', async () => { + let p!: ElementNode, a!: ElementNode; + const { dispose } = await mount(() => ( + + + + + )); + v.expect(activeElement()).toBe(a); + v.expect(hasFocusState(p)).toBe(true); + dispose(); + }); + + v.it('chains through children that forward focus themselves', async () => { + let leaf!: ElementNode; + const { dispose } = await mount(() => ( + + + + + + + )); + v.expect(activeElement()).toBe(leaf); + dispose(); + }); + + v.it( + 'an index past the last child focuses the node itself (current behaviour)', + async () => { + let p!: ElementNode; + const { dispose } = await mount(() => ( + + + + )); + v.expect(activeElement()).toBe(p); + dispose(); + }, + ); + + v.it( + 'autofocus + forwardFocus reaches a child that renders with the parent', + async () => { + let b!: ElementNode; + const { dispose } = await mount(() => ( + + + + + )); + v.expect(activeElement()).toBe(b); + dispose(); + }, + ); + + v.it( + 'a function is called with this = node and the node as argument; it can focus another node', + async () => { + let p!: ElementNode, b!: ElementNode; + const fn = v.vi.fn(function (this: ElementNode, _elm: ElementNode) { + b.setFocus(); + return true; + }); + const { dispose } = await mount(() => ( + + + + + )); + v.expect(activeElement()).toBe(b); + v.expect(fn).toHaveBeenCalled(); + v.expect(fn.mock.contexts[0]).toBe(p); + v.expect(fn.mock.calls[0]).toEqual([p]); + dispose(); + }, + ); + + v.it( + 'a function returning false lets the node itself take focus', + async () => { + let p!: ElementNode; + const { dispose } = await mount(() => ( + false}> + + + )); + v.expect(activeElement()).toBe(p); + dispose(); + }, + ); + + v.it( + 'a function returning anything but false (here undefined) blocks focus on the node: focus stays put (current behaviour)', + async () => { + let p!: ElementNode, other!: ElementNode; + const fn = v.vi.fn(() => undefined); + const { dispose } = await mount(() => ( + + + + + + + )); + await focus(p); + v.expect(fn).toHaveBeenCalled(); + v.expect(activeElement()).toBe(other); + dispose(); + }, + ); +}); + +v.describe('contract: skipFocus', () => { + v.it( + 'Row navigation skips a skipFocus child in both directions', + async () => { + let a!: ElementNode, b!: ElementNode, c!: ElementNode; + const { target, dispose } = await mount(() => ( + + + + + + )); + v.expect(activeElement()).toBe(a); + target.down('ArrowRight'); + await flush(); + v.expect(activeElement()).toBe(c); + v.expect(hasFocusState(b)).toBe(false); + target.down('ArrowLeft'); + await flush(); + v.expect(activeElement()).toBe(a); + dispose(); + }, + ); + + v.it( + 'a Row forwards initial focus past a leading skipFocus child', + async () => { + let second!: ElementNode; + const { dispose } = await mount(() => ( + + + + + )); + v.expect(activeElement()).toBe(second); + dispose(); + }, + ); + + v.it( + 'setFocus() on a skipFocus node still focuses it: skipFocus is only read by navigation (current behaviour)', + async () => { + let skipped!: ElementNode; + const { dispose } = await mount(() => ( + + + + + )); + await focus(skipped); + v.expect(activeElement()).toBe(skipped); + dispose(); + }, + ); +}); + +v.describe('contract: autofocus', () => { + v.it('bare autofocus focuses the node once it renders', async () => { + let a!: ElementNode; + const { dispose } = await mount(() => ( + + + + + )); + v.expect(activeElement()).toBe(a); + v.expect(hasFocusState(a)).toBe(true); + dispose(); + }); + + v.it( + 'a node added later with autofocus takes focus when it renders', + async () => { + const [show, setShow] = createSignal(false); + let first!: ElementNode, late!: ElementNode; + const { dispose } = await mount(() => ( + + + {show() && } + + )); + v.expect(activeElement()).toBe(first); + setShow(true); + await flush(); + v.expect(activeElement()).toBe(late); + dispose(); + }, + ); + + v.it( + 'a reactive value re-focuses the node each time it changes to a new truthy value; falsy values do nothing', + async () => { + const [token, setToken] = createSignal(undefined); + let x!: ElementNode, y!: ElementNode; + const { dispose } = await mount(() => ( + + + + + )); + // Falsy at mount: x does not take focus. + v.expect(activeElement()).toBe(y); + + setToken(1); + await flush(); + v.expect(activeElement()).toBe(x); + + await focus(y); + setToken(2); + await flush(); + v.expect(activeElement()).toBe(x); + + await focus(y); + setToken(0); + await flush(); + v.expect(activeElement()).toBe(y); + dispose(); + }, + ); +}); diff --git a/tests/contract-grid.test.tsx b/tests/contract-grid.test.tsx new file mode 100644 index 0000000..7b152a3 --- /dev/null +++ b/tests/contract-grid.test.tsx @@ -0,0 +1,346 @@ +// Contract tests: Grid (navigation, looping, selected, scroll, +// onSelectedChanged). +// +// Pins today's behaviour (1.6.4) through the public surface: keys go through +// the focus manager's real keydown listener; assertions read the focused +// element, callback arguments and final x/y. Animations are off so y is final. +import * as v from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { + Grid, + useFocusManager, + type GridItemProps, + type KeyEventTarget, +} from '@solidtv/solid/primitives'; +import { renderer } from './setup.js'; + +// Real KeyboardEvents on the target the focus manager listens to. A private +// target (instead of `document`) keeps listeners other files leave on +// `document` (vitest isolate: false) from handling a press twice. +const keys = new EventTarget(); +const flush = () => new Promise((r) => setTimeout(r, 0)); + +async function press(...names: string[]) { + for (const key of names) { + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + await flush(); + } +} + +async function mount(ui: () => s.JSX.Element) { + const dispose = renderer.render(() => { + useFocusManager(undefined, keys as unknown as KeyEventTarget); + return ui(); + }) as unknown as () => void; + await flush(); + return dispose; +} + +const focusedId = () => lng.activeElement()?.id; + +const Cell = (p: GridItemProps) => ( + +); + +// 10 items in 3 columns, 100x50 cells with itemOffset 10: +// g0 g1 g2 +// g3 g4 g5 +// g6 g7 g8 +// g9 +const tenItems = Array.from({ length: 10 }, (_, i) => i); + +let dispose: (() => void) | undefined; +const animationsEnabled = lng.Config.animationsEnabled; + +v.beforeAll(() => { + lng.Config.animationsEnabled = false; +}); +v.afterAll(() => { + lng.Config.animationsEnabled = animationsEnabled; +}); +v.afterEach(() => { + dispose?.(); + dispose = undefined; +}); + +async function walk(sequence: string[], read: () => unknown) { + const seen: unknown[] = []; + for (const key of sequence) { + await press(key); + seen.push(read()); + } + return seen; +} + +v.describe('Grid', () => { + v.it( + 'lays out cells from columns/itemWidth/itemHeight/itemOffset', + async () => { + let grid!: lng.ElementNode; + dispose = await mount(() => ( + + + {Cell} + + + )); + v.expect(grid.children.map((c) => [c.x, c.y])).toEqual([ + [0, 0], + [110, 0], + [220, 0], + [0, 60], + [110, 60], + [220, 60], + [0, 120], + [110, 120], + [220, 120], + [0, 180], + ]); + // 4 rows * (50 + 10) + v.expect(grid.height).toBe(240); + v.expect([grid.y, focusedId()]).toEqual([20, 'g0']); + }, + ); + + v.it( + 'navigation: Left/Right stay in the row, Up/Down move by a row; at an edge the key bubbles; y scrolls by row', + async () => { + let grid!: lng.ElementNode; + const parent = v.vi.fn(() => true); + dispose = await mount(() => ( + + + {Cell} + + + )); + const seen = await walk( + [ + 'ArrowRight', + 'ArrowRight', + 'ArrowRight', // end of row: bubbles + 'ArrowLeft', + 'ArrowDown', + 'ArrowDown', + 'ArrowDown', // g7 + 3 = 10 is past the end: bubbles (does not go to g9) + 'ArrowRight', + 'ArrowUp', + 'ArrowUp', + 'ArrowUp', // top row: bubbles + ], + () => [focusedId(), grid.y, parent.mock.calls.length], + ); + v.expect(seen).toEqual([ + ['g1', 20, 0], + ['g2', 20, 0], + ['g2', 20, 1], + ['g1', 20, 1], + ['g4', -40, 1], + ['g7', -100, 1], + ['g7', -100, 2], + ['g8', -100, 2], + ['g5', -40, 2], + ['g2', 20, 2], + ['g2', 20, 3], + ]); + }, + ); + + v.it( + 'looping: Left/Right wrap within the row, Up/Down wrap to the same column', + async () => { + let grid!: lng.ElementNode; + const parent = v.vi.fn(() => true); + dispose = await mount(() => ( + + + {Cell} + + + )); + const seen = await walk( + [ + 'ArrowRight', + 'ArrowRight', + 'ArrowRight', // g2 -> g0 + 'ArrowLeft', // g0 -> g2 + 'ArrowDown', + 'ArrowDown', + 'ArrowDown', // g8 -> g2 + 'ArrowDown', + 'ArrowRight', // g5 -> g3 + 'ArrowUp', + 'ArrowUp', // g0 -> g9 (last row, same column) + 'ArrowUp', + 'ArrowUp', + 'ArrowLeft', // g3 -> g5 + ], + () => [focusedId(), grid.y], + ); + v.expect(seen).toEqual([ + ['g1', 20], + ['g2', 20], + ['g0', 20], + ['g2', 20], + ['g5', -40], + ['g8', -100], + ['g2', 20], + ['g5', -40], + ['g3', -40], + ['g0', 20], + ['g9', -160], + ['g6', -100], + ['g3', -40], + ['g5', -40], + ]); + v.expect(parent).not.toHaveBeenCalled(); + }, + ); + + v.it('scroll="none": y does not follow the focused row', async () => { + let grid!: lng.ElementNode; + dispose = await mount(() => ( + + + {Cell} + + + )); + await press('ArrowDown', 'ArrowDown'); + v.expect([focusedId(), grid.y]).toEqual(['g6', 20]); + }); + + v.it( + 'onSelectedChanged is called as (idx, grid, elm) with this = grid; once on mount', + async () => { + let grid!: lng.ElementNode; + const calls: unknown[][] = []; + dispose = await mount(() => ( + + + {Cell} + + + )); + v.expect(calls).toEqual([[true, 3, 0, true, 'g0']]); + calls.length = 0; + await press('ArrowRight', 'ArrowDown', 'ArrowRight', 'ArrowRight'); + // The last Right is at the row end: no call. + v.expect(calls).toEqual([ + [true, 3, 1, true, 'g1'], + [true, 3, 4, true, 'g4'], + [true, 3, 5, true, 'g5'], + ]); + }, + ); + + v.it( + 'selected: initial and reactive values move focus and scroll; onSelectedChanged fires twice on mount with selected', + async () => { + let grid!: lng.ElementNode; + const [sel, setSel] = s.createSignal(4); + const calls: unknown[][] = []; + dispose = await mount(() => ( + + calls.push([idx, elm?.id])} + > + {Cell} + + + )); + // Row 1, 50 high, no itemOffset. + v.expect([focusedId(), grid.y]).toEqual(['g4', -50]); + // Current behaviour: the selected effect and the Grid's onFocus each + // report the same cell. + v.expect(calls).toEqual([ + [4, 'g4'], + [4, 'g4'], + ]); + calls.length = 0; + + setSel(8); + await flush(); + v.expect([focusedId(), grid.y]).toEqual(['g8', -100]); + v.expect(calls).toEqual([[8, 'g8']]); + + await press('ArrowLeft'); + v.expect(focusedId()).toBe('g7'); + }, + ); +}); diff --git a/tests/contract-keys.test.tsx b/tests/contract-keys.test.tsx new file mode 100644 index 0000000..b892641 --- /dev/null +++ b/tests/contract-keys.test.tsx @@ -0,0 +1,851 @@ +// Contract tests: key handling. Pins the behaviour of solid 1.6.4 (renderer +// 1.9): `this` binding, return values, capture → bubble → +// onKeyPress order and arguments, key releases, custom key-map names, hold, +// and per-element throttleInput. Behavioural only: handler call order, +// arguments and return values, never underscore fields. +import * as v from 'vitest'; +import { createRoot } from 'solid-js'; +import { Config, type ElementNode } from '@solidtv/solid'; +import { + useFocusManager, + useHold, + type KeyEventLike, + type KeyEventTarget, + type KeyMap, +} from '@solidtv/solid/primitives'; +import { + KeyTarget, + flush, + mount, + recorder, + type TestKeyEvent, +} from './contract-focus-helpers.js'; +import { renderer } from './setup.js'; + +// useFocusManager rebinds Config.setActiveElement on every mount; afterAll +// puts back whatever the worker had before this file ran. +const savedSetActiveElement = Config.setActiveElement; + +// Keys this file adds to the shared key map. Unique names, so leaving them +// would be harmless, but they are removed again in afterAll. +const MENU_KEY = 'ContractMenuKey'; +const MENU_KEYCODE = 9077; +const LEGACY_MENU_KEY = 'ContractLegacyMenuKey'; +const UNMAPPED_KEY = 'ContractUnmappedKey'; +const menuKeyMap = { Menu: [MENU_KEY, MENU_KEYCODE] } as Partial; + +v.afterAll(() => { + // `{ : null }` deletes the key map entry for that *key* (see the + // null-value BUG test below), which is exactly what undoes menuKeyMap. + createRoot((dispose) => { + useFocusManager( + { + [MENU_KEY]: null, + [MENU_KEYCODE]: null, + [LEGACY_MENU_KEY]: null, + } as Partial, + new KeyTarget(), + ); + dispose(); + }); + Config.setActiveElement = savedSetActiveElement; +}); + +type Rec = ReturnType; +type Nodes = Record; + +// outer > mid > leaf (leaf autofocused); `handlers(id)` supplies each node's +// handler props. +async function mountChain( + handlers: (id: 'outer' | 'mid' | 'leaf') => Record, + keyMap?: Partial, +) { + const n: Nodes = {}; + const { target, dispose } = await mount( + () => ( + + + + + + ), + keyMap, + ); + return { n, target, dispose }; +} + +/** Every handler name on every node, each returning undefined. */ +const allEnterHandlers = (r: Rec) => () => ({ + onCaptureEnter: r.handler('onCaptureEnter'), + onEnter: r.handler('onEnter'), + onKeyPress: r.handler('onKeyPress'), + onCaptureEnterRelease: r.handler('onCaptureEnterRelease'), + onEnterRelease: r.handler('onEnterRelease'), +}); + +v.describe('contract: handlers are called with this bound to the node', () => { + v.it( + 'capture, bubble, onKeyPress, onCaptureKey and release handlers all get this = their node', + async () => { + const r = recorder(); + const h = (name: string, owner: string) => + r.handler(name, undefined, owner); + const { n, target, dispose } = await mountChain((id) => + id === 'outer' + ? { + onCaptureKey: h('onCaptureKey', id), + onCaptureKeyRelease: h('onCaptureKeyRelease', id), + onEnter: h('onEnter', id), + onKeyPress: h('onKeyPress', id), + onEnterRelease: h('onEnterRelease', id), + } + : { + onCaptureEnter: h('onCaptureEnter', id), + onEnter: h('onEnter', id), + onKeyPress: h('onKeyPress', id), + onCaptureEnterRelease: h('onCaptureEnterRelease', id), + onEnterRelease: h('onEnterRelease', id), + }, + ); + target.down('Enter'); + target.up('Enter'); + // 3 capture + 6 bubble on press, 3 capture + 3 bubble on release. + v.expect(r.calls.length).toBe(15); + v.expect(new Set(r.calls.map((c) => c.handler))).toEqual( + new Set([ + 'onCaptureKey', + 'onCaptureKeyRelease', + 'onCaptureEnter', + 'onEnter', + 'onKeyPress', + 'onCaptureEnterRelease', + 'onEnterRelease', + ]), + ); + for (const c of r.calls) v.expect(c.self).toBe(n[c.owner!]); + dispose(); + }, + ); +}); + +v.describe('contract: return values', () => { + v.it('true from on stops propagation at that node', async () => { + const r = recorder(); + const { target, dispose } = await mountChain((id) => ({ + onEnter: r.handler('onEnter', id === 'mid' ? true : undefined), + })); + target.down('Enter'); + v.expect(r.order()).toEqual(['leaf.onEnter', 'mid.onEnter']); + dispose(); + }); + + v.it( + 'false and undefined both let the key bubble to the parent', + async () => { + const r = recorder(); + const { target, dispose } = await mountChain((id) => ({ + onEnter: r.handler('onEnter', id === 'leaf' ? false : undefined), + })); + target.down('Enter'); + v.expect(r.order()).toEqual([ + 'leaf.onEnter', + 'mid.onEnter', + 'outer.onEnter', + ]); + dispose(); + }, + ); + + v.it( + 'only the boolean true stops: truthy values like 1 or "yes" bubble (current behaviour)', + async () => { + const r = recorder(); + const ret = { leaf: 1, mid: 'yes', outer: true }; + const { target, dispose } = await mountChain((id) => ({ + onEnter: r.handler('onEnter', ret[id]), + })); + target.down('Enter'); + v.expect(r.order()).toEqual([ + 'leaf.onEnter', + 'mid.onEnter', + 'outer.onEnter', + ]); + dispose(); + }, + ); + + v.it( + 'true from a capture handler ends the event: deeper capture handlers and the whole bubble phase are skipped', + async () => { + const r = recorder(); + const { target, dispose } = await mountChain((id) => ({ + ...allEnterHandlers(r)(), + onCaptureEnter: r.handler( + 'onCaptureEnter', + id === 'mid' ? true : undefined, + ), + })); + target.down('Enter'); + v.expect(r.order()).toEqual([ + 'outer.onCaptureEnter', + 'mid.onCaptureEnter', + ]); + dispose(); + }, + ); + + v.it('true from onKeyPress stops bubbling to the parent', async () => { + const r = recorder(); + const { target, dispose } = await mountChain((id) => ({ + onEnter: r.handler('onEnter'), + onKeyPress: r.handler('onKeyPress', id === 'leaf' ? true : undefined), + })); + target.down('Enter'); + v.expect(r.order()).toEqual(['leaf.onEnter', 'leaf.onKeyPress']); + dispose(); + }); + + v.it('true from onRelease stops the release bubbling', async () => { + const r = recorder(); + const { target, dispose } = await mountChain((id) => ({ + onEnterRelease: r.handler( + 'onEnterRelease', + id === 'leaf' ? true : undefined, + ), + })); + target.down('Enter'); + target.up('Enter'); + v.expect(r.order()).toEqual(['leaf.onEnterRelease']); + dispose(); + }); +}); + +v.describe('contract: capture → bubble → onKeyPress order', () => { + v.it( + "runs onCapture root→leaf, then per node leaf→root: on then that node's onKeyPress", + async () => { + const r = recorder(); + const { target, dispose } = await mountChain(allEnterHandlers(r)); + target.down('Enter'); + // onKeyPress is not a separate pass after bubbling: it is tried on each + // node right after that node's on, before the parent's on. + v.expect(r.order()).toEqual([ + 'outer.onCaptureEnter', + 'mid.onCaptureEnter', + 'leaf.onCaptureEnter', + 'leaf.onEnter', + 'leaf.onKeyPress', + 'mid.onEnter', + 'mid.onKeyPress', + 'outer.onEnter', + 'outer.onKeyPress', + ]); + dispose(); + }, + ); + + v.it( + 'arguments: capture (e, node, focusedLeaf, mappedName); on (e, node, focusedLeaf); onKeyPress (e, mappedName, node, focusedLeaf)', + async () => { + const r = recorder(); + const { n, target, dispose } = await mountChain(allEnterHandlers(r)); + const e = target.down('Enter'); + const call = (key: string) => + r.calls.find((c) => `${c.node}.${c.handler}` === key)!; + v.expect(call('outer.onCaptureEnter').args).toEqual([ + e, + n.outer, + n.leaf, + 'Enter', + ]); + v.expect(call('mid.onEnter').args).toEqual([e, n.mid, n.leaf]); + v.expect(call('mid.onKeyPress').args).toEqual([ + e, + 'Enter', + n.mid, + n.leaf, + ]); + v.expect(call('outer.onCaptureEnter').args[0]).toBe(e); + dispose(); + }, + ); + + v.it( + 'onCaptureKey runs only on nodes without onCapture, with the same arguments', + async () => { + const r = recorder(); + const { n, target, dispose } = await mountChain((id) => + id === 'mid' + ? { + onCaptureEnter: r.handler('onCaptureEnter'), + onCaptureKey: r.handler('onCaptureKey'), + } + : { onCaptureKey: r.handler('onCaptureKey') }, + ); + const e = target.down('Enter'); + v.expect(r.order()).toEqual([ + 'outer.onCaptureKey', + 'mid.onCaptureEnter', + 'leaf.onCaptureKey', + ]); + v.expect(r.calls[0]!.args).toEqual([e, n.outer, n.leaf, 'Enter']); + dispose(); + }, + ); + + v.it( + 'an unmapped key: capture looks up onCapture then onCaptureKey; bubble calls only onKeyPress, with mappedName undefined', + async () => { + const r = recorder(); + const { n, target, dispose } = await mountChain((id) => ({ + ...(id === 'outer' + ? { + [`onCapture${UNMAPPED_KEY}`]: r.handler( + `onCapture${UNMAPPED_KEY}`, + ), + } + : { onCaptureKey: r.handler('onCaptureKey') }), + [`on${UNMAPPED_KEY}`]: r.handler(`on${UNMAPPED_KEY}`), + onKeyPress: r.handler('onKeyPress'), + })); + const e = target.down(UNMAPPED_KEY); + v.expect(r.order()).toEqual([ + `outer.onCapture${UNMAPPED_KEY}`, + 'mid.onCaptureKey', + 'leaf.onCaptureKey', + 'leaf.onKeyPress', + 'mid.onKeyPress', + 'outer.onKeyPress', + ]); + v.expect(r.calls[0]!.args).toEqual([e, n.outer, n.leaf, undefined]); + v.expect(r.calls[3]!.args).toEqual([e, undefined, n.leaf, n.leaf]); + dispose(); + }, + ); + + v.it( + 'key release: onCaptureRelease / onCaptureKeyRelease root→leaf, then onRelease leaf→root; no onKeyPress, on or onCapture', + async () => { + const r = recorder(); + const { n, target, dispose } = await mountChain((id) => ({ + ...allEnterHandlers(r)(), + ...(id === 'mid' + ? { + onCaptureEnterRelease: undefined, + onCaptureKeyRelease: r.handler('onCaptureKeyRelease'), + } + : {}), + })); + target.down('Enter'); + r.clear(); + const e = target.up('Enter'); + v.expect(r.order()).toEqual([ + 'outer.onCaptureEnterRelease', + 'mid.onCaptureKeyRelease', + 'leaf.onCaptureEnterRelease', + 'leaf.onEnterRelease', + 'mid.onEnterRelease', + 'outer.onEnterRelease', + ]); + v.expect(r.calls[0]!.args).toEqual([e, n.outer, n.leaf, 'Enter']); + v.expect(r.calls[3]!.args).toEqual([e, n.leaf, n.leaf]); + dispose(); + }, + ); +}); + +v.describe('contract: key map', () => { + v.it( + 'custom names dispatch on, onCapture, onRelease and onCaptureRelease', + async () => { + const r = recorder(); + const { target, dispose } = await mountChain( + (id) => + id === 'leaf' + ? { + onCaptureMenu: r.handler('onCaptureMenu'), + onMenu: r.handler('onMenu'), + onCaptureMenuRelease: r.handler('onCaptureMenuRelease'), + onMenuRelease: r.handler('onMenuRelease'), + onKeyPress: r.handler('onKeyPress'), + } + : {}, + menuKeyMap, + ); + target.down(MENU_KEY); + target.up(MENU_KEY); + v.expect(r.order()).toEqual([ + 'leaf.onCaptureMenu', + 'leaf.onMenu', + 'leaf.onKeyPress', + 'leaf.onCaptureMenuRelease', + 'leaf.onMenuRelease', + ]); + v.expect(r.calls[2]!.args[1]).toBe('Menu'); + dispose(); + }, + ); + + v.it( + 'one name can map several keys, by key string or by numeric keyCode', + async () => { + const r = recorder(); + const { target, dispose } = await mountChain( + (id) => (id === 'leaf' ? { onMenu: r.handler('onMenu') } : {}), + menuKeyMap, + ); + target.down(MENU_KEY); + // A host that reports only a keyCode (webOS reports 'Unidentified'). + target.down('Unidentified', { keyCode: MENU_KEYCODE }); + target.down('', { keyCode: MENU_KEYCODE }); + v.expect(r.order()).toEqual([ + 'leaf.onMenu', + 'leaf.onMenu', + 'leaf.onMenu', + ]); + dispose(); + }, + ); + + v.it('a mapped e.key wins over a mapped e.keyCode', async () => { + const r = recorder(); + const { target, dispose } = await mountChain( + (id) => + id === 'leaf' + ? { onMenu: r.handler('onMenu'), onLeft: r.handler('onLeft') } + : {}, + menuKeyMap, + ); + target.down('ArrowLeft', { keyCode: MENU_KEYCODE }); + v.expect(r.order()).toEqual(['leaf.onLeft']); + dispose(); + }); + + v.it( + 'a custom map is merged over the defaults: default keys keep working', + async () => { + const r = recorder(); + const { target, dispose } = await mountChain( + (id) => + id === 'leaf' + ? { + onLeft: r.handler('onLeft'), + onRight: r.handler('onRight'), + onUp: r.handler('onUp'), + onDown: r.handler('onDown'), + onEnter: r.handler('onEnter'), + onMenu: r.handler('onMenu'), + } + : {}, + menuKeyMap, + ); + for (const key of [ + 'ArrowLeft', + 'ArrowRight', + 'ArrowUp', + 'ArrowDown', + 'Enter', + MENU_KEY, + ]) { + target.down(key); + } + v.expect(r.order()).toEqual([ + 'leaf.onLeft', + 'leaf.onRight', + 'leaf.onUp', + 'leaf.onDown', + 'leaf.onEnter', + 'leaf.onMenu', + ]); + dispose(); + }, + ); + + // BUG: KeyMap types allow `{ Left: null }` (src/core/focusKeyTypes.ts), but + // flattenKeyMap (src/core/focusManager.ts:42) runs `delete targetMap['Left']` + // on a table keyed by *key* ('ArrowLeft' → 'Left'), so the defaults for that + // name are never removed. Expected: null unmaps the name's keys. + v.it.skip('a null value removes the keys mapped to that name', async () => { + const r = recorder(); + try { + const { target, dispose } = await mountChain( + (id) => (id === 'leaf' ? { onLeft: r.handler('onLeft') } : {}), + { Left: null }, + ); + target.down('ArrowLeft'); + v.expect(r.order()).toEqual([]); + dispose(); + } finally { + createRoot((d) => { + useFocusManager({ Left: ['ArrowLeft'] }, new KeyTarget()); + d(); + }); + } + }); +}); + +v.describe('contract: hold', () => { + // The demo app calls useFocusManager(keyMap, { userKeyHoldMap, holdThreshold }) + // and uses onHold. That path was removed in 1.5.1 (78f5fde) in favour + // of the useHold primitive; the second argument is now an event target and + // an object that cannot listen is ignored. + const legacyOptions = { + userKeyHoldMap: { EnterHold: ['Enter', 13] }, + holdThreshold: 1000, + }; + + // Mounts with the legacy options. They make the focus manager listen on + // document, which other test files also listen on, so call the registered + // listeners directly instead of dispatching on document. + async function mountLegacy(handlers: Record) { + const add = v.vi.spyOn(document, 'addEventListener'); + const dispose = renderer.render(() => { + useFocusManager( + { Menu: [LEGACY_MENU_KEY] }, + legacyOptions as unknown as KeyEventTarget, + ); + return ; + }) as unknown as () => void; + const registered = add.mock.calls.filter( + ([type]) => type === 'keydown' || type === 'keyup', + ); + add.mockRestore(); + await flush(); + const listener = (type: 'keydown' | 'keyup') => + registered.find(([t]) => t === type)![1] as unknown as ( + e: KeyEventLike, + ) => void; + const fire = + (type: 'keydown' | 'keyup') => + (key: string, keyCode = 0, repeat = false) => + listener(type)({ key, keyCode, repeat }); + return { + registered, + down: fire('keydown'), + up: fire('keyup'), + dispose, + }; + } + + v.afterEach(() => { + v.vi.useRealTimers(); + }); + + v.it( + 'useFocusManager(keyMap, { userKeyHoldMap, holdThreshold }) is accepted: it listens on document and applies keyMap', + async () => { + const r = recorder(); + const { registered, down, dispose } = await mountLegacy({ + id: 'legacy', + onMenu: r.handler('onMenu'), + }); + v.expect(registered.map(([type]) => type).sort()).toEqual([ + 'keydown', + 'keyup', + ]); + // LEGACY_MENU_KEY is mapped only by this useFocusManager call. + down(LEGACY_MENU_KEY); + v.expect(r.order()).toEqual(['legacy.onMenu']); + dispose(); + }, + ); + + v.it( + 'onHold is not dispatched: a key held past holdThreshold only repeats on (current behaviour since 1.5.1)', + async () => { + const r = recorder(); + const { down, up, dispose } = await mountLegacy({ + id: 'legacy', + onEnter: r.handler('onEnter'), + onEnterHold: r.handler('onEnterHold'), + onKeyHold: r.handler('onKeyHold'), + onEnterRelease: r.handler('onEnterRelease'), + }); + v.vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + down('Enter', 13); + v.vi.advanceTimersByTime(600); + down('Enter', 13, true); + v.vi.advanceTimersByTime(600); + down('Enter', 13, true); + up('Enter', 13); + v.expect(r.order()).toEqual([ + 'legacy.onEnter', + 'legacy.onEnter', + 'legacy.onEnter', + 'legacy.onEnterRelease', + ]); + dispose(); + }, + ); + + // CONTRACT GAP: onHold and the { userKeyHoldMap, holdThreshold } + // options were part of the key contract until 1.5.1 removed them (1.6.4 + // has neither); useHold is the replacement. This is the pre-1.5.1 + // behaviour (78f5fde^:src/core/focusManager.ts, handleKeyEvents): a + // hold-mapped key-down is delayed; past holdThreshold onHold fires and + // on does not; a release before it fires on on key-up. Unskip, + // and delete the "not dispatched" test above, if hold maps come back. + v.it.skip( + 'onHold fires once a key is held past holdThreshold; a quicker release is an on tap', + async () => { + const r = recorder(); + const { down, up, dispose } = await mountLegacy({ + id: 'legacy', + onEnter: r.handler('onEnter'), + onEnterHold: r.handler('onEnterHold'), + }); + v.vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + down('Enter', 13); + v.vi.advanceTimersByTime(999); + v.expect(r.order()).toEqual([]); + v.vi.advanceTimersByTime(1); + v.expect(r.order()).toEqual(['legacy.onEnterHold']); + up('Enter', 13); + r.clear(); + + down('Enter', 13); + v.vi.advanceTimersByTime(100); + up('Enter', 13); + v.expect(r.order()).toEqual(['legacy.onEnter']); + dispose(); + }, + ); + + async function mountUseHold() { + const r = recorder(); + const onHold = v.vi.fn(); + const onTap = v.vi.fn(); + const onRelease = v.vi.fn(); + let holder!: ElementNode; + const { target, dispose } = await mount(() => { + const [startHold, releaseHold] = useHold({ + onHold, + onEnter: onTap, + onRelease, + holdThreshold: 1000, + }); + return ( + + ); + }); + return { r, onHold, onTap, onRelease, holder, target, dispose }; + } + + v.it( + 'useHold on on/onRelease: held past holdThreshold fires onHold once, release fires onRelease, no tap', + async () => { + const { r, onHold, onTap, onRelease, holder, target, dispose } = + await mountUseHold(); + v.vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const e = target.down('Enter'); + v.vi.advanceTimersByTime(500); + target.down('Enter', { repeat: true }); + v.expect(onHold).not.toHaveBeenCalled(); + v.vi.advanceTimersByTime(500); + v.expect(onHold).toHaveBeenCalledTimes(1); + v.expect(onHold).toHaveBeenCalledWith(e, holder, holder); + // The focus manager also drops the repeats that follow a hold + // (suppressKeyUntilRelease). That is pinned in keySuppression.test.tsx, + // not here: useHold.spec.ts vi.mock()s the focus manager, and with + // `isolate: false` the cached useHold module can keep that mock. + target.up('Enter'); + v.expect(onRelease).toHaveBeenCalledTimes(1); + v.expect(onTap).not.toHaveBeenCalled(); + v.expect(r.order()).toEqual([ + 'holder.onEnter', + 'holder.onEnter', + 'holder.onEnterRelease', + ]); + dispose(); + }, + ); + + v.it( + 'useHold: a release before holdThreshold is a tap and fires onEnter at once', + async () => { + const { onHold, onTap, holder, target, dispose } = await mountUseHold(); + v.vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + target.down('Enter'); + v.vi.advanceTimersByTime(100); + const up = target.up('Enter'); + v.expect(onTap).toHaveBeenCalledTimes(1); + v.expect(onTap).toHaveBeenCalledWith(up, holder, holder); + v.vi.advanceTimersByTime(2000); + v.expect(onHold).not.toHaveBeenCalled(); + v.expect(onTap).toHaveBeenCalledTimes(1); + dispose(); + }, + ); +}); + +v.describe('contract: per-element throttleInput', () => { + // throttleInput compares performance.now() readings; drive the clock. + let now: v.MockInstance<() => number>; + const at = (ms: number) => now.mockReturnValue(ms); + + v.beforeEach(() => { + now = v.vi.spyOn(performance, 'now'); + }); + v.afterEach(() => { + now.mockRestore(); + Config.preventDefaultOnHandledKeys = false; + }); + + // outer > row (throttleInput=500) > item (autofocused) + async function mountRow( + rowRight: unknown, + extra: { + outer?: Record; + item?: Record; + } = {}, + ) { + const r = recorder(); + const n: Nodes = {}; + const { target, dispose } = await mount(() => ( + + + + + + )); + return { r, n, target, dispose }; + } + + const count = (r: Rec, key: string) => + r.order().filter((k) => k === key).length; + + v.it( + 'a node drops the same key within throttleInput ms of a key it handled, and accepts it again from throttleInput ms on', + async () => { + const { r, target, dispose } = await mountRow(true); + for (const t of [1000, 1100, 1499, 1500, 1600, 2000]) { + at(t); + target.down('ArrowRight'); + } + // 1000 handled; 1100 and 1499 dropped; 1500 handled (exactly 500 later); + // 1600 dropped; 2000 handled. + v.expect(count(r, 'row.onRight')).toBe(3); + dispose(); + }, + ); + + v.it( + 'only repeats of the same key are throttled: a different key in between resets it', + async () => { + const { r, target, dispose } = await mountRow(true); + at(1000); + target.down('ArrowRight'); + at(1100); + target.down('ArrowLeft'); + at(1200); + target.down('ArrowRight'); + v.expect(r.order()).toEqual(['row.onRight', 'row.onLeft', 'row.onRight']); + dispose(); + }, + ); + + v.it( + 'the window starts only from a key the node itself handled (returned true)', + async () => { + const { r, target, dispose } = await mountRow(undefined); + at(1000); + target.down('ArrowRight'); + at(1100); + target.down('ArrowRight'); + v.expect(count(r, 'row.onRight')).toBe(2); + dispose(); + }, + ); + + v.it( + "a throttled ancestor drops the press before the focused leaf's handlers; capture handlers above it still run (current behaviour)", + async () => { + const outer = recorder(); + const item = recorder(); + const { r, target, dispose } = await mountRow(true, { + outer: { onCaptureRight: outer.handler('onCaptureRight') }, + item: { onRight: item.handler('onRight') }, + }); + at(1000); + target.down('ArrowRight'); + v.expect([...outer.order(), ...item.order(), ...r.order()]).toEqual([ + 'outer.onCaptureRight', + 'item.onRight', + 'row.onRight', + ]); + outer.clear(); + item.clear(); + r.clear(); + at(1100); + target.down('ArrowRight'); + v.expect(outer.order()).toEqual(['outer.onCaptureRight']); + v.expect(item.order()).toEqual([]); + v.expect(r.order()).toEqual([]); + dispose(); + }, + ); + + v.it( + 'a press dropped by throttleInput counts as consumed (preventDefault under Config.preventDefaultOnHandledKeys)', + async () => { + const { target, dispose } = await mountRow(true); + Config.preventDefaultOnHandledKeys = true; + at(1000); + const first: TestKeyEvent = target.down('ArrowRight'); + at(1100); + const dropped: TestKeyEvent = target.down('ArrowRight'); + v.expect(first.preventDefault).toHaveBeenCalledTimes(1); + v.expect(dropped.preventDefault).toHaveBeenCalledTimes(1); + dispose(); + }, + ); + + // BUG: per-element throttling also drops key-ups. isElementThrottled + // (src/core/focusManager.ts:285) is checked for releases too (lines 312 and + // 350), and a key-up has the same key as the press it follows, so a release + // within throttleInput ms of a handled press never reaches onRelease + // or onCaptureRelease. The global Config.throttleInput skips key-ups + // (line 390). Breaks useHold on a throttled node (the release is lost, so a + // tap waits for the hold timer). Expected: releases are never throttled. + v.it.skip( + 'a key release right after a handled press still reaches onRelease', + async () => { + const { r, target, dispose } = await mountRow(true); + at(1000); + target.down('ArrowRight'); + at(1050); + target.up('ArrowRight'); + v.expect(r.order()).toEqual(['row.onRight', 'row.onRightRelease']); + dispose(); + }, + ); +}); diff --git a/tests/contract-lazy.test.tsx b/tests/contract-lazy.test.tsx new file mode 100644 index 0000000..dfe8df2 --- /dev/null +++ b/tests/contract-lazy.test.tsx @@ -0,0 +1,428 @@ +// Contract tests: LazyRow and LazyColumn (upCount, sync, buffer, +// delay, eagerLoad; items mount progressively as you navigate). +// +// Pins today's behaviour (1.6.4) through the public surface: keys go through +// the focus manager's real keydown listener (so the handler runs inside the +// focus manager's owner, as in an app); assertions read the mounted child +// count, the focused element and final x/y. Animations are off. +// +// Note: `bufferSize` is not a Lazy prop (it is VirtualRow/VirtualColumn's); +// Lazy's is `buffer`. The demo app passes `bufferSize` to LazyRow/LazyColumn, +// where it has no effect (pinned below). +import * as v from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { + LazyRow, + LazyColumn, + useFocusManager, + type KeyEventTarget, +} from '@solidtv/solid/primitives'; +import { renderer } from './setup.js'; + +// Real KeyboardEvents on the target the focus manager listens to. A private +// target (instead of `document`) keeps listeners other files leave on +// `document` (vitest isolate: false) from handling a press twice. +const keys = new EventTarget(); +const flush = () => new Promise((r) => setTimeout(r, 0)); +const wait = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +async function press(...names: string[]) { + for (const key of names) { + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + await flush(); + } +} + +async function mount(ui: () => s.JSX.Element) { + const dispose = renderer.render(() => { + useFocusManager(undefined, keys as unknown as KeyEventTarget); + return ui(); + }) as unknown as () => void; + await flush(); + return dispose; +} + +const focusedId = () => lng.activeElement()?.id; + +const twenty = Array.from({ length: 20 }, (_, i) => i); + +let dispose: (() => void) | undefined; +const animationsEnabled = lng.Config.animationsEnabled; +const taskDelay = lng.Config.taskDelay; + +v.beforeAll(() => { + lng.Config.animationsEnabled = false; +}); +v.afterAll(() => { + lng.Config.animationsEnabled = animationsEnabled; +}); +v.afterEach(() => { + dispose?.(); + dispose = undefined; + lng.Config.taskDelay = taskDelay; + lng.clearTasks(); +}); + +/** Presses Right `n` times; returns [mounted count, focused id, x] after each. */ +async function walkRight(row: lng.ElementNode, n: number) { + const seen: unknown[] = []; + for (let i = 0; i < n; i++) { + await press('ArrowRight'); + seen.push([row.children.length, focusedId(), row.x]); + } + return seen; +} + +v.describe('LazyRow', () => { + v.it( + 'upCount with the default buffer (upCount + 1): mounts upCount + 1, then one more per Right; x scrolls once the row is wider than the screen', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect([row.children.length, focusedId()]).toEqual([4, 'l0']); + // Nothing more mounts on its own. + await wait(50); + v.expect(row.children.length).toBe(4); + + // 200 wide + gap 30. Current behaviour: the auto-scroll clamp uses the + // Row width from before this press's item is laid out, so the first + // scroll is -180 rather than -230. + v.expect(await walkRight(row, 8)).toEqual([ + [5, 'l1', 0], + [6, 'l2', 0], + [7, 'l3', 0], + [8, 'l4', 0], + [9, 'l5', 0], + [10, 'l6', -180], + [11, 'l7', -410], + [12, 'l8', -640], + ]); + }, + ); + + v.it( + 'without sync and with the default buffer the first mount is the same (upCount + 1)', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect(row.children.length).toBe(4); + await wait(50); + v.expect(row.children.length).toBe(4); + v.expect( + (await walkRight(row, 3)).map((s) => (s as unknown[])[0]), + ).toEqual([5, 6, 7]); + }, + ); + + v.it( + 'upCount without sync ramps up to upCount (one item per ~16ms) when buffer < upCount; sync mounts upCount at once', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect(row.children.length).toBe(2); + await v.vi.waitFor(() => v.expect(row.children.length).toBe(3)); + await wait(60); + v.expect(row.children.length).toBe(3); + dispose(); + + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect(row.children.length).toBe(3); + }, + ); + + v.it( + 'buffer={2}: mounts once the selection is within 2 of the rendered edge', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect(row.children.length).toBe(3); + v.expect(await walkRight(row, 5)).toEqual([ + [3, 'l1', 0], + [4, 'l2', 0], + [5, 'l3', 0], + [6, 'l4', 0], + [7, 'l5', 0], + ]); + }, + ); + + v.it( + 'scroll="center" defaults buffer to ceil(upCount / 2) + 1; scroll="edge" to 2', + async () => { + const counts = async (scroll: 'center' | 'edge') => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen = [row.children.length]; + for (let i = 0; i < 3; i++) { + await press('ArrowRight'); + seen.push(row.children.length); + } + dispose(); + dispose = undefined; + return seen; + }; + // center: buffer 3, so the first Right (selected 0 >= 3 - 3) mounts. + v.expect(await counts('center')).toEqual([3, 4, 5, 6]); + // edge: buffer 2, so the first Right does not mount. + v.expect(await counts('edge')).toEqual([3, 3, 4, 5]); + }, + ); + + v.it( + 'bufferSize is not a Lazy prop: same mounts as without it (the demo app passes it)', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect(row.children.length).toBe(4); + v.expect( + (await walkRight(row, 3)).map((s) => (s as unknown[])[0]), + ).toEqual([5, 6, 7]); + }, + ); + + v.it( + 'delay: the mount waits for the delay; pressing faster mounts immediately and re-arms the timer', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen: unknown[] = []; + for (let i = 0; i < 8; i++) { + await press('ArrowRight'); + seen.push([row.children.length, focusedId()]); + } + v.expect(seen).toEqual([ + [4, 'l1'], // first press: mount deferred by `delay` + [5, 'l2'], + [6, 'l3'], + [7, 'l4'], + [8, 'l5'], + [9, 'l6'], + [10, 'l7'], + [11, 'l8'], + ]); + // The trailing timer adds one more. + await v.vi.waitFor(() => v.expect(row.children.length).toBe(12), { + timeout: 2000, + }); + }, + ); + + v.it('stops mounting at the end of `each`', async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen = (await walkRight(row, 7)).map((s) => + (s as unknown[]).slice(0, 2), + ); + v.expect(seen).toEqual([ + [5, 'l1'], + [6, 'l2'], + [6, 'l3'], + [6, 'l4'], + [6, 'l5'], + [6, 'l5'], + [6, 'l5'], + ]); + }); + + // Today, with buffer={1}: the press that leaves the selection on the last + // rendered child does not mount (updateOffset checks the selection before + // the move); the next press mounts, but the key handler runs inside the + // focus manager's runWithOwner, which batches the write, so the new child + // exists only after the Row's handler has run. That press finds nothing to + // move to and bubbles. Every other press is lost: focus goes l1, l2, l2, + // l3, l3, l4, ... (Lazy.tsx:157-165). + v.it.skip( + 'BUG: with a small buffer the press that reaches the rendered edge is lost and bubbles (Lazy.tsx:157-165)', + async () => { + let row!: lng.ElementNode; + const parentRight = v.vi.fn(() => true); + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen: unknown[] = []; + for (let i = 0; i < 5; i++) { + await press('ArrowRight'); + seen.push(focusedId()); + } + v.expect(seen).toEqual(['l1', 'l2', 'l3', 'l4', 'l5']); + v.expect(parentRight).not.toHaveBeenCalled(); + }, + ); +}); + +v.describe('LazyColumn', () => { + v.it( + 'mounts upCount + 1, then one more per Down until the end; y scrolls', + async () => { + let col!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen: unknown[] = [[col.children.length, focusedId(), col.y]]; + for (let i = 0; i < 9; i++) { + await press('ArrowDown'); + seen.push([col.children.length, focusedId(), col.y]); + } + await press('ArrowUp'); + seen.push([col.children.length, focusedId(), col.y]); + // 200 high + gap 30. + v.expect(seen).toEqual([ + [4, 'l0', 20], + [5, 'l1', 20], + [6, 'l2', -100], + [7, 'l3', -330], + [8, 'l4', -560], + [8, 'l5', -790], + [8, 'l6', -790], + [8, 'l7', -790], + [8, 'l7', -790], + [8, 'l7', -790], + [8, 'l6', -560], + ]); + }, + ); + + v.it( + 'eagerLoad: mounts one past the initial count, then the rest one per task once the renderer reports idle', + async () => { + lng.Config.taskDelay = 1; + let col!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect(col.children.length).toBe(5); + // Background mounts go through scheduleTask, which runs only after the + // renderer's 'idle' event. The DOM renderer used by these tests never + // emits it, so raise it as the WebGL renderer would. + await wait(50); + v.expect(col.children.length).toBe(5); + ( + renderer.renderer as unknown as { + emit: (event: string, data: unknown) => void; + } + ).emit('idle', {}); + await v.vi.waitFor(() => v.expect(col.children.length).toBe(20), { + timeout: 2000, + }); + v.expect(focusedId()).toBe('l0'); + }, + ); +}); diff --git a/tests/contract-nodes.test.tsx b/tests/contract-nodes.test.tsx new file mode 100644 index 0000000..f4772a3 --- /dev/null +++ b/tests/contract-nodes.test.tsx @@ -0,0 +1,865 @@ +// Contract tests: nodes (props, el.lng, animate, events, child order). +// +// Behavioural, through the public API only: JSX props, ElementNode members +// apps use, and `el.lng` (the raw renderer node, which apps also use). These +// run on the DOM renderer (SOLIDTV_DOM_RENDERING), so `el.lng` is a DOMNode. +import * as v from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { + useFocusManager, + type KeyEventLike, + type KeyEventTarget, +} from '@solidtv/solid/primitives'; +import { renderer } from './setup.js'; + +/** Lets every queued microtask and the post-mutation pass run. */ +const settle = () => new Promise((r) => setTimeout(r, 0)); + +// Under jsdom the DOM renderer's animations finish a few hundred ms late +// (its rAF clock and performance.now() differ), so give them room. +const ANIMATION_WAIT = { timeout: 3000 }; + +/** The renderer node behind an element, as the DOM renderer makes it. */ +const dom = (el: lng.ElementNode) => el.lng as unknown as lng.DOMNode; + +// Child ids, with the empty text nodes Solid uses as placeholders shown as +// '#text' (they are children too: code walking children must skip them). +const ids = (el: lng.ElementNode) => + el.children.map((c) => (c instanceof lng.ElementNode ? c.id : '#text')); + +/** x of every element child, in children order. */ +const xs = (el: lng.ElementNode) => + el.children.filter((c) => c instanceof lng.ElementNode).map((c) => c.x); + +// A key source the focus manager can listen to, so a test can drive real key +// handlers without binding listeners on the shared document (vitest runs with +// isolate: false). +type Listener = (event: KeyEventLike) => void; +class FakeKeys implements KeyEventTarget { + listeners: Record<'keydown' | 'keyup', Listener[]> = { + keydown: [], + keyup: [], + }; + addEventListener(type: 'keydown' | 'keyup', listener: Listener) { + this.listeners[type].push(listener); + } + removeEventListener(type: 'keydown' | 'keyup', listener: Listener) { + const list = this.listeners[type]; + const idx = list.indexOf(listener); + if (idx !== -1) list.splice(idx, 1); + } + press(key: string, keyCode: number) { + const event: KeyEventLike = { key, keyCode, repeat: false }; + for (const l of this.listeners.keydown.slice()) l(event); + for (const l of this.listeners.keyup.slice()) l(event); + } +} + +/** Renders `view` with a focus manager on a FakeKeys source, then settles. */ +async function renderWithKeys(view: () => s.JSX.Element) { + const keys = new FakeKeys(); + const dispose = renderer.render(() => { + useFocusManager(undefined, keys); + return view(); + }); + await settle(); + return { keys, dispose }; +} + +v.describe( + 'Nodes: arbitrary data props are stored on the node and read back', + () => { + v.test( + 'elm.item.href reads back, follows a reactive update, and is readable from a parent handler', + async () => { + const [item, setItem] = s.createSignal({ href: '/a' }); + let row!: lng.ElementNode; + let tile!: lng.ElementNode; + let txt!: lng.ElementNode; + let readInHandler: unknown; + const { keys, dispose } = await renderWithKeys(() => ( + + + + label + + + )); + + v.expect((tile.item as { href: string }).href).toBe('/a'); + v.expect(tile.entityInfo).toEqual({ id: 7 }); + v.expect(tile.heroContent).toBe(true); + v.expect(tile.backdrop).toBe('bg.png'); + v.expect((txt.item as { href: string }).href).toBe('/text'); + + setItem({ href: '/b' }); + v.expect((tile.item as { href: string }).href).toBe('/b'); + + keys.press('Enter', 13); + v.expect(readInHandler).toBe('/b'); + v.expect(row.children[0]).toBe(tile); + + // Today data props stay on the ElementNode: they are not forwarded to + // the renderer node (a renderer node could take an unknown name as + // a field of its own). + v.expect((tile.lng as unknown as Record).item).toBe( + undefined, + ); + dispose(); + }, + ); + }, +); + +v.describe('Nodes: el.lng is the raw renderer node, with props applied', () => { + v.test( + 'a rendered view and text expose their renderer node through .lng', + () => { + let parent!: lng.ElementNode; + let el!: lng.ElementNode; + let txt!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + Hello + + + )); + + v.expect(el.lng).toBeInstanceOf(lng.DOMNode); + const node = dom(el); + v.expect(node.x).toBe(10); + v.expect(node.y).toBe(20); + v.expect(node.w).toBe(100); + v.expect(node.h).toBe(50); + v.expect(node.color).toBe(0xff0000ff); + v.expect(node.alpha).toBe(0.5); + // The renderer tree mirrors the element tree. + v.expect(node.parent).toBe(parent.lng); + + v.expect(txt.lng).toBeInstanceOf(lng.DOMNode); + const textNode = txt.lng as unknown as { text: string; fontSize: number }; + v.expect(textNode.text).toBe('Hello'); + v.expect(textNode.fontSize).toBe(30); + dispose(); + }, + ); + + v.test( + 'a write straight to el.lng reads back through the element (KeepAlive.tsx pattern)', + () => { + let el!: lng.ElementNode; + const dispose = renderer.render(() => ); + + dom(el).x = 42; + dom(el).y = 43; + v.expect(el.x).toBe(42); + v.expect(el.y).toBe(43); + dispose(); + }, + ); +}); + +v.describe('Nodes: .animate(...).start().waitUntilStopped()', () => { + v.test( + 'resolves once the animation stops, and the final values land on the renderer node', + async () => { + let el!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + + const done = el + .animate({ x: 100, alpha: 0.5 }, { duration: 30 }) + .start() + .waitUntilStopped(); + v.expect(done).toBeInstanceOf(Promise); + await done; + + v.expect(dom(el).x).toBe(100); + v.expect(dom(el).alpha).toBe(0.5); + v.expect(el.x).toBe(100); + v.expect(el.alpha).toBe(0.5); + dispose(); + }, + ); +}); + +v.describe('Nodes: direct writes from handlers reach the renderer node', () => { + v.test('this.x, this.y, this.alpha and this.color', async () => { + let el!: lng.ElementNode; + let self: unknown; + const { keys, dispose } = await renderWithKeys(() => ( + + )); + + keys.press('Enter', 13); + v.expect(self).toBe(el); + v.expect(dom(el).x).toBe(50); + v.expect(dom(el).y).toBe(60); + v.expect(dom(el).alpha).toBe(0.25); + v.expect(dom(el).color).toBe(0x00ff00ff); + dispose(); + }); + + v.test( + 'this.src lands on the renderer node; a view with no color turns white so the image shows', + async () => { + let plain!: lng.ElementNode; + let tinted!: lng.ElementNode; + const { keys, dispose } = await renderWithKeys(() => ( + + + + + )); + + // A view with neither color nor src renders transparent. + v.expect(dom(plain).color).toBe(0x00000000); + + keys.press('Enter', 13); + v.expect(dom(plain).src).toBe('poster.png'); + v.expect(plain.src).toBe('poster.png'); + v.expect(dom(plain).color).toBe(0xffffffff); + v.expect(dom(tinted).src).toBe('tinted.png'); + v.expect(dom(tinted).color).toBe(0x336699ff); + dispose(); + }, + ); + + v.test( + 'this.selected is stored on the node and picks selectedNode (NavDrawer.tsx pattern)', + async () => { + let el!: lng.ElementNode; + const { keys, dispose } = await renderWithKeys(() => ( + + + + + )); + + v.expect(el.selected).toBe(undefined); + keys.press('Enter', 13); + v.expect(el.selected).toBe(1); + v.expect(el.selectedNode).toBe(el.children[1]); + dispose(); + }, + ); + + v.test( + 'this.display = "flex" turns flex on for the next layout pass (Browse.tsx pattern)', + async () => { + let el!: lng.ElementNode; + const { keys, dispose } = await renderWithKeys(() => ( + + + + + )); + + keys.press('Enter', 13); + v.expect(el.display).toBe('flex'); + v.expect(el.requiresLayout()).toBe(true); + + // Today the write alone schedules no layout pass: the children keep + // their positions until something lays the container out. + await settle(); + v.expect(el.children.map((c) => c.x)).toEqual([0, 0]); + + el.updateLayout(); + v.expect(el.children.map((c) => c.x)).toEqual([0, 10]); + v.expect(el.children.map((c) => dom(c as lng.ElementNode).x)).toEqual([ + 0, 10, + ]); + dispose(); + }, + ); +}); + +v.describe('Nodes: getChildById', () => { + v.test('finds a direct child by id, and only a direct child', () => { + let parent!: lng.ElementNode; + let b!: lng.ElementNode; + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + + + x + + + )); + + v.expect(parent.getChildById('b')).toBe(b); + v.expect(parent.getChildById('t')).toBe(t); + // Grandchildren are not searched (searchChildrenById does that). + v.expect(parent.getChildById('deep')).toBe(undefined); + v.expect(parent.getChildById('missing')).toBe(undefined); + dispose(); + }); +}); + +v.describe('Nodes: .rendered', () => { + v.test( + 'false until the renderer node exists; onCreate runs outside-in, before children render', + async () => { + const [show, setShow] = s.createSignal(true); + let parent!: lng.ElementNode; + let child!: lng.ElementNode; + const log: string[] = []; + const dispose = renderer.render(() => ( + + { + parent = e; + log.push(`ref parent rendered=${e.rendered}`); + }} + onCreate={() => { + log.push( + `create parent rendered=${parent.rendered} child=${child.rendered}`, + ); + }} + > + { + child = e; + log.push(`ref child rendered=${e.rendered}`); + }} + onCreate={() => + log.push(`create child rendered=${child.rendered}`) + } + /> + + + )); + + // Refs run before render (their order is Solid's); onCreate runs once the + // renderer node exists, parent first, before its children render. + v.expect(log.slice(0, 2).sort()).toEqual([ + 'ref child rendered=false', + 'ref parent rendered=false', + ]); + v.expect(log.slice(2)).toEqual([ + 'create parent rendered=true child=false', + 'create child rendered=true', + ]); + v.expect(new lng.ElementNode('view').rendered).toBe(false); + + // Today `rendered` is not reset when the node is removed and destroyed. + setShow(false); + await settle(); + v.expect(parent.rendered).toBe(true); + dispose(); + }, + ); +}); + +v.describe('Nodes: destroy()', () => { + v.test( + 'removal calls onDestroy in the post-mutation pass, then destroys the renderer node', + async () => { + const [show, setShow] = s.createSignal(true); + let parent!: lng.ElementNode; + let el!: lng.ElementNode; + const onDestroy = v.vi.fn(); + const dispose = renderer.render(() => ( + + + + + + )); + const destroySpy = v.vi.spyOn(dom(el), 'destroy'); + + setShow(false); + // Out of the element tree at once; destroyed in the post-mutation pass. + v.expect(parent.children).not.toContain(el); + v.expect(onDestroy).not.toHaveBeenCalled(); + v.expect(destroySpy).not.toHaveBeenCalled(); + + await settle(); + v.expect(onDestroy).toHaveBeenCalledTimes(1); + v.expect(onDestroy.mock.contexts[0]).toBe(el); + v.expect(onDestroy.mock.calls[0]).toEqual([el]); + v.expect(destroySpy).toHaveBeenCalledTimes(1); + dispose(); + }, + ); + + v.test( + 'onDestroy fires on the removed top-level node only, not on its children (docs/flow/ondestroy.md)', + async () => { + const [show, setShow] = s.createSignal(true); + const destroyed: string[] = []; + const onDestroy = function (this: lng.ElementNode) { + destroyed.push(this.id!); + }; + const dispose = renderer.render(() => ( + + + + + + + + )); + + setShow(false); + await settle(); + v.expect(destroyed).toEqual(['top']); + dispose(); + }, + ); + + v.test( + 'calling el.destroy() runs onDestroy and destroys the renderer node, leaving the element tree alone', + () => { + let parent!: lng.ElementNode; + let el!: lng.ElementNode; + let bare!: lng.ElementNode; + const onDestroy = v.vi.fn(); + const dispose = renderer.render(() => ( + + + + + )); + const destroySpy = v.vi.spyOn(dom(el), 'destroy'); + const bareSpy = v.vi.spyOn(dom(bare), 'destroy'); + + el.destroy(); + v.expect(onDestroy).toHaveBeenCalledTimes(1); + v.expect(onDestroy.mock.contexts[0]).toBe(el); + // onDestroy returned no promise, so the renderer node goes at once. + v.expect(destroySpy).toHaveBeenCalledTimes(1); + + bare.destroy(); + v.expect(bareSpy).toHaveBeenCalledTimes(1); + + // Today destroy() tears down the renderer node only: the ElementNode + // stays in its parent's children until Solid removes it. + v.expect(parent.children).toContain(el); + + // A node that never rendered has no renderer node to destroy. + v.expect(() => new lng.ElementNode('view').destroy()).not.toThrow(); + dispose(); + }, + ); +}); + +v.describe('Nodes: onCreate and onDestroy returning a promise', () => { + v.test('render does not wait for a promise from onCreate', () => { + let el!: lng.ElementNode; + let child!: lng.ElementNode; + const onCreate = v.vi.fn(() => new Promise(() => {})); + const dispose = renderer.render(() => ( + + + + )); + + v.expect(onCreate).toHaveBeenCalledTimes(1); + v.expect(onCreate.mock.contexts[0]).toBe(el); + v.expect(onCreate.mock.calls[0]).toEqual([el]); + v.expect(el.rendered).toBe(true); + v.expect(child.rendered).toBe(true); + dispose(); + }); + + v.test( + 'onCreate can write props and animate in (Destroy.tsx animateIn)', + async () => { + let el!: lng.ElementNode; + let animatedIn!: Promise; + const animateIn = (node: lng.ElementNode) => { + node.alpha = 0; + node.y = -100; + return node + .animate({ y: 0, alpha: 1 }, { duration: 30 }) + .start() + .waitUntilStopped(); + }; + const dispose = renderer.render(() => ( + { + animatedIn = animateIn(node); + }} + /> + )); + + v.expect(dom(el).y).toBe(-100); + v.expect(dom(el).alpha).toBe(0); + await animatedIn; + v.expect(dom(el).y).toBe(0); + v.expect(dom(el).alpha).toBe(1); + dispose(); + }, + ); + + v.test( + 'destroy waits for the promise onDestroy returns before destroying the renderer node', + async () => { + const [show, setShow] = s.createSignal(true); + let el!: lng.ElementNode; + let finishExit!: () => void; + const onDestroy = v.vi.fn( + () => new Promise((resolve) => (finishExit = resolve)), + ); + const dispose = renderer.render(() => ( + + + + )); + const destroySpy = v.vi.spyOn(dom(el), 'destroy'); + + setShow(false); + await settle(); + v.expect(onDestroy).toHaveBeenCalledTimes(1); + v.expect(destroySpy).not.toHaveBeenCalled(); + + finishExit(); + await settle(); + v.expect(destroySpy).toHaveBeenCalledTimes(1); + dispose(); + }, + ); + + v.test( + 'an exit animation finishes before the renderer node is destroyed (Destroy.tsx animateOut)', + async () => { + const [show, setShow] = s.createSignal(true); + let el!: lng.ElementNode; + const animateOut = (node: lng.ElementNode) => + node + .animate({ y: 200, alpha: 0 }, { duration: 30 }) + .start() + .waitUntilStopped(); + const dispose = renderer.render(() => ( + + + + )); + const node = dom(el); + let atDestroy: { y: number; alpha: number } | undefined; + const destroy = node.destroy.bind(node); + v.vi.spyOn(node, 'destroy').mockImplementation(() => { + atDestroy = { y: node.y, alpha: node.alpha }; + destroy(); + }); + + setShow(false); + await v.vi.waitFor( + () => v.expect(atDestroy).toBeDefined(), + ANIMATION_WAIT, + ); + v.expect(atDestroy).toEqual({ y: 200, alpha: 0 }); + dispose(); + }, + ); +}); + +v.describe('Nodes: onEvent={{ loaded }}', () => { + v.test( + 'a text fires loaded with (node, payload) once the DOM renderer measures it', + async () => { + let el!: lng.ElementNode; + const loaded = v.vi.fn(); + const dispose = renderer.render(() => ( + + Hello + + )); + + await v.vi.waitFor(() => v.expect(loaded).toHaveBeenCalled()); + v.expect(loaded.mock.contexts[0]).toBe(el); + const [target, payload] = loaded.mock.calls[0]!; + v.expect(target).toBe(el); + v.expect(payload).toMatchObject({ + type: 'text', + dimensions: { w: v.expect.any(Number), h: v.expect.any(Number) }, + }); + dispose(); + }, + ); + + v.test( + 'an image view fires loaded and failed from the DOM renderer image', + () => { + let el!: lng.ElementNode; + const loaded = v.vi.fn(); + const failed = v.vi.fn(); + const dispose = renderer.render(() => ( + + )); + + const img = dom(el).imgEl!; + img.dispatchEvent(new Event('load')); + v.expect(loaded).toHaveBeenCalledTimes(1); + v.expect(loaded.mock.contexts[0]).toBe(el); + v.expect(loaded.mock.calls[0]![0]).toBe(el); + v.expect(loaded.mock.calls[0]![1]).toMatchObject({ type: 'texture' }); + + img.dispatchEvent(new Event('error')); + v.expect(failed).toHaveBeenCalledTimes(1); + v.expect(failed.mock.calls[0]![0]).toBe(el); + v.expect(failed.mock.calls[0]![1]).toMatchObject({ type: 'texture' }); + dispose(); + }, + ); + + v.test('every onEvent entry listens on the renderer node', () => { + let el!: lng.ElementNode; + const inViewport = v.vi.fn(); + const dispose = renderer.render(() => ( + + )); + + const payload = { previous: 0, current: 8 }; + dom(el).emit('inViewport', payload); + v.expect(inViewport).toHaveBeenCalledTimes(1); + v.expect(inViewport.mock.contexts[0]).toBe(el); + v.expect(inViewport.mock.calls[0]).toEqual([el, payload]); + dispose(); + }); +}); + +v.describe('Nodes: onAnimation={{ animating, stopped }}', () => { + v.test( + 'a transition calls animating at once and stopped after its duration', + async () => { + const [x, setX] = s.createSignal(0); + let el!: lng.ElementNode; + const animating = v.vi.fn(); + const stopped = v.vi.fn(); + const dispose = renderer.render(() => ( + + )); + + // The first value is set before render: no transition, no callbacks. + v.expect(animating).not.toHaveBeenCalled(); + + setX(100); + v.expect(animating).toHaveBeenCalledTimes(1); + v.expect(animating.mock.contexts[0]).toBe(el); + v.expect(animating.mock.calls[0]).toEqual(['x', 100]); + v.expect(stopped).not.toHaveBeenCalled(); + + // `stopped` is a timer for duration + delay, not the animation's own end. + await v.vi.waitFor( + () => v.expect(stopped).toHaveBeenCalledTimes(1), + ANIMATION_WAIT, + ); + v.expect(stopped.mock.contexts[0]).toBe(el); + v.expect(stopped.mock.calls[0]).toEqual(['x', 100]); + + await v.vi.waitFor(() => v.expect(dom(el).x).toBe(100), ANIMATION_WAIT); + dispose(); + }, + ); + + v.test( + 'el.animate() does not call onAnimation (demo app Transitions.tsx)', + async () => { + let el!: lng.ElementNode; + const animating = v.vi.fn(); + const stopped = v.vi.fn(); + const dispose = renderer.render(() => ( + + )); + + await el.animate({ x: 50 }, { duration: 10 }).start().waitUntilStopped(); + await settle(); + v.expect(dom(el).x).toBe(50); + v.expect(animating).not.toHaveBeenCalled(); + v.expect(stopped).not.toHaveBeenCalled(); + dispose(); + }, + ); +}); + +v.describe('Nodes: insertChild ordering with a before anchor', () => { + v.test(' in the middle inserts before its next sibling', async () => { + const [show, setShow] = s.createSignal(false); + let parent!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + + + + )); + await settle(); + // A falsy between siblings leaves an empty text placeholder. + v.expect(ids(parent)).toEqual(['first', '#text', 'last']); + v.expect(xs(parent)).toEqual([0, 10]); + + setShow(true); + await settle(); + v.expect(ids(parent)).toEqual(['first', 'middle', 'last']); + v.expect(xs(parent)).toEqual([0, 10, 20]); + + setShow(false); + await settle(); + v.expect(ids(parent)).toEqual(['first', '#text', 'last']); + v.expect(xs(parent)).toEqual([0, 10]); + dispose(); + }); + + v.test( + ' between siblings: insert in the middle, reorder, remove', + async () => { + const [items, setItems] = s.createSignal(['a', 'c']); + let parent!: lng.ElementNode; + const destroyed: string[] = []; + const onDestroy = function (this: lng.ElementNode) { + destroyed.push(this.id!); + }; + const dispose = renderer.render(() => ( + + + + {(id) => ( + + )} + + + + )); + await settle(); + v.expect(ids(parent)).toEqual(['head', 'a', 'c', 'tail']); + const a = parent.getChildById('a')!; + const c = parent.getChildById('c')!; + + setItems(['a', 'b', 'c']); + await settle(); + v.expect(ids(parent)).toEqual(['head', 'a', 'b', 'c', 'tail']); + v.expect(xs(parent)).toEqual([0, 10, 20, 30, 40]); + + setItems(['c', 'b', 'a']); + await settle(); + v.expect(ids(parent)).toEqual(['head', 'c', 'b', 'a', 'tail']); + v.expect(xs(parent)).toEqual([0, 10, 20, 30, 40]); + // Moved, not re-created: same elements, nothing destroyed. + v.expect(parent.getChildById('a')).toBe(a); + v.expect(parent.getChildById('c')).toBe(c); + v.expect(destroyed).toEqual([]); + + setItems(['b']); + await settle(); + v.expect(ids(parent)).toEqual(['head', 'b', 'tail']); + v.expect(xs(parent)).toEqual([0, 10, 20]); + v.expect(destroyed.sort()).toEqual(['a', 'c']); + dispose(); + }, + ); + + v.test('insertChild(node, before) called directly', () => { + const parent = new lng.ElementNode('view'); + const [a, b, c, d] = ['a', 'b', 'c', 'd'].map((id) => { + const n = new lng.ElementNode('view'); + n.id = id; + return n; + }) as [lng.ElementNode, lng.ElementNode, lng.ElementNode, lng.ElementNode]; + + parent.insertChild(a); + parent.insertChild(c); + parent.insertChild(b, c); + v.expect(ids(parent)).toEqual(['a', 'b', 'c']); + v.expect(b.parent).toBe(parent); + + // Moving a child before another one (DOM insertBefore semantics). + parent.insertChild(a, c); + v.expect(ids(parent)).toEqual(['b', 'a', 'c']); + + // An anchor that is not a child appends. + parent.insertChild(d, new lng.ElementNode('view')); + v.expect(ids(parent)).toEqual(['b', 'a', 'c', 'd']); + }); +}); diff --git a/tests/contract-row-column.test.tsx b/tests/contract-row-column.test.tsx new file mode 100644 index 0000000..247d41f --- /dev/null +++ b/tests/contract-row-column.test.tsx @@ -0,0 +1,837 @@ +// Contract tests: Row and Column (navigation, wrap, plinko, selected, +// skipFocus, scroll modes, centerScroll, onSelectedChanged, throttleInput). +// +// Pins today's behaviour (1.6.4) through the public surface only: keys go +// through the focus manager's real keydown listener, and assertions read the +// focused element, `selected`, callback arguments and final x/y. Numbers are +// what HEAD produces with fixed-size children; where today's behaviour looks +// odd the test says so in a comment. Animations are off so x/y are final. +import * as v from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { + Row, + Column, + useFocusManager, + type KeyEventTarget, +} from '@solidtv/solid/primitives'; +import { renderer } from './setup.js'; + +// The focus manager listens for `keydown` on the target given to +// useFocusManager (`document` by default). A private target gets the same +// listener and real KeyboardEvents, and keeps listeners that other test files +// leave on `document` (vitest runs with isolate: false) from handling a press +// twice. +const keys = new EventTarget(); +const flush = () => new Promise((r) => setTimeout(r, 0)); + +async function press(...names: string[]) { + for (const key of names) { + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + // Focus is applied in a microtask; wait for it to settle. + await flush(); + } +} + +async function mount(ui: () => s.JSX.Element) { + const dispose = renderer.render(() => { + useFocusManager(undefined, keys as unknown as KeyEventTarget); + return ui(); + }) as unknown as () => void; + await flush(); + return dispose; +} + +const focusedId = () => lng.activeElement()?.id; + +const items = ( + n: number, + props: (i: number) => Record = () => ({}), +) => + Array.from({ length: n }, (_, i) => ( + + )); + +let dispose: (() => void) | undefined; +const animationsEnabled = lng.Config.animationsEnabled; + +v.beforeAll(() => { + lng.Config.animationsEnabled = false; +}); +v.afterAll(() => { + lng.Config.animationsEnabled = animationsEnabled; +}); +v.afterEach(() => { + dispose?.(); + dispose = undefined; + v.vi.restoreAllMocks(); +}); + +v.describe('Row and Column: navigation', () => { + v.it( + 'Row: Right/Left move focus and selected; at either end the key bubbles to the parent', + async () => { + let row!: lng.ElementNode; + const parentRight = v.vi.fn(() => true); + const parentLeft = v.vi.fn(() => true); + dispose = await mount(() => ( + + + {items(3)} + + + )); + v.expect([row.selected, focusedId()]).toEqual([0, 'c0']); + + await press('ArrowRight'); + v.expect([row.selected, focusedId()]).toEqual([1, 'c1']); + await press('ArrowRight'); + v.expect([row.selected, focusedId()]).toEqual([2, 'c2']); + v.expect(parentRight).not.toHaveBeenCalled(); + + // At the last child the Row does not handle Right: it bubbles. + await press('ArrowRight'); + v.expect([row.selected, focusedId()]).toEqual([2, 'c2']); + v.expect(parentRight).toHaveBeenCalledTimes(1); + + await press('ArrowLeft', 'ArrowLeft'); + v.expect([row.selected, focusedId()]).toEqual([0, 'c0']); + v.expect(parentLeft).not.toHaveBeenCalled(); + await press('ArrowLeft'); + v.expect([row.selected, focusedId()]).toEqual([0, 'c0']); + v.expect(parentLeft).toHaveBeenCalledTimes(1); + }, + ); + + v.it( + 'Column: Down/Up move focus and selected; Left/Right are not handled and bubble', + async () => { + let col!: lng.ElementNode; + const parentRight = v.vi.fn(() => true); + const parentDown = v.vi.fn(() => true); + dispose = await mount(() => ( + + + {items(3)} + + + )); + v.expect([col.selected, focusedId()]).toEqual([0, 'c0']); + await press('ArrowDown', 'ArrowDown'); + v.expect([col.selected, focusedId()]).toEqual([2, 'c2']); + await press('ArrowUp'); + v.expect([col.selected, focusedId()]).toEqual([1, 'c1']); + + await press('ArrowRight'); + v.expect(parentRight).toHaveBeenCalledTimes(1); + v.expect([col.selected, focusedId()]).toEqual([1, 'c1']); + + await press('ArrowDown', 'ArrowDown'); + v.expect(parentDown).toHaveBeenCalledTimes(1); + v.expect([col.selected, focusedId()]).toEqual([2, 'c2']); + }, + ); + + v.it( + 'skipFocus: children with skipFocus are skipped, including for the initial focus', + async () => { + let row!: lng.ElementNode; + const parentRight = v.vi.fn(() => true); + dispose = await mount(() => ( + + + {items(5, (i) => ({ skipFocus: i % 2 === 0 }))} + + + )); + // c0 is skipFocus, so the first focus lands on c1. + v.expect([row.selected, focusedId()]).toEqual([1, 'c1']); + await press('ArrowRight'); + v.expect([row.selected, focusedId()]).toEqual([3, 'c3']); + // Only skipFocus children remain to the right: the key bubbles. + await press('ArrowRight'); + v.expect([row.selected, focusedId()]).toEqual([3, 'c3']); + v.expect(parentRight).toHaveBeenCalledTimes(1); + await press('ArrowLeft'); + v.expect([row.selected, focusedId()]).toEqual([1, 'c1']); + }, + ); + + v.it( + 'wrap: Row wraps at both ends and still skips skipFocus children', + async () => { + let row!: lng.ElementNode; + const parentRight = v.vi.fn(() => true); + const parentLeft = v.vi.fn(() => true); + dispose = await mount(() => ( + + + {items(4, (i) => ({ skipFocus: i === 0 }))} + + + )); + const seen: unknown[] = [focusedId()]; + for (const key of [ + 'ArrowRight', + 'ArrowRight', + 'ArrowRight', // c3 -> wraps past skipFocus c0 to c1 + 'ArrowLeft', // c1 -> wraps past skipFocus c0 to c3 + 'ArrowLeft', + ]) { + await press(key); + seen.push(focusedId()); + } + v.expect(seen).toEqual(['c1', 'c2', 'c3', 'c1', 'c3', 'c2']); + v.expect(row.selected).toBe(2); + // With wrap the Row always handles the key: nothing bubbles. + v.expect(parentRight).not.toHaveBeenCalled(); + v.expect(parentLeft).not.toHaveBeenCalled(); + }, + ); + + v.it('wrap: Column wraps at both ends', async () => { + let col!: lng.ElementNode; + dispose = await mount(() => ( + + + {items(3)} + + + )); + await press('ArrowUp'); + v.expect([col.selected, focusedId()]).toEqual([2, 'c2']); + await press('ArrowDown'); + v.expect([col.selected, focusedId()]).toEqual([0, 'c0']); + }); + + // findFirstFocusableChildIdx (src/primitives/utils/handleNavigation.ts:37-53) + // is a `for (let i = from; ; i += delta)` loop whose only exits are + // `return i` for a child without skipFocus (line 48) and `break` when the + // index leaves the array without wrap (line 46). With wrap, line 45 maps the + // index back into range, so if every child has skipFocus neither exit is + // reached. Reached from navigableForwardFocus (line 114, on focus) and + // moveSelection (line 194, on a key press). Not run: it would hang the + // worker. + v.it.todo( + 'BUG: wrap with every child skipFocus loops forever in findFirstFocusableChildIdx (handleNavigation.ts:42-51); expected: focus is not taken and the key bubbles', + ); +}); + +v.describe('Row and Column: plinko', () => { + const rows = () => ( + <> + + {[0, 1, 2, 3].map((i) => ( + + ))} + + + {[0, 1].map((i) => ( + + ))} + + + {[0, 1, 2, 3].map((i) => ( + + ))} + + + ); + const walk = [ + 'ArrowRight', + 'ArrowRight', + 'ArrowDown', + 'ArrowDown', + 'ArrowRight', + 'ArrowUp', + 'ArrowUp', + ]; + + v.it( + 'plinko: moving down/up a Column of Rows keeps the horizontal index, clamped to shorter rows', + async () => { + let col!: lng.ElementNode; + dispose = await mount(() => ( + + + {rows()} + + + )); + const seen: unknown[] = [focusedId()]; + for (const key of walk) { + await press(key); + seen.push(focusedId()); + } + // a2 -> Down -> r1 has 2 items, so index 2 clamps to b1; then d1 (not d2). + v.expect(seen).toEqual(['a0', 'a1', 'a2', 'b1', 'd1', 'd2', 'b1', 'a1']); + v.expect(col.children.map((r) => r.selected)).toEqual([1, 1, 2]); + }, + ); + + v.it('without plinko each Row keeps its own selected', async () => { + let col!: lng.ElementNode; + dispose = await mount(() => ( + + + {rows()} + + + )); + const seen: unknown[] = [focusedId()]; + for (const key of walk) { + await press(key); + seen.push(focusedId()); + } + v.expect(seen).toEqual(['a0', 'a1', 'a2', 'b0', 'd0', 'd1', 'b0', 'a2']); + v.expect(col.children.map((r) => r.selected)).toEqual([2, 0, 1]); + }); +}); + +v.describe('Row and Column: selected', () => { + v.it( + 'initial selected focuses that child and scrolls it to the start on layout', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {items(10)} + + + )); + v.expect([row.selected, focusedId()]).toEqual([3, 'c3']); + // c3 is at x=1290; the first scroll aligns it with the Row's start. + v.expect(row.x).toBe(-1290); + }, + ); + + v.it( + 'reactive selected: updates .selected but does not move focus or scroll until the next key', + async () => { + let row!: lng.ElementNode; + const [sel, setSel] = s.createSignal(3); + const changed = v.vi.fn(); + dispose = await mount(() => ( + + + {items(10)} + + + )); + changed.mockClear(); + + setSel(6); + await flush(); + v.expect([row.selected, focusedId(), row.x]).toEqual([6, 'c3', -1290]); + v.expect(changed).not.toHaveBeenCalled(); + + // The next key moves from the new selected (6), not from the focused c3. + await press('ArrowRight'); + v.expect([row.selected, focusedId(), row.x]).toEqual([7, 'c7', -1720]); + + // Current behaviour (odd): after selected goes back to 0, Left focuses c0 + // in place (moveSelection falls back to `selected` when there is nothing + // to the left) and scrollRow sees idx === lastIdx and does not scroll, so + // c0 stays off screen at x = -1720. + setSel(0); + await flush(); + v.expect([row.selected, focusedId()]).toEqual([0, 'c7']); + await press('ArrowLeft'); + v.expect([row.selected, focusedId(), row.x]).toEqual([0, 'c0', -1720]); + }, + ); + + v.it( + 'Row with a truthy selected prop re-runs the scroll on every layout (aligns selected to the start, unclamped)', + async () => { + let row!: lng.ElementNode; + const [count, setCount] = s.createSignal(10); + dispose = await mount(() => ( + + + i)}> + {(i) => } + + + + )); + v.expect(row.x).toBe(-430); + await press(...Array.from({ length: 7 }, () => 'ArrowRight')); + v.expect([row.selected, row.x]).toEqual([8, -2410]); + + // Adding a child re-lays out the Row; with `selected` set the layout + // handler re-scrolls as if first shown ('always': x = -childX), past the + // clamp that auto scrolling applied. Current behaviour. + setCount(11); + await flush(); + v.expect([row.selected, focusedId(), row.x]).toEqual([8, 'c8', -3440]); + }, + ); + + v.it( + 'Row without a selected prop keeps its scroll position on layout', + async () => { + let row!: lng.ElementNode; + const [count, setCount] = s.createSignal(10); + dispose = await mount(() => ( + + + i)}> + {(i) => } + + + + )); + await press(...Array.from({ length: 8 }, () => 'ArrowRight')); + v.expect([row.selected, row.x]).toEqual([8, -2410]); + setCount(11); + await flush(); + v.expect([row.selected, row.x]).toEqual([8, -2410]); + }, + ); + + v.it( + 'scrollToIndex(i) selects and focuses child i; the scroll is one auto step, not an alignment', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {items(10)} + + + )); + const scrollToIndex = (i: number) => + ( + row as lng.ElementNode & { scrollToIndex: (i: number) => void } + ).scrollToIndex(i); + scrollToIndex(5); + await flush(); + // Current behaviour: the scroll moves one step (400 + gap 30) whatever the + // distance jumped. + v.expect([row.selected, focusedId(), row.x]).toEqual([5, 'c5', -430]); + scrollToIndex(2); + await flush(); + // ... and a jump back by more than one still counts as "incrementing". + v.expect([row.selected, focusedId(), row.x]).toEqual([2, 'c2', -860]); + }, + ); +}); + +v.describe('Row and Column: onSelectedChanged', () => { + v.it( + 'called as (idx, container, activeChild, lastIdx) with this = container; twice on autofocus mount; not at an edge', + async () => { + let row!: lng.ElementNode; + const calls: unknown[][] = []; + dispose = await mount(() => ( + + + {items(4, (i) => ({ skipFocus: i === 2 }))} + + + )); + // Fires on mount, currently twice: once when the node is rendered with + // autofocus and again from the deferred autofocus pass. + v.expect(calls).toEqual([ + [true, 0, true, 'c0', 0], + [true, 0, true, 'c0', 0], + ]); + calls.length = 0; + + await press('ArrowRight'); + await press('ArrowRight'); // skips c2 + await press('ArrowRight'); // at the end: no call + await press('ArrowLeft'); + v.expect(calls).toEqual([ + [true, 1, true, 'c1', 0], + [true, 3, true, 'c3', 1], + [true, 1, true, 'c1', 3], + ]); + }, + ); + + v.it( + 'fires again with idx === lastIdx when focus re-enters the container', + async () => { + const calls: unknown[][] = []; + dispose = await mount(() => ( + + + { + calls.push([idx, active?.id, lastIdx]); + }} + > + {items(4)} + + + + + )); + // Focus forwarded from the autofocused Column: also twice. + v.expect(calls).toEqual([ + [0, 'c0', 0], + [0, 'c0', 0], + ]); + calls.length = 0; + await press('ArrowRight', 'ArrowDown'); + v.expect(focusedId()).toBe('below'); + await press('ArrowUp'); + v.expect(focusedId()).toBe('c1'); + v.expect(calls).toEqual([ + [1, 'c1', 0], + [1, 'c1', 1], + ]); + }, + ); +}); + +v.describe('Row and Column: scroll modes', () => { + // Row: x=100 inside a parent at x=50, ten 400x200 children, default gap 30 + // (children at x = 0, 430, ... 3870; Row width 4270). Ten Right presses, + // then ten Left presses; the Row's x after each press. + const rowCases: Record = { + auto: { + right: [ + -330, -760, -1190, -1620, -2050, -2460, -2460, -2460, -2460, -2460, + ], + left: [-2030, -1600, -1170, -740, -310, 100, 100, 100, 100, 100], + }, + edge: { + right: [100, 100, 100, -330, -760, -1190, -1620, -2050, -2460, -2460], + left: [-2460, -2460, -2460, -2050, -1620, -1190, -760, -330, 100, 100], + }, + always: { + right: [ + -330, -760, -1190, -1620, -2050, -2480, -2910, -3340, -3770, -3770, + ], + left: [-3340, -2910, -2480, -2050, -1620, -1190, -760, -330, 100, 100], + }, + center: { + right: [100, -150, -580, -1010, -1440, -1870, -2300, -2460, -2460, -2460], + left: [-2460, -2300, -1870, -1440, -1010, -580, -150, 100, 100, 100], + }, + none: { + right: Array(10).fill(100), + left: Array(10).fill(100), + }, + }; + + for (const [mode, expected] of Object.entries(rowCases)) { + v.it( + `scroll="${mode}": Row x after each Right then each Left`, + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {/* edge asks the renderer whether the next child is in the + viewport; the DOM renderer only tracks that for nodes with a + texture source, hence src. */} + {items(10, () => (mode === 'edge' ? { src: 'edge.png' } : {}))} + + + )); + v.expect(row.children.map((c) => c.x)).toEqual([ + 0, 430, 860, 1290, 1720, 2150, 2580, 3010, 3440, 3870, + ]); + v.expect(row.x).toBe(100); + + const right: number[] = []; + const selected: number[] = []; + for (let i = 0; i < 10; i++) { + await press('ArrowRight'); + right.push(row.x); + selected.push(row.selected!); + } + v.expect(selected).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 9]); + v.expect(focusedId()).toBe('c9'); + v.expect(right).toEqual(expected.right); + + const left: number[] = []; + for (let i = 0; i < 10; i++) { + await press('ArrowLeft'); + left.push(row.x); + } + v.expect(focusedId()).toBe('c0'); + v.expect(left).toEqual(expected.left); + }, + ); + } + + v.it('no scroll prop: behaves as "auto"', async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {items(10)} + + + )); + const right: number[] = []; + for (let i = 0; i < 10; i++) { + await press('ArrowRight'); + right.push(row.x); + } + v.expect(right).toEqual(rowCases.auto!.right); + const left: number[] = []; + for (let i = 0; i < 10; i++) { + await press('ArrowLeft'); + left.push(row.x); + } + v.expect(left).toEqual(rowCases.auto!.left); + }); + + // Column: x=10, y=100 inside a parent at (20, 40), eight 400x200 children, + // default gap 30 (children at y = 0, 230, ... 1610; Column height 1810). + // Eight Down presses, then eight Up presses; the Column's y after each. + const colCases: Record = { + auto: { + down: [-130, -360, -590, -820, -830, -830, -830, -830], + // Current behaviour: coming back up lands on 90, not on a child edge, + // because the down scroll was clamped at -830. + up: [-600, -370, -140, 90, 100, 100, 100, 100], + }, + edge: { + down: [100, 100, 100, -130, -360, -590, -830, -830], + up: [-830, -830, -830, -590, -360, -130, 100, 100], + }, + always: { + down: [-130, -360, -590, -820, -1050, -1280, -1510, -1510], + up: [-1280, -1050, -820, -590, -360, -130, 100, 100], + }, + center: { + down: [100, -60, -290, -520, -750, -830, -830, -830], + up: [-830, -750, -520, -290, -60, 100, 100, 100], + }, + none: { + down: Array(8).fill(100), + up: Array(8).fill(100), + }, + }; + + for (const [mode, expected] of Object.entries(colCases)) { + v.it( + `scroll="${mode}": Column y after each Down then each Up`, + async () => { + let col!: lng.ElementNode; + dispose = await mount(() => ( + + + {items(8, () => (mode === 'edge' ? { src: 'edge.png' } : {}))} + + + )); + v.expect(col.children.map((c) => c.y)).toEqual([ + 0, 230, 460, 690, 920, 1150, 1380, 1610, + ]); + v.expect([col.x, col.y]).toEqual([10, 100]); + + const down: number[] = []; + for (let i = 0; i < 8; i++) { + await press('ArrowDown'); + down.push(col.y); + } + v.expect([col.selected, focusedId()]).toEqual([7, 'c7']); + v.expect(down).toEqual(expected.down); + + const up: number[] = []; + for (let i = 0; i < 8; i++) { + await press('ArrowUp'); + up.push(col.y); + } + v.expect([col.selected, focusedId()]).toEqual([0, 'c0']); + v.expect(up).toEqual(expected.up); + // Scrolling never moves the cross axis. + v.expect(col.x).toBe(10); + }, + ); + } + + v.it( + 'centerScroll on a child: the parent centres that child on screen', + async () => { + let col!: lng.ElementNode; + dispose = await mount(() => ( + + + {/* 300 high, gap 30: children at y = 0, 330, ... 2310 */} + {items(8, () => ({ height: 300, centerScroll: true }))} + + + )); + const down: number[] = []; + for (let i = 0; i < 8; i++) { + await press('ArrowDown'); + down.push(col.y); + } + // y = -childY + (1080 - 300) / 2, clamped at the end (-1590). + // Current behaviour: moving down to c1 puts y at +60, below the Column's + // starting position (the down clamp has no upper bound). + v.expect(down).toEqual([ + 60, -270, -600, -930, -1260, -1590, -1590, -1590, + ]); + const up: number[] = []; + for (let i = 0; i < 8; i++) { + await press('ArrowUp'); + up.push(col.y); + } + // Moving up is clamped at the starting position, so c1 is at 0 not 60. + v.expect(up).toEqual([-1590, -1260, -930, -600, -270, 0, 0, 0]); + }, + ); + + v.it( + 'scroll="bounded" with upCount: stops scrolling for the last upCount children', + async () => { + let col!: lng.ElementNode; + dispose = await mount(() => ( + + {/* 'bounded' is accepted by withScrolling but missing from the + public `scroll` type. */} + + {items(8)} + + + )); + const down: number[] = []; + for (let i = 0; i < 8; i++) { + await press('ArrowDown'); + down.push(col.y); + } + // 8 children, upCount 3: the zone starts at child 5 (y = 1150). + v.expect(down).toEqual([ + -230, -460, -690, -920, -1150, -1150, -1150, -1150, + ]); + const up: number[] = []; + for (let i = 0; i < 8; i++) { + await press('ArrowUp'); + up.push(col.y); + } + v.expect(up).toEqual([-1150, -1150, -920, -690, -460, -230, 0, 0]); + }, + ); +}); + +v.describe('Row and Column: transitions and throttleInput', () => { + v.it( + 'transitionRight/transitionLeft are written to `transition` on each press; a user `transition` wins', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {items(4)} + + + )); + v.expect(row.transition).toBeUndefined(); + await press('ArrowRight'); + v.expect(row.transition).toEqual({ + x: { duration: 180, easing: 'cubic-bezier(0.2, 0, 0, 1)' }, + }); + await press('ArrowLeft'); + v.expect(row.transition).toEqual({ + x: { duration: 180, easing: 'cubic-bezier(0.4, 0, 0.2, 1)' }, + }); + dispose(); + + dispose = await mount(() => ( + + + {items(4)} + + + )); + await press('ArrowRight'); + v.expect(row.transition).toEqual({ x: { duration: 999 }, alpha: true }); + await press('ArrowLeft'); + v.expect(row.transition).toEqual({ x: { duration: 999 }, alpha: true }); + }, + ); + + v.it( + 'throttleInput on a Row: the same key within the window is dropped; another key is not', + async () => { + let now = 1000; + v.vi.spyOn(performance, 'now').mockImplementation(() => now); + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {items(6)} + + + )); + await press('ArrowRight'); + v.expect(focusedId()).toBe('c1'); + now = 1050; + await press('ArrowRight'); + v.expect(focusedId()).toBe('c1'); + // A dropped press does not restart the window. + now = 1099; + await press('ArrowRight'); + v.expect(focusedId()).toBe('c1'); + now = 1100; + await press('ArrowRight'); + v.expect(focusedId()).toBe('c2'); + // A different key is never throttled. + now = 1101; + await press('ArrowLeft'); + v.expect(focusedId()).toBe('c1'); + v.expect(row.selected).toBe(1); + }, + ); +}); diff --git a/tests/contract-states.test.tsx b/tests/contract-states.test.tsx new file mode 100644 index 0000000..7780aa0 --- /dev/null +++ b/tests/contract-states.test.tsx @@ -0,0 +1,875 @@ +// Contract tests: States (the `states` prop, `$state` blocks and their undo). +// +// These pin today's behaviour (1.6.4) through public node props and the +// public `states` API only. Where today's behaviour is odd it is pinned anyway +// and the comment says so; where it looks like a genuine bug the +// correct-behaviour test is an `it.skip` marked `// BUG:`. +// +// vitest runs with `isolate: false`: Config and focus state are shared across +// files in a worker, so every test disposes its render and restores Config. +import * as v from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { renderer } from './setup.js'; + +const RED = 0xff0000ff; +const BLUE = 0x0000ffff; +const GREEN = 0x00ff00ff; +const WHITE = 0xffffffff; + +/** A state name without the `$` prefix, which the types do not allow. */ +const bare = (name: string) => name as lng.DollarString; + +/** The states on a node, as a plain array. */ +const statesOf = (node: lng.ElementNode) => [...node.states]; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 0)); + +// Undoing a key with no base value logs a dev warning ("fallback style key +// not found"); several tests below do that on purpose. +v.beforeEach(() => { + v.vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +v.afterEach(() => { + v.vi.restoreAllMocks(); +}); + +v.describe('contract: states accepts a string, an array or an object', () => { + v.it('string form: states="$active" turns on the $active block', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(statesOf(node)).toEqual(['$active']); + v.expect(node.color).toBe(GREEN); + dispose(); + }); + + v.it('array form: every listed state is on', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(statesOf(node)).toEqual(['$active', '$disabled']); + v.expect(node.color).toBe(GREEN); + v.expect(node.alpha).toBe(0.2); + dispose(); + }); + + v.it('object form: only truthy keys are on', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(statesOf(node)).toEqual(['$active']); + v.expect(node.color).toBe(GREEN); + v.expect(node.alpha).toBe(1); + dispose(); + }); + + v.it( + 'reactive string: a new string replaces the whole list, dropping a state added imperatively (e.g. $focus)', + () => { + // Pinned as is: the string and array forms replace every state, so a + // reactive `states` string also wipes the $focus the focus manager added. + let node!: lng.ElementNode; + const [states, setStates] = s.createSignal('$active'); + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + v.expect(statesOf(node)).toEqual(['$active', '$focus']); + v.expect(node.scale).toBe(1.5); + + setStates('$disabled'); + v.expect(statesOf(node)).toEqual(['$disabled']); + v.expect(node.color).toBe(RED); + v.expect(node.alpha).toBe(0.2); + v.expect(node.states.has('$focus')).toBe(false); + dispose(); + }, + ); + + v.it( + 'reactive array: a new array replaces the whole list, dropping a state added imperatively', + () => { + let node!: lng.ElementNode; + const [states, setStates] = s.createSignal([ + '$active', + ]); + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + + setStates(['$disabled', '$active']); + v.expect(statesOf(node)).toEqual(['$disabled', '$active']); + v.expect(node.color).toBe(GREEN); + v.expect(node.alpha).toBe(0.2); + v.expect(node.states.has('$focus')).toBe(false); + + setStates([]); + v.expect(statesOf(node)).toEqual([]); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it( + 'reactive object: truthy keys are added, falsy keys removed, other states (e.g. $focus) kept', + () => { + let node!: lng.ElementNode; + const [states, setStates] = s.createSignal< + Record + >({ $active: true }); + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + + setStates({ $active: false, $disabled: true }); + v.expect(statesOf(node)).toEqual(['$focus', '$disabled']); + v.expect(node.color).toBe(RED); + v.expect(node.alpha).toBe(0.2); + v.expect(node.scale).toBe(1.5); + dispose(); + }, + ); + + v.it( + 'object form with a key without $ stores it verbatim; it does not turn on the $-prefixed block', + () => { + // Pinned as is: only `has` treats `active` and `$active` as the same. + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(statesOf(node)).toEqual(['active']); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); +}); + +v.describe('contract: state keys with and without $', () => { + v.it('has("focus") and has("$focus") both match "$focus"', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ); + v.expect(node.states.has('$focus')).toBe(true); + v.expect(node.states.has(bare('focus'))).toBe(true); + v.expect(node.states.has('$active')).toBe(false); + v.expect(node.states.has(bare('active'))).toBe(false); + dispose(); + }); + + v.it('has("$focus") does not match a bare "focus" entry', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.states.has(bare('focus'))).toBe(true); + v.expect(node.states.has('$focus')).toBe(false); + dispose(); + }); + + v.it('is() is strict: is("focus") does not match "$focus"', () => { + // Pinned as is: docs/essentials/states.md says "is and has are + // identical", but only `has` accepts the name without `$`. + let node!: lng.ElementNode; + const dispose = renderer.render(() => ); + v.expect(node.states.is('$focus')).toBe(true); + v.expect(node.states.is(bare('focus'))).toBe(false); + dispose(); + }); + + v.it( + 'add("$focus") turns the $focus block on; remove("$focus") undoes it', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + v.expect(statesOf(node)).toEqual(['$focus']); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + v.expect(statesOf(node)).toEqual([]); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it( + 'add() of a state already on changes nothing (styles are not re-applied)', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + node.color = WHITE; + node.states.add('$focus'); + node.states.add(bare('focus')); + v.expect(statesOf(node)).toEqual(['$focus']); + v.expect(node.color).toBe(WHITE); + dispose(); + }, + ); + + v.it( + 'add("focus") without "$focus" on stores "focus" verbatim and does not turn on the $focus block', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add(bare('focus')); + v.expect(statesOf(node)).toEqual(['focus']); + v.expect(node.states.has(bare('focus'))).toBe(true); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it('remove() is strict: remove("focus") leaves "$focus" on', () => { + // Pinned as is: `has("focus")` is true here, yet `remove("focus")` does + // not remove "$focus". + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + node.states.remove(bare('focus')); + v.expect(statesOf(node)).toEqual(['$focus']); + v.expect(node.color).toBe(BLUE); + dispose(); + }); + + v.it('toggle("$focus") turns the state on, then off', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.toggle('$focus'); + v.expect(statesOf(node)).toEqual(['$focus']); + v.expect(node.color).toBe(BLUE); + node.states.toggle('$focus'); + v.expect(statesOf(node)).toEqual([]); + v.expect(node.color).toBe(RED); + dispose(); + }); + + v.it( + 'toggle(state, true) only adds, toggle(state, false) only removes', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.toggle('$focus', true); + node.states.toggle('$focus', true); + v.expect(statesOf(node)).toEqual(['$focus']); + v.expect(node.color).toBe(BLUE); + node.states.toggle('$focus', false); + node.states.toggle('$focus', false); + v.expect(statesOf(node)).toEqual([]); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + // BUG: toggle("focus") while "$focus" is on does nothing. toggle() asks + // has("focus") (true: has() accepts the bare name), then calls + // remove("focus"), which is strict and finds nothing to remove + // (src/core/states.ts:60-61 in toggle, :94 in remove). A toggle must + // change the state. + v.it.skip('toggle("focus") while "$focus" is on turns it off', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + node.states.toggle(bare('focus')); + v.expect(node.states.has('$focus')).toBe(false); + v.expect(node.color).toBe(RED); + dispose(); + }); +}); + +v.describe('contract: $state blocks inside style apply and undo', () => { + v.it( + 'a $focus block in style applies every key while the state is on', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(RED); + v.expect(node.alpha).toBe(0.8); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + v.expect(node.alpha).toBe(1); + v.expect(node.scale).toBe(1.2); + v.expect(node.width).toBe(200); + node.states.remove('$focus'); + v.expect(node.color).toBe(RED); + v.expect(node.alpha).toBe(0.8); + v.expect(node.scale).toBe(1); + v.expect(node.width).toBe(100); + dispose(); + }, + ); + + v.it('any $name block works, not only $focus', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$selected'); + v.expect(node.color).toBe(GREEN); + node.states.remove('$selected'); + v.expect(node.color).toBe(RED); + dispose(); + }); + + v.it( + 'a $state block passed as a JSX prop works like one inside style', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it('states given at creation are applied when the node renders', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + v.expect(node.color).toBe(RED); + dispose(); + }); + + // The undo table. "Undo restores the theme and style values, not JSX + // props" (docs/essentials/states.md). For each key the last state + // application wrote, undo writes theme[key], else style[key], else + // undefined. + + v.it('undo: a prop only in style returns to the style value', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + node.states.remove('$focus'); + v.expect(node.color).toBe(RED); + dispose(); + }); + + v.it( + 'undo: a prop in both style and JSX returns to the STYLE value, not the JSX value', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(GREEN); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it( + 'undo: a prop only in JSX, overridden by $focus, returns to undefined (the JSX value is lost)', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(GREEN); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + v.expect(node.color).toBeUndefined(); + dispose(); + }, + ); + + v.it('undo: a prop with no base value anywhere is written undefined', () => { + // The DOM renderer reads back an undefined alpha as undefined and an + // undefined scale as 1 (its getter is `props.scale ?? 1`). + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.alpha).toBe(1); + node.states.add('$focus'); + v.expect(node.alpha).toBe(0.5); + v.expect(node.scale).toBe(1.2); + node.states.remove('$focus'); + v.expect(node.alpha).toBeUndefined(); + v.expect(node.scale).toBe(1); + dispose(); + }); + + v.it( + 'undo: a value a handler wrote while the state was on is replaced by the style value', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + node.color = WHITE; + v.expect(node.color).toBe(WHITE); + node.states.remove('$focus'); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it( + 'undo: a handler write to a key the state does not touch survives the undo', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + node.alpha = 0.4; + node.states.remove('$focus'); + v.expect(node.alpha).toBe(0.4); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it( + 'undo: a reactive JSX prop that changes while the state is on wins until undo, then style wins', + () => { + let node!: lng.ElementNode; + const [color, setColor] = s.createSignal(GREEN); + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + setColor(WHITE); + v.expect(node.color).toBe(WHITE); + node.states.remove('$focus'); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it('undo: a theme value takes precedence over the style value', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(GREEN); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + v.expect(node.color).toBe(GREEN); + dispose(); + }); + + v.it( + 'the focus manager drives $focus: the focused node and its ancestors get it, and lose it on blur', + async () => { + let row!: lng.ElementNode; + let a!: lng.ElementNode; + let b!: lng.ElementNode; + const Item = { color: RED, $focus: { color: BLUE } }; + const dispose = renderer.render(() => ( + + + + + )); + a.setFocus(); + await tick(); + v.expect(a.states.has(lng.Config.focusStateKey)).toBe(true); + v.expect(a.color).toBe(BLUE); + v.expect(row.alpha).toBe(1); + v.expect(b.color).toBe(RED); + + b.setFocus(); + await tick(); + v.expect(a.states.has('$focus')).toBe(false); + v.expect(a.color).toBe(RED); + v.expect(b.color).toBe(BLUE); + v.expect(row.alpha).toBe(1); + dispose(); + }, + ); +}); + +v.describe('contract: multiple states and Config.stateOrder', () => { + let savedOrder: lng.DollarString[] | undefined; + v.beforeEach(() => { + savedOrder = lng.Config.stateOrder; + }); + v.afterEach(() => { + lng.Config.stateOrder = savedOrder; + }); + + v.it( + 'with an empty Config.stateOrder the state added last wins on shared keys', + () => { + lng.Config.stateOrder = []; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$active'); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + node.states.remove('$active'); + node.states.add('$active'); + v.expect(node.color).toBe(GREEN); + dispose(); + }, + ); + + v.it( + 'removing one of two states undoes only its keys; the other state is re-applied', + () => { + lng.Config.stateOrder = ['$active', '$focus']; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$active'); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + v.expect(node.alpha).toBe(1); + + node.states.remove('$focus'); + v.expect(node.color).toBe(GREEN); + v.expect(node.alpha).toBe(0.8); + v.expect(node.scale).toBe(1); + + node.states.remove('$active'); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it( + 'stateOrder decides precedence whatever order the states were added in', + () => { + lng.Config.stateOrder = ['$active', '$focus']; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + node.states.add('$active'); + v.expect(node.color).toBe(BLUE); + dispose(); + }, + ); + + v.it( + 'a state with no block on the node is ignored when combined with others', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(BLUE); + dispose(); + }, + ); +}); + +v.describe('contract: forwardStates', () => { + v.it( + 'children receive the parent states and apply their own $state blocks; removing undoes them', + () => { + let parent!: lng.ElementNode; + let child!: lng.ElementNode; + let label!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + Label + + + )); + parent.states.add('$focus'); + v.expect(parent.alpha).toBe(1); + v.expect(statesOf(child)).toEqual(['$focus']); + v.expect(child.color).toBe(BLUE); + v.expect(statesOf(label)).toEqual(['$focus']); + v.expect(label.color).toBe(GREEN); + + parent.states.remove('$focus'); + v.expect(statesOf(child)).toEqual([]); + v.expect(child.color).toBe(RED); + v.expect(label.color).toBe(WHITE); + dispose(); + }, + ); + + v.it( + 'children get a copy: a child can change its own states afterwards without touching the parent', + () => { + let parent!: lng.ElementNode; + let a!: lng.ElementNode; + let b!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + parent.states.add('$focus'); + a.states.add('$active'); + v.expect(statesOf(a)).toEqual(['$focus', '$active']); + v.expect(a.color).toBe(GREEN); + v.expect(statesOf(b)).toEqual(['$focus']); + v.expect(statesOf(parent)).toEqual(['$focus']); + dispose(); + }, + ); + + v.it( + "a child's own states are overwritten by the parent's states (current behaviour)", + () => { + // Pinned as is (a known quirk): forwardStates overwrites the children's + // own states. The child's $active is dropped when the parent's states + // change, and stays dropped after the parent's state is removed. + let parent!: lng.ElementNode; + let child!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + )); + v.expect(statesOf(child)).toEqual(['$active']); + v.expect(child.color).toBe(GREEN); + v.expect(child.alpha).toBe(0.3); + + parent.states.add('$focus'); + v.expect(statesOf(child)).toEqual(['$focus']); + v.expect(child.color).toBe(BLUE); + // $active's alpha was undone; the style has no alpha, so it is undefined. + v.expect(child.alpha).toBeUndefined(); + + parent.states.remove('$focus'); + v.expect(statesOf(child)).toEqual([]); + v.expect(child.color).toBe(RED); + dispose(); + }, + ); + + v.it( + "initial render: the parent's initial states replace a child's initial states", + () => { + let child!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + )); + v.expect(statesOf(child)).toEqual(['$focus']); + v.expect(child.color).toBe(BLUE); + v.expect(child.alpha).toBe(1); + dispose(); + }, + ); + + v.it( + "a child inserted later does not get the parent's current states until they change again", + () => { + // Pinned as is: states are forwarded only when the parent's states change + // (or when the parent first renders with states). + let parent!: lng.ElementNode; + let late!: lng.ElementNode; + const [show, setShow] = s.createSignal(false); + const Child = { color: RED, $focus: { color: BLUE } }; + const dispose = renderer.render(() => ( + + + + + + )); + parent.states.add('$focus'); + setShow(true); + v.expect(statesOf(late)).toEqual([]); + v.expect(late.color).toBe(RED); + + parent.states.add('$active'); + v.expect(statesOf(late)).toEqual(['$focus', '$active']); + v.expect(late.color).toBe(BLUE); + dispose(); + }, + ); + + v.it( + 'forwarding goes one level down unless the child also sets forwardStates', + () => { + let parent!: lng.ElementNode; + let plainChild!: lng.ElementNode; + let plainGrandchild!: lng.ElementNode; + let forwardingChild!: lng.ElementNode; + let forwardedGrandchild!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + + + + + )); + parent.states.add('$focus'); + v.expect(statesOf(plainChild)).toEqual(['$focus']); + v.expect(statesOf(plainGrandchild)).toEqual([]); + v.expect(statesOf(forwardingChild)).toEqual(['$focus']); + v.expect(statesOf(forwardedGrandchild)).toEqual(['$focus']); + dispose(); + }, + ); +}); diff --git a/tests/contract-styles.test.tsx b/tests/contract-styles.test.tsx new file mode 100644 index 0000000..9bcd9a4 --- /dev/null +++ b/tests/contract-styles.test.tsx @@ -0,0 +1,767 @@ +// Contract tests: Styles (`style`, combineStyles, transitions). +// +// These pin today's behaviour (1.6.4) through public node props, the public +// `combineStyles` helper and, for transitions, calls to the public +// `ElementNode.prototype.animate`. Where today's behaviour is odd it is pinned +// anyway and the comment says so. +// +// vitest runs with `isolate: false`: Config is shared across files in a +// worker, so every test disposes its render and restores Config and spies. +import * as v from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { renderer } from './setup.js'; + +const RED = 0xff0000ff; +const BLUE = 0x0000ffff; +const GREEN = 0x00ff00ff; +const WHITE = 0xffffffff; + +// Undoing a key with no base value, and setting a style twice, log dev +// warnings; several tests below do that on purpose. +v.beforeEach(() => { + v.vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); +v.afterEach(() => { + v.vi.restoreAllMocks(); +}); + +/** Wait for an animation to land. The DOM renderer animates on rAF. */ +const settle = (check: () => void) => + v.vi.waitFor(check, { timeout: 3000, interval: 10 }); + +// A static module-level style object: the most common pattern in apps. +const Tile: lng.NodeStyles = { + width: 100, + height: 50, + color: RED, + alpha: 0.8, + $focus: { color: BLUE, alpha: 1 }, +}; + +v.describe('contract: static module-level style objects', () => { + v.it( + 'one style object shared by several nodes gives every node its values', + () => { + const nodes: lng.ElementNode[] = []; + const dispose = renderer.render(() => ( + + + {(i) => (nodes[i] = el)} style={Tile} />} + + + )); + v.expect(nodes).toHaveLength(3); + for (const node of nodes) { + v.expect(node.width).toBe(100); + v.expect(node.height).toBe(50); + v.expect(node.color).toBe(RED); + v.expect(node.alpha).toBe(0.8); + } + dispose(); + }, + ); + + v.it( + 'a state on one node does not change the other nodes sharing the object', + () => { + let a!: lng.ElementNode; + let b!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + a.states.add('$focus'); + v.expect(a.color).toBe(BLUE); + v.expect(b.color).toBe(RED); + a.states.remove('$focus'); + b.states.add('$focus'); + v.expect(a.color).toBe(RED); + v.expect(b.color).toBe(BLUE); + dispose(); + }, + ); + + v.it( + 'applying and undoing states never mutates the shared style object', + () => { + const Shared: lng.NodeStyles = { + color: RED, + scale: 1, + transition: { scale: { duration: 10 } }, + $focus: { color: BLUE, scale: 1.2 }, + $active: { color: GREEN, alpha: 0.5 }, + }; + const before = structuredClone(Shared); + let a!: lng.ElementNode; + let b!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + a.states.add('$focus'); + a.states.add('$active'); + b.states.add('$active'); + a.color = WHITE; + a.states.remove('$focus'); + a.states.remove('$active'); + b.states.remove('$active'); + v.expect(Shared).toEqual(before); + dispose(); + }, + ); +}); + +v.describe('contract: inline style literals re-created on every render', () => { + v.it( + 'a parent that re-renders its child gives each new node the values of its new literal, $state blocks included', + () => { + let node!: lng.ElementNode; + const [color, setColor] = s.createSignal(RED); + const dispose = renderer.render(() => ( + + + {(c) => ( + + )} + + + )); + const first = node; + v.expect(first.color).toBe(RED); + + setColor(GREEN); + v.expect(node).not.toBe(first); + v.expect(node.color).toBe(GREEN); + v.expect(node.alpha).toBe(0.8); + node.states.add('$focus'); + v.expect(node.alpha).toBe(1); + v.expect(node.scale).toBe(1.1); + node.states.remove('$focus'); + v.expect(node.alpha).toBe(0.8); + v.expect(node.color).toBe(GREEN); + dispose(); + }, + ); + + v.it( + 'a reactive literal on the same node is read once; later literals are ignored (Config.lockStyles, default true)', + () => { + // Pinned as is (documented in docs/essentials/styling.md: style is + // read-only once set). The compiler re-creates the literal and sets + // `style` again when color() changes; the node keeps the first one, for + // its values and as the undo baseline. + let node!: lng.ElementNode; + const [color, setColor] = s.createSignal(RED); + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(RED); + setColor(GREEN); + v.expect(node.color).toBe(RED); + v.expect(node.style.color).toBe(RED); + node.states.add('$focus'); + node.states.remove('$focus'); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); +}); + +v.describe('contract: object spread', () => { + const Base: lng.NodeStyles = { + width: 100, + color: RED, + $focus: { color: BLUE, scale: 1.1 }, + }; + + v.it( + 'a style built by spreading a base keeps the base keys and $state blocks; its own keys win', + () => { + const Wide: lng.NodeStyles = { ...Base, width: 300 }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ); + v.expect(node.width).toBe(300); + v.expect(node.color).toBe(RED); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + v.expect(node.scale).toBe(1.1); + dispose(); + }, + ); + + v.it( + 'spread is shallow: a $state block in the new object replaces the base block wholesale', + () => { + const Override: lng.NodeStyles = { ...Base, $focus: { alpha: 0.5 } }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + v.expect(node.alpha).toBe(0.5); + v.expect(node.color).toBe(RED); + v.expect(node.scale).toBe(1); + dispose(); + }, + ); + + v.it( + 'an inline literal with spread (style={{ ...Base, color }}) applies and undoes like a static object', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(GREEN); + v.expect(node.width).toBe(100); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + v.expect(node.color).toBe(GREEN); + dispose(); + }, + ); + + v.it( + 'spreading a style object as JSX props ({...obj}) sets its keys as props; undo then treats them as JSX props', + () => { + // The pattern in docs/essentials/theming.md. Pinned as is: the values are + // props, not style, so undo has no baseline and writes undefined. + const Dark: lng.NodeStyles = { color: RED, $focus: { color: BLUE } }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ); + v.expect(node.color).toBe(RED); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + v.expect(node.color).toBeUndefined(); + dispose(); + }, + ); +}); + +v.describe('contract: combineStyles', () => { + v.it( + 'the first argument wins on shared keys; keys from both are kept', + () => { + const combined = lng.combineStyles( + { color: GREEN, alpha: 0.5 }, + { color: RED, width: 100 }, + ); + v.expect(combined).toEqual({ color: GREEN, alpha: 0.5, width: 100 }); + }, + ); + + v.it('when one argument is undefined the other is returned as is', () => { + const style: lng.NodeStyles = { color: RED }; + v.expect(lng.combineStyles(undefined, style)).toBe(style); + v.expect(lng.combineStyles(style, undefined)).toBe(style); + }); + + v.it( + 'merges shallowly: a $state block in the first replaces the one in the second; a block only in the second is kept', + () => { + // Pinned as is: $state blocks are not merged key by key. + const combined = lng.combineStyles( + { $focus: { color: GREEN } }, + { + color: RED, + $focus: { color: BLUE, scale: 1.2 }, + $active: { alpha: 0.5 }, + }, + ); + v.expect(combined.$focus).toEqual({ color: GREEN }); + v.expect(combined.$active).toEqual({ alpha: 0.5 }); + }, + ); + + v.it( + 'a combined style applies and undoes its $state blocks on a node', + () => { + const Defaults: lng.NodeStyles = { + color: RED, + alpha: 0.8, + $focus: { color: BLUE, alpha: 1 }, + $active: { scale: 1.2 }, + }; + const style = lng.combineStyles( + { color: WHITE, $focus: { color: GREEN } }, + Defaults, + ); + let node!: lng.ElementNode; + const dispose = renderer.render(() => ); + v.expect(node.color).toBe(WHITE); + v.expect(node.alpha).toBe(0.8); + node.states.add('$focus'); + v.expect(node.color).toBe(GREEN); + v.expect(node.alpha).toBe(0.8); + node.states.add('$active'); + v.expect(node.scale).toBe(1.2); + node.states.remove('$focus'); + node.states.remove('$active'); + v.expect(node.color).toBe(WHITE); + v.expect(node.scale).toBe(1); + dispose(); + }, + ); + + v.it( + 'getters are read once, at combine time: the result holds plain values', + () => { + const [color, setColor] = s.createSignal(RED); + const combined = lng.combineStyles( + { + get color() { + return color(); + }, + }, + { alpha: 1 }, + ); + setColor(GREEN); + v.expect(combined.color).toBe(RED); + v.expect(Object.getOwnPropertyDescriptor(combined, 'color')?.get).toBe( + undefined, + ); + }, + ); +}); + +v.describe('contract: getter properties inside styles', () => { + v.it( + 'a constant getter works like a plain value (pattern in the demo app)', + () => { + const Key: lng.NodeStyles = { + get color() { + return RED; + }, + $focus: { + get color() { + return BLUE; + }, + }, + }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ); + v.expect(node.color).toBe(RED); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it( + 'a getter in the base style is read when the style is set; a later signal change does not update the node', + () => { + // Pinned as is: style getters are not reactive. + const [color, setColor] = s.createSignal(RED); + const Style: lng.NodeStyles = { + get color() { + return color(); + }, + }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ); + v.expect(node.color).toBe(RED); + setColor(GREEN); + v.expect(node.color).toBe(RED); + dispose(); + }, + ); + + v.it( + 'a getter in a $state block is read each time the state is applied, not while it stays on', + () => { + const [focusColor, setFocusColor] = s.createSignal(BLUE); + const Style: lng.NodeStyles = { + color: RED, + $focus: { + get color() { + return focusColor(); + }, + }, + }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ); + node.states.add('$focus'); + v.expect(node.color).toBe(BLUE); + setFocusColor(GREEN); + v.expect(node.color).toBe(BLUE); + node.states.remove('$focus'); + node.states.add('$focus'); + v.expect(node.color).toBe(GREEN); + dispose(); + }, + ); + + v.it( + 'undo re-reads a getter in the base style, so it lands on the current value', + () => { + const [color, setColor] = s.createSignal(RED); + const Style: lng.NodeStyles = { + get color() { + return color(); + }, + $focus: { color: BLUE }, + }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ); + node.states.add('$focus'); + setColor(GREEN); + node.states.remove('$focus'); + v.expect(node.color).toBe(GREEN); + dispose(); + }, + ); +}); + +v.describe('contract: explicit props override style', () => { + const Style: lng.NodeStyles = { x: 10, color: RED, alpha: 0.8 }; + + v.it('a JSX prop written before style wins', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(GREEN); + v.expect(node.alpha).toBe(0.8); + dispose(); + }); + + v.it('a JSX prop written after style wins', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.color).toBe(GREEN); + v.expect(node.alpha).toBe(0.8); + dispose(); + }); + + v.it('an explicit 0 wins over a non-zero style value', () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.x).toBe(0); + v.expect(node.alpha).toBe(0); + dispose(); + }); + + v.it('spread props ({...props}) win over style, before or after it', () => { + const props = { color: GREEN }; + let a!: lng.ElementNode; + let b!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + v.expect(a.color).toBe(GREEN); + v.expect(b.color).toBe(GREEN); + dispose(); + }); + + v.it( + 'a reactive JSX prop keeps updating the node; the style value is never re-applied', + () => { + let node!: lng.ElementNode; + const [color, setColor] = s.createSignal(GREEN); + const dispose = renderer.render(() => ( + + )); + setColor(WHITE); + v.expect(node.color).toBe(WHITE); + setColor(BLUE); + v.expect(node.color).toBe(BLUE); + dispose(); + }, + ); +}); + +v.describe('contract: transition', () => { + let animate: v.MockInstance; + v.beforeEach(() => { + animate = v.vi.spyOn(lng.ElementNode.prototype, 'animate'); + }); + + v.it( + 'no transition, or transition={false}: a write lands at once and nothing animates', + () => { + let a!: lng.ElementNode; + let b!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + + + )); + a.x = 100; + b.x = 100; + v.expect(a.x).toBe(100); + v.expect(b.x).toBe(100); + v.expect(animate).not.toHaveBeenCalled(); + dispose(); + }, + ); + + v.it( + 'transition={true}: every animatable prop animates with the default settings, then lands', + async () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.x = 100; + node.alpha = 0.5; + // The write goes through an animation, so it is not there yet. + v.expect(node.x).toBe(0); + v.expect(animate).toHaveBeenCalledTimes(2); + v.expect(animate.mock.calls[0]![0]).toEqual({ x: 100 }); + v.expect(animate.mock.calls[1]![0]).toEqual({ alpha: 0.5 }); + // Settings are the node's animationSettings, which default to + // Config.animationSettings (passed, or resolved inside animate()). + v.expect(animate.mock.calls[0]![1] ?? node.animationSettings).toEqual( + lng.Config.animationSettings, + ); + await settle(() => { + v.expect(node.x).toBe(100); + v.expect(node.alpha).toBe(0.5); + }); + dispose(); + }, + ); + + v.it( + 'transition={{ y: true }}: only y animates, with the default settings; x lands at once', + async () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.x = 50; + node.y = 80; + v.expect(node.x).toBe(50); + v.expect(node.y).toBe(0); + v.expect(animate).toHaveBeenCalledTimes(1); + v.expect(animate.mock.calls[0]![0]).toEqual({ y: 80 }); + v.expect(animate.mock.calls[0]![1] ?? node.animationSettings).toEqual( + lng.Config.animationSettings, + ); + await settle(() => v.expect(node.y).toBe(80)); + dispose(); + }, + ); + + v.it( + 'transition={{ x: { duration, easing, delay } }}: x animates with exactly those settings, then lands', + async () => { + let node!: lng.ElementNode; + const settings = { duration: 30, easing: 'linear', delay: 5 }; + const dispose = renderer.render(() => ( + + )); + node.x = 100; + v.expect(node.x).toBe(0); + v.expect(animate).toHaveBeenCalledTimes(1); + v.expect(animate.mock.calls[0]).toEqual([{ x: 100 }, settings]); + await settle(() => v.expect(node.x).toBe(100)); + dispose(); + }, + ); + + v.it('transition keys accept width/height for w/h', async () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.width = 300; + node.height = 50; + v.expect(node.width).toBe(100); + v.expect(node.height).toBe(50); + v.expect(animate).toHaveBeenCalledTimes(1); + await settle(() => v.expect(node.width).toBe(300)); + dispose(); + }); + + v.it('transition inside a style object works like the prop', async () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.x = 100; + v.expect(node.x).toBe(0); + v.expect(animate).toHaveBeenCalledTimes(1); + await settle(() => v.expect(node.x).toBe(100)); + dispose(); + }); + + v.it( + 'nothing animates before the node renders: initial props and initial states land at once', + () => { + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + v.expect(node.x).toBe(50); + v.expect(node.scale).toBe(1.2); + v.expect(animate).not.toHaveBeenCalled(); + dispose(); + }, + ); + + v.it( + 'Config.animationsEnabled = false: transitions are skipped and writes land at once', + () => { + const saved = lng.Config.animationsEnabled; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + try { + lng.Config.animationsEnabled = false; + node.x = 100; + v.expect(node.x).toBe(100); + v.expect(animate).not.toHaveBeenCalled(); + } finally { + lng.Config.animationsEnabled = saved; + dispose(); + } + }, + ); + + v.it( + 'a base transition in style animates a $focus block on and off (pattern in the demo app)', + async () => { + const settings = { duration: 10, easing: 'linear' }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + v.expect(node.scale).toBe(1); + v.expect(animate.mock.calls[0]).toEqual([{ scale: 1.1 }, settings]); + await settle(() => v.expect(node.scale).toBe(1.1)); + + node.states.remove('$focus'); + v.expect(node.scale).toBe(1.1); + v.expect(animate.mock.calls[1]).toEqual([{ scale: 1 }, settings]); + await settle(() => v.expect(node.scale).toBe(1)); + dispose(); + }, + ); + + v.it( + 'a transition inside a $focus block is set before the block is applied, so the block animates', + async () => { + const settings = { duration: 10 }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + v.expect(node.scale).toBe(1); + v.expect(animate.mock.calls[0]).toEqual([{ scale: 1.2 }, settings]); + v.expect(node.transition).toEqual({ scale: settings }); + await settle(() => v.expect(node.scale).toBe(1.2)); + dispose(); + }, + ); + + v.it( + 'undo of a $focus block with its own transition: transition key LAST, the undo animates with it', + async () => { + // Pinned as is, and order-dependent: undo writes the block's keys back in + // the block's key order. `scale` comes before `transition` here, so scale + // is written while the block's transition is still set and animates; then + // transition is restored to the style value (none). + const settings = { duration: 10 }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + await settle(() => v.expect(node.scale).toBe(1.2)); + + node.states.remove('$focus'); + v.expect(node.scale).toBe(1.2); + v.expect(animate).toHaveBeenCalledTimes(2); + v.expect(animate.mock.calls[1]).toEqual([{ scale: 1 }, settings]); + v.expect(node.transition).toBeUndefined(); + await settle(() => v.expect(node.scale).toBe(1)); + dispose(); + }, + ); + + v.it( + 'undo of a $focus block with its own transition: transition key FIRST, the undo lands at once', + async () => { + // Pinned as is, the other half of the order dependence above: here + // `transition` is restored (to none) before `scale` is written back. + const settings = { duration: 10 }; + let node!: lng.ElementNode; + const dispose = renderer.render(() => ( + + )); + node.states.add('$focus'); + v.expect(animate.mock.calls[0]).toEqual([{ scale: 1.2 }, settings]); + await settle(() => v.expect(node.scale).toBe(1.2)); + + node.states.remove('$focus'); + v.expect(node.scale).toBe(1); + v.expect(animate).toHaveBeenCalledTimes(1); + v.expect(node.transition).toBeUndefined(); + dispose(); + }, + ); +}); diff --git a/tests/contract-text.test.tsx b/tests/contract-text.test.tsx new file mode 100644 index 0000000..58d4946 --- /dev/null +++ b/tests/contract-text.test.tsx @@ -0,0 +1,768 @@ +/** + * Contract tests: text props, text children, font defaults, + * autosize, and text inside flex containers — on the DOM renderer (jsdom). + * + * jsdom has no layout, so the DOM renderer measures every text as 0 x 0 + * (getBoundingClientRect). Tests that need real sizes install + * `mockTextMeasure()`: 10px per character, 20px per line, for text divs only. + * The DOM renderer measures a text once when it is created (synchronously) + * and again after `document.fonts.ready`, when it emits `loaded`. + * Real-WebGL text-in-flex is covered elsewhere. + */ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { renderer, waitForUpdate } from './setup.js'; + +const CHAR_W = 10; +const LINE_H = 20; + +function mockTextMeasure() { + return vi + .spyOn(Element.prototype, 'getBoundingClientRect') + .mockImplementation(function (this: Element) { + const text = ( + this as unknown as { _node?: { props?: { text?: unknown } } } + )._node?.props?.text; + const isText = typeof text === 'string'; + const width = isText ? text.length * CHAR_W : 0; + const height = isText ? LINE_H : 0; + return { + x: 0, + y: 0, + top: 0, + left: 0, + right: width, + bottom: height, + width, + height, + toJSON() {}, + } as DOMRect; + }); +} + +type RendererTextNode = lng.ElementNode['lng'] & Record; +const raw = (el: lng.ElementNode) => el.lng as RendererTextNode; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('contract: text children concatenated from several JSX expressions', () => { + it('static text and several expressions concatenate into el.text and el.lng.text', () => { + const [name] = s.createSignal('Bob'); + const [other] = s.createSignal('Ann'); + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + Hello {name()} and {other()} + + )); + expect(t.text).toBe('Hello Bob and Ann'); + expect(raw(t).text).toBe('Hello Bob and Ann'); + dispose(); + }); + + it('reactive updates re-concatenate synchronously, each expression on its own', () => { + const [name, setName] = s.createSignal('Bob'); + const [other, setOther] = s.createSignal('Ann'); + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + Hello {name()} and {other()} + + )); + setName('Zed'); + expect(t.text).toBe('Hello Zed and Ann'); + expect(raw(t).text).toBe('Hello Zed and Ann'); + setOther('Kim'); + expect(t.text).toBe('Hello Zed and Kim'); + expect(raw(t).text).toBe('Hello Zed and Kim'); + dispose(); + }); + + it('number expressions are stringified', () => { + // Works at runtime, but TextProps.children is typed `string | string[]` + // (src/core/intrinsicTypes.ts:192), so a number child is a type error. + const [n, setN] = s.createSignal(1); + let t!: lng.ElementNode; + const dispose = renderer.render(() => Count {n()}!); + expect(t.text).toBe('Count 1!'); + setN(22); + expect(t.text).toBe('Count 22!'); + expect(raw(t).text).toBe('Count 22!'); + dispose(); + }); + + it('a inside text adds and removes its part', () => { + // Works at runtime; a JSX element child is a type error, as above. + const [show, setShow] = s.createSignal(false); + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + ABC + + )); + expect(t.text).toBe('AC'); + setShow(true); + expect(t.text).toBe('ABC'); + expect(raw(t).text).toBe('ABC'); + setShow(false); + expect(t.text).toBe('AC'); + dispose(); + }); +}); + +describe('contract: text props reach the renderer node (el.lng)', () => { + it('contain "width" with a width: maxWidth = width, maxLines defaults to 99', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + A + + + )); + expect(raw(t).contain).toBe('width'); + expect(raw(t).maxWidth).toBe(300); + expect(raw(t).maxLines).toBe(99); + expect(raw(t).maxHeight).toBe(0); // DOM renderer default: unset + // el.width reads maxWidth first (elementNode.ts: width = maxWidth || w). + expect(t.width).toBe(300); + dispose(); + }); + + it('contain "width" without a width: maxWidth = parent width - x (marginRight is ignored today)', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + B + + + )); + expect(raw(t).maxWidth).toBe(700); + expect(raw(t).maxLines).toBe(99); + dispose(); + }); + + // BUG: src/core/elementNode.ts:1612 (and :1617 for marginBottom) reads + // marginRight from the renderer props bag, where it is never stored (margins + // live on the ElementNode), so the subtraction is always 0. + it.skip('BUG: contain "width" without a width subtracts marginRight from maxWidth', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + B + + + )); + expect(raw(t).maxWidth).toBe(660); + dispose(); + }); + + it('contain "both" with width and height: maxWidth/maxHeight from them, maxLines not defaulted', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + C + + + )); + expect(raw(t).contain).toBe('both'); + expect(raw(t).maxWidth).toBe(300); + expect(raw(t).maxHeight).toBe(90); + expect(raw(t).maxLines).toBe(0); // DOM renderer default: unset + expect(t.width).toBe(300); + expect(t.height).toBe(90); + dispose(); + }); + + it('contain "both" without a size: maxWidth/maxHeight = parent size - x/y, maxLines 99', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + D + + + )); + expect(raw(t).maxWidth).toBe(700); + expect(raw(t).maxHeight).toBe(550); + expect(raw(t).maxLines).toBe(99); + dispose(); + }); + + it('maxLines={1} with contain: maxHeight = lineHeight, or fontSize when there is no lineHeight', () => { + let withLineHeight!: lng.ElementNode; + let withFontSize!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + E + + + F + + + )); + expect(raw(withLineHeight).maxLines).toBe(1); + expect(raw(withLineHeight).maxHeight).toBe(40); + expect(withLineHeight.height).toBe(40); + expect(raw(withFontSize).maxHeight).toBe(30); + dispose(); + }); + + it('maxWidth/maxHeight without contain pass through, and el.width/el.height read them', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + G + + )); + expect(raw(t).maxWidth).toBe(222); + expect(raw(t).maxHeight).toBe(33); + expect(t.width).toBe(222); + expect(t.height).toBe(33); + dispose(); + }); + + it('textAlign without contain warns "Text align requires contain" and is still passed on', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + H + + )); + expect(warn).toHaveBeenCalledWith('Text align requires contain: ', 'H'); + expect(raw(t).textAlign).toBe('center'); + dispose(); + }); + + it('textAlign with contain is passed on without a warning', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + I + + + )); + expect(raw(t).textAlign).toBe('right'); + expect(warn).not.toHaveBeenCalled(); + dispose(); + }); + + it('lineHeight, letterSpacing, overflowSuffix, wordBreak, fontSize and fontFamily pass through', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + J + + )); + expect(raw(t).lineHeight).toBe(30); + expect(raw(t).letterSpacing).toBe(2); + expect(raw(t).overflowSuffix).toBe('--'); + expect(raw(t).wordBreak).toBe('break-all'); + expect(raw(t).fontSize).toBe(20); + expect(raw(t).fontFamily).toBe('Roboto'); + dispose(); + }); + + it('unset text props get the DOM renderer defaults', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => K); + expect(raw(t).contain).toBe('none'); + expect(raw(t).textAlign).toBe('left'); + expect(raw(t).lineHeight).toBe(0); + expect(raw(t).letterSpacing).toBe(0); + expect(raw(t).overflowSuffix).toBe('...'); + expect(raw(t).wordBreak).toBe('overflow'); + expect(raw(t).maxLines).toBe(0); + expect(raw(t).maxWidth).toBe(0); + expect(raw(t).maxHeight).toBe(0); + dispose(); + }); + + it('reactive text props update the renderer node after render', () => { + const [spacing, setSpacing] = s.createSignal(1); + const [lines, setLines] = s.createSignal(2); + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + L + + + )); + setSpacing(5); + setLines(3); + expect(raw(t).letterSpacing).toBe(5); + expect(raw(t).maxLines).toBe(3); + dispose(); + }); +}); + +describe('contract: font defaults from Config.fontSettings', () => { + it('unset fontFamily and fontSize come from Config.fontSettings', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => a); + expect(raw(t).fontFamily).toBe(lng.Config.fontSettings.fontFamily); + expect(raw(t).fontSize).toBe(lng.Config.fontSettings.fontSize); + dispose(); + }); + + it('JSX props override the defaults', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + a + + )); + expect(raw(t).fontFamily).toBe('Roboto'); + expect(raw(t).fontSize).toBe(24); + dispose(); + }); + + it('fontWeight is folded into the family name through Config.fontWeightAlias', () => { + const family = lng.Config.fontSettings.fontFamily; + let bold!: lng.ElementNode; + let regular!: lng.ElementNode; + let numeric!: lng.ElementNode; + let custom!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + b + + + c + + + d + + + e + + + )); + expect(raw(bold).fontFamily).toBe(`${family}700`); + expect(raw(regular).fontFamily).toBe(family); + expect(raw(numeric).fontFamily).toBe(`${family}600`); + expect(raw(custom).fontFamily).toBe('Roboto700'); + dispose(); + }); + + it('fontWeight before fontFamily: the weight is lost (prop order matters today)', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + a + + )); + expect(raw(t).fontFamily).toBe('Roboto'); + dispose(); + }); + + // BUG: the fontFamily setter (src/core/elementNode.ts, `set fontFamily`) + // overwrites the family+weight name that `set fontWeight` wrote, so the + // result depends on JSX attribute order. + it.skip('BUG: fontWeight and fontFamily give the same family in either order', () => { + let t!: lng.ElementNode; + const dispose = renderer.render(() => ( + + a + + )); + expect(raw(t).fontFamily).toBe('Roboto700'); + dispose(); + }); + + it('Config.fontSettings is read once, at the first text render: later changes are ignored', () => { + // Documented at src/core/elementNode.ts (font template comment): + // fontSettings is expected to be set at app startup and not change. + let first!: lng.ElementNode; + const d1 = renderer.render(() => a); + const family = raw(first).fontFamily; + const size = raw(first).fontSize; + d1(); + + const saved = { ...lng.Config.fontSettings }; + try { + lng.Config.fontSettings.fontFamily = 'ChangedFamily'; + lng.Config.fontSettings.fontSize = 42; + let later!: lng.ElementNode; + const d2 = renderer.render(() => b); + expect(raw(later).fontFamily).toBe(family); + expect(raw(later).fontSize).toBe(size); + d2(); + } finally { + Object.assign(lng.Config.fontSettings, saved); + } + }); +}); + +describe('contract: autosize', () => { + it('autosize reaches the renderer node; a loaded event on an autosize flex child relays out its parent synchronously', async () => { + let count = 0; + let row!: lng.ElementNode; + let auto!: lng.ElementNode; + let plain!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + void count++}> + + + + + )); + await waitForUpdate(); + expect(auto.lng.autosize).toBe(true); + expect(count).toBe(1); + expect(last.x).toBe(70); + + // The renderer resized the node (e.g. to its texture) and says so. + auto.lng.w = 120; + (auto.lng as unknown as lng.IEventEmitter).emit('loaded', { + type: 'texture', + dimensions: { w: 120, h: 50 }, + }); + expect(count).toBe(2); + expect(last.x).toBe(140); + expect(row.width).toBe(190); + + // Without autosize, a loaded event on a view child does not relay out. + (plain.lng as unknown as lng.IEventEmitter).emit('loaded', { + type: 'texture', + dimensions: { w: 1, h: 1 }, + }); + await waitForUpdate(); + expect(count).toBe(2); + dispose(); + }); +}); + +describe('contract: text in a flex container (DOM renderer)', () => { + it('unmeasured text (jsdom 0 x 0): the flex pass gives up, siblings are not placed, onLayout still fires', async () => { + let count = 0; + let row!: lng.ElementNode; + let t!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + void count++}> + + Hello + + + )); + await waitForUpdate(); + expect(t.width).toBe(0); + expect(t.height).toBe(0); + expect(raw(t).loaded).toBe(true); + // Not laid out: the last child keeps the render default x = 0 and the + // container keeps its render-time width (0 for a fit-to-content row). + expect(t.x).toBe(0); + expect(last.x).toBe(0); + expect(row.width).toBe(0); + expect(count).toBe(1); + dispose(); + }); + + it('measured text is laid out like a sized view; loaded is emitted as {type: "text", dimensions}; one flex pass', async () => { + mockTextMeasure(); + let count = 0; + const loaded: unknown[][] = []; + let row!: lng.ElementNode; + let t!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + void count++}> + + + Hello + + + + )); + // Measured synchronously when the renderer node is created. + expect(t.width).toBe(5 * CHAR_W); + expect(t.height).toBe(LINE_H); + await waitForUpdate(); + expect(t.x).toBe(60); + expect(last.x).toBe(120); + expect(row.width).toBe(170); + expect(row.height).toBe(50); + expect(loaded).toEqual([ + [t, t, { type: 'text', dimensions: { w: 50, h: 20 } }], + ]); + // The loaded handler runs the first flex pass and takes the container off + // the layout queue, so onLayout fires once. + expect(count).toBe(1); + dispose(); + }); + + it('flex waits for the text size: nothing is placed until the text is measured, then loaded relays out', async () => { + const spy = mockTextMeasure(); + // Hold the font-ready measurement and report 0 x 0 until then. + spy.mockImplementation( + () => + ({ + x: 0, + y: 0, + top: 0, + left: 0, + right: 0, + bottom: 0, + width: 0, + height: 0, + toJSON() {}, + }) as DOMRect, + ); + const fonts = document.fonts as unknown as { ready: Promise }; + const savedReady = fonts.ready; + let fontsReady!: () => void; + fonts.ready = new Promise((r) => (fontsReady = r)); + let dispose = () => {}; + try { + let count = 0; + let row!: lng.ElementNode; + let t!: lng.ElementNode; + let last!: lng.ElementNode; + dispose = renderer.render(() => ( + void count++}> + Hello + + + )); + await waitForUpdate(); + expect(raw(t).loaded).toBe(false); + expect(last.x).toBe(0); + expect(row.width).toBe(0); + expect(count).toBe(1); + + spy.mockRestore(); + mockTextMeasure(); + fontsReady(); + await waitForUpdate(); + expect(raw(t).loaded).toBe(true); + expect(t.width).toBe(50); + expect(last.x).toBe(60); + expect(row.width).toBe(110); + expect(row.height).toBe(50); + expect(count).toBe(2); + } finally { + fontsReady(); + fonts.ready = savedReady; + await waitForUpdate(); + dispose(); + } + }); + + it('a text change relays out after the re-measure (not synchronously), once', async () => { + mockTextMeasure(); + const [label, setLabel] = s.createSignal('Hello'); + let count = 0; + let row!: lng.ElementNode; + let t!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + void count++}> + {label()} + + + )); + await waitForUpdate(); + expect(last.x).toBe(60); + expect(count).toBe(1); + + setLabel('Hello world'); + expect(t.text).toBe('Hello world'); + expect(last.x).toBe(60); // stale until the renderer re-measures + await waitForUpdate(); + expect(t.width).toBe(110); + expect(last.x).toBe(120); + expect(row.width).toBe(170); + expect(count).toBe(2); + dispose(); + }); + + it('contain "width": flex uses maxWidth as the item width and the measured height', async () => { + mockTextMeasure(); + let row!: lng.ElementNode; + let t!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + Hello + + + + )); + await waitForUpdate(); + expect(t.width).toBe(300); + expect(t.height).toBe(LINE_H); + expect(last.x).toBe(310); + expect(row.width).toBe(360); + dispose(); + }); + + it('contain "width" + maxLines={1}: flex uses maxWidth x maxHeight (fontSize) and a text change does not relay out', async () => { + mockTextMeasure(); + const [label, setLabel] = s.createSignal('Hello'); + let count = 0; + let t!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + void count++}> + + {label()} + + + + )); + await waitForUpdate(); + expect(t.width).toBe(300); + expect(t.height).toBe(30); + expect(last.x).toBe(310); + const before = count; + + setLabel('Hello there, a much longer title'); + await waitForUpdate(); + expect(count).toBe(before); + expect(last.x).toBe(310); + dispose(); + }); + + it('contain "both": flex uses width x height (= maxWidth x maxHeight)', async () => { + mockTextMeasure(); + let t!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + Hello + + + + )); + await waitForUpdate(); + expect(t.width).toBe(300); + expect(t.height).toBe(40); + expect(last.x).toBe(310); + dispose(); + }); + + it('an explicit width on a text without contain is replaced by the measured width', async () => { + mockTextMeasure(); + let t!: lng.ElementNode; + let last!: lng.ElementNode; + const dispose = renderer.render(() => ( + + + Hello + + + + )); + await waitForUpdate(); + expect(t.width).toBe(50); + expect(last.x).toBe(60); + dispose(); + }); + + it('flexGrow next to a text (docs example): the grow item takes the rest; it never shrinks back when the text grows', async () => { + mockTextMeasure(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const [label, setLabel] = s.createSignal('Flex Grow'); + let t!: lng.ElementNode; + let grow!: lng.ElementNode; + let row!: lng.ElementNode; + const dispose = renderer.render(() => ( + + {label()} + + + )); + await waitForUpdate(); + expect(t.width).toBe(90); + expect(grow.x).toBe(110); + expect(grow.width).toBe(490); + expect(row.width).toBe(600); + + // Today the grown width is the item's base on the next pass, so it keeps + // 490 and the row overflows (200 + 490 = 690 > 600). + setLabel('Flex Grow Longer!!'); + await waitForUpdate(); + expect(t.width).toBe(180); + expect(grow.x).toBe(200); + expect(grow.width).toBe(490); + + // A shorter text lets it grow again. + setLabel('Flex'); + await waitForUpdate(); + expect(grow.x).toBe(60); + expect(grow.width).toBe(540); + expect(warn).not.toHaveBeenCalled(); + dispose(); + }); + + // BUG: flexGrow should be computed from the item's own size each pass, so + // the grow item shrinks to 600 - 180 - 20 = 400 when the text grows. + // flex.ts:146 / flexLayout.ts:199 add the free space to the current + // (already grown) width. + it.skip('BUG: a flexGrow item shrinks back when a text sibling grows', async () => { + mockTextMeasure(); + const [label, setLabel] = s.createSignal('Flex Grow'); + let grow!: lng.ElementNode; + const dispose = renderer.render(() => ( + + {label()} + + + )); + await waitForUpdate(); + setLabel('Flex Grow Longer!!'); + await waitForUpdate(); + expect(grow.x).toBe(200); + expect(grow.width).toBe(400); + dispose(); + }); +}); diff --git a/tests/contract-types.tsx b/tests/contract-types.tsx new file mode 100644 index 0000000..a9b064e --- /dev/null +++ b/tests/contract-types.tsx @@ -0,0 +1,371 @@ +// Contract tests: the public types apps augment, and the type-only export +// names of every entry point. +// +// Type-level only: never executed, not a vitest file. `pnpm tsc` checks it +// through tests/tsconfig.contract.json. A failure here is a type error. +import type { + ElementNode, + IntrinsicNodeStyleProps, + IntrinsicTextNodeStyleProps, + KeyMap, + NodeStyles, + TextStyles, +} from '@solidtv/solid'; +import { useFocusManager } from '@solidtv/solid/primitives'; +import type { KeyMap as PrimitivesKeyMap } from '@solidtv/solid/primitives'; + +// --- Types: augmentable with `declare module "@solidtv/solid"` ------------- +// +// Each augmentation adds a member with a precise type, then uses it both ways: +// a valid use, and an invalid one under @ts-expect-error. The index +// signatures on ElementNode, NodeProps and KeyMap would accept the valid use +// even without the augmentation; the invalid one only fails if it merged. + +declare module '@solidtv/solid' { + // demo app LeftNavWrapper.tsx, announcer.ts + interface ElementNode { + contractItem?: { href: string }; + } + interface NodeProps { + contractNodeProp?: number; + } + interface NodeStyles { + contractNodeStyle?: number; + } + interface TextStyles { + contractTextStyle?: number; + } + // demo app styles.ts: app-specific states + interface IntrinsicNodeStyleProps { + contractActive?: IntrinsicNodeStyleProps; + } + interface IntrinsicTextNodeStyleProps { + contractActive?: IntrinsicTextNodeStyleProps; + } + // demo app LeftNavWrapper.tsx: custom keys, required like the demo's + interface KeyMap { + ContractMenu: (string | number)[]; + } +} + +export function elementNodeAugmentation(el: ElementNode) { + const href: string | undefined = el.contractItem?.href; + // @ts-expect-error -- contractItem.href is a string + const wrong: number | undefined = el.contractItem?.href; + // An ElementNode member is also a JSX prop. + const ok = ; + // @ts-expect-error -- contractItem.href is a string + const bad = ; + return [href, wrong, ok, bad]; +} + +export function nodePropsAugmentation() { + const ok = ; + // @ts-expect-error -- contractNodeProp is a number + const bad = ; + return [ok, bad]; +} + +export function nodeStylesAugmentation() { + const style: NodeStyles = { contractNodeStyle: 1, alpha: 0.5 }; + // @ts-expect-error -- contractNodeStyle is a number + const bad: NodeStyles = { contractNodeStyle: '1' }; + const inJsx = ; + return [style, bad, inJsx]; +} + +export function textStylesAugmentation() { + const style: TextStyles = { contractTextStyle: 1, fontSize: 20 }; + // @ts-expect-error -- contractTextStyle is a number + const bad: TextStyles = { contractTextStyle: '1' }; + const inJsx = x; + return [style, bad, inJsx]; +} + +export function intrinsicStylePropsAugmentation() { + const node = { + alpha: 1, + contractActive: { alpha: 0.5 }, + } satisfies IntrinsicNodeStyleProps; + const text = { + fontSize: 20, + contractActive: { fontSize: 30 }, + } satisfies IntrinsicTextNodeStyleProps; + // @ts-expect-error -- contractActive is a style object + const bad: IntrinsicNodeStyleProps = { contractActive: 1 }; + // @ts-expect-error -- contractActive is a style object + const badText: IntrinsicTextNodeStyleProps = { contractActive: 1 }; + return [node, text, bad, badText]; +} + +export function keyMapAugmentation() { + useFocusManager({ ContractMenu: ['m', 77], Left: ['ArrowLeft', 37] }); + const keyMap = { + Left: 'ArrowLeft', + Right: 'ArrowRight', + Up: 'ArrowUp', + Down: 'ArrowDown', + Enter: 'Enter', + Last: null, + ContractMenu: ['m'], + } satisfies KeyMap; + // @ts-expect-error -- the augmented member is required + const missing: KeyMap = { + Left: null, + Right: null, + Up: null, + Down: null, + Enter: null, + Last: null, + }; + // @ts-expect-error -- the index signature allows null, ContractMenu does not + const nullMenu: KeyMap['ContractMenu'] = null; + // The primitives entry exports the same, augmented KeyMap. + const viaPrimitives: PrimitivesKeyMap['ContractMenu'] = ['m']; + // @ts-expect-error -- the index signature allows null, ContractMenu does not + const nullViaPrimitives: PrimitivesKeyMap['ContractMenu'] = null; + return [keyMap, missing, nullMenu, viaPrimitives, nullViaPrimitives]; +} + +// --- Exports: type-only names ---------------------------------------------- +// +// Every type-only export of each entry point today. Importing a name that is +// gone is a type error. (Runtime names are pinned in publicApi.test.ts.) + +// @solidtv/solid: Solid's own types (82). Six of them, the Shader*Props +// names and WebGlShader, are re-exported by src/core/shaders.ts from +// @solidtv/renderer/webgl[/shaders] (the renderer's root does not export +// them, so they are not in the next list). +export type { + AddColorString, + AnimationEventHandler, + AnimationEvents, + AnimationSettings, + BorderRadius, + BorderStyle, + BorderStyleObject, + CoreShaderManager, + DefaultKeyMap, + DollarString, + DomRendererMainSettings, + Effects, + ElementText, + EventHandlers, + ExtractProps, + FocusNode, + FontLoadOptions, + ForwardFocusHandler, + IEventEmitter, + IRendererCoreRenderer, + IRendererFontManager, + IRendererMain, + IRendererNode, + IRendererNodeProps, + IRendererNodeShaded, + IRendererShader, + IRendererShaderManager, + IRendererShaderProps, + IRendererShaderType, + IRendererStage, + IRendererTextNode, + IRendererTextNodeProps, + IntrinsicNodeProps, + IntrinsicNodeStyleProps, + IntrinsicTextNodeStyleProps, + JSX, + KeyEventLike, + KeyEventTarget, + KeyHandler, + KeyHandlerReturn, + KeyMap, + KeyNameOrKeyCode, + NewOmit, + NodeProps, + NodeStyles, + NodeTypes, + OnEvent, + Rect, + RemoveUnderscoreProps, + RenderedNode, + RendererNode, + SdfFontType, + ShaderBorderPrefixedProps, + ShaderBorderProps, + ShaderHolePunch, + ShaderHolePunchProps, + ShaderLinearGradient, + ShaderLinearGradientProps, + ShaderRadialGradient, + ShaderRadialGradientProps, + ShaderRounded, + ShaderRoundedProps, + ShaderRoundedWithBorder, + ShaderRoundedWithBorderAndShadow, + ShaderRoundedWithBorderAndShadowProps, + ShaderRoundedWithBorderProps, + ShaderRoundedWithShadow, + ShaderRoundedWithShadowProps, + ShaderShadow, + ShaderShadowPrefixedProps, + ShaderShadowProps, + SingleBorderStyle, + SingleBorderStyleObject, + SolidNode, + SolidRendererOptions, + SolidStyles, + StyleEffects, + Styles, + TextProps, + TextStyles, + Vec4, + WebGlShader, +} from '@solidtv/solid'; + +// @solidtv/solid: the renderer's names, via `export type * from +// '@solidtv/renderer'` in src/core/index.ts: 99 on the installed +// @solidtv/renderer 1.9 (AnimationSettings is Solid's own). Some are values +// in the renderer but type-only here. A renderer-side rename or removal is a +// break inherited from the renderer. +export type { + AdvShaderProp, + AdvancedShaderProp, + AnimationControllerState, + AnimationTickPayload, + BorderProps, + BorderTemplate, + CompressedData, + CoreNodeRenderState, + CoreShaderNode, + CoreShaderType, + CoreTextureManager, + Dimensions, + EdgeFadeProps, + EdgeFadeTemplate, + FRAME_TIME_BUCKET_COUNT, + FRAME_TIME_COARSE_MS, + FRAME_TIME_FINE_MS, + FRAME_TIME_MAX_MS, + FRAME_TIME_SPLIT_MS, + FontPrefetchOptions, + FpsUpdatePayload, + FrameTickPayload, + HolePunchProps, + HolePunchTemplate, + IAnimationController, + INode, + INodeAnimateProps, + INodeProps, + ITextNode, + ITextNodeProps, + ImageTexture, + Inspector, + LinearGradientProps, + LinearGradientTemplate, + MemoryInfo, + NodeFailedEventHandler, + NodeFailedPayload, + NodeLoadedEventHandler, + NodeLoadedPayload, + NodeRenderableEventHandler, + NodeRenderablePayload, + NodeTextFailedPayload, + NodeTextLoadedPayload, + NodeTextureFailedPayload, + NodeTextureFreedPayload, + NodeTextureLoadedPayload, + NodeViewportPayload, + RadialGradientProps, + RadialGradientTemplate, + RenderUpdatePayload, + RendererCapabilities, + RendererMain, + RendererMainContextLostEvent, + RendererMainCriticalCleanupEvent, + RendererMainCriticalCleanupFailedEvent, + RendererMainFpsUpdateEvent, + RendererMainFrameTickEvent, + RendererMainIdleEvent, + RendererMainOutOfMemoryEvent, + RendererMainRenderUpdateEvent, + RendererMainSettings, + RendererRuntimeSettings, + RoundedProps, + RoundedTemplate, + ShaderProgramSources, + ShaderProp, + ShaderProps, + ShadowProps, + ShadowTemplate, + Stage, + StageFpsUpdateHandler, + StageFrameTickHandler, + StageOptions, + Texture, + TextureCoords, + TextureData, + TextureError, + TextureErrorCode, + TextureFailedEventHandler, + TextureFreedEventHandler, + TextureLoadedEventHandler, + TextureLoadingEventHandler, + TextureMap, + TextureState, + TextureType, + TimingFunction, + WebGlCoreCtxTexture, + WebGlCoreRenderer, + WebGlShaderProgram, + clearFontPrefetch, + closeImageBitmap, + frameTimeBucketLowerBound, + getBorderProps, + getShadowProps, + isAdvancedShaderProp, + isTextureError, + prefetchFont, + resolveShaderProps, + resolveTargetFPS, +} from '@solidtv/solid'; + +// @solidtv/solid/primitives (28). +export type { + AnyFunction, + ColumnProps, + FocusHistoryEntry, + GridItemProps, + GridProps, + HoldCallback, + HoldHandler, + ImageProps, + KeyEventLike as PrimitivesKeyEventLike, + KeyEventTarget as PrimitivesKeyEventTarget, + KeyHandler as PrimitivesKeyHandler, + KeyMap as PrimitivesKeyMap, + MarqueeAnimationProps, + MarqueeControlProps, + MarqueeProps, + MarqueeTextProps, + NavigableElement, + NavigableProps, + NavigableStyleProperties, + OnSelectedChanged, + RowProps, + ScrollableElement, + Scroller, + SpeechType, + SpriteDef, + UseHoldProps, + VirtualGridProps, + VirtualProps, +} from '@solidtv/solid/primitives'; + +// @solidtv/solid/primitives/router (3). +export type { + Branch, + HashRouterProps, + KeepAliveElement, +} from '@solidtv/solid/primitives/router'; + +// @solidtv/solid/devtools (1). +export type { HexColorTransformOptions } from '@solidtv/solid/devtools'; diff --git a/tests/contract-virtual-grid.test.tsx b/tests/contract-virtual-grid.test.tsx new file mode 100644 index 0000000..6d4fac2 --- /dev/null +++ b/tests/contract-virtual-grid.test.tsx @@ -0,0 +1,469 @@ +// Contract tests: VirtualGrid (mounted rows per press, window shift, +// selected -> data index mapping, final y, onSelectedChanged, selected, +// onEndReached, buffer/rows/columns). +// +// Pins today's behaviour (1.6.4) through the public surface: keys go through +// the focus manager's real keydown listener; assertions read the focused +// element, `selected`, `cursor` (documented as the data index), the data +// items of the mounted children, callback arguments and final y. +// Animations are off so positions are final. +// +// Note: VirtualGrid has no `upCount` prop. `upCount` belongs to LazyRow / +// LazyColumn (contract-lazy.test.tsx) and to Row/Column scroll="bounded" +// (contract-row-column.test.tsx). +import * as v from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { + VirtualGrid, + useFocusManager, + type KeyEventTarget, +} from '@solidtv/solid/primitives'; +import { renderer } from './setup.js'; + +// Real KeyboardEvents on the target the focus manager listens to. A private +// target (instead of `document`) keeps listeners other files leave on +// `document` (vitest isolate: false) from handling a press twice. +const keys = new EventTarget(); +const flush = () => new Promise((r) => setTimeout(r, 0)); + +async function press(...names: string[]) { + for (const key of names) { + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + await flush(); + } +} + +async function mount(ui: () => s.JSX.Element) { + const dispose = renderer.render(() => { + useFocusManager(undefined, keys as unknown as KeyEventTarget); + return ui(); + }) as unknown as () => void; + await flush(); + return dispose; +} + +const focusedId = () => lng.activeElement()?.id; + +/** Data items of the mounted children, as "first-last" (they are contiguous). */ +const mounted = (el: lng.ElementNode) => { + const items = el.children.map((c) => (c as lng.ElementNode).item as number); + return `${items[0]}-${items[items.length - 1]}`; +}; + +// Each child carries its data item as `item`; VirtualGrid finds the selected +// child by it. 200x100 cells; the grid is 700 wide so 3 fit per line. +const Cell = (props: { item: number }) => ( + +); + +const range = (n: number) => Array.from({ length: n }, (_, i) => i); + +let dispose: (() => void) | undefined; +const animationsEnabled = lng.Config.animationsEnabled; + +v.beforeAll(() => { + lng.Config.animationsEnabled = false; +}); +v.afterAll(() => { + lng.Config.animationsEnabled = animationsEnabled; +}); +v.afterEach(() => { + dispose?.(); + dispose = undefined; +}); + +v.describe('VirtualGrid', () => { + v.it( + 'mounts (row + buffer) * columns + columns * rows items and lays them out with flex wrap', + async () => { + let grid!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect([focusedId(), grid.selected, grid.cursor, grid.y]).toEqual([ + 'g0', + 0, + 0, + 40, + ]); + // Row 0, buffer 1: items 0 .. (0 + 1) * 3 + 3 * 2 - 1. + v.expect(mounted(grid)).toBe('0-8'); + v.expect(grid.children.map((c) => [c.x, c.y])).toEqual([ + [0, 0], + [200, 0], + [400, 0], + [0, 100], + [200, 100], + [400, 100], + [0, 200], + [200, 200], + [400, 200], + ]); + }, + ); + + v.it( + 'navigation: mounted items, selected, cursor, y and onEndReached after each press', + async () => { + let grid!: lng.ElementNode; + const onEndReached = v.vi.fn(); + const parent = v.vi.fn(() => true); + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen: unknown[] = []; + for (const key of [ + 'ArrowRight', + 'ArrowRight', + // Left/Right walk the mounted children in order, so Right at the end + // of a line moves to the start of the next one. + 'ArrowRight', + 'ArrowDown', + 'ArrowDown', + 'ArrowDown', + 'ArrowDown', + 'ArrowDown', + 'ArrowLeft', + 'ArrowUp', + 'ArrowUp', + 'ArrowUp', + 'ArrowUp', + 'ArrowUp', + 'ArrowUp', // top row: bubbles + ]) { + await press(key); + seen.push([ + key, + focusedId(), + grid.selected, + grid.cursor, + grid.y, + mounted(grid), + onEndReached.mock.calls.length, + parent.mock.calls.length, + ]); + } + v.expect(seen).toEqual([ + ['ArrowRight', 'g1', 1, 1, 40, '0-8', 0, 0], + ['ArrowRight', 'g2', 2, 2, 40, '0-8', 0, 0], + ['ArrowRight', 'g3', 3, 3, -60, '0-11', 0, 0], + ['ArrowDown', 'g6', 3, 6, -60, '3-14', 0, 0], + ['ArrowDown', 'g9', 3, 9, -60, '6-17', 0, 0], + ['ArrowDown', 'g12', 3, 12, -60, '9-19', 0, 0], + ['ArrowDown', 'g15', 3, 15, -60, '12-19', 0, 0], + // 18 >= 20 - 4: onEndReached fires (only on a row change). + ['ArrowDown', 'g18', 3, 18, -60, '15-19', 1, 0], + ['ArrowLeft', 'g17', 5, 17, -60, '12-19', 2, 0], + ['ArrowUp', 'g14', 5, 14, -60, '9-19', 2, 0], + ['ArrowUp', 'g11', 5, 11, -60, '6-17', 2, 0], + ['ArrowUp', 'g8', 5, 8, -60, '3-14', 2, 0], + ['ArrowUp', 'g5', 5, 5, -60, '0-11', 2, 0], + ['ArrowUp', 'g2', 2, 2, 40, '0-8', 2, 0], + ['ArrowUp', 'g2', 2, 2, 40, '0-8', 2, 1], + ]); + }, + ); + + v.it('defaults: rows 1, buffer 2, scroll "always"', async () => { + let grid!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen: unknown[] = [ + [focusedId(), grid.selected, grid.cursor, grid.y, mounted(grid)], + ]; + for (let i = 0; i < 4; i++) { + await press('ArrowDown'); + seen.push([ + focusedId(), + grid.selected, + grid.cursor, + grid.y, + mounted(grid), + ]); + } + v.expect(seen).toEqual([ + ['g0', 0, 0, 40, '0-8'], + ['g3', 3, 3, -60, '0-11'], + ['g6', 6, 6, -160, '0-14'], + ['g9', 6, 9, -160, '3-17'], + ['g12', 6, 12, -160, '6-19'], + ]); + }); + + v.it( + 'onSelectedChanged is called as (idx, grid, active, lastIdx), this = grid; idx is the child index before the window shift; twice on autofocus mount', + async () => { + let grid!: lng.ElementNode; + const calls: unknown[][] = []; + dispose = await mount(() => ( + + + {(item) => } + + + )); + // VirtualGrid's callback is sometimes described as (idx, col, elm); it + // gets four arguments like Row/Column. + // Fires twice on mount: from the selected effect and from forwardFocus. + v.expect(calls).toEqual([ + [true, 4, 0, true, 'g0', 0], + [true, 4, 0, true, 'g0', 0], + ]); + calls.length = 0; + await press('ArrowRight', 'ArrowRight', 'ArrowDown', 'ArrowDown'); + v.expect(calls).toEqual([ + [true, 4, 1, true, 'g1', 0], + [true, 4, 2, true, 'g2', 1], + [true, 4, 5, true, 'g5', 2], + // g8 is child 8 of 0-11 when the callback runs; the window then moves + // to 3-14 and selected becomes 5. + [true, 4, 8, true, 'g8', 5], + ]); + v.expect([grid.selected, grid.cursor, mounted(grid)]).toEqual([ + 5, + 8, + '3-14', + ]); + }, + ); + + v.it( + 'selected (reactive) is a data index; past the end it calls onEndReached and is applied once the items arrive', + async () => { + let grid!: lng.ElementNode; + const calls: unknown[][] = []; + const onEndReached = v.vi.fn(); + const [sel, setSel] = s.createSignal(undefined); + const [items, setItems] = s.createSignal(range(20)); + dispose = await mount(() => ( + + + calls.push([idx, active?.id, lastIdx]) + } + > + {(item) => } + + + )); + calls.length = 0; + const state = () => [ + focusedId(), + grid.selected, + grid.cursor, + grid.y, + mounted(grid), + onEndReached.mock.calls.length, + ]; + + setSel(10); + await flush(); + v.expect(state()).toEqual(['g10', 4, 10, -60, '6-17', 0]); + // Current behaviour: lastIdx is the new data index (the `selected` prop + // already wrote it to the node), not the previous child index. + v.expect(calls).toEqual([[4, 'g10', 10]]); + calls.length = 0; + + // Past the end: onEndReached (no threshold needed), focus stays. + setSel(25); + await flush(); + v.expect([ + focusedId(), + grid.cursor, + onEndReached.mock.calls.length, + ]).toEqual(['g10', 10, 1]); + v.expect(calls).toEqual([]); + + setItems(range(30)); + await flush(); + v.expect(state()).toEqual(['g25', 4, 25, -60, '21-29', 1]); + v.expect(calls).toEqual([ + [4, 'g25', 25], + [4, 'g25', 25], + ]); + + await press('ArrowRight'); + v.expect(state()).toEqual(['g26', 5, 26, -60, '21-29', 1]); + }, + ); + + // Today: with autofocus and selected={10}, the first forwardFocus runs + // before the selected effect and treats 10 as a child index of the initial + // window (items 6-17), i.e. item 16; the grid ends on g16 with cursor 16. + // `selected={props.selected || 0}` passes the data index straight to the + // node (VirtualGrid.tsx:210) and navigableForwardFocus reads it as a child + // index. Without autofocus (setFocus later) g10 is focused correctly. + v.it.skip( + 'BUG: autofocus with an initial selected past the first window focuses the wrong item (VirtualGrid.tsx:210)', + async () => { + let grid!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect([focusedId(), grid.cursor]).toEqual(['g10', 10]); + }, + ); + + // Today: after selected 10 -> 1, g1 is focused with y still -60, so the + // top row is drawn 60px above the grid's start. updateSelected reads + // lastSelected from the node (VirtualGrid.tsx:145) after the reactive + // `selected` prop has overwritten it with the new value, so the row-change + // check in onSelectedChanged (line 103) sees no change and skips the scroll. + v.it.skip( + 'BUG: reactive selected back to the first row leaves the grid scrolled (VirtualGrid.tsx:145)', + async () => { + let grid!: lng.ElementNode; + const [sel, setSel] = s.createSignal(undefined); + dispose = await mount(() => ( + + + {(item) => } + + + )); + setSel(10); + await flush(); + setSel(1); + await flush(); + v.expect([focusedId(), grid.cursor, grid.y]).toEqual(['g1', 1, 40]); + }, + ); + + // Today: Down from g17 (no item below it) bubbles but leaves `selected` at + // 8, past the 8 mounted children; the next Up computes 8 - 3 = 5, lands on + // g17 again and the press is lost. onVerticalNav writes this.selected + // (VirtualGrid.tsx:74) before checking that the child exists (line 77). + // Its last-row guard (line 63, maxRows = floor(length / columns)) is also + // one row late when length is a multiple of columns. + v.it.skip( + 'BUG: a Down with nothing below corrupts selected, so the next Up is lost (VirtualGrid.tsx:74)', + async () => { + let grid!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + await press('ArrowRight', 'ArrowRight'); + await press(...Array.from({ length: 5 }, () => 'ArrowDown')); + v.expect(focusedId()).toBe('g17'); + const selectedBefore = grid.selected; + await press('ArrowDown'); // nothing below g17 + v.expect([focusedId(), grid.selected]).toEqual(['g17', selectedBefore]); + await press('ArrowUp'); + v.expect(focusedId()).toBe('g14'); + }, + ); +}); diff --git a/tests/contract-virtual.test.tsx b/tests/contract-virtual.test.tsx new file mode 100644 index 0000000..f13e044 --- /dev/null +++ b/tests/contract-virtual.test.tsx @@ -0,0 +1,616 @@ +// Contract tests: VirtualRow and VirtualColumn (window of mounted +// items, window shift per press, selected -> data index mapping, final x/y, +// scroll modes, wrap, displaySize, bufferSize, selected, onSelectedChanged, +// onEndReached). +// +// Pins today's behaviour (1.6.4) through the public surface: keys go through +// the focus manager's real keydown listener; assertions read the focused +// element, `selected`, `cursor` (documented as the data index), the data +// items of the mounted children, callback arguments and final x/y. +// Animations are off so positions are final. +import * as v from 'vitest'; +import * as s from 'solid-js'; +import * as lng from '@solidtv/solid'; +import { + VirtualRow, + VirtualColumn, + useFocusManager, + type KeyEventTarget, +} from '@solidtv/solid/primitives'; +import { renderer } from './setup.js'; + +// Real KeyboardEvents on the target the focus manager listens to. A private +// target (instead of `document`) keeps listeners other files leave on +// `document` (vitest isolate: false) from handling a press twice. +const keys = new EventTarget(); +const flush = () => new Promise((r) => setTimeout(r, 0)); + +async function press(...names: string[]) { + for (const key of names) { + keys.dispatchEvent(new KeyboardEvent('keydown', { key })); + // Focus and the window shift settle in microtasks. + await flush(); + } +} + +async function mount(ui: () => s.JSX.Element) { + const dispose = renderer.render(() => { + useFocusManager(undefined, keys as unknown as KeyEventTarget); + return ui(); + }) as unknown as () => void; + await flush(); + return dispose; +} + +const focusedId = () => lng.activeElement()?.id; + +/** Data items of the mounted children, in order. */ +const mounted = (el: lng.ElementNode) => + el.children.map((c) => (c as lng.ElementNode).item as number).join(','); + +type Step = [ + focused: string | undefined, + selected: number | undefined, + cursor: number, + pos: number, + mounted: string, +]; + +const rowStep = (row: lng.ElementNode): Step => [ + focusedId(), + row.selected, + row.cursor as number, + row.x, + mounted(row), +]; + +// Each child carries its data item as `item`, as the docs' Thumbnail does; +// VirtualRow finds the selected child by it. +const Item = (props: { item: number }) => ( + +); + +const twelve = Array.from({ length: 12 }, (_, i) => i); + +let dispose: (() => void) | undefined; +const animationsEnabled = lng.Config.animationsEnabled; + +v.beforeAll(() => { + lng.Config.animationsEnabled = false; +}); +v.afterAll(() => { + lng.Config.animationsEnabled = animationsEnabled; +}); +v.afterEach(() => { + dispose?.(); + dispose = undefined; +}); + +v.describe('VirtualRow: window and scroll modes', () => { + v.it( + 'mounts displaySize + bufferSize (default 2) items, laid out by flex with gap 30', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect(rowStep(row)).toEqual(['v0', 0, 0, 50, '0,1,2,3,4,5']); + v.expect(row.children.map((c) => c.x)).toEqual([ + 0, 230, 460, 690, 920, 1150, + ]); + }, + ); + + // 12 items, displaySize 4, x=50, 200-wide children + gap 30. Thirteen Right + // presses then four Left presses; after each press: + // [focused, selected, cursor, x, data items mounted]. + const cases: Record = + { + auto: { + initial: ['v0', 0, 0, 50, '0,1,2,3,4,5'], + right: [ + ['v1', 1, 1, -180, '0,1,2,3,4,5'], + ['v2', 1, 2, -180, '1,2,3,4,5,6'], + ['v3', 1, 3, -180, '2,3,4,5,6,7'], + ['v4', 1, 4, -180, '3,4,5,6,7,8'], + ['v5', 1, 5, -180, '4,5,6,7,8,9'], + ['v6', 1, 6, -180, '5,6,7,8,9,10'], + ['v7', 1, 7, -180, '6,7,8,9,10,11'], + // The window runs off the end of the data and shrinks. + ['v8', 1, 8, -180, '7,8,9,10,11'], + ['v9', 1, 9, -180, '8,9,10,11'], + ['v10', 2, 10, -180, '8,9,10,11'], + ['v11', 3, 11, -180, '8,9,10,11'], + ['v11', 3, 11, -180, '8,9,10,11'], + ['v11', 3, 11, -180, '8,9,10,11'], + ], + left: [ + ['v10', 3, 10, -180, '7,8,9,10,11'], + ['v9', 3, 9, -180, '6,7,8,9,10,11'], + ['v8', 3, 8, -180, '5,6,7,8,9,10'], + ['v7', 3, 7, -180, '4,5,6,7,8,9'], + ], + }, + 'auto + wrap': { + // Wrap keeps one item before the selection: the window starts at the + // last item and x is shifted left by one slot. + initial: ['v0', 1, 0, -180, '11,0,1,2,3,4'], + right: [ + ['v1', 1, 1, -180, '0,1,2,3,4,5'], + ['v2', 1, 2, -180, '1,2,3,4,5,6'], + ['v3', 1, 3, -180, '2,3,4,5,6,7'], + ['v4', 1, 4, -180, '3,4,5,6,7,8'], + ['v5', 1, 5, -180, '4,5,6,7,8,9'], + ['v6', 1, 6, -180, '5,6,7,8,9,10'], + ['v7', 1, 7, -180, '6,7,8,9,10,11'], + ['v8', 1, 8, -180, '7,8,9,10,11,0'], + ['v9', 1, 9, -180, '8,9,10,11,0,1'], + ['v10', 1, 10, -180, '9,10,11,0,1,2'], + ['v11', 1, 11, -180, '10,11,0,1,2,3'], + ['v0', 1, 0, -180, '11,0,1,2,3,4'], + ['v1', 1, 1, -180, '0,1,2,3,4,5'], + ], + left: [ + ['v0', 1, 0, -180, '11,0,1,2,3,4'], + ['v11', 1, 11, -180, '10,11,0,1,2,3'], + ['v10', 1, 10, -180, '9,10,11,0,1,2'], + ['v9', 1, 9, -180, '8,9,10,11,0,1'], + ], + }, + edge: { + initial: ['v0', 0, 0, 50, '0,1,2,3,4,5'], + right: [ + ['v1', 1, 1, 50, '0,1,2,3,4,5'], + ['v2', 2, 2, 50, '0,1,2,3,4,5'], + ['v3', 3, 3, 50, '0,1,2,3,4,5'], + ['v4', 4, 4, -180, '0,1,2,3,4,5'], + ['v5', 4, 5, -180, '1,2,3,4,5,6'], + ['v6', 4, 6, -180, '2,3,4,5,6,7'], + ['v7', 4, 7, -180, '3,4,5,6,7,8'], + ['v8', 4, 8, -180, '4,5,6,7,8,9'], + ['v9', 4, 9, -180, '5,6,7,8,9,10'], + ['v10', 4, 10, -180, '6,7,8,9,10,11'], + ['v11', 4, 11, -180, '7,8,9,10,11'], + ['v11', 4, 11, -180, '7,8,9,10,11'], + ['v11', 4, 11, -180, '7,8,9,10,11'], + ], + left: [ + ['v10', 3, 10, -180, '7,8,9,10,11'], + ['v9', 2, 9, -180, '7,8,9,10,11'], + ['v8', 1, 8, -180, '7,8,9,10,11'], + ['v7', 1, 7, -180, '6,7,8,9,10,11'], + ], + }, + 'edge + wrap': { + initial: ['v0', 1, 0, -180, '11,0,1,2,3,4'], + right: [ + ['v1', 2, 1, -180, '11,0,1,2,3,4'], + ['v2', 3, 2, -180, '11,0,1,2,3,4'], + ['v3', 4, 3, -180, '11,0,1,2,3,4'], + ['v4', 4, 4, -180, '0,1,2,3,4,5'], + ['v5', 4, 5, -180, '1,2,3,4,5,6'], + ['v6', 4, 6, -180, '2,3,4,5,6,7'], + ['v7', 4, 7, -180, '3,4,5,6,7,8'], + ['v8', 4, 8, -180, '4,5,6,7,8,9'], + ['v9', 4, 9, -180, '5,6,7,8,9,10'], + ['v10', 4, 10, -180, '6,7,8,9,10,11'], + ['v11', 4, 11, -180, '7,8,9,10,11,0'], + ['v0', 4, 0, -180, '8,9,10,11,0,1'], + ['v1', 4, 1, -180, '9,10,11,0,1,2'], + ], + left: [ + ['v0', 3, 0, -180, '9,10,11,0,1,2'], + ['v11', 2, 11, -180, '9,10,11,0,1,2'], + ['v10', 1, 10, -180, '9,10,11,0,1,2'], + ['v9', 1, 9, -180, '8,9,10,11,0,1'], + ], + }, + always: { + initial: ['v0', 0, 0, 50, '0,1,2,3,4,5'], + right: [ + ['v1', 1, 1, -180, '0,1,2,3,4,5'], + ['v2', 2, 2, -410, '0,1,2,3,4,5'], + ['v3', 2, 3, -410, '1,2,3,4,5,6'], + ['v4', 2, 4, -410, '2,3,4,5,6,7'], + ['v5', 2, 5, -410, '3,4,5,6,7,8'], + ['v6', 2, 6, -410, '4,5,6,7,8,9'], + ['v7', 2, 7, -410, '5,6,7,8,9,10'], + ['v8', 2, 8, -410, '6,7,8,9,10,11'], + ['v9', 3, 9, -640, '6,7,8,9,10,11'], + ['v10', 4, 10, -870, '6,7,8,9,10,11'], + ['v11', 5, 11, -1100, '6,7,8,9,10,11'], + ['v11', 5, 11, -1100, '6,7,8,9,10,11'], + ['v11', 5, 11, -1100, '6,7,8,9,10,11'], + ], + left: [ + ['v10', 4, 10, -870, '6,7,8,9,10,11'], + ['v9', 3, 9, -640, '6,7,8,9,10,11'], + ['v8', 2, 8, -410, '6,7,8,9,10,11'], + ['v7', 2, 7, -410, '5,6,7,8,9,10'], + ], + }, + }; + // 'always + wrap' produces exactly the 'auto + wrap' sequence today. + cases['always + wrap'] = cases['auto + wrap']!; + + for (const [name, expected] of Object.entries(cases)) { + v.it( + `scroll="${name}": mounted items, selected, cursor and x after each press`, + async () => { + const [mode, wrap] = name.split(' + ') as [ + 'auto' | 'edge' | 'always', + string | undefined, + ]; + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + v.expect(rowStep(row)).toEqual(expected.initial); + const right: Step[] = []; + for (let i = 0; i < 13; i++) { + await press('ArrowRight'); + right.push(rowStep(row)); + } + v.expect(right).toEqual(expected.right); + const left: Step[] = []; + for (let i = 0; i < 4; i++) { + await press('ArrowLeft'); + left.push(rowStep(row)); + } + v.expect(left).toEqual(expected.left); + }, + ); + } + + // Today scroll="none" (and "center", which VirtualRow does not implement + // and treats like "none") never moves the window: only items 0-5 are ever + // mounted, Right stops at v5 and the press bubbles; cursor never passes 5. + // Row and Column with scroll="none" keep every child reachable. + v.it.skip( + 'BUG: scroll="none"/"center" on VirtualRow cannot reach items past displaySize + bufferSize (Virtual.tsx:329-334)', + async () => { + for (const mode of ['none', 'center'] as const) { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + await press(...Array.from({ length: 11 }, () => 'ArrowRight')); + v.expect([focusedId(), row.cursor]).toEqual(['v11', 11]); + dispose(); + dispose = undefined; + } + }, + ); + + v.it( + 'displaySize >= item count: everything is mounted, nothing shifts, wrap wraps the children', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen: Step[] = [rowStep(row)]; + for (const key of [ + 'ArrowRight', + 'ArrowRight', + 'ArrowRight', + 'ArrowLeft', + ]) { + await press(key); + seen.push(rowStep(row)); + } + v.expect(seen).toEqual([ + ['v0', 0, 0, 50, '0,1,2'], + ['v1', 1, 1, 50, '0,1,2'], + ['v2', 2, 2, 50, '0,1,2'], + ['v0', 0, 0, 50, '0,1,2'], + ['v2', 2, 2, 50, '0,1,2'], + ]); + }, + ); +}); + +v.describe('VirtualRow: bufferSize and onEndReached', () => { + v.it( + 'bufferSize sets how many extra items are mounted; onEndReached fires on every press within onEndReachedThreshold; appended items extend the window', + async () => { + let row!: lng.ElementNode; + const [items, setItems] = s.createSignal(twelve); + const onEndReached = v.vi.fn(); + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen: [...Step, number][] = [[...rowStep(row), 0]]; + for (let i = 0; i < 11; i++) { + await press('ArrowRight'); + seen.push([...rowStep(row), onEndReached.mock.calls.length]); + } + // displaySize 3 + bufferSize 1 = 4 mounted. Threshold 3 of 12: fires once + // the cursor reaches 9, and again on each later press. + v.expect(seen).toEqual([ + ['v0', 0, 0, 50, '0,1,2,3', 0], + ['v1', 1, 1, -180, '0,1,2,3', 0], + ['v2', 1, 2, -180, '1,2,3,4', 0], + ['v3', 1, 3, -180, '2,3,4,5', 0], + ['v4', 1, 4, -180, '3,4,5,6', 0], + ['v5', 1, 5, -180, '4,5,6,7', 0], + ['v6', 1, 6, -180, '5,6,7,8', 0], + ['v7', 1, 7, -180, '6,7,8,9', 0], + ['v8', 1, 8, -180, '7,8,9,10', 0], + ['v9', 1, 9, -180, '8,9,10,11', 1], + ['v10', 1, 10, -180, '9,10,11', 2], + ['v11', 2, 11, -180, '9,10,11', 3], + ]); + + // The app appends a page in response. + setItems(Array.from({ length: 16 }, (_, i) => i)); + await flush(); + v.expect(rowStep(row)).toEqual(['v11', 1, 11, -180, '10,11,12,13']); + await press('ArrowRight', 'ArrowRight'); + v.expect(rowStep(row)).toEqual(['v13', 1, 13, -180, '12,13,14,15']); + // 16 - 3 = 13: fires for cursor 13 but not 12. + v.expect(onEndReached).toHaveBeenCalledTimes(4); + }, + ); + + v.it( + 'onEndReached is never called without onEndReachedThreshold', + async () => { + let row!: lng.ElementNode; + const onEndReached = v.vi.fn(); + dispose = await mount(() => ( + + + {(item) => } + + + )); + await press(...Array.from({ length: 12 }, () => 'ArrowRight')); + v.expect(row.cursor).toBe(11); + v.expect(onEndReached).not.toHaveBeenCalled(); + }, + ); +}); + +v.describe('VirtualRow: onSelectedChanged and selected', () => { + v.it( + 'onSelectedChanged is called as (idx, container, active, lastIdx), this = container; idx is the child index before the window shift; this.cursor is still the previous data index', + async () => { + let row!: lng.ElementNode; + const calls: unknown[][] = []; + dispose = await mount(() => ( + + + {(item) => } + + + )); + await press('ArrowRight', 'ArrowRight', 'ArrowRight'); + // [this, container, idx, active.item, lastIdx, this.cursor] + v.expect(calls).toEqual([ + // Once on mount (autofocus). + [true, true, 0, 0, 0, 0], + [true, true, 1, 1, 0, 0], + // The window then shifts and selected is corrected back to 1. + [true, true, 2, 2, 1, 1], + [true, true, 2, 3, 1, 2], + ]); + // After the press cursor is the data index of the focused item. + v.expect([row.cursor, row.selected, focusedId()]).toEqual([3, 1, 'v3']); + }, + ); + + v.it( + 'selected: initial value is a data index; a reactive change moves focus and the window without onSelectedChanged', + async () => { + let row!: lng.ElementNode; + const [sel, setSel] = s.createSignal(5); + const calls: unknown[][] = []; + dispose = await mount(() => ( + + + calls.push([idx, active.id, lastIdx]) + } + > + {(item) => } + + + )); + v.expect(rowStep(row)).toEqual(['v5', 1, 5, -180, '4,5,6,7,8,9']); + v.expect(calls).toEqual([[1, 'v5', 1]]); + + // Current behaviour: the first press after an initial selected puts x + // back to 50 as the window shifts, so the focused item sits one slot + // further right (screen x 280) than when the row starts at 0 (screen x + // 50, see the auto case). The position used is the one saved before the + // initial shift (Virtual.tsx:500-502). + await press('ArrowRight'); + v.expect(rowStep(row)).toEqual(['v6', 1, 6, 50, '5,6,7,8,9,10']); + await press('ArrowLeft', 'ArrowLeft'); + v.expect(rowStep(row)).toEqual(['v4', 1, 4, 50, '3,4,5,6,7,8']); + calls.length = 0; + + setSel(9); + await flush(); + v.expect(rowStep(row)).toEqual(['v9', 3, 9, -180, '6,7,8,9,10,11']); + v.expect(calls).toEqual([]); + + await press('ArrowRight'); + v.expect(rowStep(row)).toEqual(['v10', 3, 10, 50, '7,8,9,10,11']); + v.expect(calls).toEqual([[4, 'v10', 3]]); + }, + ); + + v.it( + 'selected + wrap: the selected data item is placed in slot 1', + async () => { + let row!: lng.ElementNode; + dispose = await mount(() => ( + + + {(item) => } + + + )); + const seen: Step[] = [rowStep(row)]; + for (const key of ['ArrowRight', 'ArrowLeft', 'ArrowLeft']) { + await press(key); + seen.push(rowStep(row)); + } + v.expect(seen).toEqual([ + ['v5', 1, 5, -180, '4,5,6,7,8,9'], + ['v6', 1, 6, -180, '5,6,7,8,9,10'], + ['v5', 1, 5, -180, '4,5,6,7,8,9'], + ['v4', 1, 4, -180, '3,4,5,6,7,8'], + ]); + }, + ); +}); + +v.describe('VirtualColumn', () => { + v.it( + 'Down/Up shift the window and y; Left/Right are not handled', + async () => { + let col!: lng.ElementNode; + const parentRight = v.vi.fn(() => true); + dispose = await mount(() => ( + + + {(item) => } + + + )); + const colStep = (): Step => [ + focusedId(), + col.selected, + col.cursor as number, + col.y, + mounted(col), + ]; + v.expect(col.children.map((c) => c.y)).toEqual([0, 130, 260, 390, 520]); + const seen: Step[] = [colStep()]; + for (const key of [ + 'ArrowDown', + 'ArrowDown', + 'ArrowDown', + 'ArrowUp', + 'ArrowRight', + ]) { + await press(key); + seen.push(colStep()); + } + // 100-high children + gap 30: one slot is 130. + v.expect(seen).toEqual([ + ['v0', 0, 0, 50, '0,1,2,3,4'], + ['v1', 1, 1, -80, '0,1,2,3,4'], + ['v2', 1, 2, -80, '1,2,3,4,5'], + ['v3', 1, 3, -80, '2,3,4,5,6'], + ['v2', 1, 2, -80, '1,2,3,4,5'], + ['v2', 1, 2, -80, '1,2,3,4,5'], + ]); + v.expect(parentRight).toHaveBeenCalledTimes(1); + v.expect(col.x).toBe(0); + }, + ); +}); diff --git a/tests/publicApi.test.ts b/tests/publicApi.test.ts new file mode 100644 index 0000000..1d1e1eb --- /dev/null +++ b/tests/publicApi.test.ts @@ -0,0 +1,376 @@ +// Contract tests: what the JSX compiler imports, the runtime export names of +// every entry point, and the package.json `exports` map. +// +// The export lists below are today's runtime export names, sorted. Removing a +// name breaks apps; adding one is an API decision. Either way, update the list +// on purpose. Type-only exports are pinned in contract-types.tsx, which +// `pnpm tsc` type-checks (tests/tsconfig.contract.json). +import * as v from 'vitest'; +import { createRequire } from 'node:module'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as main from '@solidtv/solid'; +import * as primitives from '@solidtv/solid/primitives'; +// vitest aliases @solidjs/router to tests/stubs/solidjs-router.ts. That does +// not change this entry's export list: it re-exports nothing from the router +// (checked against TypeScript's export list, which resolves the real package). +import * as router from '@solidtv/solid/primitives/router'; +import * as devtools from '@solidtv/solid/devtools'; +import jsxLocator from '@solidtv/solid/devtools/jsx-locator'; + +const names = (mod: object) => Object.keys(mod).sort(); + +v.describe('Exports: runtime export names of every entry point', () => { + v.test('@solidtv/solid (src/index.ts): 97 names', () => { + v.expect(names(main)).toEqual([ + 'Config', + 'DOMNode', + 'DOMRendererMain', + 'DOM_RENDERING', + 'Dynamic', + 'ElementNode', + 'ErrorBoundary', + 'For', + 'Index', + 'Match', + 'NodeType', + 'SHADERS_ENABLED', + 'Show', + 'Suspense', + 'SuspenseList', + 'Switch', + 'Text', + 'TextNode', + 'View', + 'activeElement', + 'assertTruthy', + 'clamp', + 'clearTasks', + 'combineStyles', + 'combineStylesMemo', + 'convertToShader', + 'createComponent', + 'createElement', + 'createRawShaderAccessor', + 'createRenderer', + 'createTextNode', + 'defaultShaderHolePunch', + 'defaultShaderLinearGradient', + 'defaultShaderRadialGradient', + 'defaultShaderRounded', + 'defaultShaderRoundedWithBorder', + 'defaultShaderRoundedWithBorderAndShadow', + 'defaultShaderRoundedWithShadow', + 'defaultShaderShadow', + 'deg2Rad', + 'effect', + 'enqueueDelete', + 'flattenStyles', + 'getElementScreenRect', + 'getRenderer', + 'getWebglSupportedVersions', + 'hasFocus', + 'hexColor', + 'insert', + 'insertNode', + 'isArray', + 'isDev', + 'isDomRenderer', + 'isDomRendererActive', + 'isElementNode', + 'isElementText', + 'isFocused', + 'isFunction', + 'isINode', + 'isInteger', + 'isNumber', + 'isObject', + 'isString', + 'isTextNode', + 'keyExists', + 'loadFontToDom', + 'loadFonts', + 'log', + 'logRenderTree', + 'memo', + 'mergeProps', + 'mod', + 'registerDefaultShader', + 'registerDefaultShaderHolePunch', + 'registerDefaultShaderLinearGradient', + 'registerDefaultShaderRadialGradient', + 'registerDefaultShaderRounded', + 'registerDefaultShaderRoundedWithBorder', + 'registerDefaultShaderRoundedWithBorderAndShadow', + 'registerDefaultShaderRoundedWithShadow', + 'registerDefaultShaderShadow', + 'registerDefaultShaders', + 'renderer', + 'rootNode', + 'scheduleTask', + 'setActiveElement', + 'setActiveElementCore', + 'setProp', + 'setTasksEnabled', + 'shaderAccessor', + 'spliceItem', + 'spread', + 'startLightningRenderer', + 'supportsOnlyWebGL2', + 'supportsWebGL', + 'supportsWebGL2', + 'use', + ]); + }); + + v.test( + '@solidtv/solid/primitives (src/primitives/index.ts): 64 names', + () => { + v.expect(names(primitives)).toEqual([ + 'ALPHA_FULL', + 'ALPHA_NONE', + 'Announcer', + 'BorderBoxStyle', + 'Column', + 'FPSCounter', + 'FadeInOut', + 'FocusStackProvider', + 'Grid', + 'Image', + 'LazyColumn', + 'LazyRow', + 'Marquee', + 'MarqueeText', + 'Portal', + 'Row', + 'Suspense', + 'VirtualColumn', + 'VirtualGrid', + 'VirtualRow', + 'Visible', + 'activeElement', + 'addCustomStateToElement', + 'borderBox', + 'chainFunctions', + 'chainRefs', + 'checkIsInNonScrollableZone', + 'createBlurredImage', + 'createInfiniteItems', + 'createSpriteMap', + 'defaultTransitionBack', + 'defaultTransitionDown', + 'defaultTransitionForward', + 'defaultTransitionUp', + 'fadeIn', + 'fadeOut', + 'focusPath', + 'getFocusHistory', + 'handleNavigation', + 'hasCustomState', + 'isCursorVisible', + 'lazy', + 'moveSelection', + 'navigableForwardFocus', + 'navigableHandleNavigation', + 'onGridFocus', + 'printFocusHistory', + 'releaseKeySuppression', + 'removeCustomStateFromElement', + 'resetCounter', + 'scrollColumn', + 'scrollRow', + 'setActiveElement', + 'setActiveElementCore', + 'setupFPS', + 'spatialForwardFocus', + 'spatialHandleNavigation', + 'suppressKeyUntilRelease', + 'useAnnouncer', + 'useFocusManager', + 'useFocusStack', + 'useHold', + 'useMouse', + 'withScrolling', + ]); + }, + ); + + v.test( + '@solidtv/solid/primitives/router (src/primitives/routerIndex.ts): 16 names', + () => { + v.expect(names(router)).toEqual([ + 'HashRouter', + 'KeepAlive', + 'KeepAliveRoute', + 'SUPPORTS_PROXY', + 'bindEvent', + 'clearKeepAlive', + 'clearKeepAliveRoute', + 'clearKeepAliveRouteCache', + 'collectDynamicParams', + 'createMemoWithoutProxy', + 'hashParser', + 'keepAliveRouteElements', + 'removeKeepAlive', + 'removeKeepAliveRoute', + 'storeKeepAlive', + 'storeKeepAliveRoute', + ]); + }, + ); + + v.test('@solidtv/solid/devtools (src/devtools/index.ts): 2 names', () => { + v.expect(names(devtools)).toEqual([ + 'elementInterface', + 'hexColorTransform', + ]); + }); + + v.test( + '@solidtv/solid/devtools/jsx-locator is a Babel plugin (default export)', + () => { + v.expect(typeof jsxLocator).toBe('function'); + const plugin = jsxLocator({ types: {} }); + v.expect(plugin.name).toBe('solid-tv-jsx-locator'); + v.expect(typeof plugin.visitor.Program.enter).toBe('function'); + }, + ); +}); + +// vite-plugin-solid runs with these options in the demo app (vite.config.js), +// and the compiled JSX imports the universal-renderer helpers from +// @solidtv/solid. Compile a component that uses every JSX form apps use and +// read back what the compiler imports. +const COMPILER_HELPERS = [ + 'createComponent', + 'createElement', + 'createTextNode', + 'effect', + 'insert', + 'insertNode', + 'memo', + 'mergeProps', + 'setProp', + 'spread', + 'use', +]; + +const COMPONENT = ` +import { Show } from '@solidtv/solid'; +const Tile = (props) => {props.children}; +export const App = (props) => { + let ref; + return ( + + Hello {props.name}! + + + {props.cond ? : null} + + + + + {props.list} + + ); +}; +`; + +/** Compiles COMPONENT as the demo app does in dev (jsx-locator included). */ +function compile(): string { + // @babel/core and babel-preset-solid are vite-plugin-solid's own deps. + const fromVitePluginSolid = createRequire( + createRequire(import.meta.url).resolve('vite-plugin-solid'), + ); + const babel = fromVitePluginSolid('@babel/core') as { + transformSync: (code: string, opts: object) => { code: string } | null; + }; + return babel.transformSync(COMPONENT, { + filename: '/app/src/App.jsx', + cwd: '/app', + babelrc: false, + configFile: false, + plugins: [jsxLocator], + presets: [ + [ + fromVitePluginSolid.resolve('babel-preset-solid'), + { moduleName: '@solidtv/solid', generate: 'universal', builtIns: [] }, + ], + ], + })!.code; +} + +v.describe('Compiler contract: vite-plugin-solid universal output', () => { + const code = compile(); + + v.test( + 'the compiler imports exactly the universal-renderer helpers from @solidtv/solid', + () => { + const imported = [ + ...code.matchAll( + /import \{ (\w+) as _\$\w+ \} from "@solidtv\/solid";/g, + ), + ].map((m) => m[1]); + v.expect(imported.sort()).toEqual(COMPILER_HELPERS); + }, + ); + + v.test.each(COMPILER_HELPERS)('%s is exported as a function', (name) => { + v.expect(typeof (main as Record)[name]).toBe('function'); + }); + + v.test( + 'jsx-locator tags components (not intrinsic elements) with componentName and componentLocation', + () => { + v.expect(code).toContain('componentName: "Tile"'); + v.expect(code).toContain('componentLocation: "src/App.jsx:10:6"'); + v.expect(code).toContain('componentName: "Show"'); + v.expect(code).not.toContain('componentName: "view"'); + }, + ); +}); + +v.describe('package.json exports', () => { + // Paths, not URLs: under jsdom `URL` is not Node's, and fs rejects it. + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const pkg = JSON.parse( + readFileSync(resolve(root, 'package.json'), 'utf8'), + ) as { exports: Record> }; + const exists = (target: string) => existsSync(resolve(root, target)); + /** The source file an export resolves to under `@solidtv/source`. */ + const sourceTarget = (entry: string | Record) => + typeof entry === 'string' ? entry : (entry['@solidtv/source'] as string); + + v.test('the subpaths exported today', () => { + v.expect(Object.keys(pkg.exports)).toEqual([ + '.', + './primitives', + './primitives/router', + './devtools', + './devtools/jsx-locator', + './shaders', + './jsx-runtime', + ]); + }); + + v.test('every source target exists, except ./shaders', () => { + for (const [subpath, entry] of Object.entries(pkg.exports)) { + if (subpath === './shaders') continue; + v.expect(exists(sourceTarget(entry)), subpath).toBe(true); + } + }); + + // BUG: ./shaders points at src/shaders/index.ts (and dist/src/shaders/*), + // deleted in cf3b1e0 ("use shaders from renderer"). Expected: it exists. + v.test.skip('BUG: ./shaders points at a file that exists', () => { + v.expect(exists(sourceTarget(pkg.exports['./shaders']!))).toBe(true); + }); + + // BUG: the demo app imports `KeyMap, KeyHoldMap` from + // "@solidtv/solid/focusManager" (src/pages/App.tsx:5), which is not in + // `exports`. Expected: it is exported. + v.test.skip('BUG: ./focusManager is exported', () => { + v.expect(pkg.exports['./focusManager']).toBeDefined(); + }); +}); diff --git a/tests/spatialNavigation.test.tsx b/tests/spatialNavigation.test.tsx index 3d7b3cb..71e9c95 100644 --- a/tests/spatialNavigation.test.tsx +++ b/tests/spatialNavigation.test.tsx @@ -14,9 +14,10 @@ const key = (k: string) => // 0 1 2 // 3 4 let grid!: lng.ElementNode; +let dispose: () => void; v.beforeAll(async () => { - renderer.render(() => { + dispose = renderer.render(() => { useFocusManager(); return ( { await waitForUpdate(); }); +// Vitest runs files in one worker without isolation: dispose so the focus +// manager's document listener does not handle the next file's keys too. +v.afterAll(() => dispose()); + // Each test starts from an explicit child so it doesn't inherit the previous // test's selection. function focusChild(index: number) { diff --git a/tests/tsconfig.contract.json b/tests/tsconfig.contract.json new file mode 100644 index 0000000..7368ec2 --- /dev/null +++ b/tests/tsconfig.contract.json @@ -0,0 +1,14 @@ +{ + // Type-checks the type-level contract tests (`pnpm tsc` runs it after the + // root build). Same compiler options as the root, but no emit and only the + // contract file: the other tests are not type-clean. + "extends": "../tsconfig.json", + "compilerOptions": { + "composite": false, + "incremental": false, + "declaration": false, + "sourceMap": false, + "noEmit": true + }, + "include": ["../vite-env.d.ts", "contract-types.tsx"] +} diff --git a/tests/useHold.spec.ts b/tests/useHold.spec.ts index 67d224c..8b95168 100644 --- a/tests/useHold.spec.ts +++ b/tests/useHold.spec.ts @@ -1,16 +1,34 @@ import { createRoot } from 'solid-js'; -import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + vi, + describe, + it, + expect, + beforeEach, + afterEach, + afterAll, +} from 'vitest'; import type { ElementNode } from '../src/core/elementNode.ts'; const suppressKeyUntilRelease = vi.fn<(e: KeyboardEvent | string | number, onRelease?: () => void) => void>(); -vi.mock('../src/core/focusManager.ts', () => ({ +// Vitest runs files in one worker without isolation, so useHold.ts may already +// be loaded with the real focus manager: load a fresh copy against the mock. +vi.resetModules(); +vi.doMock('../src/core/focusManager.ts', () => ({ suppressKeyUntilRelease: (...args: unknown[]) => (suppressKeyUntilRelease as (...a: unknown[]) => void)(...args), })); const { useHold } = await import('../src/primitives/useHold.ts'); +// The mock must not outlive this file: a later file in the same worker would +// load useHold against it, and its holds would never suppress the key. +afterAll(() => { + vi.doUnmock('../src/core/focusManager.ts'); + vi.resetModules(); +}); + const down = { key: 'Enter', repeat: false } as KeyboardEvent; const downRepeat = { key: 'Enter', repeat: true } as KeyboardEvent; const target = { id: 'target' } as unknown as ElementNode; diff --git a/tests/webgl/flex-text.test.tsx b/tests/webgl/flex-text.test.tsx new file mode 100644 index 0000000..64e198b --- /dev/null +++ b/tests/webgl/flex-text.test.tsx @@ -0,0 +1,654 @@ +/** + * Flex layout with text children on the real renderer. + * + * Runs in Vitest browser mode (headless Chromium through Playwright, WebGL + * on SwiftShader) against @solidtv/renderer with SDF text, not the DOM + * renderer: SDF measurement differs from the DOM's, and text sizes arrive + * through the renderer's `loaded` event, so these numbers only hold here. + * + * pnpm test:webgl + * npx vitest run --config=vitest.webgl.config.ts + * + * Needs Playwright's Chromium once: `npx playwright install chromium`. + * The jsdom suite (`npx vitest run`) does not include this directory. + * + * Every text uses Lato Regular (MSDF, tests/webgl/fonts), loaded before the + * first render (setup.ts) except where a test says otherwise; the default + * font size is 30 (Config.fontSettings). `settle()` waits until the + * renderer has no scene update pending for two frames in a row. + * + * The numbers are the final positions and sizes, after fonts load, on + * solid 1.6.4 with SDF text on renderer 1.9. They are not derived from a + * formula, they were read from a run; the comments show how they add up. + * Each one is compared with + * `expect.closeTo(n, 3)`, |actual - expected| < 0.0005: flex.ts sums child + * sizes in Float32Arrays, so positions carry float32 rounding (109.70999908 + * for 109.71) while text sizes are float64. + * + * Only final states are asserted. Intermediate ones (a container before its + * texts have sizes, the number of frames, `loaded` events and flex + * passes) are printed with console.info for information: a change to + * Solid or the renderer may change them. Add `--reporter=verbose` if the reporter hides them. + */ +import * as v from 'vitest'; +import * as s from 'solid-js'; +import { loadFonts, type ElementNode } from '@solidtv/solid'; +import { frameCount, latoFont, render, settle } from './setup.js'; + +/** x, y, width and height, as the app reads them from the ElementNode. */ +function expectBox( + node: ElementNode, + x: number, + y: number, + width: number, + height: number, +) { + v.expect({ + x: node.x, + y: node.y, + width: node.width, + height: node.height, + }).toEqual({ + x: v.expect.closeTo(x, 3), + y: v.expect.closeTo(y, 3), + width: v.expect.closeTo(width, 3), + height: v.expect.closeTo(height, 3), + }); +} + +const info = (...args: unknown[]) => + console.info( + '[flex-text]', + ...args.map((a) => (typeof a === 'string' ? a : JSON.stringify(a))), + ); + +// Lato at the default size 30: a line is 43.2 high. +const LINE_30 = 43.2; + +v.test('a row of text children with gap and padding', async () => { + let row!: ElementNode; + let home!: ElementNode; + let movies!: ElementNode; + let shows!: ElementNode; + + const f0 = frameCount(); + const dispose = render(() => ( + + Home + Movies + Series and Shows + + )); + await settle(); + info('row of 3 texts, first render: frames', frameCount() - f0); + + // Padding on the main axis only: y stays 0. + expectBox(home, 10, 0, 79.71, LINE_30); + expectBox(movies, 109.71, 0, 95.67, LINE_30); // 10 + 79.71 + 20 + expectBox(shows, 225.38, 0, 225.63, LINE_30); // 109.71 + 95.67 + 20 + // Width fits the children: 225.38 + 225.63 + 10. Height: the tallest. + expectBox(row, 0, 0, 461.01, LINE_30); + + dispose(); +}); + +const Tile = (props: { + ref?: (el: ElementNode) => void; + title: string; + subtitle: string; + description: string; + titleRef?: (el: ElementNode) => void; + subtitleRef?: (el: ElementNode) => void; + descriptionRef?: (el: ElementNode) => void; +}) => ( + + + {props.title} + + + {props.subtitle} + + + {props.description} + + +); + +v.test( + 'a column tile: title, subtitle and a contained description', + async () => { + let tile!: ElementNode; + let title!: ElementNode; + let subtitle!: ElementNode; + let description!: ElementNode; + + const dispose = render(() => ( + (tile = el)} + titleRef={(el) => (title = el)} + subtitleRef={(el) => (subtitle = el)} + descriptionRef={(el) => (description = el)} + title="Title" + subtitle="Subtitle" + description="A description long enough to wrap onto a second line of the tile." + /> + )); + await settle(); + + // Padding on the main axis only: x stays 0 in a column. + expectBox(title, 0, 12, 63.968, 46.08); + expectBox(subtitle, 0, 66.08, 75.394, 31.68); // 12 + 46.08 + 8 + // contain="width": width reads maxWidth (276), two lines of 28.8. + expectBox(description, 0, 105.76, 276, 57.6); // 66.08 + 31.68 + 8 + v.expect(description.lng.w).toBeCloseTo(271.7, 3); // the laid-out lines + // Width as given; height fits: 105.76 + 57.6 + 12. + expectBox(tile, 0, 0, 300, 175.36); + + dispose(); + }, +); + +v.test('a row of column tiles takes the tallest tile', async () => { + let row!: ElementNode; + const tiles: ElementNode[] = []; + const titles: ElementNode[] = []; + const descriptions: ElementNode[] = []; + + const dispose = render(() => ( + + (tiles[0] = el)} + titleRef={(el) => (titles[0] = el)} + descriptionRef={(el) => (descriptions[0] = el)} + title="Short" + subtitle="One line" + description="Fits." + /> + (tiles[1] = el)} + titleRef={(el) => (titles[1] = el)} + descriptionRef={(el) => (descriptions[1] = el)} + title="A longer title" + subtitle="Subtitle two" + description="This description is long enough that it has to be cut off after the second line, with more words." + /> + (tiles[2] = el)} + titleRef={(el) => (titles[2] = el)} + descriptionRef={(el) => (descriptions[2] = el)} + title="Third" + subtitle="Sub" + description="Two lines of description text for the third tile here." + /> + + )); + await settle(); + + expectBox(titles[0]!, 0, 12, 77.376, 46.08); + expectBox(titles[1]!, 0, 12, 180.928, 46.08); + expectBox(titles[2]!, 0, 12, 75.648, 46.08); + // One line; two lines; cut at maxLines 2. + expectBox(descriptions[0]!, 0, 105.76, 276, 28.8); + expectBox(descriptions[1]!, 0, 105.76, 276, 57.6); + expectBox(descriptions[2]!, 0, 105.76, 276, 57.6); + + expectBox(tiles[0]!, 0, 0, 300, 146.56); // 105.76 + 28.8 + 12 + expectBox(tiles[1]!, 324, 0, 300, 175.36); // 105.76 + 57.6 + 12 + expectBox(tiles[2]!, 648, 0, 300, 175.36); + // 3 * 300 + 2 * 24; the tallest tile. + expectBox(row, 0, 0, 948, 175.36); + + dispose(); +}); + +v.test('justifyContent center with text children', async () => { + let row!: ElementNode; + let left!: ElementNode; + let middle!: ElementNode; + + const dispose = render(() => ( + + Left + Middle text + + )); + await settle(); + + // (800 - (51.9 + 20 + 150.33)) / 2 + expectBox(left, 288.885, 0, 51.9, LINE_30); + expectBox(middle, 360.785, 0, 150.33, LINE_30); // 288.885 + 51.9 + 20 + expectBox(row, 0, 0, 800, LINE_30); + + dispose(); +}); + +v.test( + 'justifyContent spaceBetween with padding and text children', + async () => { + let row!: ElementNode; + let first!: ElementNode; + let second!: ElementNode; + let third!: ElementNode; + + const dispose = render(() => ( + + First + Second item + Third + + )); + await settle(); + + // Space: (800 - 2 * 20 - (60.96 + 160.77 + 70.92)) / 2 = 233.675 + expectBox(first, 20, 0, 60.96, LINE_30); + expectBox(second, 314.635, 0, 160.77, LINE_30); // 20 + 60.96 + 233.675 + expectBox(third, 709.08, 0, 70.92, LINE_30); // 800 - 20 - 70.92 + expectBox(row, 0, 0, 800, LINE_30); + + dispose(); + }, +); + +v.test( + 'justifyContent and alignItems center on a fixed-height row', + async () => { + let row!: ElementNode; + let small!: ElementNode; + let large!: ElementNode; + + const dispose = render(() => ( + + + small + + + Large + + + )); + await settle(); + + // x: (800 - (45.48 + 122.95)) / 2; y: (100 - height) / 2 + expectBox(small, 315.785, 35.6, 45.48, 28.8); + expectBox(large, 361.265, 14, 122.95, 72); + expectBox(row, 0, 0, 800, 100); + + dispose(); + }, +); + +v.test( + 'a text whose content changes in a flex row lays the row out again', + async () => { + const [label, setLabel] = s.createSignal('Short'); + let row!: ElementNode; + let prev!: ElementNode; + let middle!: ElementNode; + let next!: ElementNode; + let loaded = 0; + let flexPasses = 0; + + const dispose = render(() => ( + { + flexPasses++; + }} + > + Prev + { + loaded++; + }, + }} + > + {label()} + + Next + + )); + await settle(); + info('3 texts, first render: flex passes on the row', flexPasses); + + expectBox(prev, 0, 0, 61.11, LINE_30); + expectBox(middle, 81.11, 0, 72.54, LINE_30); // 61.11 + 20 + expectBox(next, 173.65, 0, 63.81, LINE_30); // 81.11 + 72.54 + 20 + expectBox(row, 0, 0, 237.46, LINE_30); + + const measure = async (text: string) => { + loaded = 0; + flexPasses = 0; + const f0 = frameCount(); + setLabel(text); + await settle(); + info( + `text change to "${text}": frames ${frameCount() - f0},` + + ` loaded events ${loaded}, flex passes on the row ${flexPasses}`, + ); + }; + + await measure('A much longer label'); + expectBox(prev, 0, 0, 61.11, LINE_30); + expectBox(middle, 81.11, 0, 257.01, LINE_30); + expectBox(next, 358.12, 0, 63.81, LINE_30); // 81.11 + 257.01 + 20 + expectBox(row, 0, 0, 421.93, LINE_30); + + await measure('Hi'); + expectBox(prev, 0, 0, 61.11, LINE_30); + expectBox(middle, 81.11, 0, 30.36, LINE_30); + expectBox(next, 131.47, 0, 63.81, LINE_30); // 81.11 + 30.36 + 20 + expectBox(row, 0, 0, 195.28, LINE_30); + + dispose(); + }, +); + +v.test( + 'a text concatenated from several expressions in a flex row', + async () => { + const [episode, setEpisode] = s.createSignal('1'); + const total = '12'; + let row!: ElementNode; + let label!: ElementNode; + let next!: ElementNode; + + const dispose = render(() => ( + + + Episode {episode()} of {total} + + Next + + )); + await settle(); + + v.expect(label.text).toBe('Episode 1 of 12'); + expectBox(label, 0, 0, 200.22, LINE_30); + expectBox(next, 220.22, 0, 63.81, LINE_30); + expectBox(row, 0, 0, 284.03, LINE_30); + + setEpisode('10'); + await settle(); + + v.expect(label.text).toBe('Episode 10 of 12'); + expectBox(label, 0, 0, 217.62, LINE_30); + expectBox(next, 237.62, 0, 63.81, LINE_30); + expectBox(row, 0, 0, 301.43, LINE_30); + + dispose(); + }, +); + +v.test('text with a fixed width and contain in a flex row', async () => { + let widthRow!: ElementNode; + let widthText!: ElementNode; + let widthAfter!: ElementNode; + let bothRow!: ElementNode; + let bothText!: ElementNode; + let bothAfter!: ElementNode; + + const dispose = render(() => ( + <> + + + Fixed width label that is long + + After + + + + Contained both ways with a lot of words + + After + + + )); + await settle(); + + // contain="width", maxLines 1: width reads maxWidth (200) and height + // maxHeight, which Solid sets to the font size (30) without a lineHeight, + // though the laid-out line is 43.2 high. + expectBox(widthText, 0, 0, 200, 30); + v.expect(widthText.lng.w).toBeCloseTo(171.54, 3); + v.expect(widthText.lng.h).toBeCloseTo(LINE_30, 3); + expectBox(widthAfter, 210, 0, 69.51, LINE_30); // 200 + 10 + expectBox(widthRow, 0, 0, 279.51, LINE_30); + + // contain="both": width and height read maxWidth and maxHeight; one line + // of 34.56 fits in 60. + expectBox(bothText, 0, 0, 150, 60); + v.expect(bothText.lng.w).toBeCloseTo(124.992, 3); + v.expect(bothText.lng.h).toBeCloseTo(34.56, 3); + expectBox(bothAfter, 160, 0, 69.51, LINE_30); // 150 + 10 + expectBox(bothRow, 0, 100, 229.51, 60); + + dispose(); +}); + +v.test( + 'a width on a text without contain is replaced by the layout', + async () => { + let row!: ElementNode; + let text!: ElementNode; + let after!: ElementNode; + + const dispose = render(() => ( + + + Width only + + After + + )); + await settle(); + + // The 300 is only the size before the first layout. + expectBox(text, 0, 0, 145.08, LINE_30); + expectBox(after, 155.08, 0, 69.51, LINE_30); // 145.08 + 10 + expectBox(row, 0, 0, 224.59, LINE_30); + + dispose(); + }, +); + +v.test('a text with flexItem={false} is left out of the row', async () => { + let row!: ElementNode; + let one!: ElementNode; + let badge!: ElementNode; + let two!: ElementNode; + + const dispose = render(() => ( + + One + + Badge + + Two + + )); + await settle(); + + expectBox(one, 0, 0, 56.34, LINE_30); + expectBox(badge, 500, 50, 82.44, LINE_30); // where it was put + expectBox(two, 66.34, 0, 55.26, LINE_30); // 56.34 + 10: no gap for the badge + expectBox(row, 0, 0, 121.6, LINE_30); // the badge is not counted + + dispose(); +}); + +v.test('nested flex containers with text', async () => { + let column!: ElementNode; + let title!: ElementNode; + let rowOne!: ElementNode; + let alpha!: ElementNode; + let beta!: ElementNode; + let rowTwo!: ElementNode; + let gamma!: ElementNode; + let delta!: ElementNode; + + const f0 = frameCount(); + const dispose = render(() => ( + + + Section + + + Alpha + Beta + + + + Gamma + + + Delta Epsilon + + + + )); + await settle(); + info('nested column of rows, first render: frames', frameCount() - f0); + + expectBox(title, 0, 20, 130.48, 57.6); + expectBox(alpha, 0, 0, 76.53, LINE_30); + expectBox(beta, 91.53, 0, 61.53, LINE_30); // 76.53 + 15 + expectBox(rowOne, 0, 87.6, 153.06, LINE_30); // y: 20 + 57.6 + 10 + expectBox(gamma, 5, 0, 81.36, 34.56); + expectBox(delta, 101.36, 0, 208.692, 51.84); // 5 + 81.36 + 15 + // y: 87.6 + 43.2 + 10; width: 101.36 + 208.692 + 5; height: the tallest + // text (no cross-axis padding). + expectBox(rowTwo, 0, 140.8, 315.052, 51.84); + // A column fills its parent's width (1920 - 100); its height fits: + // 140.8 + 51.84 + 20. + expectBox(column, 100, 50, 1820, 212.64); + + dispose(); +}); + +// Renderer 1.9 creates a text only for a family that is loaded or loading +// (Stage.createTextNode throws "No compatible text renderer found" for any +// other), so the load starts first, as an app's loadFonts call before its +// first render. The files are Lato's, already fetched, so the font may well +// arrive before the first frame: only the layout after it is asserted. +v.test( + 'text rendered after loadFonts() for its family lays out with that font', + async () => { + let row!: ElementNode; + let home!: ElementNode; + let movies!: ElementNode; + let shows!: ElementNode; + + // A family whose load has started but not finished. + const fontLoaded = loadFonts([latoFont('LatoLate')]); + const dispose = render(() => ( + + + Home + + + Movies + + + Series and Shows + + + )); + await settle(); + info( + 'first settle: row', + [row.width, row.height], + 'texts', + [home, movies, shows].map((t) => [t.x, t.width, t.height]), + ); + + await fontLoaded; + await settle(); + + // The same as with the font loaded first (the first test). + expectBox(home, 10, 0, 79.71, LINE_30); + expectBox(movies, 109.71, 0, 95.67, LINE_30); + expectBox(shows, 225.38, 0, 225.63, LINE_30); + expectBox(row, 0, 0, 461.01, LINE_30); + + dispose(); + }, +); + +v.test( + 'text in a row hidden with alpha 0 lays out when the row is shown', + async () => { + const [alpha, setAlpha] = s.createSignal(0); + let row!: ElementNode; + let one!: ElementNode; + let two!: ElementNode; + + const dispose = render(() => ( + + One + Two + + )); + await settle(); + info( + 'under alpha 0: row', + [row.width, row.height], + 'texts', + [one, two].map((t) => [t.x, t.width, t.height]), + ); + + const f0 = frameCount(); + setAlpha(1); + await settle(); + info(`shown: frames ${frameCount() - f0}`); + + expectBox(one, 0, 0, 56.34, LINE_30); + expectBox(two, 66.34, 0, 55.26, LINE_30); // 56.34 + 10 + expectBox(row, 0, 0, 121.6, LINE_30); + + dispose(); + }, +); diff --git a/tests/webgl/fonts/Lato-Regular.msdf.json b/tests/webgl/fonts/Lato-Regular.msdf.json new file mode 100644 index 0000000..0965a72 --- /dev/null +++ b/tests/webgl/fonts/Lato-Regular.msdf.json @@ -0,0 +1,9034 @@ +{ + "pages": [ + "Lato-Regular.png" + ], + "chars": [ + { + "id": 87, + "index": 58, + "char": "W", + "width": 46, + "height": 34, + "xoffset": -2, + "yoffset": 1.8099999999999996, + "xadvance": 42.798, + "chnl": 15, + "x": 0, + "y": 0, + "page": 0 + }, + { + "id": 36, + "index": 7, + "char": "$", + "width": 24, + "height": 43, + "xoffset": 0, + "yoffset": -2.19, + "xadvance": 24.36, + "chnl": 15, + "x": 47, + "y": 0, + "page": 0 + }, + { + "id": 40, + "index": 11, + "char": "(", + "width": 12, + "height": 43, + "xoffset": 1, + "yoffset": -1.19, + "xadvance": 12.6, + "chnl": 15, + "x": 72, + "y": 0, + "page": 0 + }, + { + "id": 41, + "index": 12, + "char": ")", + "width": 12, + "height": 43, + "xoffset": 0, + "yoffset": -1.19, + "xadvance": 12.6, + "chnl": 15, + "x": 85, + "y": 0, + "page": 0 + }, + { + "id": 124, + "index": 95, + "char": "|", + "width": 7, + "height": 43, + "xoffset": 3, + "yoffset": -0.18999999999999995, + "xadvance": 12.6, + "chnl": 15, + "x": 98, + "y": 0, + "page": 0 + }, + { + "id": 91, + "index": 62, + "char": "[", + "width": 12, + "height": 42, + "xoffset": 1, + "yoffset": -0.18999999999999995, + "xadvance": 12.6, + "chnl": 15, + "x": 106, + "y": 0, + "page": 0 + }, + { + "id": 93, + "index": 64, + "char": "]", + "width": 12, + "height": 42, + "xoffset": 0, + "yoffset": -0.18999999999999995, + "xadvance": 12.6, + "chnl": 15, + "x": 106, + "y": 43, + "page": 0 + }, + { + "id": 106, + "index": 77, + "char": "j", + "width": 13, + "height": 42, + "xoffset": -3, + "yoffset": 0.8099999999999996, + "xadvance": 10.668, + "chnl": 15, + "x": 0, + "y": 86, + "page": 0 + }, + { + "id": 123, + "index": 94, + "char": "{", + "width": 14, + "height": 42, + "xoffset": -1, + "yoffset": -0.18999999999999995, + "xadvance": 12.6, + "chnl": 15, + "x": 14, + "y": 86, + "page": 0 + }, + { + "id": 125, + "index": 96, + "char": "}", + "width": 14, + "height": 42, + "xoffset": 0, + "yoffset": -0.18999999999999995, + "xadvance": 12.6, + "chnl": 15, + "x": 29, + "y": 86, + "page": 0 + }, + { + "id": 199, + "index": 137, + "char": "Ç", + "width": 29, + "height": 42, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 28.77, + "chnl": 15, + "x": 44, + "y": 86, + "page": 0 + }, + { + "id": 218, + "index": 156, + "char": "Ú", + "width": 28, + "height": 42, + "xoffset": 1, + "yoffset": -5.1899999999999995, + "xadvance": 30.66, + "chnl": 15, + "x": 74, + "y": 86, + "page": 0 + }, + { + "id": 81, + "index": 52, + "char": "Q", + "width": 35, + "height": 41, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 33.516, + "chnl": 15, + "x": 0, + "y": 44, + "page": 0 + }, + { + "id": 192, + "index": 130, + "char": "À", + "width": 32, + "height": 41, + "xoffset": -2, + "yoffset": -5.1899999999999995, + "xadvance": 28.56, + "chnl": 15, + "x": 36, + "y": 44, + "page": 0 + }, + { + "id": 193, + "index": 131, + "char": "Á", + "width": 32, + "height": 41, + "xoffset": -2, + "yoffset": -5.1899999999999995, + "xadvance": 28.56, + "chnl": 15, + "x": 69, + "y": 44, + "page": 0 + }, + { + "id": 195, + "index": 133, + "char": "Ã", + "width": 32, + "height": 41, + "xoffset": -2, + "yoffset": -5.1899999999999995, + "xadvance": 28.56, + "chnl": 15, + "x": 103, + "y": 86, + "page": 0 + }, + { + "id": 201, + "index": 139, + "char": "É", + "width": 23, + "height": 41, + "xoffset": 2, + "yoffset": -5.1899999999999995, + "xadvance": 24.402, + "chnl": 15, + "x": 119, + "y": 0, + "page": 0 + }, + { + "id": 202, + "index": 140, + "char": "Ê", + "width": 23, + "height": 41, + "xoffset": 2, + "yoffset": -5.1899999999999995, + "xadvance": 24.402, + "chnl": 15, + "x": 119, + "y": 42, + "page": 0 + }, + { + "id": 205, + "index": 143, + "char": "Í", + "width": 14, + "height": 41, + "xoffset": 1, + "yoffset": -5.1899999999999995, + "xadvance": 12.894, + "chnl": 15, + "x": 136, + "y": 84, + "page": 0 + }, + { + "id": 64, + "index": 35, + "char": "@", + "width": 35, + "height": 38, + "xoffset": 0, + "yoffset": 2.8099999999999996, + "xadvance": 34.524, + "chnl": 15, + "x": 143, + "y": 0, + "page": 0 + }, + { + "id": 92, + "index": 63, + "char": "\\", + "width": 20, + "height": 37, + "xoffset": -2, + "yoffset": 0.8099999999999996, + "xadvance": 15.75, + "chnl": 15, + "x": 143, + "y": 39, + "page": 0 + }, + { + "id": 47, + "index": 18, + "char": "/", + "width": 20, + "height": 37, + "xoffset": -2, + "yoffset": 0.8099999999999996, + "xadvance": 15.666, + "chnl": 15, + "x": 151, + "y": 77, + "page": 0 + }, + { + "id": 119, + "index": 90, + "char": "w", + "width": 36, + "height": 25, + "xoffset": -2, + "yoffset": 10.81, + "xadvance": 32.172, + "chnl": 15, + "x": 164, + "y": 39, + "page": 0 + }, + { + "id": 37, + "index": 8, + "char": "%", + "width": 34, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 33.012, + "chnl": 15, + "x": 179, + "y": 0, + "page": 0 + }, + { + "id": 38, + "index": 9, + "char": "&", + "width": 32, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 29.526, + "chnl": 15, + "x": 214, + "y": 0, + "page": 0 + }, + { + "id": 48, + "index": 19, + "char": "0", + "width": 26, + "height": 35, + "xoffset": -1, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 172, + "y": 65, + "page": 0 + }, + { + "id": 51, + "index": 22, + "char": "3", + "width": 24, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 199, + "y": 65, + "page": 0 + }, + { + "id": 56, + "index": 27, + "char": "8", + "width": 24, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 224, + "y": 36, + "page": 0 + }, + { + "id": 63, + "index": 34, + "char": "?", + "width": 19, + "height": 35, + "xoffset": -1, + "yoffset": 1.8099999999999996, + "xadvance": 16.716, + "chnl": 15, + "x": 224, + "y": 72, + "page": 0 + }, + { + "id": 67, + "index": 38, + "char": "C", + "width": 29, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 28.77, + "chnl": 15, + "x": 172, + "y": 101, + "page": 0 + }, + { + "id": 71, + "index": 42, + "char": "G", + "width": 30, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 30.828, + "chnl": 15, + "x": 136, + "y": 126, + "page": 0 + }, + { + "id": 77, + "index": 48, + "char": "M", + "width": 35, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 38.64, + "chnl": 15, + "x": 202, + "y": 108, + "page": 0 + }, + { + "id": 79, + "index": 50, + "char": "O", + "width": 34, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 33.516, + "chnl": 15, + "x": 167, + "y": 137, + "page": 0 + }, + { + "id": 83, + "index": 54, + "char": "S", + "width": 24, + "height": 35, + "xoffset": -1, + "yoffset": 1.8099999999999996, + "xadvance": 22.26, + "chnl": 15, + "x": 103, + "y": 128, + "page": 0 + }, + { + "id": 98, + "index": 69, + "char": "b", + "width": 23, + "height": 35, + "xoffset": 1, + "yoffset": 0.8099999999999996, + "xadvance": 23.478, + "chnl": 15, + "x": 128, + "y": 162, + "page": 0 + }, + { + "id": 100, + "index": 71, + "char": "d", + "width": 23, + "height": 35, + "xoffset": 0, + "yoffset": 0.8099999999999996, + "xadvance": 23.478, + "chnl": 15, + "x": 0, + "y": 198, + "page": 0 + }, + { + "id": 102, + "index": 73, + "char": "f", + "width": 17, + "height": 35, + "xoffset": -1, + "yoffset": 0.8099999999999996, + "xadvance": 14.154, + "chnl": 15, + "x": 238, + "y": 108, + "page": 0 + }, + { + "id": 104, + "index": 75, + "char": "h", + "width": 22, + "height": 35, + "xoffset": 1, + "yoffset": 0.8099999999999996, + "xadvance": 23.352, + "chnl": 15, + "x": 202, + "y": 143, + "page": 0 + }, + { + "id": 105, + "index": 76, + "char": "i", + "width": 9, + "height": 35, + "xoffset": 1, + "yoffset": 0.8099999999999996, + "xadvance": 10.752, + "chnl": 15, + "x": 247, + "y": 0, + "page": 0 + }, + { + "id": 107, + "index": 78, + "char": "k", + "width": 22, + "height": 35, + "xoffset": 1, + "yoffset": 0.8099999999999996, + "xadvance": 22.008, + "chnl": 15, + "x": 225, + "y": 144, + "page": 0 + }, + { + "id": 108, + "index": 79, + "char": "l", + "width": 8, + "height": 35, + "xoffset": 1, + "yoffset": 0.8099999999999996, + "xadvance": 10.752, + "chnl": 15, + "x": 244, + "y": 72, + "page": 0 + }, + { + "id": 224, + "index": 162, + "char": "à", + "width": 21, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 21.294, + "chnl": 15, + "x": 24, + "y": 198, + "page": 0 + }, + { + "id": 225, + "index": 163, + "char": "á", + "width": 21, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 21.294, + "chnl": 15, + "x": 46, + "y": 198, + "page": 0 + }, + { + "id": 233, + "index": 171, + "char": "é", + "width": 23, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 22.008, + "chnl": 15, + "x": 68, + "y": 129, + "page": 0 + }, + { + "id": 243, + "index": 181, + "char": "ó", + "width": 24, + "height": 35, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 23.352, + "chnl": 15, + "x": 92, + "y": 164, + "page": 0 + }, + { + "id": 250, + "index": 188, + "char": "ú", + "width": 22, + "height": 35, + "xoffset": 1, + "yoffset": 1.8099999999999996, + "xadvance": 23.352, + "chnl": 15, + "x": 68, + "y": 165, + "page": 0 + }, + { + "id": 33, + "index": 4, + "char": "!", + "width": 9, + "height": 34, + "xoffset": 3, + "yoffset": 1.8099999999999996, + "xadvance": 14.406, + "chnl": 15, + "x": 92, + "y": 129, + "page": 0 + }, + { + "id": 35, + "index": 6, + "char": "#", + "width": 26, + "height": 34, + "xoffset": -1, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 68, + "y": 201, + "page": 0 + }, + { + "id": 49, + "index": 20, + "char": "1", + "width": 22, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 95, + "y": 200, + "page": 0 + }, + { + "id": 50, + "index": 21, + "char": "2", + "width": 24, + "height": 34, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 118, + "y": 198, + "page": 0 + }, + { + "id": 52, + "index": 23, + "char": "4", + "width": 27, + "height": 34, + "xoffset": -1, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 152, + "y": 173, + "page": 0 + }, + { + "id": 53, + "index": 24, + "char": "5", + "width": 23, + "height": 34, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 143, + "y": 208, + "page": 0 + }, + { + "id": 54, + "index": 25, + "char": "6", + "width": 24, + "height": 34, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 167, + "y": 208, + "page": 0 + }, + { + "id": 55, + "index": 26, + "char": "7", + "width": 24, + "height": 34, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 192, + "y": 179, + "page": 0 + }, + { + "id": 57, + "index": 28, + "char": "9", + "width": 24, + "height": 34, + "xoffset": 1, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 192, + "y": 214, + "page": 0 + }, + { + "id": 65, + "index": 36, + "char": "A", + "width": 32, + "height": 34, + "xoffset": -2, + "yoffset": 1.8099999999999996, + "xadvance": 28.56, + "chnl": 15, + "x": 217, + "y": 180, + "page": 0 + }, + { + "id": 66, + "index": 37, + "char": "B", + "width": 25, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 27.174, + "chnl": 15, + "x": 217, + "y": 215, + "page": 0 + }, + { + "id": 68, + "index": 39, + "char": "D", + "width": 30, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 31.626, + "chnl": 15, + "x": 0, + "y": 129, + "page": 0 + }, + { + "id": 69, + "index": 40, + "char": "E", + "width": 23, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 24.402, + "chnl": 15, + "x": 31, + "y": 129, + "page": 0 + }, + { + "id": 70, + "index": 41, + "char": "F", + "width": 23, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 23.772, + "chnl": 15, + "x": 249, + "y": 36, + "page": 0 + }, + { + "id": 72, + "index": 43, + "char": "H", + "width": 28, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 31.752, + "chnl": 15, + "x": 248, + "y": 144, + "page": 0 + }, + { + "id": 73, + "index": 44, + "char": "I", + "width": 8, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 12.894, + "chnl": 15, + "x": 180, + "y": 173, + "page": 0 + }, + { + "id": 74, + "index": 45, + "char": "J", + "width": 18, + "height": 34, + "xoffset": -1, + "yoffset": 1.8099999999999996, + "xadvance": 18.648, + "chnl": 15, + "x": 257, + "y": 0, + "page": 0 + }, + { + "id": 75, + "index": 46, + "char": "K", + "width": 28, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 28.602, + "chnl": 15, + "x": 253, + "y": 71, + "page": 0 + }, + { + "id": 76, + "index": 47, + "char": "L", + "width": 21, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 21.588, + "chnl": 15, + "x": 273, + "y": 35, + "page": 0 + }, + { + "id": 78, + "index": 49, + "char": "N", + "width": 28, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 31.752, + "chnl": 15, + "x": 276, + "y": 0, + "page": 0 + }, + { + "id": 80, + "index": 51, + "char": "P", + "width": 24, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 25.662, + "chnl": 15, + "x": 256, + "y": 106, + "page": 0 + }, + { + "id": 82, + "index": 53, + "char": "R", + "width": 26, + "height": 34, + "xoffset": 2, + "yoffset": 1.8099999999999996, + "xadvance": 27.048, + "chnl": 15, + "x": 243, + "y": 215, + "page": 0 + }, + { + "id": 84, + "index": 55, + "char": "T", + "width": 28, + "height": 34, + "xoffset": -1, + "yoffset": 1.8099999999999996, + "xadvance": 24.78, + "chnl": 15, + "x": 250, + "y": 179, + "page": 0 + }, + { + "id": 85, + "index": 56, + "char": "U", + "width": 28, + "height": 34, + "xoffset": 1, + "yoffset": 1.8099999999999996, + "xadvance": 30.66, + "chnl": 15, + "x": 277, + "y": 141, + "page": 0 + }, + { + "id": 86, + "index": 57, + "char": "V", + "width": 32, + "height": 34, + "xoffset": -2, + "yoffset": 1.8099999999999996, + "xadvance": 28.56, + "chnl": 15, + "x": 281, + "y": 106, + "page": 0 + }, + { + "id": 88, + "index": 59, + "char": "X", + "width": 30, + "height": 34, + "xoffset": -2, + "yoffset": 1.8099999999999996, + "xadvance": 27.006, + "chnl": 15, + "x": 282, + "y": 70, + "page": 0 + }, + { + "id": 89, + "index": 60, + "char": "Y", + "width": 30, + "height": 34, + "xoffset": -2, + "yoffset": 1.8099999999999996, + "xadvance": 26.418, + "chnl": 15, + "x": 295, + "y": 35, + "page": 0 + }, + { + "id": 90, + "index": 61, + "char": "Z", + "width": 27, + "height": 34, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 26.208, + "chnl": 15, + "x": 305, + "y": 0, + "page": 0 + }, + { + "id": 226, + "index": 164, + "char": "â", + "width": 21, + "height": 34, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 21.294, + "chnl": 15, + "x": 313, + "y": 70, + "page": 0 + }, + { + "id": 227, + "index": 165, + "char": "ã", + "width": 21, + "height": 34, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 21.294, + "chnl": 15, + "x": 326, + "y": 35, + "page": 0 + }, + { + "id": 234, + "index": 172, + "char": "ê", + "width": 23, + "height": 34, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 22.008, + "chnl": 15, + "x": 333, + "y": 0, + "page": 0 + }, + { + "id": 237, + "index": 175, + "char": "í", + "width": 12, + "height": 34, + "xoffset": 1, + "yoffset": 1.8099999999999996, + "xadvance": 10.752, + "chnl": 15, + "x": 55, + "y": 129, + "page": 0 + }, + { + "id": 245, + "index": 183, + "char": "õ", + "width": 24, + "height": 34, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 23.352, + "chnl": 15, + "x": 270, + "y": 214, + "page": 0 + }, + { + "id": 103, + "index": 74, + "char": "g", + "width": 24, + "height": 33, + "xoffset": -1, + "yoffset": 9.81, + "xadvance": 21.462, + "chnl": 15, + "x": 0, + "y": 164, + "page": 0 + }, + { + "id": 109, + "index": 80, + "char": "m", + "width": 33, + "height": 26, + "xoffset": 1, + "yoffset": 9.81, + "xadvance": 34.482, + "chnl": 15, + "x": 25, + "y": 164, + "page": 0 + }, + { + "id": 112, + "index": 83, + "char": "p", + "width": 23, + "height": 33, + "xoffset": 1, + "yoffset": 9.81, + "xadvance": 23.184, + "chnl": 15, + "x": 279, + "y": 176, + "page": 0 + }, + { + "id": 113, + "index": 84, + "char": "q", + "width": 23, + "height": 33, + "xoffset": 0, + "yoffset": 9.81, + "xadvance": 23.478, + "chnl": 15, + "x": 295, + "y": 210, + "page": 0 + }, + { + "id": 116, + "index": 87, + "char": "t", + "width": 18, + "height": 33, + "xoffset": -1, + "yoffset": 3.8099999999999996, + "xadvance": 15.666, + "chnl": 15, + "x": 303, + "y": 176, + "page": 0 + }, + { + "id": 161, + "index": 99, + "char": "¡", + "width": 9, + "height": 33, + "xoffset": 3, + "yoffset": 9.81, + "xadvance": 14.406, + "chnl": 15, + "x": 117, + "y": 164, + "page": 0 + }, + { + "id": 231, + "index": 169, + "char": "ç", + "width": 21, + "height": 33, + "xoffset": 0, + "yoffset": 9.81, + "xadvance": 19.614, + "chnl": 15, + "x": 306, + "y": 141, + "page": 0 + }, + { + "id": 121, + "index": 92, + "char": "y", + "width": 25, + "height": 32, + "xoffset": -2, + "yoffset": 10.81, + "xadvance": 21.504, + "chnl": 15, + "x": 314, + "y": 105, + "page": 0 + }, + { + "id": 8230, + "index": 240, + "char": "…", + "width": 31, + "height": 9, + "xoffset": 0, + "yoffset": 26.81, + "xadvance": 30.534, + "chnl": 15, + "x": 295, + "y": 244, + "page": 0 + }, + { + "id": 59, + "index": 30, + "char": ";", + "width": 9, + "height": 30, + "xoffset": 1, + "yoffset": 10.81, + "xadvance": 10.584, + "chnl": 15, + "x": 319, + "y": 210, + "page": 0 + }, + { + "id": 97, + "index": 68, + "char": "a", + "width": 21, + "height": 26, + "xoffset": 0, + "yoffset": 9.81, + "xadvance": 21.294, + "chnl": 15, + "x": 201, + "y": 36, + "page": 0 + }, + { + "id": 99, + "index": 70, + "char": "c", + "width": 21, + "height": 26, + "xoffset": 0, + "yoffset": 9.81, + "xadvance": 19.614, + "chnl": 15, + "x": 335, + "y": 70, + "page": 0 + }, + { + "id": 101, + "index": 72, + "char": "e", + "width": 23, + "height": 26, + "xoffset": 0, + "yoffset": 9.81, + "xadvance": 22.008, + "chnl": 15, + "x": 322, + "y": 175, + "page": 0 + }, + { + "id": 110, + "index": 81, + "char": "n", + "width": 22, + "height": 26, + "xoffset": 1, + "yoffset": 9.81, + "xadvance": 23.352, + "chnl": 15, + "x": 348, + "y": 35, + "page": 0 + }, + { + "id": 111, + "index": 82, + "char": "o", + "width": 24, + "height": 26, + "xoffset": 0, + "yoffset": 9.81, + "xadvance": 23.352, + "chnl": 15, + "x": 357, + "y": 0, + "page": 0 + }, + { + "id": 114, + "index": 85, + "char": "r", + "width": 17, + "height": 26, + "xoffset": 1, + "yoffset": 9.81, + "xadvance": 16.926, + "chnl": 15, + "x": 328, + "y": 138, + "page": 0 + }, + { + "id": 115, + "index": 86, + "char": "s", + "width": 19, + "height": 26, + "xoffset": -1, + "yoffset": 9.81, + "xadvance": 18.228, + "chnl": 15, + "x": 340, + "y": 97, + "page": 0 + }, + { + "id": 117, + "index": 88, + "char": "u", + "width": 22, + "height": 26, + "xoffset": 1, + "yoffset": 10.81, + "xadvance": 23.352, + "chnl": 15, + "x": 357, + "y": 62, + "page": 0 + }, + { + "id": 43, + "index": 14, + "char": "+", + "width": 24, + "height": 25, + "xoffset": 0, + "yoffset": 7.8100000000000005, + "xadvance": 24.36, + "chnl": 15, + "x": 371, + "y": 27, + "page": 0 + }, + { + "id": 58, + "index": 29, + "char": ":", + "width": 9, + "height": 25, + "xoffset": 1, + "yoffset": 10.81, + "xadvance": 10.584, + "chnl": 15, + "x": 382, + "y": 0, + "page": 0 + }, + { + "id": 118, + "index": 89, + "char": "v", + "width": 25, + "height": 25, + "xoffset": -2, + "yoffset": 10.81, + "xadvance": 21.504, + "chnl": 15, + "x": 392, + "y": 0, + "page": 0 + }, + { + "id": 120, + "index": 91, + "char": "x", + "width": 24, + "height": 25, + "xoffset": -1, + "yoffset": 10.81, + "xadvance": 21.168, + "chnl": 15, + "x": 329, + "y": 202, + "page": 0 + }, + { + "id": 122, + "index": 93, + "char": "z", + "width": 20, + "height": 25, + "xoffset": -1, + "yoffset": 10.81, + "xadvance": 19.404, + "chnl": 15, + "x": 329, + "y": 228, + "page": 0 + }, + { + "id": 126, + "index": 97, + "char": "~", + "width": 23, + "height": 11, + "xoffset": 0, + "yoffset": 15.809999999999999, + "xadvance": 24.36, + "chnl": 15, + "x": 118, + "y": 233, + "page": 0 + }, + { + "id": 60, + "index": 31, + "char": "<", + "width": 20, + "height": 22, + "xoffset": 1, + "yoffset": 8.81, + "xadvance": 24.36, + "chnl": 15, + "x": 0, + "y": 234, + "page": 0 + }, + { + "id": 61, + "index": 32, + "char": "=", + "width": 22, + "height": 14, + "xoffset": 1, + "yoffset": 12.809999999999999, + "xadvance": 24.36, + "chnl": 15, + "x": 95, + "y": 235, + "page": 0 + }, + { + "id": 62, + "index": 33, + "char": ">", + "width": 20, + "height": 22, + "xoffset": 3, + "yoffset": 8.81, + "xadvance": 24.36, + "chnl": 15, + "x": 21, + "y": 234, + "page": 0 + }, + { + "id": 94, + "index": 65, + "char": "^", + "width": 21, + "height": 18, + "xoffset": 1, + "yoffset": 1.8099999999999996, + "xadvance": 24.36, + "chnl": 15, + "x": 42, + "y": 236, + "page": 0 + }, + { + "id": 95, + "index": 66, + "char": "_", + "width": 21, + "height": 7, + "xoffset": -2, + "yoffset": 34.81, + "xadvance": 16.548, + "chnl": 15, + "x": 270, + "y": 249, + "page": 0 + }, + { + "id": 8211, + "index": 229, + "char": "–", + "width": 21, + "height": 7, + "xoffset": 1, + "yoffset": 17.81, + "xadvance": 23.352, + "chnl": 15, + "x": 357, + "y": 89, + "page": 0 + }, + { + "id": 8226, + "index": 239, + "char": "•", + "width": 20, + "height": 20, + "xoffset": 2, + "yoffset": 11.81, + "xadvance": 24.36, + "chnl": 15, + "x": 64, + "y": 236, + "page": 0 + }, + { + "id": 42, + "index": 13, + "char": "*", + "width": 17, + "height": 18, + "xoffset": 0, + "yoffset": -0.18999999999999995, + "xadvance": 16.8, + "chnl": 15, + "x": 350, + "y": 228, + "page": 0 + }, + { + "id": 186, + "index": 124, + "char": "º", + "width": 17, + "height": 17, + "xoffset": 0, + "yoffset": 1.8099999999999996, + "xadvance": 16.002, + "chnl": 15, + "x": 346, + "y": 124, + "page": 0 + }, + { + "id": 34, + "index": 5, + "char": "\"", + "width": 14, + "height": 15, + "xoffset": 1, + "yoffset": 1.8099999999999996, + "xadvance": 16.674, + "chnl": 15, + "x": 360, + "y": 97, + "page": 0 + }, + { + "id": 39, + "index": 10, + "char": "'", + "width": 7, + "height": 15, + "xoffset": 1, + "yoffset": 1.8099999999999996, + "xadvance": 9.66, + "chnl": 15, + "x": 128, + "y": 128, + "page": 0 + }, + { + "id": 44, + "index": 15, + "char": ",", + "width": 9, + "height": 15, + "xoffset": 0, + "yoffset": 26.81, + "xadvance": 8.904, + "chnl": 15, + "x": 85, + "y": 236, + "page": 0 + }, + { + "id": 8217, + "index": 232, + "char": "’", + "width": 9, + "height": 15, + "xoffset": 0, + "yoffset": -0.18999999999999995, + "xadvance": 8.904, + "chnl": 15, + "x": 346, + "y": 142, + "page": 0 + }, + { + "id": 8220, + "index": 234, + "char": "“", + "width": 15, + "height": 15, + "xoffset": -1, + "yoffset": -0.18999999999999995, + "xadvance": 15.288, + "chnl": 15, + "x": 346, + "y": 158, + "page": 0 + }, + { + "id": 8221, + "index": 235, + "char": "”", + "width": 15, + "height": 15, + "xoffset": 0, + "yoffset": -0.18999999999999995, + "xadvance": 15.288, + "chnl": 15, + "x": 356, + "y": 142, + "page": 0 + }, + { + "id": 45, + "index": 16, + "char": "-", + "width": 14, + "height": 7, + "xoffset": 0, + "yoffset": 17.81, + "xadvance": 14.574, + "chnl": 15, + "x": 152, + "y": 162, + "page": 0 + }, + { + "id": 173, + "index": 111, + "char": "­", + "width": 14, + "height": 7, + "xoffset": 0, + "yoffset": 17.81, + "xadvance": 14.574, + "chnl": 15, + "x": 118, + "y": 249, + "page": 0 + }, + { + "id": 96, + "index": 67, + "char": "`", + "width": 12, + "height": 10, + "xoffset": -1, + "yoffset": 1.8099999999999996, + "xadvance": 12.894, + "chnl": 15, + "x": 151, + "y": 115, + "page": 0 + }, + { + "id": 46, + "index": 17, + "char": ".", + "width": 9, + "height": 9, + "xoffset": 0, + "yoffset": 26.81, + "xadvance": 8.904, + "chnl": 15, + "x": 350, + "y": 247, + "page": 0 + }, + { + "id": 32, + "index": 3, + "char": " ", + "width": 0, + "height": 0, + "xoffset": -2, + "yoffset": 31.810000000000002, + "xadvance": 8.106, + "chnl": 15, + "x": 225, + "y": 143, + "page": 0 + } + ], + "info": { + "face": "Lato-Regular", + "size": 42, + "bold": 0, + "italic": 0, + "charset": [ + " ", + "!", + "\\", + "\"", + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + "0", + "1", + "2", + "3", + "4", + "5", + "6", + "7", + "8", + "9", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "A", + "B", + "C", + "D", + "E", + "F", + "G", + "H", + "I", + "J", + "K", + "L", + "M", + "N", + "O", + "P", + "Q", + "R", + "S", + "T", + "U", + "V", + "W", + "X", + "Y", + "Z", + "[", + "]", + "^", + "_", + "`", + "a", + "b", + "c", + "d", + "e", + "f", + "g", + "h", + "i", + "j", + "k", + "l", + "m", + "n", + "o", + "p", + "q", + "r", + "s", + "t", + "u", + "v", + "w", + "x", + "y", + "z", + "{", + "|", + "}", + "~", + "’", + "“", + "”", + "¡", + "­", + "º", + "À", + "Á", + "Ã", + "Ç", + "É", + "Ê", + "Í", + "Ú", + "à", + "á", + "â", + "ã", + "ç", + "é", + "ê", + "í", + "ó", + "õ", + "ú", + "–", + "•", + "…" + ], + "unicode": 1, + "stretchH": 100, + "smooth": 1, + "aa": 1, + "padding": [ + 2, + 2, + 2, + 2 + ], + "spacing": [ + 0, + 0 + ], + "outline": 0 + }, + "common": { + "lineHeight": 50.4, + "base": 33.81, + "scaleW": 512, + "scaleH": 256, + "pages": 1, + "packed": 0, + "alphaChnl": 0, + "redChnl": 0, + "greenChnl": 0, + "blueChnl": 0 + }, + "distanceField": { + "fieldType": "msdf", + "distanceRange": 4 + }, + "kernings": [ + { + "first": 92, + "second": 92, + "amount": -2.856 + }, + { + "first": 92, + "second": 34, + "amount": -3.822 + }, + { + "first": 92, + "second": 39, + "amount": -3.822 + }, + { + "first": 92, + "second": 42, + "amount": -3.822 + }, + { + "first": 92, + "second": 45, + "amount": -1.092 + }, + { + "first": 92, + "second": 63, + "amount": -1.176 + }, + { + "first": 92, + "second": 64, + "amount": -0.882 + }, + { + "first": 92, + "second": 67, + "amount": -0.882 + }, + { + "first": 92, + "second": 71, + "amount": -0.882 + }, + { + "first": 92, + "second": 74, + "amount": 1.05 + }, + { + "first": 92, + "second": 79, + "amount": -0.882 + }, + { + "first": 92, + "second": 81, + "amount": -0.882 + }, + { + "first": 92, + "second": 84, + "amount": -2.772 + }, + { + "first": 92, + "second": 85, + "amount": -1.176 + }, + { + "first": 92, + "second": 86, + "amount": -2.856 + }, + { + "first": 92, + "second": 87, + "amount": -1.764 + }, + { + "first": 92, + "second": 89, + "amount": -3.444 + }, + { + "first": 92, + "second": 118, + "amount": -1.722 + }, + { + "first": 92, + "second": 121, + "amount": -1.722 + }, + { + "first": 92, + "second": 8217, + "amount": -3.822 + }, + { + "first": 92, + "second": 8220, + "amount": -3.822 + }, + { + "first": 92, + "second": 8221, + "amount": -3.822 + }, + { + "first": 92, + "second": 173, + "amount": -1.092 + }, + { + "first": 92, + "second": 186, + "amount": -3.822 + }, + { + "first": 92, + "second": 199, + "amount": -0.882 + }, + { + "first": 92, + "second": 218, + "amount": -1.176 + }, + { + "first": 92, + "second": 8211, + "amount": -1.092 + }, + { + "first": 92, + "second": 8226, + "amount": -1.092 + }, + { + "first": 34, + "second": 92, + "amount": 1.008 + }, + { + "first": 34, + "second": 38, + "amount": -3.822 + }, + { + "first": 34, + "second": 44, + "amount": -4.788 + }, + { + "first": 34, + "second": 45, + "amount": -3.738 + }, + { + "first": 34, + "second": 46, + "amount": -4.788 + }, + { + "first": 34, + "second": 47, + "amount": -3.822 + }, + { + "first": 34, + "second": 64, + "amount": -0.966 + }, + { + "first": 34, + "second": 65, + "amount": -3.822 + }, + { + "first": 34, + "second": 67, + "amount": -0.966 + }, + { + "first": 34, + "second": 71, + "amount": -0.966 + }, + { + "first": 34, + "second": 79, + "amount": -0.966 + }, + { + "first": 34, + "second": 81, + "amount": -0.966 + }, + { + "first": 34, + "second": 86, + "amount": 1.008 + }, + { + "first": 34, + "second": 87, + "amount": 1.008 + }, + { + "first": 34, + "second": 89, + "amount": 0.63 + }, + { + "first": 34, + "second": 97, + "amount": -1.344 + }, + { + "first": 34, + "second": 99, + "amount": -1.932 + }, + { + "first": 34, + "second": 100, + "amount": -1.932 + }, + { + "first": 34, + "second": 101, + "amount": -1.932 + }, + { + "first": 34, + "second": 111, + "amount": -1.932 + }, + { + "first": 34, + "second": 113, + "amount": -1.932 + }, + { + "first": 34, + "second": 173, + "amount": -3.738 + }, + { + "first": 34, + "second": 192, + "amount": -3.822 + }, + { + "first": 34, + "second": 193, + "amount": -3.822 + }, + { + "first": 34, + "second": 195, + "amount": -3.822 + }, + { + "first": 34, + "second": 199, + "amount": -0.966 + }, + { + "first": 34, + "second": 224, + "amount": -1.344 + }, + { + "first": 34, + "second": 225, + "amount": -1.344 + }, + { + "first": 34, + "second": 226, + "amount": -1.344 + }, + { + "first": 34, + "second": 227, + "amount": -1.344 + }, + { + "first": 34, + "second": 231, + "amount": -1.932 + }, + { + "first": 34, + "second": 233, + "amount": -1.932 + }, + { + "first": 34, + "second": 234, + "amount": -1.932 + }, + { + "first": 34, + "second": 243, + "amount": -1.932 + }, + { + "first": 34, + "second": 245, + "amount": -1.932 + }, + { + "first": 34, + "second": 8211, + "amount": -3.738 + }, + { + "first": 34, + "second": 8226, + "amount": -3.738 + }, + { + "first": 39, + "second": 92, + "amount": 1.008 + }, + { + "first": 39, + "second": 38, + "amount": -3.822 + }, + { + "first": 39, + "second": 44, + "amount": -4.788 + }, + { + "first": 39, + "second": 45, + "amount": -3.738 + }, + { + "first": 39, + "second": 46, + "amount": -4.788 + }, + { + "first": 39, + "second": 47, + "amount": -3.822 + }, + { + "first": 39, + "second": 64, + "amount": -0.966 + }, + { + "first": 39, + "second": 65, + "amount": -3.822 + }, + { + "first": 39, + "second": 67, + "amount": -0.966 + }, + { + "first": 39, + "second": 71, + "amount": -0.966 + }, + { + "first": 39, + "second": 79, + "amount": -0.966 + }, + { + "first": 39, + "second": 81, + "amount": -0.966 + }, + { + "first": 39, + "second": 86, + "amount": 1.008 + }, + { + "first": 39, + "second": 87, + "amount": 1.008 + }, + { + "first": 39, + "second": 89, + "amount": 0.63 + }, + { + "first": 39, + "second": 97, + "amount": -1.344 + }, + { + "first": 39, + "second": 99, + "amount": -1.932 + }, + { + "first": 39, + "second": 100, + "amount": -1.932 + }, + { + "first": 39, + "second": 101, + "amount": -1.932 + }, + { + "first": 39, + "second": 111, + "amount": -1.932 + }, + { + "first": 39, + "second": 113, + "amount": -1.932 + }, + { + "first": 39, + "second": 173, + "amount": -3.738 + }, + { + "first": 39, + "second": 192, + "amount": -3.822 + }, + { + "first": 39, + "second": 193, + "amount": -3.822 + }, + { + "first": 39, + "second": 195, + "amount": -3.822 + }, + { + "first": 39, + "second": 199, + "amount": -0.966 + }, + { + "first": 39, + "second": 224, + "amount": -1.344 + }, + { + "first": 39, + "second": 225, + "amount": -1.344 + }, + { + "first": 39, + "second": 226, + "amount": -1.344 + }, + { + "first": 39, + "second": 227, + "amount": -1.344 + }, + { + "first": 39, + "second": 231, + "amount": -1.932 + }, + { + "first": 39, + "second": 233, + "amount": -1.932 + }, + { + "first": 39, + "second": 234, + "amount": -1.932 + }, + { + "first": 39, + "second": 243, + "amount": -1.932 + }, + { + "first": 39, + "second": 245, + "amount": -1.932 + }, + { + "first": 39, + "second": 8211, + "amount": -3.738 + }, + { + "first": 39, + "second": 8226, + "amount": -3.738 + }, + { + "first": 40, + "second": 64, + "amount": -0.84 + }, + { + "first": 40, + "second": 67, + "amount": -0.84 + }, + { + "first": 40, + "second": 71, + "amount": -0.84 + }, + { + "first": 40, + "second": 79, + "amount": -0.84 + }, + { + "first": 40, + "second": 81, + "amount": -0.84 + }, + { + "first": 40, + "second": 99, + "amount": -0.672 + }, + { + "first": 40, + "second": 100, + "amount": -0.672 + }, + { + "first": 40, + "second": 101, + "amount": -0.672 + }, + { + "first": 40, + "second": 111, + "amount": -0.672 + }, + { + "first": 40, + "second": 113, + "amount": -0.672 + }, + { + "first": 40, + "second": 199, + "amount": -0.84 + }, + { + "first": 40, + "second": 231, + "amount": -0.672 + }, + { + "first": 40, + "second": 233, + "amount": -0.672 + }, + { + "first": 40, + "second": 234, + "amount": -0.672 + }, + { + "first": 40, + "second": 243, + "amount": -0.672 + }, + { + "first": 40, + "second": 245, + "amount": -0.672 + }, + { + "first": 42, + "second": 92, + "amount": 1.008 + }, + { + "first": 42, + "second": 38, + "amount": -3.822 + }, + { + "first": 42, + "second": 44, + "amount": -4.788 + }, + { + "first": 42, + "second": 45, + "amount": -3.738 + }, + { + "first": 42, + "second": 46, + "amount": -4.788 + }, + { + "first": 42, + "second": 47, + "amount": -3.822 + }, + { + "first": 42, + "second": 64, + "amount": -0.966 + }, + { + "first": 42, + "second": 65, + "amount": -3.822 + }, + { + "first": 42, + "second": 67, + "amount": -0.966 + }, + { + "first": 42, + "second": 71, + "amount": -0.966 + }, + { + "first": 42, + "second": 79, + "amount": -0.966 + }, + { + "first": 42, + "second": 81, + "amount": -0.966 + }, + { + "first": 42, + "second": 86, + "amount": 1.008 + }, + { + "first": 42, + "second": 87, + "amount": 1.008 + }, + { + "first": 42, + "second": 89, + "amount": 0.63 + }, + { + "first": 42, + "second": 97, + "amount": -1.344 + }, + { + "first": 42, + "second": 99, + "amount": -1.932 + }, + { + "first": 42, + "second": 100, + "amount": -1.932 + }, + { + "first": 42, + "second": 101, + "amount": -1.932 + }, + { + "first": 42, + "second": 111, + "amount": -1.932 + }, + { + "first": 42, + "second": 113, + "amount": -1.932 + }, + { + "first": 42, + "second": 173, + "amount": -3.738 + }, + { + "first": 42, + "second": 192, + "amount": -3.822 + }, + { + "first": 42, + "second": 193, + "amount": -3.822 + }, + { + "first": 42, + "second": 195, + "amount": -3.822 + }, + { + "first": 42, + "second": 199, + "amount": -0.966 + }, + { + "first": 42, + "second": 224, + "amount": -1.344 + }, + { + "first": 42, + "second": 225, + "amount": -1.344 + }, + { + "first": 42, + "second": 226, + "amount": -1.344 + }, + { + "first": 42, + "second": 227, + "amount": -1.344 + }, + { + "first": 42, + "second": 231, + "amount": -1.932 + }, + { + "first": 42, + "second": 233, + "amount": -1.932 + }, + { + "first": 42, + "second": 234, + "amount": -1.932 + }, + { + "first": 42, + "second": 243, + "amount": -1.932 + }, + { + "first": 42, + "second": 245, + "amount": -1.932 + }, + { + "first": 42, + "second": 8211, + "amount": -3.738 + }, + { + "first": 42, + "second": 8226, + "amount": -3.738 + }, + { + "first": 44, + "second": 92, + "amount": -3.78 + }, + { + "first": 44, + "second": 34, + "amount": -4.788 + }, + { + "first": 44, + "second": 39, + "amount": -4.788 + }, + { + "first": 44, + "second": 42, + "amount": -4.788 + }, + { + "first": 44, + "second": 45, + "amount": -2.856 + }, + { + "first": 44, + "second": 64, + "amount": -1.176 + }, + { + "first": 44, + "second": 67, + "amount": -1.176 + }, + { + "first": 44, + "second": 71, + "amount": -1.176 + }, + { + "first": 44, + "second": 79, + "amount": -1.176 + }, + { + "first": 44, + "second": 81, + "amount": -1.176 + }, + { + "first": 44, + "second": 84, + "amount": -3.78 + }, + { + "first": 44, + "second": 86, + "amount": -3.78 + }, + { + "first": 44, + "second": 87, + "amount": -2.562 + }, + { + "first": 44, + "second": 89, + "amount": -3.192 + }, + { + "first": 44, + "second": 118, + "amount": -2.772 + }, + { + "first": 44, + "second": 119, + "amount": -1.302 + }, + { + "first": 44, + "second": 121, + "amount": -2.772 + }, + { + "first": 44, + "second": 8217, + "amount": -4.788 + }, + { + "first": 44, + "second": 8220, + "amount": -4.788 + }, + { + "first": 44, + "second": 8221, + "amount": -4.788 + }, + { + "first": 44, + "second": 173, + "amount": -2.856 + }, + { + "first": 44, + "second": 186, + "amount": -4.788 + }, + { + "first": 44, + "second": 199, + "amount": -1.176 + }, + { + "first": 44, + "second": 8211, + "amount": -2.856 + }, + { + "first": 44, + "second": 8226, + "amount": -2.856 + }, + { + "first": 45, + "second": 92, + "amount": -2.352 + }, + { + "first": 45, + "second": 34, + "amount": -3.738 + }, + { + "first": 45, + "second": 38, + "amount": -1.092 + }, + { + "first": 45, + "second": 39, + "amount": -3.738 + }, + { + "first": 45, + "second": 42, + "amount": -3.738 + }, + { + "first": 45, + "second": 44, + "amount": -2.856 + }, + { + "first": 45, + "second": 46, + "amount": -2.856 + }, + { + "first": 45, + "second": 47, + "amount": -1.092 + }, + { + "first": 45, + "second": 65, + "amount": -1.092 + }, + { + "first": 45, + "second": 84, + "amount": -3.78 + }, + { + "first": 45, + "second": 86, + "amount": -2.352 + }, + { + "first": 45, + "second": 87, + "amount": -0.672 + }, + { + "first": 45, + "second": 88, + "amount": -1.302 + }, + { + "first": 45, + "second": 89, + "amount": -3.36 + }, + { + "first": 45, + "second": 90, + "amount": -0.966 + }, + { + "first": 45, + "second": 8217, + "amount": -3.738 + }, + { + "first": 45, + "second": 8220, + "amount": -3.738 + }, + { + "first": 45, + "second": 8221, + "amount": -3.738 + }, + { + "first": 45, + "second": 186, + "amount": -3.738 + }, + { + "first": 45, + "second": 192, + "amount": -1.092 + }, + { + "first": 45, + "second": 193, + "amount": -1.092 + }, + { + "first": 45, + "second": 195, + "amount": -1.092 + }, + { + "first": 46, + "second": 92, + "amount": -3.78 + }, + { + "first": 46, + "second": 34, + "amount": -4.788 + }, + { + "first": 46, + "second": 39, + "amount": -4.788 + }, + { + "first": 46, + "second": 42, + "amount": -4.788 + }, + { + "first": 46, + "second": 45, + "amount": -2.856 + }, + { + "first": 46, + "second": 64, + "amount": -1.176 + }, + { + "first": 46, + "second": 67, + "amount": -1.176 + }, + { + "first": 46, + "second": 71, + "amount": -1.176 + }, + { + "first": 46, + "second": 79, + "amount": -1.176 + }, + { + "first": 46, + "second": 81, + "amount": -1.176 + }, + { + "first": 46, + "second": 84, + "amount": -3.78 + }, + { + "first": 46, + "second": 86, + "amount": -3.78 + }, + { + "first": 46, + "second": 87, + "amount": -2.562 + }, + { + "first": 46, + "second": 89, + "amount": -3.192 + }, + { + "first": 46, + "second": 118, + "amount": -2.772 + }, + { + "first": 46, + "second": 119, + "amount": -1.302 + }, + { + "first": 46, + "second": 121, + "amount": -2.772 + }, + { + "first": 46, + "second": 8217, + "amount": -4.788 + }, + { + "first": 46, + "second": 8220, + "amount": -4.788 + }, + { + "first": 46, + "second": 8221, + "amount": -4.788 + }, + { + "first": 46, + "second": 173, + "amount": -2.856 + }, + { + "first": 46, + "second": 186, + "amount": -4.788 + }, + { + "first": 46, + "second": 199, + "amount": -1.176 + }, + { + "first": 46, + "second": 8211, + "amount": -2.856 + }, + { + "first": 46, + "second": 8226, + "amount": -2.856 + }, + { + "first": 47, + "second": 34, + "amount": 1.008 + }, + { + "first": 47, + "second": 38, + "amount": -2.856 + }, + { + "first": 47, + "second": 39, + "amount": 1.008 + }, + { + "first": 47, + "second": 42, + "amount": 1.008 + }, + { + "first": 47, + "second": 44, + "amount": -4.032 + }, + { + "first": 47, + "second": 45, + "amount": -2.352 + }, + { + "first": 47, + "second": 46, + "amount": -4.032 + }, + { + "first": 47, + "second": 47, + "amount": -2.856 + }, + { + "first": 47, + "second": 58, + "amount": -1.848 + }, + { + "first": 47, + "second": 59, + "amount": -1.848 + }, + { + "first": 47, + "second": 63, + "amount": 1.008 + }, + { + "first": 47, + "second": 64, + "amount": -1.092 + }, + { + "first": 47, + "second": 65, + "amount": -2.856 + }, + { + "first": 47, + "second": 67, + "amount": -1.092 + }, + { + "first": 47, + "second": 71, + "amount": -1.092 + }, + { + "first": 47, + "second": 74, + "amount": -3.192 + }, + { + "first": 47, + "second": 79, + "amount": -1.092 + }, + { + "first": 47, + "second": 81, + "amount": -1.092 + }, + { + "first": 47, + "second": 97, + "amount": -2.436 + }, + { + "first": 47, + "second": 99, + "amount": -2.436 + }, + { + "first": 47, + "second": 100, + "amount": -2.436 + }, + { + "first": 47, + "second": 101, + "amount": -2.436 + }, + { + "first": 47, + "second": 102, + "amount": -0.63 + }, + { + "first": 47, + "second": 103, + "amount": -2.856 + }, + { + "first": 47, + "second": 109, + "amount": -1.848 + }, + { + "first": 47, + "second": 110, + "amount": -1.848 + }, + { + "first": 47, + "second": 111, + "amount": -2.436 + }, + { + "first": 47, + "second": 112, + "amount": -1.848 + }, + { + "first": 47, + "second": 113, + "amount": -2.436 + }, + { + "first": 47, + "second": 114, + "amount": -1.848 + }, + { + "first": 47, + "second": 115, + "amount": -2.226 + }, + { + "first": 47, + "second": 116, + "amount": -0.882 + }, + { + "first": 47, + "second": 117, + "amount": -1.848 + }, + { + "first": 47, + "second": 118, + "amount": -1.008 + }, + { + "first": 47, + "second": 120, + "amount": -1.092 + }, + { + "first": 47, + "second": 121, + "amount": -1.008 + }, + { + "first": 47, + "second": 122, + "amount": -1.722 + }, + { + "first": 47, + "second": 8217, + "amount": 1.008 + }, + { + "first": 47, + "second": 8220, + "amount": 1.008 + }, + { + "first": 47, + "second": 8221, + "amount": 1.008 + }, + { + "first": 47, + "second": 173, + "amount": -2.352 + }, + { + "first": 47, + "second": 186, + "amount": 1.008 + }, + { + "first": 47, + "second": 192, + "amount": -2.856 + }, + { + "first": 47, + "second": 193, + "amount": -2.856 + }, + { + "first": 47, + "second": 195, + "amount": -2.856 + }, + { + "first": 47, + "second": 199, + "amount": -1.092 + }, + { + "first": 47, + "second": 224, + "amount": -2.436 + }, + { + "first": 47, + "second": 225, + "amount": -2.436 + }, + { + "first": 47, + "second": 226, + "amount": -2.436 + }, + { + "first": 47, + "second": 227, + "amount": -2.436 + }, + { + "first": 47, + "second": 231, + "amount": -2.436 + }, + { + "first": 47, + "second": 233, + "amount": -2.436 + }, + { + "first": 47, + "second": 234, + "amount": -2.436 + }, + { + "first": 47, + "second": 243, + "amount": -2.436 + }, + { + "first": 47, + "second": 245, + "amount": -2.436 + }, + { + "first": 47, + "second": 250, + "amount": -1.848 + }, + { + "first": 47, + "second": 8211, + "amount": -2.352 + }, + { + "first": 47, + "second": 8226, + "amount": -2.352 + }, + { + "first": 64, + "second": 92, + "amount": -1.092 + }, + { + "first": 64, + "second": 34, + "amount": -0.966 + }, + { + "first": 64, + "second": 38, + "amount": -0.882 + }, + { + "first": 64, + "second": 39, + "amount": -0.966 + }, + { + "first": 64, + "second": 41, + "amount": -0.84 + }, + { + "first": 64, + "second": 42, + "amount": -0.966 + }, + { + "first": 64, + "second": 44, + "amount": -1.176 + }, + { + "first": 64, + "second": 46, + "amount": -1.176 + }, + { + "first": 64, + "second": 47, + "amount": -0.882 + }, + { + "first": 64, + "second": 65, + "amount": -0.882 + }, + { + "first": 64, + "second": 84, + "amount": -2.058 + }, + { + "first": 64, + "second": 86, + "amount": -1.092 + }, + { + "first": 64, + "second": 88, + "amount": -0.63 + }, + { + "first": 64, + "second": 89, + "amount": -1.68 + }, + { + "first": 64, + "second": 90, + "amount": -1.47 + }, + { + "first": 64, + "second": 93, + "amount": -0.84 + }, + { + "first": 64, + "second": 125, + "amount": -0.84 + }, + { + "first": 64, + "second": 8217, + "amount": -0.966 + }, + { + "first": 64, + "second": 8220, + "amount": -0.966 + }, + { + "first": 64, + "second": 8221, + "amount": -0.966 + }, + { + "first": 64, + "second": 186, + "amount": -0.966 + }, + { + "first": 64, + "second": 192, + "amount": -0.882 + }, + { + "first": 64, + "second": 193, + "amount": -0.882 + }, + { + "first": 64, + "second": 195, + "amount": -0.882 + }, + { + "first": 65, + "second": 92, + "amount": -2.856 + }, + { + "first": 65, + "second": 34, + "amount": -3.822 + }, + { + "first": 65, + "second": 39, + "amount": -3.822 + }, + { + "first": 65, + "second": 42, + "amount": -3.822 + }, + { + "first": 65, + "second": 45, + "amount": -1.092 + }, + { + "first": 65, + "second": 63, + "amount": -1.176 + }, + { + "first": 65, + "second": 64, + "amount": -0.882 + }, + { + "first": 65, + "second": 67, + "amount": -0.882 + }, + { + "first": 65, + "second": 71, + "amount": -0.882 + }, + { + "first": 65, + "second": 74, + "amount": 1.05 + }, + { + "first": 65, + "second": 79, + "amount": -0.882 + }, + { + "first": 65, + "second": 81, + "amount": -0.882 + }, + { + "first": 65, + "second": 84, + "amount": -2.772 + }, + { + "first": 65, + "second": 85, + "amount": -1.176 + }, + { + "first": 65, + "second": 86, + "amount": -2.856 + }, + { + "first": 65, + "second": 87, + "amount": -1.764 + }, + { + "first": 65, + "second": 89, + "amount": -3.444 + }, + { + "first": 65, + "second": 118, + "amount": -1.722 + }, + { + "first": 65, + "second": 121, + "amount": -1.722 + }, + { + "first": 65, + "second": 8217, + "amount": -3.822 + }, + { + "first": 65, + "second": 8220, + "amount": -3.822 + }, + { + "first": 65, + "second": 8221, + "amount": -3.822 + }, + { + "first": 65, + "second": 173, + "amount": -1.092 + }, + { + "first": 65, + "second": 186, + "amount": -3.822 + }, + { + "first": 65, + "second": 199, + "amount": -0.882 + }, + { + "first": 65, + "second": 218, + "amount": -1.176 + }, + { + "first": 65, + "second": 8211, + "amount": -1.092 + }, + { + "first": 65, + "second": 8226, + "amount": -1.092 + }, + { + "first": 67, + "second": 45, + "amount": -3.15 + }, + { + "first": 67, + "second": 173, + "amount": -3.15 + }, + { + "first": 67, + "second": 8211, + "amount": -3.15 + }, + { + "first": 67, + "second": 8226, + "amount": -3.15 + }, + { + "first": 68, + "second": 92, + "amount": -1.092 + }, + { + "first": 68, + "second": 34, + "amount": -0.966 + }, + { + "first": 68, + "second": 38, + "amount": -0.882 + }, + { + "first": 68, + "second": 39, + "amount": -0.966 + }, + { + "first": 68, + "second": 41, + "amount": -0.84 + }, + { + "first": 68, + "second": 42, + "amount": -0.966 + }, + { + "first": 68, + "second": 44, + "amount": -1.176 + }, + { + "first": 68, + "second": 46, + "amount": -1.176 + }, + { + "first": 68, + "second": 47, + "amount": -0.882 + }, + { + "first": 68, + "second": 65, + "amount": -0.882 + }, + { + "first": 68, + "second": 84, + "amount": -2.058 + }, + { + "first": 68, + "second": 86, + "amount": -1.092 + }, + { + "first": 68, + "second": 88, + "amount": -0.63 + }, + { + "first": 68, + "second": 89, + "amount": -1.68 + }, + { + "first": 68, + "second": 90, + "amount": -1.47 + }, + { + "first": 68, + "second": 93, + "amount": -0.84 + }, + { + "first": 68, + "second": 125, + "amount": -0.84 + }, + { + "first": 68, + "second": 8217, + "amount": -0.966 + }, + { + "first": 68, + "second": 8220, + "amount": -0.966 + }, + { + "first": 68, + "second": 8221, + "amount": -0.966 + }, + { + "first": 68, + "second": 186, + "amount": -0.966 + }, + { + "first": 68, + "second": 192, + "amount": -0.882 + }, + { + "first": 68, + "second": 193, + "amount": -0.882 + }, + { + "first": 68, + "second": 195, + "amount": -0.882 + }, + { + "first": 70, + "second": 38, + "amount": -2.772 + }, + { + "first": 70, + "second": 44, + "amount": -3.78 + }, + { + "first": 70, + "second": 46, + "amount": -3.78 + }, + { + "first": 70, + "second": 47, + "amount": -2.772 + }, + { + "first": 70, + "second": 58, + "amount": -1.26 + }, + { + "first": 70, + "second": 59, + "amount": -1.26 + }, + { + "first": 70, + "second": 63, + "amount": 0.63 + }, + { + "first": 70, + "second": 65, + "amount": -2.772 + }, + { + "first": 70, + "second": 74, + "amount": -4.158 + }, + { + "first": 70, + "second": 99, + "amount": -1.47 + }, + { + "first": 70, + "second": 100, + "amount": -1.47 + }, + { + "first": 70, + "second": 101, + "amount": -1.47 + }, + { + "first": 70, + "second": 109, + "amount": -1.26 + }, + { + "first": 70, + "second": 110, + "amount": -1.26 + }, + { + "first": 70, + "second": 111, + "amount": -1.47 + }, + { + "first": 70, + "second": 112, + "amount": -1.26 + }, + { + "first": 70, + "second": 113, + "amount": -1.47 + }, + { + "first": 70, + "second": 114, + "amount": -1.26 + }, + { + "first": 70, + "second": 117, + "amount": -1.26 + }, + { + "first": 70, + "second": 192, + "amount": -2.772 + }, + { + "first": 70, + "second": 193, + "amount": -2.772 + }, + { + "first": 70, + "second": 195, + "amount": -2.772 + }, + { + "first": 70, + "second": 231, + "amount": -1.47 + }, + { + "first": 70, + "second": 233, + "amount": -1.47 + }, + { + "first": 70, + "second": 234, + "amount": -1.47 + }, + { + "first": 70, + "second": 243, + "amount": -1.47 + }, + { + "first": 70, + "second": 245, + "amount": -1.47 + }, + { + "first": 70, + "second": 250, + "amount": -1.26 + }, + { + "first": 74, + "second": 38, + "amount": -1.176 + }, + { + "first": 74, + "second": 44, + "amount": -1.05 + }, + { + "first": 74, + "second": 46, + "amount": -1.05 + }, + { + "first": 74, + "second": 47, + "amount": -1.176 + }, + { + "first": 74, + "second": 65, + "amount": -1.176 + }, + { + "first": 74, + "second": 192, + "amount": -1.176 + }, + { + "first": 74, + "second": 193, + "amount": -1.176 + }, + { + "first": 74, + "second": 195, + "amount": -1.176 + }, + { + "first": 75, + "second": 45, + "amount": -1.302 + }, + { + "first": 75, + "second": 64, + "amount": -0.63 + }, + { + "first": 75, + "second": 67, + "amount": -0.63 + }, + { + "first": 75, + "second": 71, + "amount": -0.63 + }, + { + "first": 75, + "second": 79, + "amount": -0.63 + }, + { + "first": 75, + "second": 81, + "amount": -0.63 + }, + { + "first": 75, + "second": 99, + "amount": -0.756 + }, + { + "first": 75, + "second": 100, + "amount": -0.756 + }, + { + "first": 75, + "second": 101, + "amount": -0.756 + }, + { + "first": 75, + "second": 102, + "amount": -1.092 + }, + { + "first": 75, + "second": 111, + "amount": -0.756 + }, + { + "first": 75, + "second": 113, + "amount": -0.756 + }, + { + "first": 75, + "second": 116, + "amount": -1.722 + }, + { + "first": 75, + "second": 118, + "amount": -1.386 + }, + { + "first": 75, + "second": 119, + "amount": -1.176 + }, + { + "first": 75, + "second": 121, + "amount": -1.386 + }, + { + "first": 75, + "second": 173, + "amount": -1.302 + }, + { + "first": 75, + "second": 199, + "amount": -0.63 + }, + { + "first": 75, + "second": 231, + "amount": -0.756 + }, + { + "first": 75, + "second": 233, + "amount": -0.756 + }, + { + "first": 75, + "second": 234, + "amount": -0.756 + }, + { + "first": 75, + "second": 243, + "amount": -0.756 + }, + { + "first": 75, + "second": 245, + "amount": -0.756 + }, + { + "first": 75, + "second": 8211, + "amount": -1.302 + }, + { + "first": 75, + "second": 8226, + "amount": -1.302 + }, + { + "first": 76, + "second": 92, + "amount": -3.822 + }, + { + "first": 76, + "second": 34, + "amount": -6.09 + }, + { + "first": 76, + "second": 39, + "amount": -6.09 + }, + { + "first": 76, + "second": 42, + "amount": -6.09 + }, + { + "first": 76, + "second": 44, + "amount": 1.134 + }, + { + "first": 76, + "second": 45, + "amount": -4.158 + }, + { + "first": 76, + "second": 46, + "amount": 1.134 + }, + { + "first": 76, + "second": 63, + "amount": -1.05 + }, + { + "first": 76, + "second": 64, + "amount": -1.68 + }, + { + "first": 76, + "second": 67, + "amount": -1.68 + }, + { + "first": 76, + "second": 71, + "amount": -1.68 + }, + { + "first": 76, + "second": 79, + "amount": -1.68 + }, + { + "first": 76, + "second": 81, + "amount": -1.68 + }, + { + "first": 76, + "second": 84, + "amount": -3.612 + }, + { + "first": 76, + "second": 86, + "amount": -3.822 + }, + { + "first": 76, + "second": 87, + "amount": -3.192 + }, + { + "first": 76, + "second": 89, + "amount": -4.452 + }, + { + "first": 76, + "second": 99, + "amount": -0.756 + }, + { + "first": 76, + "second": 100, + "amount": -0.756 + }, + { + "first": 76, + "second": 101, + "amount": -0.756 + }, + { + "first": 76, + "second": 111, + "amount": -0.756 + }, + { + "first": 76, + "second": 113, + "amount": -0.756 + }, + { + "first": 76, + "second": 118, + "amount": -2.268 + }, + { + "first": 76, + "second": 119, + "amount": -1.68 + }, + { + "first": 76, + "second": 121, + "amount": -2.268 + }, + { + "first": 76, + "second": 8217, + "amount": -6.09 + }, + { + "first": 76, + "second": 8220, + "amount": -6.09 + }, + { + "first": 76, + "second": 8221, + "amount": -6.09 + }, + { + "first": 76, + "second": 173, + "amount": -4.158 + }, + { + "first": 76, + "second": 186, + "amount": -6.09 + }, + { + "first": 76, + "second": 199, + "amount": -1.68 + }, + { + "first": 76, + "second": 231, + "amount": -0.756 + }, + { + "first": 76, + "second": 233, + "amount": -0.756 + }, + { + "first": 76, + "second": 234, + "amount": -0.756 + }, + { + "first": 76, + "second": 243, + "amount": -0.756 + }, + { + "first": 76, + "second": 245, + "amount": -0.756 + }, + { + "first": 76, + "second": 8211, + "amount": -4.158 + }, + { + "first": 76, + "second": 8226, + "amount": -4.158 + }, + { + "first": 79, + "second": 92, + "amount": -1.092 + }, + { + "first": 79, + "second": 34, + "amount": -0.966 + }, + { + "first": 79, + "second": 38, + "amount": -0.882 + }, + { + "first": 79, + "second": 39, + "amount": -0.966 + }, + { + "first": 79, + "second": 41, + "amount": -0.84 + }, + { + "first": 79, + "second": 42, + "amount": -0.966 + }, + { + "first": 79, + "second": 44, + "amount": -1.176 + }, + { + "first": 79, + "second": 46, + "amount": -1.176 + }, + { + "first": 79, + "second": 47, + "amount": -0.882 + }, + { + "first": 79, + "second": 65, + "amount": -0.882 + }, + { + "first": 79, + "second": 84, + "amount": -2.058 + }, + { + "first": 79, + "second": 86, + "amount": -1.092 + }, + { + "first": 79, + "second": 88, + "amount": -0.63 + }, + { + "first": 79, + "second": 89, + "amount": -1.68 + }, + { + "first": 79, + "second": 90, + "amount": -1.47 + }, + { + "first": 79, + "second": 93, + "amount": -0.84 + }, + { + "first": 79, + "second": 125, + "amount": -0.84 + }, + { + "first": 79, + "second": 8217, + "amount": -0.966 + }, + { + "first": 79, + "second": 8220, + "amount": -0.966 + }, + { + "first": 79, + "second": 8221, + "amount": -0.966 + }, + { + "first": 79, + "second": 186, + "amount": -0.966 + }, + { + "first": 79, + "second": 192, + "amount": -0.882 + }, + { + "first": 79, + "second": 193, + "amount": -0.882 + }, + { + "first": 79, + "second": 195, + "amount": -0.882 + }, + { + "first": 80, + "second": 38, + "amount": -2.898 + }, + { + "first": 80, + "second": 44, + "amount": -5.208 + }, + { + "first": 80, + "second": 46, + "amount": -5.208 + }, + { + "first": 80, + "second": 47, + "amount": -2.898 + }, + { + "first": 80, + "second": 65, + "amount": -2.898 + }, + { + "first": 80, + "second": 74, + "amount": -3.822 + }, + { + "first": 80, + "second": 97, + "amount": -1.05 + }, + { + "first": 80, + "second": 99, + "amount": -0.63 + }, + { + "first": 80, + "second": 100, + "amount": -0.63 + }, + { + "first": 80, + "second": 101, + "amount": -0.63 + }, + { + "first": 80, + "second": 111, + "amount": -0.63 + }, + { + "first": 80, + "second": 113, + "amount": -0.63 + }, + { + "first": 80, + "second": 192, + "amount": -2.898 + }, + { + "first": 80, + "second": 193, + "amount": -2.898 + }, + { + "first": 80, + "second": 195, + "amount": -2.898 + }, + { + "first": 80, + "second": 224, + "amount": -1.05 + }, + { + "first": 80, + "second": 225, + "amount": -1.05 + }, + { + "first": 80, + "second": 226, + "amount": -1.05 + }, + { + "first": 80, + "second": 227, + "amount": -1.05 + }, + { + "first": 80, + "second": 231, + "amount": -0.63 + }, + { + "first": 80, + "second": 233, + "amount": -0.63 + }, + { + "first": 80, + "second": 234, + "amount": -0.63 + }, + { + "first": 80, + "second": 243, + "amount": -0.63 + }, + { + "first": 80, + "second": 245, + "amount": -0.63 + }, + { + "first": 81, + "second": 92, + "amount": -1.092 + }, + { + "first": 81, + "second": 34, + "amount": -0.966 + }, + { + "first": 81, + "second": 38, + "amount": -0.882 + }, + { + "first": 81, + "second": 39, + "amount": -0.966 + }, + { + "first": 81, + "second": 41, + "amount": -0.84 + }, + { + "first": 81, + "second": 42, + "amount": -0.966 + }, + { + "first": 81, + "second": 44, + "amount": -1.176 + }, + { + "first": 81, + "second": 46, + "amount": -1.176 + }, + { + "first": 81, + "second": 47, + "amount": -0.882 + }, + { + "first": 81, + "second": 65, + "amount": -0.882 + }, + { + "first": 81, + "second": 84, + "amount": -2.058 + }, + { + "first": 81, + "second": 86, + "amount": -1.092 + }, + { + "first": 81, + "second": 88, + "amount": -0.63 + }, + { + "first": 81, + "second": 89, + "amount": -1.68 + }, + { + "first": 81, + "second": 90, + "amount": -1.47 + }, + { + "first": 81, + "second": 93, + "amount": -0.84 + }, + { + "first": 81, + "second": 125, + "amount": -0.84 + }, + { + "first": 81, + "second": 8217, + "amount": -0.966 + }, + { + "first": 81, + "second": 8220, + "amount": -0.966 + }, + { + "first": 81, + "second": 8221, + "amount": -0.966 + }, + { + "first": 81, + "second": 186, + "amount": -0.966 + }, + { + "first": 81, + "second": 192, + "amount": -0.882 + }, + { + "first": 81, + "second": 193, + "amount": -0.882 + }, + { + "first": 81, + "second": 195, + "amount": -0.882 + }, + { + "first": 82, + "second": 64, + "amount": -0.966 + }, + { + "first": 82, + "second": 67, + "amount": -0.966 + }, + { + "first": 82, + "second": 71, + "amount": -0.966 + }, + { + "first": 82, + "second": 79, + "amount": -0.966 + }, + { + "first": 82, + "second": 81, + "amount": -0.966 + }, + { + "first": 82, + "second": 84, + "amount": -1.092 + }, + { + "first": 82, + "second": 85, + "amount": -0.882 + }, + { + "first": 82, + "second": 199, + "amount": -0.966 + }, + { + "first": 82, + "second": 218, + "amount": -0.882 + }, + { + "first": 84, + "second": 38, + "amount": -2.772 + }, + { + "first": 84, + "second": 44, + "amount": -3.78 + }, + { + "first": 84, + "second": 45, + "amount": -3.78 + }, + { + "first": 84, + "second": 46, + "amount": -3.78 + }, + { + "first": 84, + "second": 47, + "amount": -2.772 + }, + { + "first": 84, + "second": 58, + "amount": -3.36 + }, + { + "first": 84, + "second": 59, + "amount": -3.36 + }, + { + "first": 84, + "second": 64, + "amount": -2.058 + }, + { + "first": 84, + "second": 65, + "amount": -2.772 + }, + { + "first": 84, + "second": 67, + "amount": -2.058 + }, + { + "first": 84, + "second": 71, + "amount": -2.058 + }, + { + "first": 84, + "second": 74, + "amount": -4.2 + }, + { + "first": 84, + "second": 79, + "amount": -2.058 + }, + { + "first": 84, + "second": 81, + "amount": -2.058 + }, + { + "first": 84, + "second": 97, + "amount": -5.25 + }, + { + "first": 84, + "second": 99, + "amount": -4.41 + }, + { + "first": 84, + "second": 100, + "amount": -4.41 + }, + { + "first": 84, + "second": 101, + "amount": -4.41 + }, + { + "first": 84, + "second": 103, + "amount": -3.948 + }, + { + "first": 84, + "second": 109, + "amount": -3.36 + }, + { + "first": 84, + "second": 110, + "amount": -3.36 + }, + { + "first": 84, + "second": 111, + "amount": -4.41 + }, + { + "first": 84, + "second": 112, + "amount": -3.36 + }, + { + "first": 84, + "second": 113, + "amount": -4.41 + }, + { + "first": 84, + "second": 114, + "amount": -3.36 + }, + { + "first": 84, + "second": 115, + "amount": -3.402 + }, + { + "first": 84, + "second": 117, + "amount": -3.36 + }, + { + "first": 84, + "second": 118, + "amount": -3.78 + }, + { + "first": 84, + "second": 119, + "amount": -2.94 + }, + { + "first": 84, + "second": 120, + "amount": -3.024 + }, + { + "first": 84, + "second": 121, + "amount": -3.78 + }, + { + "first": 84, + "second": 122, + "amount": -2.52 + }, + { + "first": 84, + "second": 173, + "amount": -3.78 + }, + { + "first": 84, + "second": 192, + "amount": -2.772 + }, + { + "first": 84, + "second": 193, + "amount": -2.772 + }, + { + "first": 84, + "second": 195, + "amount": -2.772 + }, + { + "first": 84, + "second": 199, + "amount": -2.058 + }, + { + "first": 84, + "second": 224, + "amount": -5.25 + }, + { + "first": 84, + "second": 225, + "amount": -5.25 + }, + { + "first": 84, + "second": 226, + "amount": -5.25 + }, + { + "first": 84, + "second": 227, + "amount": -5.25 + }, + { + "first": 84, + "second": 231, + "amount": -4.41 + }, + { + "first": 84, + "second": 233, + "amount": -4.41 + }, + { + "first": 84, + "second": 234, + "amount": -4.41 + }, + { + "first": 84, + "second": 243, + "amount": -4.41 + }, + { + "first": 84, + "second": 245, + "amount": -4.41 + }, + { + "first": 84, + "second": 250, + "amount": -3.36 + }, + { + "first": 84, + "second": 8211, + "amount": -3.78 + }, + { + "first": 84, + "second": 8226, + "amount": -3.78 + }, + { + "first": 85, + "second": 38, + "amount": -1.176 + }, + { + "first": 85, + "second": 44, + "amount": -1.05 + }, + { + "first": 85, + "second": 46, + "amount": -1.05 + }, + { + "first": 85, + "second": 47, + "amount": -1.176 + }, + { + "first": 85, + "second": 65, + "amount": -1.176 + }, + { + "first": 85, + "second": 192, + "amount": -1.176 + }, + { + "first": 85, + "second": 193, + "amount": -1.176 + }, + { + "first": 85, + "second": 195, + "amount": -1.176 + }, + { + "first": 86, + "second": 34, + "amount": 1.008 + }, + { + "first": 86, + "second": 38, + "amount": -2.856 + }, + { + "first": 86, + "second": 39, + "amount": 1.008 + }, + { + "first": 86, + "second": 42, + "amount": 1.008 + }, + { + "first": 86, + "second": 44, + "amount": -4.032 + }, + { + "first": 86, + "second": 45, + "amount": -2.352 + }, + { + "first": 86, + "second": 46, + "amount": -4.032 + }, + { + "first": 86, + "second": 47, + "amount": -2.856 + }, + { + "first": 86, + "second": 58, + "amount": -1.848 + }, + { + "first": 86, + "second": 59, + "amount": -1.848 + }, + { + "first": 86, + "second": 63, + "amount": 1.008 + }, + { + "first": 86, + "second": 64, + "amount": -1.092 + }, + { + "first": 86, + "second": 65, + "amount": -2.856 + }, + { + "first": 86, + "second": 67, + "amount": -1.092 + }, + { + "first": 86, + "second": 71, + "amount": -1.092 + }, + { + "first": 86, + "second": 74, + "amount": -3.192 + }, + { + "first": 86, + "second": 79, + "amount": -1.092 + }, + { + "first": 86, + "second": 81, + "amount": -1.092 + }, + { + "first": 86, + "second": 97, + "amount": -2.436 + }, + { + "first": 86, + "second": 99, + "amount": -2.436 + }, + { + "first": 86, + "second": 100, + "amount": -2.436 + }, + { + "first": 86, + "second": 101, + "amount": -2.436 + }, + { + "first": 86, + "second": 102, + "amount": -0.63 + }, + { + "first": 86, + "second": 103, + "amount": -2.856 + }, + { + "first": 86, + "second": 109, + "amount": -1.848 + }, + { + "first": 86, + "second": 110, + "amount": -1.848 + }, + { + "first": 86, + "second": 111, + "amount": -2.436 + }, + { + "first": 86, + "second": 112, + "amount": -1.848 + }, + { + "first": 86, + "second": 113, + "amount": -2.436 + }, + { + "first": 86, + "second": 114, + "amount": -1.848 + }, + { + "first": 86, + "second": 115, + "amount": -2.226 + }, + { + "first": 86, + "second": 116, + "amount": -0.882 + }, + { + "first": 86, + "second": 117, + "amount": -1.848 + }, + { + "first": 86, + "second": 118, + "amount": -1.008 + }, + { + "first": 86, + "second": 120, + "amount": -1.092 + }, + { + "first": 86, + "second": 121, + "amount": -1.008 + }, + { + "first": 86, + "second": 122, + "amount": -1.722 + }, + { + "first": 86, + "second": 8217, + "amount": 1.008 + }, + { + "first": 86, + "second": 8220, + "amount": 1.008 + }, + { + "first": 86, + "second": 8221, + "amount": 1.008 + }, + { + "first": 86, + "second": 173, + "amount": -2.352 + }, + { + "first": 86, + "second": 186, + "amount": 1.008 + }, + { + "first": 86, + "second": 192, + "amount": -2.856 + }, + { + "first": 86, + "second": 193, + "amount": -2.856 + }, + { + "first": 86, + "second": 195, + "amount": -2.856 + }, + { + "first": 86, + "second": 199, + "amount": -1.092 + }, + { + "first": 86, + "second": 224, + "amount": -2.436 + }, + { + "first": 86, + "second": 225, + "amount": -2.436 + }, + { + "first": 86, + "second": 226, + "amount": -2.436 + }, + { + "first": 86, + "second": 227, + "amount": -2.436 + }, + { + "first": 86, + "second": 231, + "amount": -2.436 + }, + { + "first": 86, + "second": 233, + "amount": -2.436 + }, + { + "first": 86, + "second": 234, + "amount": -2.436 + }, + { + "first": 86, + "second": 243, + "amount": -2.436 + }, + { + "first": 86, + "second": 245, + "amount": -2.436 + }, + { + "first": 86, + "second": 250, + "amount": -1.848 + }, + { + "first": 86, + "second": 8211, + "amount": -2.352 + }, + { + "first": 86, + "second": 8226, + "amount": -2.352 + }, + { + "first": 87, + "second": 34, + "amount": 1.008 + }, + { + "first": 87, + "second": 38, + "amount": -1.974 + }, + { + "first": 87, + "second": 39, + "amount": 1.008 + }, + { + "first": 87, + "second": 42, + "amount": 1.008 + }, + { + "first": 87, + "second": 44, + "amount": -2.562 + }, + { + "first": 87, + "second": 45, + "amount": -0.672 + }, + { + "first": 87, + "second": 46, + "amount": -2.562 + }, + { + "first": 87, + "second": 47, + "amount": -1.974 + }, + { + "first": 87, + "second": 63, + "amount": 0.714 + }, + { + "first": 87, + "second": 65, + "amount": -1.974 + }, + { + "first": 87, + "second": 74, + "amount": -2.142 + }, + { + "first": 87, + "second": 97, + "amount": -1.848 + }, + { + "first": 87, + "second": 99, + "amount": -0.672 + }, + { + "first": 87, + "second": 100, + "amount": -0.672 + }, + { + "first": 87, + "second": 101, + "amount": -0.672 + }, + { + "first": 87, + "second": 103, + "amount": -2.058 + }, + { + "first": 87, + "second": 111, + "amount": -0.672 + }, + { + "first": 87, + "second": 113, + "amount": -0.672 + }, + { + "first": 87, + "second": 115, + "amount": -0.966 + }, + { + "first": 87, + "second": 8217, + "amount": 1.008 + }, + { + "first": 87, + "second": 8220, + "amount": 1.008 + }, + { + "first": 87, + "second": 8221, + "amount": 1.008 + }, + { + "first": 87, + "second": 173, + "amount": -0.672 + }, + { + "first": 87, + "second": 186, + "amount": 1.008 + }, + { + "first": 87, + "second": 192, + "amount": -1.974 + }, + { + "first": 87, + "second": 193, + "amount": -1.974 + }, + { + "first": 87, + "second": 195, + "amount": -1.974 + }, + { + "first": 87, + "second": 224, + "amount": -1.848 + }, + { + "first": 87, + "second": 225, + "amount": -1.848 + }, + { + "first": 87, + "second": 226, + "amount": -1.848 + }, + { + "first": 87, + "second": 227, + "amount": -1.848 + }, + { + "first": 87, + "second": 231, + "amount": -0.672 + }, + { + "first": 87, + "second": 233, + "amount": -0.672 + }, + { + "first": 87, + "second": 234, + "amount": -0.672 + }, + { + "first": 87, + "second": 243, + "amount": -0.672 + }, + { + "first": 87, + "second": 245, + "amount": -0.672 + }, + { + "first": 87, + "second": 8211, + "amount": -0.672 + }, + { + "first": 87, + "second": 8226, + "amount": -0.672 + }, + { + "first": 88, + "second": 45, + "amount": -1.302 + }, + { + "first": 88, + "second": 64, + "amount": -0.63 + }, + { + "first": 88, + "second": 67, + "amount": -0.63 + }, + { + "first": 88, + "second": 71, + "amount": -0.63 + }, + { + "first": 88, + "second": 79, + "amount": -0.63 + }, + { + "first": 88, + "second": 81, + "amount": -0.63 + }, + { + "first": 88, + "second": 99, + "amount": -0.756 + }, + { + "first": 88, + "second": 100, + "amount": -0.756 + }, + { + "first": 88, + "second": 101, + "amount": -0.756 + }, + { + "first": 88, + "second": 102, + "amount": -1.092 + }, + { + "first": 88, + "second": 111, + "amount": -0.756 + }, + { + "first": 88, + "second": 113, + "amount": -0.756 + }, + { + "first": 88, + "second": 116, + "amount": -1.722 + }, + { + "first": 88, + "second": 118, + "amount": -1.386 + }, + { + "first": 88, + "second": 119, + "amount": -1.176 + }, + { + "first": 88, + "second": 121, + "amount": -1.386 + }, + { + "first": 88, + "second": 173, + "amount": -1.302 + }, + { + "first": 88, + "second": 199, + "amount": -0.63 + }, + { + "first": 88, + "second": 231, + "amount": -0.756 + }, + { + "first": 88, + "second": 233, + "amount": -0.756 + }, + { + "first": 88, + "second": 234, + "amount": -0.756 + }, + { + "first": 88, + "second": 243, + "amount": -0.756 + }, + { + "first": 88, + "second": 245, + "amount": -0.756 + }, + { + "first": 88, + "second": 8211, + "amount": -1.302 + }, + { + "first": 88, + "second": 8226, + "amount": -1.302 + }, + { + "first": 89, + "second": 34, + "amount": 0.63 + }, + { + "first": 89, + "second": 38, + "amount": -3.444 + }, + { + "first": 89, + "second": 39, + "amount": 0.63 + }, + { + "first": 89, + "second": 42, + "amount": 0.63 + }, + { + "first": 89, + "second": 44, + "amount": -3.192 + }, + { + "first": 89, + "second": 45, + "amount": -3.36 + }, + { + "first": 89, + "second": 46, + "amount": -3.192 + }, + { + "first": 89, + "second": 47, + "amount": -3.444 + }, + { + "first": 89, + "second": 58, + "amount": -2.562 + }, + { + "first": 89, + "second": 59, + "amount": -2.562 + }, + { + "first": 89, + "second": 63, + "amount": 0.714 + }, + { + "first": 89, + "second": 64, + "amount": -1.68 + }, + { + "first": 89, + "second": 65, + "amount": -3.444 + }, + { + "first": 89, + "second": 67, + "amount": -1.68 + }, + { + "first": 89, + "second": 71, + "amount": -1.68 + }, + { + "first": 89, + "second": 74, + "amount": -4.2 + }, + { + "first": 89, + "second": 79, + "amount": -1.68 + }, + { + "first": 89, + "second": 81, + "amount": -1.68 + }, + { + "first": 89, + "second": 97, + "amount": -2.688 + }, + { + "first": 89, + "second": 99, + "amount": -3.36 + }, + { + "first": 89, + "second": 100, + "amount": -3.36 + }, + { + "first": 89, + "second": 101, + "amount": -3.36 + }, + { + "first": 89, + "second": 103, + "amount": -3.612 + }, + { + "first": 89, + "second": 109, + "amount": -2.562 + }, + { + "first": 89, + "second": 110, + "amount": -2.562 + }, + { + "first": 89, + "second": 111, + "amount": -3.36 + }, + { + "first": 89, + "second": 112, + "amount": -2.562 + }, + { + "first": 89, + "second": 113, + "amount": -3.36 + }, + { + "first": 89, + "second": 114, + "amount": -2.562 + }, + { + "first": 89, + "second": 115, + "amount": -2.688 + }, + { + "first": 89, + "second": 117, + "amount": -2.562 + }, + { + "first": 89, + "second": 118, + "amount": -2.1 + }, + { + "first": 89, + "second": 119, + "amount": -1.932 + }, + { + "first": 89, + "second": 120, + "amount": -2.772 + }, + { + "first": 89, + "second": 121, + "amount": -2.1 + }, + { + "first": 89, + "second": 8217, + "amount": 0.63 + }, + { + "first": 89, + "second": 8220, + "amount": 0.63 + }, + { + "first": 89, + "second": 8221, + "amount": 0.63 + }, + { + "first": 89, + "second": 173, + "amount": -3.36 + }, + { + "first": 89, + "second": 186, + "amount": 0.63 + }, + { + "first": 89, + "second": 192, + "amount": -3.444 + }, + { + "first": 89, + "second": 193, + "amount": -3.444 + }, + { + "first": 89, + "second": 195, + "amount": -3.444 + }, + { + "first": 89, + "second": 199, + "amount": -1.68 + }, + { + "first": 89, + "second": 224, + "amount": -2.688 + }, + { + "first": 89, + "second": 225, + "amount": -2.688 + }, + { + "first": 89, + "second": 226, + "amount": -2.688 + }, + { + "first": 89, + "second": 227, + "amount": -2.688 + }, + { + "first": 89, + "second": 231, + "amount": -3.36 + }, + { + "first": 89, + "second": 233, + "amount": -3.36 + }, + { + "first": 89, + "second": 234, + "amount": -3.36 + }, + { + "first": 89, + "second": 243, + "amount": -3.36 + }, + { + "first": 89, + "second": 245, + "amount": -3.36 + }, + { + "first": 89, + "second": 250, + "amount": -2.562 + }, + { + "first": 89, + "second": 8211, + "amount": -3.36 + }, + { + "first": 89, + "second": 8226, + "amount": -3.36 + }, + { + "first": 90, + "second": 45, + "amount": -1.47 + }, + { + "first": 90, + "second": 63, + "amount": 0.714 + }, + { + "first": 90, + "second": 64, + "amount": -1.218 + }, + { + "first": 90, + "second": 67, + "amount": -1.218 + }, + { + "first": 90, + "second": 71, + "amount": -1.218 + }, + { + "first": 90, + "second": 79, + "amount": -1.218 + }, + { + "first": 90, + "second": 81, + "amount": -1.218 + }, + { + "first": 90, + "second": 99, + "amount": -0.798 + }, + { + "first": 90, + "second": 100, + "amount": -0.798 + }, + { + "first": 90, + "second": 101, + "amount": -0.798 + }, + { + "first": 90, + "second": 111, + "amount": -0.798 + }, + { + "first": 90, + "second": 113, + "amount": -0.798 + }, + { + "first": 90, + "second": 115, + "amount": -0.588 + }, + { + "first": 90, + "second": 118, + "amount": -0.84 + }, + { + "first": 90, + "second": 121, + "amount": -0.84 + }, + { + "first": 90, + "second": 173, + "amount": -1.47 + }, + { + "first": 90, + "second": 199, + "amount": -1.218 + }, + { + "first": 90, + "second": 231, + "amount": -0.798 + }, + { + "first": 90, + "second": 233, + "amount": -0.798 + }, + { + "first": 90, + "second": 234, + "amount": -0.798 + }, + { + "first": 90, + "second": 243, + "amount": -0.798 + }, + { + "first": 90, + "second": 245, + "amount": -0.798 + }, + { + "first": 90, + "second": 8211, + "amount": -1.47 + }, + { + "first": 90, + "second": 8226, + "amount": -1.47 + }, + { + "first": 91, + "second": 64, + "amount": -0.84 + }, + { + "first": 91, + "second": 67, + "amount": -0.84 + }, + { + "first": 91, + "second": 71, + "amount": -0.84 + }, + { + "first": 91, + "second": 79, + "amount": -0.84 + }, + { + "first": 91, + "second": 81, + "amount": -0.84 + }, + { + "first": 91, + "second": 99, + "amount": -0.672 + }, + { + "first": 91, + "second": 100, + "amount": -0.672 + }, + { + "first": 91, + "second": 101, + "amount": -0.672 + }, + { + "first": 91, + "second": 111, + "amount": -0.672 + }, + { + "first": 91, + "second": 113, + "amount": -0.672 + }, + { + "first": 91, + "second": 199, + "amount": -0.84 + }, + { + "first": 91, + "second": 231, + "amount": -0.672 + }, + { + "first": 91, + "second": 233, + "amount": -0.672 + }, + { + "first": 91, + "second": 234, + "amount": -0.672 + }, + { + "first": 91, + "second": 243, + "amount": -0.672 + }, + { + "first": 91, + "second": 245, + "amount": -0.672 + }, + { + "first": 97, + "second": 34, + "amount": -1.512 + }, + { + "first": 97, + "second": 39, + "amount": -1.512 + }, + { + "first": 97, + "second": 42, + "amount": -1.512 + }, + { + "first": 97, + "second": 118, + "amount": -0.672 + }, + { + "first": 97, + "second": 119, + "amount": -0.336 + }, + { + "first": 97, + "second": 121, + "amount": -0.672 + }, + { + "first": 97, + "second": 8217, + "amount": -1.512 + }, + { + "first": 97, + "second": 8220, + "amount": -1.512 + }, + { + "first": 97, + "second": 8221, + "amount": -1.512 + }, + { + "first": 97, + "second": 186, + "amount": -1.512 + }, + { + "first": 98, + "second": 92, + "amount": -2.436 + }, + { + "first": 98, + "second": 34, + "amount": -1.932 + }, + { + "first": 98, + "second": 39, + "amount": -1.932 + }, + { + "first": 98, + "second": 41, + "amount": -0.672 + }, + { + "first": 98, + "second": 42, + "amount": -1.932 + }, + { + "first": 98, + "second": 86, + "amount": -2.436 + }, + { + "first": 98, + "second": 87, + "amount": -0.672 + }, + { + "first": 98, + "second": 93, + "amount": -0.672 + }, + { + "first": 98, + "second": 118, + "amount": -0.546 + }, + { + "first": 98, + "second": 120, + "amount": -1.26 + }, + { + "first": 98, + "second": 121, + "amount": -0.546 + }, + { + "first": 98, + "second": 125, + "amount": -0.672 + }, + { + "first": 98, + "second": 8217, + "amount": -1.932 + }, + { + "first": 98, + "second": 8220, + "amount": -1.932 + }, + { + "first": 98, + "second": 8221, + "amount": -1.932 + }, + { + "first": 98, + "second": 186, + "amount": -1.932 + }, + { + "first": 101, + "second": 92, + "amount": -2.436 + }, + { + "first": 101, + "second": 34, + "amount": -1.932 + }, + { + "first": 101, + "second": 39, + "amount": -1.932 + }, + { + "first": 101, + "second": 41, + "amount": -0.672 + }, + { + "first": 101, + "second": 42, + "amount": -1.932 + }, + { + "first": 101, + "second": 86, + "amount": -2.436 + }, + { + "first": 101, + "second": 87, + "amount": -0.672 + }, + { + "first": 101, + "second": 93, + "amount": -0.672 + }, + { + "first": 101, + "second": 118, + "amount": -0.546 + }, + { + "first": 101, + "second": 120, + "amount": -1.26 + }, + { + "first": 101, + "second": 121, + "amount": -0.546 + }, + { + "first": 101, + "second": 125, + "amount": -0.672 + }, + { + "first": 101, + "second": 8217, + "amount": -1.932 + }, + { + "first": 101, + "second": 8220, + "amount": -1.932 + }, + { + "first": 101, + "second": 8221, + "amount": -1.932 + }, + { + "first": 101, + "second": 186, + "amount": -1.932 + }, + { + "first": 102, + "second": 34, + "amount": 1.428 + }, + { + "first": 102, + "second": 39, + "amount": 1.428 + }, + { + "first": 102, + "second": 42, + "amount": 1.428 + }, + { + "first": 102, + "second": 44, + "amount": -2.73 + }, + { + "first": 102, + "second": 46, + "amount": -2.73 + }, + { + "first": 102, + "second": 8217, + "amount": 1.428 + }, + { + "first": 102, + "second": 8220, + "amount": 1.428 + }, + { + "first": 102, + "second": 8221, + "amount": 1.428 + }, + { + "first": 102, + "second": 186, + "amount": 1.428 + }, + { + "first": 104, + "second": 34, + "amount": -1.512 + }, + { + "first": 104, + "second": 39, + "amount": -1.512 + }, + { + "first": 104, + "second": 42, + "amount": -1.512 + }, + { + "first": 104, + "second": 118, + "amount": -0.672 + }, + { + "first": 104, + "second": 119, + "amount": -0.336 + }, + { + "first": 104, + "second": 121, + "amount": -0.672 + }, + { + "first": 104, + "second": 8217, + "amount": -1.512 + }, + { + "first": 104, + "second": 8220, + "amount": -1.512 + }, + { + "first": 104, + "second": 8221, + "amount": -1.512 + }, + { + "first": 104, + "second": 186, + "amount": -1.512 + }, + { + "first": 107, + "second": 99, + "amount": -1.26 + }, + { + "first": 107, + "second": 100, + "amount": -1.26 + }, + { + "first": 107, + "second": 101, + "amount": -1.26 + }, + { + "first": 107, + "second": 111, + "amount": -1.26 + }, + { + "first": 107, + "second": 113, + "amount": -1.26 + }, + { + "first": 107, + "second": 231, + "amount": -1.26 + }, + { + "first": 107, + "second": 233, + "amount": -1.26 + }, + { + "first": 107, + "second": 234, + "amount": -1.26 + }, + { + "first": 107, + "second": 243, + "amount": -1.26 + }, + { + "first": 107, + "second": 245, + "amount": -1.26 + }, + { + "first": 109, + "second": 34, + "amount": -1.512 + }, + { + "first": 109, + "second": 39, + "amount": -1.512 + }, + { + "first": 109, + "second": 42, + "amount": -1.512 + }, + { + "first": 109, + "second": 118, + "amount": -0.672 + }, + { + "first": 109, + "second": 119, + "amount": -0.336 + }, + { + "first": 109, + "second": 121, + "amount": -0.672 + }, + { + "first": 109, + "second": 8217, + "amount": -1.512 + }, + { + "first": 109, + "second": 8220, + "amount": -1.512 + }, + { + "first": 109, + "second": 8221, + "amount": -1.512 + }, + { + "first": 109, + "second": 186, + "amount": -1.512 + }, + { + "first": 110, + "second": 34, + "amount": -1.512 + }, + { + "first": 110, + "second": 39, + "amount": -1.512 + }, + { + "first": 110, + "second": 42, + "amount": -1.512 + }, + { + "first": 110, + "second": 118, + "amount": -0.672 + }, + { + "first": 110, + "second": 119, + "amount": -0.336 + }, + { + "first": 110, + "second": 121, + "amount": -0.672 + }, + { + "first": 110, + "second": 8217, + "amount": -1.512 + }, + { + "first": 110, + "second": 8220, + "amount": -1.512 + }, + { + "first": 110, + "second": 8221, + "amount": -1.512 + }, + { + "first": 110, + "second": 186, + "amount": -1.512 + }, + { + "first": 111, + "second": 92, + "amount": -2.436 + }, + { + "first": 111, + "second": 34, + "amount": -1.932 + }, + { + "first": 111, + "second": 39, + "amount": -1.932 + }, + { + "first": 111, + "second": 41, + "amount": -0.672 + }, + { + "first": 111, + "second": 42, + "amount": -1.932 + }, + { + "first": 111, + "second": 86, + "amount": -2.436 + }, + { + "first": 111, + "second": 87, + "amount": -0.672 + }, + { + "first": 111, + "second": 93, + "amount": -0.672 + }, + { + "first": 111, + "second": 118, + "amount": -0.546 + }, + { + "first": 111, + "second": 120, + "amount": -1.26 + }, + { + "first": 111, + "second": 121, + "amount": -0.546 + }, + { + "first": 111, + "second": 125, + "amount": -0.672 + }, + { + "first": 111, + "second": 8217, + "amount": -1.932 + }, + { + "first": 111, + "second": 8220, + "amount": -1.932 + }, + { + "first": 111, + "second": 8221, + "amount": -1.932 + }, + { + "first": 111, + "second": 186, + "amount": -1.932 + }, + { + "first": 112, + "second": 92, + "amount": -2.436 + }, + { + "first": 112, + "second": 34, + "amount": -1.932 + }, + { + "first": 112, + "second": 39, + "amount": -1.932 + }, + { + "first": 112, + "second": 41, + "amount": -0.672 + }, + { + "first": 112, + "second": 42, + "amount": -1.932 + }, + { + "first": 112, + "second": 86, + "amount": -2.436 + }, + { + "first": 112, + "second": 87, + "amount": -0.672 + }, + { + "first": 112, + "second": 93, + "amount": -0.672 + }, + { + "first": 112, + "second": 118, + "amount": -0.546 + }, + { + "first": 112, + "second": 120, + "amount": -1.26 + }, + { + "first": 112, + "second": 121, + "amount": -0.546 + }, + { + "first": 112, + "second": 125, + "amount": -0.672 + }, + { + "first": 112, + "second": 8217, + "amount": -1.932 + }, + { + "first": 112, + "second": 8220, + "amount": -1.932 + }, + { + "first": 112, + "second": 8221, + "amount": -1.932 + }, + { + "first": 112, + "second": 186, + "amount": -1.932 + }, + { + "first": 114, + "second": 44, + "amount": -2.772 + }, + { + "first": 114, + "second": 46, + "amount": -2.772 + }, + { + "first": 114, + "second": 97, + "amount": -0.798 + }, + { + "first": 114, + "second": 224, + "amount": -0.798 + }, + { + "first": 114, + "second": 225, + "amount": -0.798 + }, + { + "first": 114, + "second": 226, + "amount": -0.798 + }, + { + "first": 114, + "second": 227, + "amount": -0.798 + }, + { + "first": 118, + "second": 38, + "amount": -1.722 + }, + { + "first": 118, + "second": 44, + "amount": -2.772 + }, + { + "first": 118, + "second": 46, + "amount": -2.772 + }, + { + "first": 118, + "second": 47, + "amount": -1.722 + }, + { + "first": 118, + "second": 65, + "amount": -1.722 + }, + { + "first": 118, + "second": 99, + "amount": -0.546 + }, + { + "first": 118, + "second": 100, + "amount": -0.546 + }, + { + "first": 118, + "second": 101, + "amount": -0.546 + }, + { + "first": 118, + "second": 111, + "amount": -0.546 + }, + { + "first": 118, + "second": 113, + "amount": -0.546 + }, + { + "first": 118, + "second": 192, + "amount": -1.722 + }, + { + "first": 118, + "second": 193, + "amount": -1.722 + }, + { + "first": 118, + "second": 195, + "amount": -1.722 + }, + { + "first": 118, + "second": 231, + "amount": -0.546 + }, + { + "first": 118, + "second": 233, + "amount": -0.546 + }, + { + "first": 118, + "second": 234, + "amount": -0.546 + }, + { + "first": 118, + "second": 243, + "amount": -0.546 + }, + { + "first": 118, + "second": 245, + "amount": -0.546 + }, + { + "first": 119, + "second": 44, + "amount": -1.302 + }, + { + "first": 119, + "second": 46, + "amount": -1.302 + }, + { + "first": 120, + "second": 99, + "amount": -1.26 + }, + { + "first": 120, + "second": 100, + "amount": -1.26 + }, + { + "first": 120, + "second": 101, + "amount": -1.26 + }, + { + "first": 120, + "second": 111, + "amount": -1.26 + }, + { + "first": 120, + "second": 113, + "amount": -1.26 + }, + { + "first": 120, + "second": 231, + "amount": -1.26 + }, + { + "first": 120, + "second": 233, + "amount": -1.26 + }, + { + "first": 120, + "second": 234, + "amount": -1.26 + }, + { + "first": 120, + "second": 243, + "amount": -1.26 + }, + { + "first": 120, + "second": 245, + "amount": -1.26 + }, + { + "first": 121, + "second": 38, + "amount": -1.722 + }, + { + "first": 121, + "second": 44, + "amount": -2.772 + }, + { + "first": 121, + "second": 46, + "amount": -2.772 + }, + { + "first": 121, + "second": 47, + "amount": -1.722 + }, + { + "first": 121, + "second": 65, + "amount": -1.722 + }, + { + "first": 121, + "second": 99, + "amount": -0.546 + }, + { + "first": 121, + "second": 100, + "amount": -0.546 + }, + { + "first": 121, + "second": 101, + "amount": -0.546 + }, + { + "first": 121, + "second": 111, + "amount": -0.546 + }, + { + "first": 121, + "second": 113, + "amount": -0.546 + }, + { + "first": 121, + "second": 192, + "amount": -1.722 + }, + { + "first": 121, + "second": 193, + "amount": -1.722 + }, + { + "first": 121, + "second": 195, + "amount": -1.722 + }, + { + "first": 121, + "second": 231, + "amount": -0.546 + }, + { + "first": 121, + "second": 233, + "amount": -0.546 + }, + { + "first": 121, + "second": 234, + "amount": -0.546 + }, + { + "first": 121, + "second": 243, + "amount": -0.546 + }, + { + "first": 121, + "second": 245, + "amount": -0.546 + }, + { + "first": 123, + "second": 64, + "amount": -0.84 + }, + { + "first": 123, + "second": 67, + "amount": -0.84 + }, + { + "first": 123, + "second": 71, + "amount": -0.84 + }, + { + "first": 123, + "second": 79, + "amount": -0.84 + }, + { + "first": 123, + "second": 81, + "amount": -0.84 + }, + { + "first": 123, + "second": 99, + "amount": -0.672 + }, + { + "first": 123, + "second": 100, + "amount": -0.672 + }, + { + "first": 123, + "second": 101, + "amount": -0.672 + }, + { + "first": 123, + "second": 111, + "amount": -0.672 + }, + { + "first": 123, + "second": 113, + "amount": -0.672 + }, + { + "first": 123, + "second": 199, + "amount": -0.84 + }, + { + "first": 123, + "second": 231, + "amount": -0.672 + }, + { + "first": 123, + "second": 233, + "amount": -0.672 + }, + { + "first": 123, + "second": 234, + "amount": -0.672 + }, + { + "first": 123, + "second": 243, + "amount": -0.672 + }, + { + "first": 123, + "second": 245, + "amount": -0.672 + }, + { + "first": 8217, + "second": 92, + "amount": 1.008 + }, + { + "first": 8217, + "second": 38, + "amount": -3.822 + }, + { + "first": 8217, + "second": 44, + "amount": -4.788 + }, + { + "first": 8217, + "second": 45, + "amount": -3.738 + }, + { + "first": 8217, + "second": 46, + "amount": -4.788 + }, + { + "first": 8217, + "second": 47, + "amount": -3.822 + }, + { + "first": 8217, + "second": 64, + "amount": -0.966 + }, + { + "first": 8217, + "second": 65, + "amount": -3.822 + }, + { + "first": 8217, + "second": 67, + "amount": -0.966 + }, + { + "first": 8217, + "second": 71, + "amount": -0.966 + }, + { + "first": 8217, + "second": 79, + "amount": -0.966 + }, + { + "first": 8217, + "second": 81, + "amount": -0.966 + }, + { + "first": 8217, + "second": 86, + "amount": 1.008 + }, + { + "first": 8217, + "second": 87, + "amount": 1.008 + }, + { + "first": 8217, + "second": 89, + "amount": 0.63 + }, + { + "first": 8217, + "second": 97, + "amount": -1.344 + }, + { + "first": 8217, + "second": 99, + "amount": -1.932 + }, + { + "first": 8217, + "second": 100, + "amount": -1.932 + }, + { + "first": 8217, + "second": 101, + "amount": -1.932 + }, + { + "first": 8217, + "second": 111, + "amount": -1.932 + }, + { + "first": 8217, + "second": 113, + "amount": -1.932 + }, + { + "first": 8217, + "second": 173, + "amount": -3.738 + }, + { + "first": 8217, + "second": 192, + "amount": -3.822 + }, + { + "first": 8217, + "second": 193, + "amount": -3.822 + }, + { + "first": 8217, + "second": 195, + "amount": -3.822 + }, + { + "first": 8217, + "second": 199, + "amount": -0.966 + }, + { + "first": 8217, + "second": 224, + "amount": -1.344 + }, + { + "first": 8217, + "second": 225, + "amount": -1.344 + }, + { + "first": 8217, + "second": 226, + "amount": -1.344 + }, + { + "first": 8217, + "second": 227, + "amount": -1.344 + }, + { + "first": 8217, + "second": 231, + "amount": -1.932 + }, + { + "first": 8217, + "second": 233, + "amount": -1.932 + }, + { + "first": 8217, + "second": 234, + "amount": -1.932 + }, + { + "first": 8217, + "second": 243, + "amount": -1.932 + }, + { + "first": 8217, + "second": 245, + "amount": -1.932 + }, + { + "first": 8217, + "second": 8211, + "amount": -3.738 + }, + { + "first": 8217, + "second": 8226, + "amount": -3.738 + }, + { + "first": 8220, + "second": 92, + "amount": 1.008 + }, + { + "first": 8220, + "second": 38, + "amount": -3.822 + }, + { + "first": 8220, + "second": 44, + "amount": -4.788 + }, + { + "first": 8220, + "second": 45, + "amount": -3.738 + }, + { + "first": 8220, + "second": 46, + "amount": -4.788 + }, + { + "first": 8220, + "second": 47, + "amount": -3.822 + }, + { + "first": 8220, + "second": 64, + "amount": -0.966 + }, + { + "first": 8220, + "second": 65, + "amount": -3.822 + }, + { + "first": 8220, + "second": 67, + "amount": -0.966 + }, + { + "first": 8220, + "second": 71, + "amount": -0.966 + }, + { + "first": 8220, + "second": 79, + "amount": -0.966 + }, + { + "first": 8220, + "second": 81, + "amount": -0.966 + }, + { + "first": 8220, + "second": 86, + "amount": 1.008 + }, + { + "first": 8220, + "second": 87, + "amount": 1.008 + }, + { + "first": 8220, + "second": 89, + "amount": 0.63 + }, + { + "first": 8220, + "second": 97, + "amount": -1.344 + }, + { + "first": 8220, + "second": 99, + "amount": -1.932 + }, + { + "first": 8220, + "second": 100, + "amount": -1.932 + }, + { + "first": 8220, + "second": 101, + "amount": -1.932 + }, + { + "first": 8220, + "second": 111, + "amount": -1.932 + }, + { + "first": 8220, + "second": 113, + "amount": -1.932 + }, + { + "first": 8220, + "second": 173, + "amount": -3.738 + }, + { + "first": 8220, + "second": 192, + "amount": -3.822 + }, + { + "first": 8220, + "second": 193, + "amount": -3.822 + }, + { + "first": 8220, + "second": 195, + "amount": -3.822 + }, + { + "first": 8220, + "second": 199, + "amount": -0.966 + }, + { + "first": 8220, + "second": 224, + "amount": -1.344 + }, + { + "first": 8220, + "second": 225, + "amount": -1.344 + }, + { + "first": 8220, + "second": 226, + "amount": -1.344 + }, + { + "first": 8220, + "second": 227, + "amount": -1.344 + }, + { + "first": 8220, + "second": 231, + "amount": -1.932 + }, + { + "first": 8220, + "second": 233, + "amount": -1.932 + }, + { + "first": 8220, + "second": 234, + "amount": -1.932 + }, + { + "first": 8220, + "second": 243, + "amount": -1.932 + }, + { + "first": 8220, + "second": 245, + "amount": -1.932 + }, + { + "first": 8220, + "second": 8211, + "amount": -3.738 + }, + { + "first": 8220, + "second": 8226, + "amount": -3.738 + }, + { + "first": 8221, + "second": 92, + "amount": 1.008 + }, + { + "first": 8221, + "second": 38, + "amount": -3.822 + }, + { + "first": 8221, + "second": 44, + "amount": -4.788 + }, + { + "first": 8221, + "second": 45, + "amount": -3.738 + }, + { + "first": 8221, + "second": 46, + "amount": -4.788 + }, + { + "first": 8221, + "second": 47, + "amount": -3.822 + }, + { + "first": 8221, + "second": 64, + "amount": -0.966 + }, + { + "first": 8221, + "second": 65, + "amount": -3.822 + }, + { + "first": 8221, + "second": 67, + "amount": -0.966 + }, + { + "first": 8221, + "second": 71, + "amount": -0.966 + }, + { + "first": 8221, + "second": 79, + "amount": -0.966 + }, + { + "first": 8221, + "second": 81, + "amount": -0.966 + }, + { + "first": 8221, + "second": 86, + "amount": 1.008 + }, + { + "first": 8221, + "second": 87, + "amount": 1.008 + }, + { + "first": 8221, + "second": 89, + "amount": 0.63 + }, + { + "first": 8221, + "second": 97, + "amount": -1.344 + }, + { + "first": 8221, + "second": 99, + "amount": -1.932 + }, + { + "first": 8221, + "second": 100, + "amount": -1.932 + }, + { + "first": 8221, + "second": 101, + "amount": -1.932 + }, + { + "first": 8221, + "second": 111, + "amount": -1.932 + }, + { + "first": 8221, + "second": 113, + "amount": -1.932 + }, + { + "first": 8221, + "second": 173, + "amount": -3.738 + }, + { + "first": 8221, + "second": 192, + "amount": -3.822 + }, + { + "first": 8221, + "second": 193, + "amount": -3.822 + }, + { + "first": 8221, + "second": 195, + "amount": -3.822 + }, + { + "first": 8221, + "second": 199, + "amount": -0.966 + }, + { + "first": 8221, + "second": 224, + "amount": -1.344 + }, + { + "first": 8221, + "second": 225, + "amount": -1.344 + }, + { + "first": 8221, + "second": 226, + "amount": -1.344 + }, + { + "first": 8221, + "second": 227, + "amount": -1.344 + }, + { + "first": 8221, + "second": 231, + "amount": -1.932 + }, + { + "first": 8221, + "second": 233, + "amount": -1.932 + }, + { + "first": 8221, + "second": 234, + "amount": -1.932 + }, + { + "first": 8221, + "second": 243, + "amount": -1.932 + }, + { + "first": 8221, + "second": 245, + "amount": -1.932 + }, + { + "first": 8221, + "second": 8211, + "amount": -3.738 + }, + { + "first": 8221, + "second": 8226, + "amount": -3.738 + }, + { + "first": 173, + "second": 92, + "amount": -2.352 + }, + { + "first": 173, + "second": 34, + "amount": -3.738 + }, + { + "first": 173, + "second": 38, + "amount": -1.092 + }, + { + "first": 173, + "second": 39, + "amount": -3.738 + }, + { + "first": 173, + "second": 42, + "amount": -3.738 + }, + { + "first": 173, + "second": 44, + "amount": -2.856 + }, + { + "first": 173, + "second": 46, + "amount": -2.856 + }, + { + "first": 173, + "second": 47, + "amount": -1.092 + }, + { + "first": 173, + "second": 65, + "amount": -1.092 + }, + { + "first": 173, + "second": 84, + "amount": -3.78 + }, + { + "first": 173, + "second": 86, + "amount": -2.352 + }, + { + "first": 173, + "second": 87, + "amount": -0.672 + }, + { + "first": 173, + "second": 88, + "amount": -1.302 + }, + { + "first": 173, + "second": 89, + "amount": -3.36 + }, + { + "first": 173, + "second": 90, + "amount": -0.966 + }, + { + "first": 173, + "second": 8217, + "amount": -3.738 + }, + { + "first": 173, + "second": 8220, + "amount": -3.738 + }, + { + "first": 173, + "second": 8221, + "amount": -3.738 + }, + { + "first": 173, + "second": 186, + "amount": -3.738 + }, + { + "first": 173, + "second": 192, + "amount": -1.092 + }, + { + "first": 173, + "second": 193, + "amount": -1.092 + }, + { + "first": 173, + "second": 195, + "amount": -1.092 + }, + { + "first": 186, + "second": 92, + "amount": 1.008 + }, + { + "first": 186, + "second": 38, + "amount": -3.822 + }, + { + "first": 186, + "second": 44, + "amount": -4.788 + }, + { + "first": 186, + "second": 45, + "amount": -3.738 + }, + { + "first": 186, + "second": 46, + "amount": -4.788 + }, + { + "first": 186, + "second": 47, + "amount": -3.822 + }, + { + "first": 186, + "second": 64, + "amount": -0.966 + }, + { + "first": 186, + "second": 65, + "amount": -3.822 + }, + { + "first": 186, + "second": 67, + "amount": -0.966 + }, + { + "first": 186, + "second": 71, + "amount": -0.966 + }, + { + "first": 186, + "second": 79, + "amount": -0.966 + }, + { + "first": 186, + "second": 81, + "amount": -0.966 + }, + { + "first": 186, + "second": 86, + "amount": 1.008 + }, + { + "first": 186, + "second": 87, + "amount": 1.008 + }, + { + "first": 186, + "second": 89, + "amount": 0.63 + }, + { + "first": 186, + "second": 97, + "amount": -1.344 + }, + { + "first": 186, + "second": 99, + "amount": -1.932 + }, + { + "first": 186, + "second": 100, + "amount": -1.932 + }, + { + "first": 186, + "second": 101, + "amount": -1.932 + }, + { + "first": 186, + "second": 111, + "amount": -1.932 + }, + { + "first": 186, + "second": 113, + "amount": -1.932 + }, + { + "first": 186, + "second": 173, + "amount": -3.738 + }, + { + "first": 186, + "second": 192, + "amount": -3.822 + }, + { + "first": 186, + "second": 193, + "amount": -3.822 + }, + { + "first": 186, + "second": 195, + "amount": -3.822 + }, + { + "first": 186, + "second": 199, + "amount": -0.966 + }, + { + "first": 186, + "second": 224, + "amount": -1.344 + }, + { + "first": 186, + "second": 225, + "amount": -1.344 + }, + { + "first": 186, + "second": 226, + "amount": -1.344 + }, + { + "first": 186, + "second": 227, + "amount": -1.344 + }, + { + "first": 186, + "second": 231, + "amount": -1.932 + }, + { + "first": 186, + "second": 233, + "amount": -1.932 + }, + { + "first": 186, + "second": 234, + "amount": -1.932 + }, + { + "first": 186, + "second": 243, + "amount": -1.932 + }, + { + "first": 186, + "second": 245, + "amount": -1.932 + }, + { + "first": 186, + "second": 8211, + "amount": -3.738 + }, + { + "first": 186, + "second": 8226, + "amount": -3.738 + }, + { + "first": 192, + "second": 92, + "amount": -2.856 + }, + { + "first": 192, + "second": 34, + "amount": -3.822 + }, + { + "first": 192, + "second": 39, + "amount": -3.822 + }, + { + "first": 192, + "second": 42, + "amount": -3.822 + }, + { + "first": 192, + "second": 45, + "amount": -1.092 + }, + { + "first": 192, + "second": 63, + "amount": -1.176 + }, + { + "first": 192, + "second": 64, + "amount": -0.882 + }, + { + "first": 192, + "second": 67, + "amount": -0.882 + }, + { + "first": 192, + "second": 71, + "amount": -0.882 + }, + { + "first": 192, + "second": 74, + "amount": 1.05 + }, + { + "first": 192, + "second": 79, + "amount": -0.882 + }, + { + "first": 192, + "second": 81, + "amount": -0.882 + }, + { + "first": 192, + "second": 84, + "amount": -2.772 + }, + { + "first": 192, + "second": 85, + "amount": -1.176 + }, + { + "first": 192, + "second": 86, + "amount": -2.856 + }, + { + "first": 192, + "second": 87, + "amount": -1.764 + }, + { + "first": 192, + "second": 89, + "amount": -3.444 + }, + { + "first": 192, + "second": 118, + "amount": -1.722 + }, + { + "first": 192, + "second": 121, + "amount": -1.722 + }, + { + "first": 192, + "second": 8217, + "amount": -3.822 + }, + { + "first": 192, + "second": 8220, + "amount": -3.822 + }, + { + "first": 192, + "second": 8221, + "amount": -3.822 + }, + { + "first": 192, + "second": 173, + "amount": -1.092 + }, + { + "first": 192, + "second": 186, + "amount": -3.822 + }, + { + "first": 192, + "second": 199, + "amount": -0.882 + }, + { + "first": 192, + "second": 218, + "amount": -1.176 + }, + { + "first": 192, + "second": 8211, + "amount": -1.092 + }, + { + "first": 192, + "second": 8226, + "amount": -1.092 + }, + { + "first": 193, + "second": 92, + "amount": -2.856 + }, + { + "first": 193, + "second": 34, + "amount": -3.822 + }, + { + "first": 193, + "second": 39, + "amount": -3.822 + }, + { + "first": 193, + "second": 42, + "amount": -3.822 + }, + { + "first": 193, + "second": 45, + "amount": -1.092 + }, + { + "first": 193, + "second": 63, + "amount": -1.176 + }, + { + "first": 193, + "second": 64, + "amount": -0.882 + }, + { + "first": 193, + "second": 67, + "amount": -0.882 + }, + { + "first": 193, + "second": 71, + "amount": -0.882 + }, + { + "first": 193, + "second": 74, + "amount": 1.05 + }, + { + "first": 193, + "second": 79, + "amount": -0.882 + }, + { + "first": 193, + "second": 81, + "amount": -0.882 + }, + { + "first": 193, + "second": 84, + "amount": -2.772 + }, + { + "first": 193, + "second": 85, + "amount": -1.176 + }, + { + "first": 193, + "second": 86, + "amount": -2.856 + }, + { + "first": 193, + "second": 87, + "amount": -1.764 + }, + { + "first": 193, + "second": 89, + "amount": -3.444 + }, + { + "first": 193, + "second": 118, + "amount": -1.722 + }, + { + "first": 193, + "second": 121, + "amount": -1.722 + }, + { + "first": 193, + "second": 8217, + "amount": -3.822 + }, + { + "first": 193, + "second": 8220, + "amount": -3.822 + }, + { + "first": 193, + "second": 8221, + "amount": -3.822 + }, + { + "first": 193, + "second": 173, + "amount": -1.092 + }, + { + "first": 193, + "second": 186, + "amount": -3.822 + }, + { + "first": 193, + "second": 199, + "amount": -0.882 + }, + { + "first": 193, + "second": 218, + "amount": -1.176 + }, + { + "first": 193, + "second": 8211, + "amount": -1.092 + }, + { + "first": 193, + "second": 8226, + "amount": -1.092 + }, + { + "first": 195, + "second": 92, + "amount": -2.856 + }, + { + "first": 195, + "second": 34, + "amount": -3.822 + }, + { + "first": 195, + "second": 39, + "amount": -3.822 + }, + { + "first": 195, + "second": 42, + "amount": -3.822 + }, + { + "first": 195, + "second": 45, + "amount": -1.092 + }, + { + "first": 195, + "second": 63, + "amount": -1.176 + }, + { + "first": 195, + "second": 64, + "amount": -0.882 + }, + { + "first": 195, + "second": 67, + "amount": -0.882 + }, + { + "first": 195, + "second": 71, + "amount": -0.882 + }, + { + "first": 195, + "second": 74, + "amount": 1.05 + }, + { + "first": 195, + "second": 79, + "amount": -0.882 + }, + { + "first": 195, + "second": 81, + "amount": -0.882 + }, + { + "first": 195, + "second": 84, + "amount": -2.772 + }, + { + "first": 195, + "second": 85, + "amount": -1.176 + }, + { + "first": 195, + "second": 86, + "amount": -2.856 + }, + { + "first": 195, + "second": 87, + "amount": -1.764 + }, + { + "first": 195, + "second": 89, + "amount": -3.444 + }, + { + "first": 195, + "second": 118, + "amount": -1.722 + }, + { + "first": 195, + "second": 121, + "amount": -1.722 + }, + { + "first": 195, + "second": 8217, + "amount": -3.822 + }, + { + "first": 195, + "second": 8220, + "amount": -3.822 + }, + { + "first": 195, + "second": 8221, + "amount": -3.822 + }, + { + "first": 195, + "second": 173, + "amount": -1.092 + }, + { + "first": 195, + "second": 186, + "amount": -3.822 + }, + { + "first": 195, + "second": 199, + "amount": -0.882 + }, + { + "first": 195, + "second": 218, + "amount": -1.176 + }, + { + "first": 195, + "second": 8211, + "amount": -1.092 + }, + { + "first": 195, + "second": 8226, + "amount": -1.092 + }, + { + "first": 199, + "second": 45, + "amount": -3.15 + }, + { + "first": 199, + "second": 173, + "amount": -3.15 + }, + { + "first": 199, + "second": 8211, + "amount": -3.15 + }, + { + "first": 199, + "second": 8226, + "amount": -3.15 + }, + { + "first": 218, + "second": 38, + "amount": -1.176 + }, + { + "first": 218, + "second": 44, + "amount": -1.05 + }, + { + "first": 218, + "second": 46, + "amount": -1.05 + }, + { + "first": 218, + "second": 47, + "amount": -1.176 + }, + { + "first": 218, + "second": 65, + "amount": -1.176 + }, + { + "first": 218, + "second": 192, + "amount": -1.176 + }, + { + "first": 218, + "second": 193, + "amount": -1.176 + }, + { + "first": 218, + "second": 195, + "amount": -1.176 + }, + { + "first": 224, + "second": 34, + "amount": -1.512 + }, + { + "first": 224, + "second": 39, + "amount": -1.512 + }, + { + "first": 224, + "second": 42, + "amount": -1.512 + }, + { + "first": 224, + "second": 118, + "amount": -0.672 + }, + { + "first": 224, + "second": 119, + "amount": -0.336 + }, + { + "first": 224, + "second": 121, + "amount": -0.672 + }, + { + "first": 224, + "second": 8217, + "amount": -1.512 + }, + { + "first": 224, + "second": 8220, + "amount": -1.512 + }, + { + "first": 224, + "second": 8221, + "amount": -1.512 + }, + { + "first": 224, + "second": 186, + "amount": -1.512 + }, + { + "first": 225, + "second": 34, + "amount": -1.512 + }, + { + "first": 225, + "second": 39, + "amount": -1.512 + }, + { + "first": 225, + "second": 42, + "amount": -1.512 + }, + { + "first": 225, + "second": 118, + "amount": -0.672 + }, + { + "first": 225, + "second": 119, + "amount": -0.336 + }, + { + "first": 225, + "second": 121, + "amount": -0.672 + }, + { + "first": 225, + "second": 8217, + "amount": -1.512 + }, + { + "first": 225, + "second": 8220, + "amount": -1.512 + }, + { + "first": 225, + "second": 8221, + "amount": -1.512 + }, + { + "first": 225, + "second": 186, + "amount": -1.512 + }, + { + "first": 226, + "second": 34, + "amount": -1.512 + }, + { + "first": 226, + "second": 39, + "amount": -1.512 + }, + { + "first": 226, + "second": 42, + "amount": -1.512 + }, + { + "first": 226, + "second": 118, + "amount": -0.672 + }, + { + "first": 226, + "second": 119, + "amount": -0.336 + }, + { + "first": 226, + "second": 121, + "amount": -0.672 + }, + { + "first": 226, + "second": 8217, + "amount": -1.512 + }, + { + "first": 226, + "second": 8220, + "amount": -1.512 + }, + { + "first": 226, + "second": 8221, + "amount": -1.512 + }, + { + "first": 226, + "second": 186, + "amount": -1.512 + }, + { + "first": 227, + "second": 34, + "amount": -1.512 + }, + { + "first": 227, + "second": 39, + "amount": -1.512 + }, + { + "first": 227, + "second": 42, + "amount": -1.512 + }, + { + "first": 227, + "second": 118, + "amount": -0.672 + }, + { + "first": 227, + "second": 119, + "amount": -0.336 + }, + { + "first": 227, + "second": 121, + "amount": -0.672 + }, + { + "first": 227, + "second": 8217, + "amount": -1.512 + }, + { + "first": 227, + "second": 8220, + "amount": -1.512 + }, + { + "first": 227, + "second": 8221, + "amount": -1.512 + }, + { + "first": 227, + "second": 186, + "amount": -1.512 + }, + { + "first": 233, + "second": 92, + "amount": -2.436 + }, + { + "first": 233, + "second": 34, + "amount": -1.932 + }, + { + "first": 233, + "second": 39, + "amount": -1.932 + }, + { + "first": 233, + "second": 41, + "amount": -0.672 + }, + { + "first": 233, + "second": 42, + "amount": -1.932 + }, + { + "first": 233, + "second": 86, + "amount": -2.436 + }, + { + "first": 233, + "second": 87, + "amount": -0.672 + }, + { + "first": 233, + "second": 93, + "amount": -0.672 + }, + { + "first": 233, + "second": 118, + "amount": -0.546 + }, + { + "first": 233, + "second": 120, + "amount": -1.26 + }, + { + "first": 233, + "second": 121, + "amount": -0.546 + }, + { + "first": 233, + "second": 125, + "amount": -0.672 + }, + { + "first": 233, + "second": 8217, + "amount": -1.932 + }, + { + "first": 233, + "second": 8220, + "amount": -1.932 + }, + { + "first": 233, + "second": 8221, + "amount": -1.932 + }, + { + "first": 233, + "second": 186, + "amount": -1.932 + }, + { + "first": 234, + "second": 92, + "amount": -2.436 + }, + { + "first": 234, + "second": 34, + "amount": -1.932 + }, + { + "first": 234, + "second": 39, + "amount": -1.932 + }, + { + "first": 234, + "second": 41, + "amount": -0.672 + }, + { + "first": 234, + "second": 42, + "amount": -1.932 + }, + { + "first": 234, + "second": 86, + "amount": -2.436 + }, + { + "first": 234, + "second": 87, + "amount": -0.672 + }, + { + "first": 234, + "second": 93, + "amount": -0.672 + }, + { + "first": 234, + "second": 118, + "amount": -0.546 + }, + { + "first": 234, + "second": 120, + "amount": -1.26 + }, + { + "first": 234, + "second": 121, + "amount": -0.546 + }, + { + "first": 234, + "second": 125, + "amount": -0.672 + }, + { + "first": 234, + "second": 8217, + "amount": -1.932 + }, + { + "first": 234, + "second": 8220, + "amount": -1.932 + }, + { + "first": 234, + "second": 8221, + "amount": -1.932 + }, + { + "first": 234, + "second": 186, + "amount": -1.932 + }, + { + "first": 243, + "second": 92, + "amount": -2.436 + }, + { + "first": 243, + "second": 34, + "amount": -1.932 + }, + { + "first": 243, + "second": 39, + "amount": -1.932 + }, + { + "first": 243, + "second": 41, + "amount": -0.672 + }, + { + "first": 243, + "second": 42, + "amount": -1.932 + }, + { + "first": 243, + "second": 86, + "amount": -2.436 + }, + { + "first": 243, + "second": 87, + "amount": -0.672 + }, + { + "first": 243, + "second": 93, + "amount": -0.672 + }, + { + "first": 243, + "second": 118, + "amount": -0.546 + }, + { + "first": 243, + "second": 120, + "amount": -1.26 + }, + { + "first": 243, + "second": 121, + "amount": -0.546 + }, + { + "first": 243, + "second": 125, + "amount": -0.672 + }, + { + "first": 243, + "second": 8217, + "amount": -1.932 + }, + { + "first": 243, + "second": 8220, + "amount": -1.932 + }, + { + "first": 243, + "second": 8221, + "amount": -1.932 + }, + { + "first": 243, + "second": 186, + "amount": -1.932 + }, + { + "first": 245, + "second": 92, + "amount": -2.436 + }, + { + "first": 245, + "second": 34, + "amount": -1.932 + }, + { + "first": 245, + "second": 39, + "amount": -1.932 + }, + { + "first": 245, + "second": 41, + "amount": -0.672 + }, + { + "first": 245, + "second": 42, + "amount": -1.932 + }, + { + "first": 245, + "second": 86, + "amount": -2.436 + }, + { + "first": 245, + "second": 87, + "amount": -0.672 + }, + { + "first": 245, + "second": 93, + "amount": -0.672 + }, + { + "first": 245, + "second": 118, + "amount": -0.546 + }, + { + "first": 245, + "second": 120, + "amount": -1.26 + }, + { + "first": 245, + "second": 121, + "amount": -0.546 + }, + { + "first": 245, + "second": 125, + "amount": -0.672 + }, + { + "first": 245, + "second": 8217, + "amount": -1.932 + }, + { + "first": 245, + "second": 8220, + "amount": -1.932 + }, + { + "first": 245, + "second": 8221, + "amount": -1.932 + }, + { + "first": 245, + "second": 186, + "amount": -1.932 + }, + { + "first": 8211, + "second": 92, + "amount": -2.352 + }, + { + "first": 8211, + "second": 34, + "amount": -3.738 + }, + { + "first": 8211, + "second": 38, + "amount": -1.092 + }, + { + "first": 8211, + "second": 39, + "amount": -3.738 + }, + { + "first": 8211, + "second": 42, + "amount": -3.738 + }, + { + "first": 8211, + "second": 44, + "amount": -2.856 + }, + { + "first": 8211, + "second": 46, + "amount": -2.856 + }, + { + "first": 8211, + "second": 47, + "amount": -1.092 + }, + { + "first": 8211, + "second": 65, + "amount": -1.092 + }, + { + "first": 8211, + "second": 84, + "amount": -3.78 + }, + { + "first": 8211, + "second": 86, + "amount": -2.352 + }, + { + "first": 8211, + "second": 87, + "amount": -0.672 + }, + { + "first": 8211, + "second": 88, + "amount": -1.302 + }, + { + "first": 8211, + "second": 89, + "amount": -3.36 + }, + { + "first": 8211, + "second": 90, + "amount": -0.966 + }, + { + "first": 8211, + "second": 8217, + "amount": -3.738 + }, + { + "first": 8211, + "second": 8220, + "amount": -3.738 + }, + { + "first": 8211, + "second": 8221, + "amount": -3.738 + }, + { + "first": 8211, + "second": 186, + "amount": -3.738 + }, + { + "first": 8211, + "second": 192, + "amount": -1.092 + }, + { + "first": 8211, + "second": 193, + "amount": -1.092 + }, + { + "first": 8211, + "second": 195, + "amount": -1.092 + }, + { + "first": 8226, + "second": 92, + "amount": -2.352 + }, + { + "first": 8226, + "second": 34, + "amount": -3.738 + }, + { + "first": 8226, + "second": 38, + "amount": -1.092 + }, + { + "first": 8226, + "second": 39, + "amount": -3.738 + }, + { + "first": 8226, + "second": 42, + "amount": -3.738 + }, + { + "first": 8226, + "second": 44, + "amount": -2.856 + }, + { + "first": 8226, + "second": 46, + "amount": -2.856 + }, + { + "first": 8226, + "second": 47, + "amount": -1.092 + }, + { + "first": 8226, + "second": 65, + "amount": -1.092 + }, + { + "first": 8226, + "second": 84, + "amount": -3.78 + }, + { + "first": 8226, + "second": 86, + "amount": -2.352 + }, + { + "first": 8226, + "second": 87, + "amount": -0.672 + }, + { + "first": 8226, + "second": 88, + "amount": -1.302 + }, + { + "first": 8226, + "second": 89, + "amount": -3.36 + }, + { + "first": 8226, + "second": 90, + "amount": -0.966 + }, + { + "first": 8226, + "second": 8217, + "amount": -3.738 + }, + { + "first": 8226, + "second": 8220, + "amount": -3.738 + }, + { + "first": 8226, + "second": 8221, + "amount": -3.738 + }, + { + "first": 8226, + "second": 186, + "amount": -3.738 + }, + { + "first": 8226, + "second": 192, + "amount": -1.092 + }, + { + "first": 8226, + "second": 193, + "amount": -1.092 + }, + { + "first": 8226, + "second": 195, + "amount": -1.092 + } + ], + "lightningMetrics": { + "ascender": 1610, + "descender": -390, + "lineGap": 400, + "unitsPerEm": 2000 + } +} \ No newline at end of file diff --git a/tests/webgl/fonts/Lato-Regular.msdf.png b/tests/webgl/fonts/Lato-Regular.msdf.png new file mode 100644 index 0000000..6d1fc42 Binary files /dev/null and b/tests/webgl/fonts/Lato-Regular.msdf.png differ diff --git a/tests/webgl/fonts/README.md b/tests/webgl/fonts/README.md new file mode 100644 index 0000000..ddd92c8 --- /dev/null +++ b/tests/webgl/fonts/README.md @@ -0,0 +1,10 @@ +# Test fonts + +`Lato-Regular.msdf.{json,png}` is an MSDF atlas of **Lato** (Łukasz Dziedzic), +the same files as solid-demo-app's `public/fonts`. The WebGL tests load it as +`Lato` and `LatoLate` (`tests/webgl/setup.ts`). + +Lato: Copyright (c) 2010-2015 by tyPoland Łukasz Dziedzic, with Reserved Font +Name "Lato". Licensed under the SIL Open Font License, Version 1.1 +(https://openfontlicense.org). Source: https://www.latofonts.com and +https://fonts.google.com/specimen/Lato. diff --git a/tests/webgl/setup.ts b/tests/webgl/setup.ts new file mode 100644 index 0000000..4f2b258 --- /dev/null +++ b/tests/webgl/setup.ts @@ -0,0 +1,90 @@ +/// +// Shared setup for the real-renderer tests (vitest.webgl.config.ts): one +// @solidtv/renderer instance (WebGL, SDF text) per test file, with the Lato +// MSDF font loaded before anything renders. +import { RendererMain } from '@solidtv/renderer'; +import { SdfTextRenderer, WebGlCoreRenderer } from '@solidtv/renderer/webgl'; +import { Config, createRenderer, loadFonts } from '@solidtv/solid'; +import latoData from './fonts/Lato-Regular.msdf.json?url'; +import latoAtlas from './fonts/Lato-Regular.msdf.png?url'; + +export const FONT_FAMILY = 'Lato'; + +/** The font every test lays text out with. */ +export function latoFont(fontFamily: string) { + return { + type: 'msdf' as const, + fontFamily, + atlasDataUrl: latoData, + atlasUrl: latoAtlas, + }; +} + +// Read once, at the first text render: set before anything renders. +Config.fontSettings = { fontFamily: FONT_FAMILY, fontSize: 30 }; + +const root = document.createElement('div'); +document.body.appendChild(root); + +// Renderer 1.x takes its engines as options: WebGL, and SDF text only. +Config.rendererOptions = { + appWidth: 1920, + appHeight: 1080, + renderEngine: WebGlCoreRenderer, + fontEngines: [SdfTextRenderer], +}; + +// Called before createRenderer, as apps do: the download overlaps the boot. +const fontsReady = loadFonts([latoFont(FONT_FAMILY)]); + +const created = createRenderer(Config.rendererOptions, root); + +// The point of these tests: the WebGL renderer, not the DOM one. +if (!(created.renderer instanceof RendererMain)) { + throw new Error('tests/webgl must run on @solidtv/renderer, not the DOM'); +} + +export const render = created.render; +export const renderer: RendererMain = created.renderer; + +// Resolves once the description and the atlas are in (FontRegistry). +await fontsReady; + +let frames = 0; +renderer.on('frameTick', () => { + frames++; +}); + +/** Frames the renderer has run since it started (its `frameTick` events). */ +export const frameCount = () => frames; + +const nextFrame = () => + new Promise((resolve) => { + requestAnimationFrame(() => { + // A macrotask after the frame: Solid's post-mutation microtask and + // anything the frame's events queued have run by then. + setTimeout(resolve, 0); + }); + }); + +/** + * Wait until layout has settled: no scene update pending (renderer 1.9's + * Stage.hasSceneUpdates: a dirty node, a requested render, a texture to + * upload) for two animation frames in a row. Text lays out in the renderer's + * frame, its `loaded` event runs Solid's flex, and the writes it makes + * mark nodes dirty again, so "no update pending" means nothing is. + */ +export async function settle(maxFrames = 300): Promise { + let quiet = 0; + for (let i = 0; i < maxFrames; i++) { + await nextFrame(); + if (!renderer.stage.hasSceneUpdates()) { + if (++quiet === 2) { + return; + } + } else { + quiet = 0; + } + } + throw new Error(`layout did not settle in ${maxFrames} frames`); +} diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 7b9cd60..9875a67 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; import { playwright } from '@vitest/browser-playwright'; import solid from 'vite-plugin-solid'; @@ -18,6 +18,23 @@ export default defineConfig(({ mode }) => ({ }), ], test: { + exclude: [ + ...configDefaults.exclude, + // vitest.webgl.config.ts runs these; bench/.arms holds copies of tests. + 'tests/webgl/**', + 'bench/**', + // Contract tests that pin the DOM renderer of the jsdom run + // (vitest.config.ts defines SOLIDTV_DOM_RENDERING; here Solid renders + // through WebGL, so they fail), and publicApi (node:fs). The other + // contract files pass here. + 'tests/contract-lazy.test.tsx', + 'tests/contract-nodes.test.tsx', + 'tests/contract-row-column.test.tsx', + 'tests/contract-states.test.tsx', + 'tests/contract-styles.test.tsx', + 'tests/contract-text.test.tsx', + 'tests/publicApi.test.ts', + ], browser: { enabled: true, provider: playwright(), diff --git a/vitest.config.ts b/vitest.config.ts index 18fe28b..d29c91d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config'; +import { configDefaults, defineConfig } from 'vitest/config'; import solidPlugin from 'vite-plugin-solid'; export default defineConfig(({ mode }) => ({ @@ -18,6 +18,8 @@ export default defineConfig(({ mode }) => ({ ], test: { watch: false, + // Real-renderer tests run in a browser: vitest.webgl.config.ts. + exclude: [...configDefaults.exclude, 'tests/webgl/**', 'bench/**'], isolate: false, passWithNoTests: true, environment: 'jsdom', diff --git a/vitest.webgl.config.ts b/vitest.webgl.config.ts new file mode 100644 index 0000000..cccbce1 --- /dev/null +++ b/vitest.webgl.config.ts @@ -0,0 +1,56 @@ +// Real-renderer tests: Vitest browser mode in headless Chromium, drawing +// through @solidtv/renderer (WebGL, SDF text), not the DOM renderer. +// +// pnpm test:webgl (vitest run --config=vitest.webgl.config.ts) +// +// Needs Playwright's Chromium: `npx playwright install chromium`. +// The default `vitest run` (vitest.config.ts, jsdom) excludes tests/webgl. +import { defineConfig } from 'vitest/config'; +import { playwright } from '@vitest/browser-playwright'; +import solid from 'vite-plugin-solid'; + +export default defineConfig({ + define: { + // Solid: build without the DOM renderer, as a WebGL app does. + SOLIDTV_DOM_RENDERING: false, + // Renderer build flags (src/common/flags.ts): production build, no + // inspector, no bounds events, as the bench and the demo app build it. + __DEV__: false, + __enableInspector__: false, + __emitBoundsEvents__: false, + __enableCompressedTextures__: false, + }, + plugins: [ + solid({ + hot: false, + solid: { + moduleName: '@solidtv/solid', + generate: 'universal', + builtIns: [], + }, + }), + ], + test: { + include: ['tests/webgl/**/*.test.tsx'], + watch: false, + browser: { + enabled: true, + headless: true, + // A failure's screenshot of the canvas says nothing the assertion + // does not, and would litter the tree with __screenshots__. + screenshotFailures: false, + // https://vitest.dev/guide/browser/playwright + provider: playwright({ + launchOptions: { + // Headless Chromium has no GPU: run WebGL on SwiftShader. + args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'], + }, + }), + instances: [{ browser: 'chromium' }], + }, + }, + resolve: { + conditions: ['@solidtv/source', 'browser', 'development'], + dedupe: ['solid-js', 'solid-js/universal', 'solid-js/store'], + }, +});