tuix
Version:
A performant TUI framework for Bun with JSX and reactive state management
631 lines (536 loc) • 18.8 kB
text/typescript
/**
* Testing Utilities - Test helpers and mocks for TUI framework testing
*
* This module provides utilities for testing TUI components, including
* mock services, test runners, and assertion helpers.
*/
import { Effect, Context, Layer, Ref, Queue, Stream, Option, Cause } from "effect"
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
import {
TerminalService,
InputService,
RendererService,
StorageService
} from "@/services/index.ts"
import type {
Component,
KeyEvent,
MouseEvent,
WindowSize,
View,
Viewport,
TerminalCapabilities,
Cmd
} from "@/services/index.ts"
import {
TerminalError,
InputError,
RenderError,
StorageError
} from "@/core/errors.ts"
// =============================================================================
// Test Environment
// =============================================================================
/**
* Test environment that captures all terminal output and input.
*/
export interface TestEnvironment {
readonly output: ReadonlyArray<string>
readonly cursor: { x: number; y: number }
readonly size: WindowSize
readonly capabilities: TerminalCapabilities
readonly rawMode: boolean
readonly alternateScreen: boolean
readonly mouseEnabled: boolean
}
/**
* Create a test environment with default values.
*/
export const createTestEnvironment = (
overrides: Partial<TestEnvironment> = {}
): TestEnvironment => ({
output: [],
cursor: { x: 1, y: 1 },
size: { width: 80, height: 24 },
capabilities: {
colors: '256',
unicode: true,
mouse: true,
alternateScreen: true,
cursorShapes: true
},
rawMode: false,
alternateScreen: false,
mouseEnabled: false,
...overrides
})
// =============================================================================
// Mock Services
// =============================================================================
/**
* Mock terminal service for testing.
*/
export const createMockTerminalService = (
initialEnv: TestEnvironment = createTestEnvironment()
) => {
const env = Ref.unsafeMake(initialEnv)
return Layer.succeed(
TerminalService,
{
clear: Effect.gen(function* (_) {
yield* _(Ref.update(env, (e) => ({ ...e, output: [] })))
}),
write: (text: string) =>
Effect.gen(function* (_) {
yield* _(Ref.update(env, (e) => ({
...e,
output: [...e.output, text]
})))
}),
writeLine: (text: string) =>
Effect.gen(function* (_) {
yield* _(Ref.update(env, (e) => ({
...e,
output: [...e.output, text + '\n']
})))
}),
moveCursor: (x: number, y: number) =>
Effect.gen(function* (_) {
yield* _(Ref.update(env, (e) => ({
...e,
cursor: { x, y }
})))
}),
moveCursorRelative: (dx: number, dy: number) =>
Effect.gen(function* (_) {
yield* _(Ref.update(env, (e) => ({
...e,
cursor: { x: e.cursor.x + dx, y: e.cursor.y + dy }
})))
}),
hideCursor: Effect.sync(() => {}),
showCursor: Effect.sync(() => {}),
getSize: Effect.gen(function* (_) {
const current = yield* _(Ref.get(env))
return current.size
}),
setRawMode: (enabled: boolean) =>
Effect.gen(function* (_) {
yield* _(Ref.update(env, (e) => ({ ...e, rawMode: enabled })))
}),
setAlternateScreen: (enabled: boolean) =>
Effect.gen(function* (_) {
yield* _(Ref.update(env, (e) => ({ ...e, alternateScreen: enabled })))
}),
saveCursor: Effect.sync(() => {}),
restoreCursor: Effect.sync(() => {}),
getCapabilities: Effect.gen(function* (_) {
const current = yield* _(Ref.get(env))
return current.capabilities
}),
supportsTrueColor: Effect.gen(function* (_) {
const current = yield* _(Ref.get(env))
return current.capabilities.colors === 'truecolor'
}),
supports256Colors: Effect.gen(function* (_) {
const current = yield* _(Ref.get(env))
return current.capabilities.colors === '256' || current.capabilities.colors === 'truecolor'
}),
supportsUnicode: Effect.gen(function* (_) {
const current = yield* _(Ref.get(env))
return current.capabilities.unicode
}),
clearToEndOfLine: Effect.sync(() => {}),
clearToStartOfLine: Effect.sync(() => {}),
clearLine: Effect.sync(() => {}),
clearToEndOfScreen: Effect.sync(() => {}),
clearToStartOfScreen: Effect.sync(() => {}),
scrollUp: (_lines: number) => Effect.sync(() => {}),
scrollDown: (_lines: number) => Effect.sync(() => {}),
setTitle: (_title: string) => Effect.sync(() => {}),
bell: Effect.sync(() => {}),
getCursorPosition: Effect.gen(function* (_) {
const current = yield* _(Ref.get(env))
return current.cursor
}),
setCursorShape: (_shape: 'block' | 'underline' | 'bar') => Effect.sync(() => {}),
setCursorBlink: (_enabled: boolean) => Effect.sync(() => {})
}
)
}
/**
* Mock input service for testing.
*/
export const createMockInputService = (): Layer.Layer<InputService, never, never> => {
const keyQueue = Queue.unbounded<KeyEvent>()
const mouseQueue = Queue.unbounded<MouseEvent>()
const resizeQueue = Queue.unbounded<WindowSize>()
const pasteQueue = Queue.unbounded<string>()
const focusQueue = Queue.unbounded<{ focused: boolean }>()
const [keyQ, mouseQ, resizeQ, pasteQ, focusQ] = Effect.runSync(
Effect.all([keyQueue, mouseQueue, resizeQueue, pasteQueue, focusQueue])
)
return Layer.succeed(
InputService,
{
keyEvents: Stream.fromQueue(keyQ),
mouseEvents: Stream.fromQueue(mouseQ),
resizeEvents: Stream.fromQueue(resizeQ),
pasteEvents: Stream.fromQueue(pasteQ),
focusEvents: Stream.fromQueue(focusQ),
enableMouse: Effect.sync(() => {}),
disableMouse: Effect.sync(() => {}),
enableMouseMotion: Effect.sync(() => {}),
disableMouseMotion: Effect.sync(() => {}),
enableBracketedPaste: Effect.sync(() => {}),
disableBracketedPaste: Effect.sync(() => {}),
enableFocusTracking: Effect.sync(() => {}),
disableFocusTracking: Effect.sync(() => {}),
readKey: Effect.gen(function* (_) {
return yield* _(Queue.take(keyQ))
}),
readLine: Effect.succeed("test input"),
inputAvailable: Effect.succeed(false),
flushInput: Effect.sync(() => {}),
filterKeys: (predicate) =>
Stream.fromQueue(keyQ).pipe(
Stream.filter(predicate)
),
mapKeys: <T>(mapper: (key: KeyEvent) => T | null) =>
Stream.fromQueue(keyQ).pipe(
Stream.filterMap((key): Option.Option<T> => {
const result = mapper(key)
return result !== null ? Option.some(result) : Option.none()
})
),
debounceKeys: (_ms) => Stream.fromQueue(keyQ),
parseAnsiSequence: (_sequence) => Effect.succeed(null),
rawInput: Stream.empty,
setEcho: (_enabled) => Effect.sync(() => {})
}
)
}
/**
* Mock renderer service for testing.
*/
export const createMockRendererService = (): Layer.Layer<RendererService, never, never> => {
const renderedViews = Ref.unsafeMake<ReadonlyArray<string>>([])
const viewport = Ref.unsafeMake<Viewport>({ x: 0, y: 0, width: 80, height: 24 })
const stats = Ref.unsafeMake({
framesRendered: 0,
averageFrameTime: 0,
lastFrameTime: 0,
dirtyRegionCount: 0,
bufferSwitches: 0
})
return Layer.succeed(
RendererService,
{
render: (view: View) =>
Effect.gen(function* (_) {
// Mock implementation: just return a simple string representation
const rendered = `[View ${view.width || 80}x${view.height || 24}]`
yield* _(Ref.update(renderedViews, (views) => [...views, rendered]))
yield* _(Ref.update(stats, (s) => ({
...s,
framesRendered: s.framesRendered + 1
})))
}),
beginFrame: Effect.sync(() => {}),
endFrame: Effect.sync(() => {}),
forceRedraw: Effect.sync(() => {}),
setViewport: (v: Viewport) =>
Effect.gen(function* (_) {
yield* _(Ref.set(viewport, v))
}),
getViewport: Effect.gen(function* (_) {
return yield* _(Ref.get(viewport))
}),
pushViewport: (v: Viewport) =>
Effect.gen(function* (_) {
yield* _(Ref.set(viewport, v))
}),
popViewport: Effect.sync(() => {}),
clearDirtyRegions: Effect.sync(() => {}),
markDirty: (_region) => Effect.sync(() => {}),
getDirtyRegions: Effect.succeed([]),
optimizeDirtyRegions: Effect.sync(() => {}),
getStats: Effect.gen(function* (_) {
return yield* _(Ref.get(stats))
}),
resetStats: Effect.gen(function* (_) {
yield* _(Ref.set(stats, {
framesRendered: 0,
averageFrameTime: 0,
lastFrameTime: 0,
dirtyRegionCount: 0,
bufferSwitches: 0
}))
}),
setProfilingEnabled: (_enabled) => Effect.sync(() => {}),
renderAt: (view: View, _x: number, _y: number) =>
Effect.gen(function* (_) {
const rendered = `[View at ${_x},${_y} ${view.width || 80}x${view.height || 24}]`
yield* _(Ref.update(renderedViews, (views) => [...views, rendered]))
}),
renderBatch: (views) =>
Effect.gen(function* (_) {
for (const { view, x, y } of views) {
const rendered = `[View at ${x},${y} ${view.width || 80}x${view.height || 24}]`
yield* _(Ref.update(renderedViews, (v) => [...v, rendered]))
}
}),
setClipRegion: (_region) => Effect.sync(() => {}),
saveState: Effect.sync(() => {}),
restoreState: Effect.sync(() => {}),
measureText: (text: string) =>
Effect.succeed({
width: text.length,
height: 1,
lineCount: 1
}),
wrapText: (text: string, width: number, _options) =>
Effect.succeed([text.slice(0, width)]),
truncateText: (text: string, width: number, ellipsis = '...') =>
Effect.succeed(
text.length <= width ? text : text.slice(0, width - ellipsis.length) + ellipsis
),
createLayer: (_name, _zIndex) => Effect.sync(() => {}),
removeLayer: (_name) => Effect.sync(() => {}),
renderToLayer: (_layerName, view: View, _x: number, _y: number) =>
Effect.gen(function* (_) {
const rendered = `[Layer ${_layerName}: View at ${_x},${_y} ${view.width || 80}x${view.height || 24}]`
yield* _(Ref.update(renderedViews, (views) => [...views, rendered]))
}),
setLayerVisible: (_layerName, _visible) => Effect.sync(() => {}),
compositeLayers: Effect.sync(() => {})
}
)
}
/**
* Mock storage service for testing.
*/
export const createMockStorageService = (): Layer.Layer<StorageService, never, never> => {
const storage = Ref.unsafeMake<Map<string, unknown>>(new Map())
return Layer.succeed(
StorageService,
{
saveState: <T>(key: string, data: T) =>
Effect.gen(function* (_) {
yield* _(Ref.update(storage, (map) => new Map(map).set(key, data)))
}),
loadState: <T>(key: string) =>
Effect.gen(function* (_) {
const map = yield* _(Ref.get(storage))
return (map.get(key) as T) || null
}),
clearState: (key: string) =>
Effect.gen(function* (_) {
yield* _(Ref.update(storage, (map) => {
const newMap = new Map(map)
newMap.delete(key)
return newMap
}))
}),
hasState: (key: string) =>
Effect.gen(function* (_) {
const map = yield* _(Ref.get(storage))
return map.has(key)
}),
listStateKeys: Effect.gen(function* (_) {
const map = yield* _(Ref.get(storage))
return Array.from(map.keys())
}),
loadConfig: <T>(_appName: string, _schema: any, defaults: T) =>
Effect.succeed(defaults),
saveConfig: <T>(_appName: string, _config: T, _schema: any) =>
Effect.sync(() => {}),
getConfigPath: (_appName: string) =>
Effect.succeed('/tmp/test-config.json'),
watchConfig: <T>(_appName: string, _schema: any) =>
Effect.succeed(Effect.never),
setCache: <T>(key: string, data: T, _ttlSeconds?: number) =>
Effect.gen(function* (_) {
yield* _(Ref.update(storage, (map) => new Map(map).set(`cache:${key}`, data)))
}),
getCache: <T>(key: string, _schema: any) =>
Effect.gen(function* (_) {
const map = yield* _(Ref.get(storage))
return (map.get(`cache:${key}`) as T) || null
}),
clearCache: (key: string) =>
Effect.gen(function* (_) {
yield* _(Ref.update(storage, (map) => {
const newMap = new Map(map)
newMap.delete(`cache:${key}`)
return newMap
}))
}),
clearExpiredCache: Effect.sync(() => {}),
getCacheStats: Effect.succeed({
totalEntries: 0,
expiredEntries: 0,
totalSize: 0
}),
readTextFile: <T>(_path: string, _schema?: any) =>
Effect.succeed("test file content" as T),
writeTextFile: (_path: string, _content: string, _options?: { readonly createDirs?: boolean; readonly backup?: boolean }) =>
Effect.sync(() => {}),
readJsonFile: <T>(_path: string, _schema: unknown) =>
Effect.succeed({} as T),
writeJsonFile: <T>(_path: string, _data: T, _options?: { pretty?: boolean }) =>
Effect.sync(() => {}),
fileExists: (_path: string) => Effect.succeed(true),
createDirectory: (_path: string) => Effect.sync(() => {}),
getFileStats: (_path: string) =>
Effect.succeed({
size: 1024,
modified: new Date(),
created: new Date(),
isFile: true,
isDirectory: false
}),
createBackup: (_filePath: string, _backupSuffix?: string) =>
Effect.succeed('/tmp/backup.txt'),
restoreBackup: (_filePath: string, _backupPath: string) =>
Effect.sync(() => {}),
listBackups: (_filePath: string) =>
Effect.succeed([]),
cleanupBackups: (_filePath: string, _keepCount: number) =>
Effect.sync(() => {}),
beginTransaction: Effect.succeed('test-transaction'),
addToTransaction: (_transactionId: string, _operation: 'write' | 'delete', _path: string, _content?: string) =>
Effect.sync(() => {}),
commitTransaction: (_transactionId: string) =>
Effect.sync(() => {}),
rollbackTransaction: (_transactionId: string) =>
Effect.sync(() => {})
}
)
}
// =============================================================================
// Test Utilities
// =============================================================================
/**
* Create a complete test layer with all mock services.
*/
export const createTestLayer = (env?: Partial<TestEnvironment>) =>
Layer.merge(
createMockTerminalService(env ? createTestEnvironment(env) : undefined),
Layer.merge(
createMockInputService(),
Layer.merge(
createMockRendererService(),
createMockStorageService()
)
)
)
/**
* Test a component in isolation.
*/
export const testComponent = <Model, Msg>(
component: Component<Model, Msg>,
options?: {
readonly environment?: Partial<TestEnvironment>
readonly timeout?: number
}
) => {
const testLayer = createTestLayer(options?.environment)
// Helper to run effects with the test layer
const runWithLayer = <A, E>(effect: Effect.Effect<A, E, any>): Promise<A> =>
Effect.runPromise(
effect.pipe(
Effect.provide(testLayer),
Effect.timeout(options?.timeout || 5000)
) as Effect.Effect<A, E | Cause.TimeoutException, never>
)
return {
/**
* Test component initialization.
*/
testInit: (): Promise<readonly [Model, ReadonlyArray<Cmd<Msg>>]> =>
runWithLayer(component.init),
/**
* Test a single update cycle.
*/
testUpdate: (msg: Msg, model: Model): Promise<readonly [Model, ReadonlyArray<Cmd<Msg>>]> =>
runWithLayer(component.update(msg, model)),
/**
* Test view rendering.
*/
testView: (model: Model): Promise<string> =>
runWithLayer(component.view(model).render()),
/**
* Test subscriptions.
*/
testSubscriptions: (model: Model) =>
component.subscriptions
? runWithLayer(component.subscriptions(model))
: Promise.resolve(Stream.empty)
}
}
/**
* Assertion helpers for TUI testing.
*/
export const TUIAssert = {
/**
* Assert that rendered output contains specific text.
*/
outputContains: (output: string, expected: string) => {
expect(output).toContain(expected)
},
/**
* Assert that rendered output matches a pattern.
*/
outputMatches: (output: string, pattern: RegExp) => {
expect(output).toMatch(pattern)
},
/**
* Assert that component state has specific properties.
*/
stateHas: <T>(state: T, expected: Partial<T>) => {
for (const [key, value] of Object.entries(expected)) {
expect((state as any)[key]).toEqual(value)
}
},
/**
* Assert that a view has specific dimensions.
*/
viewSize: (view: View, width?: number, height?: number) => {
if (width !== undefined) {
expect(view.width).toBe(width)
}
if (height !== undefined) {
expect(view.height).toBe(height)
}
}
} as const
// =============================================================================
// Comprehensive Service Mocks
// =============================================================================
/**
* Create all mock services for integration testing
*/
export const createMockAppServices = () => {
const terminal = createMockTerminalService()
const input = createMockInputService()
const renderer = createMockRendererService()
const storage = createMockStorageService()
return {
terminal,
input,
renderer,
storage,
// Provide combined layer
layer: Layer.mergeAll(terminal, input, renderer, storage)
}
}
/**
* Create a comprehensive test harness with all services
*/
export const withMockServices = <R, E, A>(
effect: Effect.Effect<A, E, R>
): Effect.Effect<A, E, Exclude<R, TerminalService | InputService | RendererService | StorageService>> => {
const services = createMockAppServices()
return Effect.provide(effect, services.layer)
}