diff --git a/packages/adapters/src/chalk/stress.test.ts b/packages/adapters/src/chalk/stress.test.ts new file mode 100644 index 000000000..bc56f0798 --- /dev/null +++ b/packages/adapters/src/chalk/stress.test.ts @@ -0,0 +1,136 @@ +// packages/adapters/src/chalk/stress.test.ts +// Stress tests for chalk adapter under high-volume output. + +import { describe, it, expect, afterEach } from 'vitest'; +import { chalkToTermUI } from './index.js'; + +function buildAnsiString(sequence: string, text: string): string { + return `${sequence}${text}\x1B[0m`; +} + +const MIXED_SEQUENCES = [ + '\x1B[31m', // red + '\x1B[32m', // green + '\x1B[33m', // yellow + '\x1B[34m', // blue + '\x1B[35m', // magenta + '\x1B[36m', // cyan + '\x1B[1m', // bold + '\x1B[4m', // underline + '\x1B[7m', // reverse + '\x1B[9m', // strikethrough +]; + +function mixedAnsi(text: string): string { + let result = text; + for (const seq of MIXED_SEQUENCES) { + result = buildAnsiString(seq, result); + } + return result; +} + +describe('chalk adapter stress tests', () => { + afterEach(() => { + delete process.env.NO_COLOR; + }); + + it('handles 1,000 rapid successive calls without error', () => { + const originalNoColor = process.env.NO_COLOR; + delete process.env.NO_COLOR; + + const texts = Array.from({ length: 1000 }, (_, i) => `line-${i}`); + const ansiTexts = texts.map((t, i) => { + const seq = MIXED_SEQUENCES[i % MIXED_SEQUENCES.length]; + return buildAnsiString(seq, t); + }); + + expect(() => { + for (const input of ansiTexts) { + chalkToTermUI(input); + } + }).not.toThrow(); + }); + + it('handles 5,000 rapid successive calls without error or slowdown', () => { + const originalNoColor = process.env.NO_COLOR; + delete process.env.NO_COLOR; + + const texts = Array.from({ length: 5000 }, (_, i) => `output-${i}`); + + const start = performance.now(); + for (const text of texts) { + chalkToTermUI(mixedAnsi(text)); + } + const elapsed = performance.now() - start; + + // 5,000 mixed-ANSI calls should complete in under 500ms + expect(elapsed).toBeLessThan(500); + }); + + it('produces consistent output at 1,000-call scale', () => { + const originalNoColor = process.env.NO_COLOR; + delete process.env.NO_COLOR; + + const results: string[] = []; + for (let i = 0; i < 1000; i++) { + results.push(chalkToTermUI(mixedAnsi(`item-${i}`))); + } + + // Each result should contain the original text + expect(results[0]).toContain('item-0'); + expect(results[999]).toContain('item-999'); + + // All results should have ANSI codes intact (no NO_COLOR) + for (const result of results) { + expect(result).toContain('\x1B['); + } + }); + + it('strips ANSI when NO_COLOR is set during high-volume calls', () => { + process.env.NO_COLOR = '1'; + + const texts = Array.from({ length: 1000 }, (_, i) => `line-${i}`); + const ansiTexts = texts.map((t, i) => { + const seq = MIXED_SEQUENCES[i % MIXED_SEQUENCES.length]; + return buildAnsiString(seq, t); + }); + + const results: string[] = []; + for (const input of ansiTexts) { + results.push(chalkToTermUI(input)); + } + + // All results should be stripped of ANSI codes + for (const result of results) { + expect(result).not.toContain('\x1B['); + expect(result).toMatch(/^line-\d+$/); + } + }); + + it('handles deeply nested ANSI sequences', () => { + const originalNoColor = process.env.NO_COLOR; + delete process.env.NO_COLOR; + + // Stack all sequences 10 times + let deeplyNested = 'text'; + for (let i = 0; i < 10; i++) { + deeplyNested = mixedAnsi(deeplyNested); + } + + // Should not throw and should preserve text content + const result = chalkToTermUI(deeplyNested); + expect(result).toBeDefined(); + expect(result.length).toBeGreaterThan(0); + }); + + it('handles empty strings at high volume', () => { + const originalNoColor = process.env.NO_COLOR; + delete process.env.NO_COLOR; + + expect(() => { + for (let i = 0; i < 1000; i++) { + chalkToTermUI(''); + } + }).not.toThrow(); + }); +}); diff --git a/packages/motion/src/easing-benchmark.test.ts b/packages/motion/src/easing-benchmark.test.ts new file mode 100644 index 000000000..f48bb36d1 --- /dev/null +++ b/packages/motion/src/easing-benchmark.test.ts @@ -0,0 +1,84 @@ +// packages/motion/src/easing-benchmark.test.ts +// Performance benchmarks for easing functions at simulated 60fps. + +import { describe, it, expect } from 'vitest'; +import { stepSpring, springPreset } from './spring.js'; +import { easings } from './transitions.js'; + +const FRAME_DT_MS = 1000 / 60; // ~16.67ms per frame at 60fps +const FRAME_DT_S = FRAME_DT_MS / 1000; +const ITERATIONS = 10_000; + +/** Average time in milliseconds for a single easing tick */ +function benchmarkEasing(_name: string, fn: () => void): number { + const start = performance.now(); + for (let i = 0; i < ITERATIONS; i++) { + fn(); + } + const elapsed = performance.now() - start; + return elapsed / ITERATIONS; +} + +describe('easing benchmark at 60fps', () => { + it('spring tick completes within 1ms per frame', () => { + const config = springPreset('default'); + let state = { value: 0, velocity: 0, target: 1, done: false }; + let totalMs = 0; + + for (let i = 0; i < ITERATIONS; i++) { + const frameStart = performance.now(); + state = stepSpring(state, config, FRAME_DT_S); + totalMs += performance.now() - frameStart; + } + + const avgMs = totalMs / ITERATIONS; + expect(avgMs).toBeLessThan(1); + }); + + it('linear easing tick completes within 0.1ms per frame', () => { + let avgMs = benchmarkEasing('linear', () => { + easings.linear(0.5); + }); + expect(avgMs).toBeLessThan(0.1); + }); + + it('easeInOut easing tick completes within 0.1ms per frame', () => { + let avgMs = benchmarkEasing('easeInOut', () => { + easings.easeInOut(0.5); + }); + expect(avgMs).toBeLessThan(0.1); + }); + + it('spring reaches target within 60 frames at 60fps', () => { + const config = springPreset('default'); + let state = { value: 0, velocity: 0, target: 1, done: false }; + let frames = 0; + const maxFrames = 60; // 1 second at 60fps + + while (!state.done && frames < maxFrames) { + state = stepSpring(state, config, FRAME_DT_S); + frames++; + } + + expect(state.done).toBe(true); + expect(frames).toBeLessThanOrEqual(maxFrames); + expect(state.value).toBeCloseTo(1, 2); + }); + + it('linear interpolation is deterministic across frames', () => { + const progress: number[] = []; + for (let f = 0; f <= 10; f++) { + progress.push(easings.linear(f / 10)); + } + expect(progress[5]).toBe(0.5); + expect(progress[10]).toBe(1); + }); + + it('easeInOut produces symmetric curve', () => { + const midpoint = easings.easeInOut(0.5); + expect(midpoint).toBeCloseTo(0.5); + // easeInOut(t) + easeInOut(1-t) should equal 1 + expect(easings.easeInOut(0.25) + easings.easeInOut(0.75)).toBeCloseTo(1); + expect(easings.easeInOut(0.1) + easings.easeInOut(0.9)).toBeCloseTo(1); + }); +}); diff --git a/packages/router/src/hooks-params-advanced.test.ts b/packages/router/src/hooks-params-advanced.test.ts new file mode 100644 index 000000000..ec27ec642 --- /dev/null +++ b/packages/router/src/hooks-params-advanced.test.ts @@ -0,0 +1,153 @@ +// packages/router/src/hooks-params-advanced.test.ts +// Tests for useParams with optional and catch-all route segments. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Router } from './router.js'; +import { useParams } from './hooks.js'; +import { unmountAll } from '@termuijs/jsx'; +import { render } from '@termuijs/testing'; + +describe('useParams with optional and catch-all route segments', () => { + afterEach(() => { + unmountAll(); + vi.restoreAllMocks(); + }); + + it('useParams captures catch-all segment params', () => { + const r = new Router(); + let capturedParams: any; + + const TestScreen = () => { + capturedParams = useParams(); + return { type: 'box', props: {}, children: [] } as any; + }; + + r.addRoute('/docs/[...path]', TestScreen); + + let screenToRender: any; + r.events.on('navigate', (ev) => { screenToRender = ev.screen; }); + + // Navigate to /docs/a/b/c + r.push('/docs/a/b/c'); + const t = render(screenToRender); + + expect(capturedParams).toBeDefined(); + expect(capturedParams.path).toBe('a/b/c'); + + t.unmount(); + }); + + it('useParams returns empty string for catch-all when only base path is matched', () => { + const r = new Router(); + let capturedParams: any; + + const TestScreen = () => { + capturedParams = useParams(); + return { type: 'box', props: {}, children: [] } as any; + }; + + // Add a static /docs route alongside the catch-all + r.addRoute('/docs', TestScreen); + + let screenToRender: any; + r.events.on('navigate', (ev) => { screenToRender = ev.screen; }); + + // Navigate to /docs (static route, no params) + r.push('/docs'); + const t = render(screenToRender); + + expect(capturedParams).toBeDefined(); + expect(capturedParams).toEqual({}); + + t.unmount(); + }); + + it('useParams handles catch-all with single segment', () => { + const r = new Router(); + let capturedParams: any; + + const TestScreen = () => { + capturedParams = useParams(); + return { type: 'box', props: {}, children: [] } as any; + }; + + r.addRoute('/files/[...rest]', TestScreen); + + let screenToRender: any; + r.events.on('navigate', (ev) => { screenToRender = ev.screen; }); + + r.push('/files/readme.md'); + const t = render(screenToRender); + + expect(capturedParams.rest).toBe('readme.md'); + + t.unmount(); + }); + + it('useParams handles regular param alongside catch-all', () => { + const r = new Router(); + let capturedParams: any; + + const TestScreen = () => { + capturedParams = useParams(); + return { type: 'box', props: {}, children: [] } as any; + }; + + r.addRoute('/user/[id]/[...rest]', TestScreen); + + let screenToRender: any; + r.events.on('navigate', (ev) => { screenToRender = ev.screen; }); + + r.push('/user/42/posts/2024/jan'); + const t = render(screenToRender); + + expect(capturedParams.id).toBe('42'); + expect(capturedParams.rest).toBe('posts/2024/jan'); + + t.unmount(); + }); + + it('useParams returns empty object for route without params', () => { + const r = new Router(); + let capturedParams: any; + + const AboutScreen = () => { + capturedParams = useParams(); + return { type: 'box', props: {}, children: [] } as any; + }; + + r.addRoute('/about', AboutScreen); + + let screenToRender: any; + r.events.on('navigate', (ev) => { screenToRender = ev.screen; }); + + r.push('/about'); + const t = render(screenToRender); + + expect(capturedParams).toEqual({}); + + t.unmount(); + }); + + it('useParams handles required param without optional syntax', () => { + const r = new Router(); + let capturedParams: any; + + const TestScreen = () => { + capturedParams = useParams(); + return { type: 'box', props: {}, children: [] } as any; + }; + + r.addRoute('/item/[id]', TestScreen); + + let screenToRender: any; + r.events.on('navigate', (ev) => { screenToRender = ev.screen; }); + + r.push('/item/99'); + const t = render(screenToRender); + + expect(capturedParams.id).toBe('99'); + + t.unmount(); + }); +}); diff --git a/packages/tss/src/theme-switching.test.ts b/packages/tss/src/theme-switching.test.ts new file mode 100644 index 000000000..7a69744ae --- /dev/null +++ b/packages/tss/src/theme-switching.test.ts @@ -0,0 +1,155 @@ +// packages/tss/src/theme-switching.test.ts +// Integration tests for dynamic theme switching at runtime. + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { ThemeEngine } from './engine.js'; +import { TSSWatcher } from './watcher.js'; +import * as path from 'node:path'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; + +const DARK_TSS = ` +@theme dark { + --bg: #0d1117; + --fg: #c9d1d9; + --primary: #58a6ff; +} + +widget { + background: var(--bg); + color: var(--fg); +} +`; + +const LIGHT_TSS = ` +@theme light { + --bg: #ffffff; + --fg: #24292f; + --primary: #0969da; +} + +widget { + background: var(--bg); + color: var(--fg); +} +`; + +describe('theme switching', () => { + let tmpDir: string; + let tssFile: string; + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + function setupTmpDir(): void { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tss-theme-test-')); + tssFile = path.join(tmpDir, 'theme.tss'); + } + + it('setTheme switches active theme and updates getVariable', () => { + setupTmpDir(); + fs.writeFileSync(tssFile, DARK_TSS + LIGHT_TSS); + + const engine = new ThemeEngine(); + engine.load(fs.readFileSync(tssFile, 'utf-8')); + + expect(engine.availableThemes).toContain('dark'); + expect(engine.availableThemes).toContain('light'); + + engine.setTheme('dark'); + expect(engine.activeTheme).toBe('dark'); + expect(engine.getVariable('--bg')).toBe('#0d1117'); + expect(engine.getVariable('--fg')).toBe('#c9d1d9'); + + engine.setTheme('light'); + expect(engine.activeTheme).toBe('light'); + expect(engine.getVariable('--bg')).toBe('#ffffff'); + expect(engine.getVariable('--fg')).toBe('#24292f'); + }); + + it('onChange fires when setTheme is called', () => { + setupTmpDir(); + fs.writeFileSync(tssFile, DARK_TSS + LIGHT_TSS); + + const engine = new ThemeEngine(); + engine.load(fs.readFileSync(tssFile, 'utf-8')); + + const changeSpy = vi.fn(); + engine.onChange(changeSpy); + + engine.setTheme('dark'); + expect(changeSpy).toHaveBeenCalledTimes(1); + + engine.setTheme('light'); + expect(changeSpy).toHaveBeenCalledTimes(2); + + // setTheme always calls _applyTheme which notifies listeners + engine.setTheme('light'); + expect(changeSpy).toHaveBeenCalledTimes(3); + }); + + it('setTheme updates resolveStyle for existing widget types', () => { + setupTmpDir(); + fs.writeFileSync(tssFile, DARK_TSS + LIGHT_TSS); + + const engine = new ThemeEngine(); + engine.load(fs.readFileSync(tssFile, 'utf-8')); + + engine.setTheme('dark'); + const darkStyle = engine.resolveStyle('widget'); + expect(darkStyle.bg).toEqual({ type: 'hex', hex: '#0d1117' }); + expect(darkStyle.fg).toEqual({ type: 'hex', hex: '#c9d1d9' }); + + engine.setTheme('light'); + const lightStyle = engine.resolveStyle('widget'); + expect(lightStyle.bg).toEqual({ type: 'hex', hex: '#ffffff' }); + expect(lightStyle.fg).toEqual({ type: 'hex', hex: '#24292f' }); + }); + + it('TSSWatcher reloads engine on .tss file change', async () => { + setupTmpDir(); + fs.writeFileSync(tssFile, DARK_TSS); + + const engine = new ThemeEngine(); + engine.load(fs.readFileSync(tssFile, 'utf-8')); + engine.setTheme('dark'); + + const changeSpy = vi.fn(); + engine.onChange(changeSpy); + + const watcher = new TSSWatcher({ dir: tmpDir, engine }); + watcher.start(); + + // Update the .tss file to add the light theme + fs.writeFileSync(tssFile, DARK_TSS + LIGHT_TSS); + + // Wait for debounce (default 50ms) + buffer + await new Promise(resolve => setTimeout(resolve, 150)); + + watcher.stop(); + + expect(changeSpy).toHaveBeenCalled(); + expect(engine.availableThemes).toContain('light'); + }); + + it('osc-11 background detection triggers theme switch via setTheme', () => { + setupTmpDir(); + fs.writeFileSync(tssFile, DARK_TSS + LIGHT_TSS); + + const engine = new ThemeEngine(); + engine.load(fs.readFileSync(tssFile, 'utf-8')); + + // osc-11 detects dark background -> use dark theme + engine.setTheme('dark'); + expect(engine.activeTheme).toBe('dark'); + expect(engine.getVariable('--bg')).toBe('#0d1117'); + + // osc-11 detects light background -> switch to light theme + engine.setTheme('light'); + expect(engine.activeTheme).toBe('light'); + expect(engine.getVariable('--bg')).toBe('#ffffff'); + }); +});