Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions packages/ui/src/__tests__/component-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { describe, expect, test } from 'vitest';
import { createContext, useContext } from '../component/context';
import { ErrorBoundary } from '../component/error-boundary';
import { onMount, watch } from '../component/lifecycle';
import { ref } from '../component/refs';
import { onCleanup, popScope, pushScope, runCleanups } from '../runtime/disposal';
import { signal } from '../runtime/signal';

describe('Integration Tests — Component Model', () => {
// IT-1C-1: onMount runs once, onCleanup runs on unmount
test('onMount fires once, onCleanup fires on dispose', () => {
let mounted = false;
let cleaned = false;

const scope = pushScope();
onMount(() => {
mounted = true;
onCleanup(() => {
cleaned = true;
});
});
popScope();

// onMount callback ran immediately
expect(mounted).toBe(true);
// Cleanup not yet called
expect(cleaned).toBe(false);

// Simulate unmount by running disposal
runCleanups(scope);
expect(cleaned).toBe(true);
});

// IT-1C-2: watch() re-runs callback when dependency changes
test('watch re-runs on dependency change', () => {
const values: number[] = [];
const count = signal(0);

pushScope();
watch(
() => count.value,
(val) => values.push(val),
);
popScope();

// Initial run captures value 0
expect(values).toEqual([0]);

// Dependency change triggers re-run
count.value = 1;
expect(values).toEqual([0, 1]);
});

// IT-1C-3: Context flows through component tree
test('context value flows from Provider to consumer', () => {
const ThemeCtx = createContext<string>('light');

// Without Provider, default is returned
expect(useContext(ThemeCtx)).toBe('light');

// Provider sets the value for the scope
ThemeCtx.Provider('dark', () => {
expect(useContext(ThemeCtx)).toBe('dark');

// Nested Provider shadows the outer value
ThemeCtx.Provider('blue', () => {
expect(useContext(ThemeCtx)).toBe('blue');
});

// After inner scope ends, outer value is restored
expect(useContext(ThemeCtx)).toBe('dark');
});

// After all Providers, default is restored
expect(useContext(ThemeCtx)).toBe('light');
});

// IT-1C-4: ErrorBoundary catches errors and renders fallback with retry
test('ErrorBoundary catches and allows retry', () => {
let attempts = 0;
let retryFn: (() => void) | undefined;

const container = document.createElement('div');
const result = ErrorBoundary({
children: () => {
attempts++;
if (attempts < 2) {
throw new TypeError('component error');
}
const el = document.createElement('p');
el.textContent = 'recovered';
return el;
},
fallback: (error, retry) => {
retryFn = retry;
const el = document.createElement('span');
el.textContent = `Error: ${error.message}`;
return el;
},
});
container.appendChild(result);

// First render: children throws, fallback shown
expect(container.textContent).toBe('Error: component error');
expect(retryFn).toBeDefined();
expect(attempts).toBe(1);

// Call actual retry — it replaces fallback with children result in the DOM
retryFn?.();
expect(container.textContent).toBe('recovered');
expect(attempts).toBe(2);
});

// IT-1C-5: ref provides access to DOM element after mount
test('ref.current is set after mount', () => {
const r = ref<HTMLDivElement>();

// Before mount, ref is undefined
expect(r.current).toBeUndefined();

// Simulate mount: assign the DOM element to the ref
const scope = pushScope();
onMount(() => {
const el = document.createElement('div');
el.id = 'test-ref';
r.current = el;
});
popScope();

// After mount, ref.current is set
expect(r.current).toBeDefined();
expect(r.current?.id).toBe('test-ref');
expect(r.current).toBeInstanceOf(HTMLDivElement);

// Cleanup
runCleanups(scope);
});
});
79 changes: 79 additions & 0 deletions packages/ui/src/component/__tests__/children.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, expect, test } from 'vitest';
import type { ChildrenAccessor } from '../children';
import { children, resolveChildren } from '../children';

describe('children', () => {
test('returns a getter that resolves a single child node', () => {
const el = document.createElement('div');
const accessor: ChildrenAccessor = () => el;
const resolved = children(accessor);
expect(resolved()).toEqual([el]);
});

test('returns a getter that resolves an array of child nodes', () => {
const a = document.createElement('span');
const b = document.createElement('span');
const accessor: ChildrenAccessor = () => [a, b];
const resolved = children(accessor);
expect(resolved()).toEqual([a, b]);
});

test('flattens nested arrays', () => {
const a = document.createElement('span');
const b = document.createElement('span');
const c = document.createElement('span');
const accessor: ChildrenAccessor = () => [a, [b, c]];
const resolved = children(accessor);
expect(resolved()).toEqual([a, b, c]);
});

test('filters out null and undefined', () => {
const a = document.createElement('span');
const accessor: ChildrenAccessor = () => [null, a, undefined];
const resolved = children(accessor);
expect(resolved()).toEqual([a]);
});

test('handles string children as text nodes', () => {
const accessor: ChildrenAccessor = () => 'hello';
const resolved = children(accessor);
const result = resolved();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Text);
expect((result[0] as Text).textContent).toBe('hello');
});

test('handles number children as text nodes', () => {
const accessor: ChildrenAccessor = () => 42;
const resolved = children(accessor);
const result = resolved();
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(Text);
expect((result[0] as Text).textContent).toBe('42');
});
});

describe('resolveChildren', () => {
test('resolves a single node', () => {
const el = document.createElement('div');
expect(resolveChildren(el)).toEqual([el]);
});

test('resolves null to empty array', () => {
expect(resolveChildren(null)).toEqual([]);
});

test('resolves undefined to empty array', () => {
expect(resolveChildren(undefined)).toEqual([]);
});

test('resolves mixed array with filtering and flattening', () => {
const a = document.createElement('span');
const b = document.createElement('span');
const result = resolveChildren([null, a, [b, undefined], 'text']);
expect(result).toHaveLength(3);
expect(result[0]).toBe(a);
expect(result[1]).toBe(b);
expect(result[2]).toBeInstanceOf(Text);
});
});
59 changes: 59 additions & 0 deletions packages/ui/src/component/__tests__/context.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, test } from 'vitest';
import { createContext, useContext } from '../context';

describe('createContext / useContext', () => {
test('useContext returns default value when no Provider is set', () => {
const ThemeCtx = createContext('light');
expect(useContext(ThemeCtx)).toBe('light');
});

test('useContext returns undefined when no Provider and no default', () => {
const Ctx = createContext<string>();
expect(useContext(Ctx)).toBeUndefined();
});

test('Provider sets value that useContext retrieves', () => {
const ThemeCtx = createContext('light');
ThemeCtx.Provider('dark', () => {
expect(useContext(ThemeCtx)).toBe('dark');
});
});

test('nested Providers shadow outer values', () => {
const ThemeCtx = createContext('light');
ThemeCtx.Provider('dark', () => {
expect(useContext(ThemeCtx)).toBe('dark');
ThemeCtx.Provider('blue', () => {
expect(useContext(ThemeCtx)).toBe('blue');
});
// After inner Provider scope ends, outer value restored
expect(useContext(ThemeCtx)).toBe('dark');
});
// After all Providers, default restored
expect(useContext(ThemeCtx)).toBe('light');
});

test('multiple independent contexts do not interfere', () => {
const ThemeCtx = createContext('light');
const LangCtx = createContext('en');
ThemeCtx.Provider('dark', () => {
LangCtx.Provider('fr', () => {
expect(useContext(ThemeCtx)).toBe('dark');
expect(useContext(LangCtx)).toBe('fr');
});
expect(useContext(LangCtx)).toBe('en');
});
});

test('Provider works with complex types', () => {
interface Config {
api: string;
debug: boolean;
}
const ConfigCtx = createContext<Config>({ api: '/api', debug: false });
const customConfig = { api: '/v2/api', debug: true };
ConfigCtx.Provider(customConfig, () => {
expect(useContext(ConfigCtx)).toBe(customConfig);
});
});
});
109 changes: 109 additions & 0 deletions packages/ui/src/component/__tests__/error-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, expect, test } from 'vitest';
import { ErrorBoundary } from '../error-boundary';

describe('ErrorBoundary', () => {
test('renders children when no error occurs', () => {
const result = ErrorBoundary({
children: () => document.createElement('div'),
fallback: () => document.createElement('span'),
});
expect(result.tagName).toBe('DIV');
});

test('renders fallback when children throw', () => {
const result = ErrorBoundary({
children: () => {
throw new TypeError('test error');
},
fallback: (error) => {
const el = document.createElement('span');
el.textContent = error.message;
return el;
},
});
expect(result.tagName).toBe('SPAN');
expect(result.textContent).toBe('test error');
});

test('fallback receives the error object', () => {
let capturedError: Error | undefined;
ErrorBoundary({
children: () => {
throw new RangeError('out of bounds');
},
fallback: (error) => {
capturedError = error;
return document.createElement('div');
},
});
expect(capturedError).toBeInstanceOf(RangeError);
expect(capturedError?.message).toBe('out of bounds');
});

test('fallback receives a retry function that re-renders children', () => {
let attempts = 0;
let retryFn: (() => void) | undefined;

const container = document.createElement('div');
const result = ErrorBoundary({
children: () => {
attempts++;
if (attempts < 2) {
throw new TypeError('not ready');
}
const el = document.createElement('p');
el.textContent = 'success';
return el;
},
fallback: (_error, retry) => {
retryFn = retry;
const el = document.createElement('span');
el.textContent = 'error';
return el;
},
});
container.appendChild(result);

// First render: children throws, fallback shown
expect(container.textContent).toBe('error');
expect(retryFn).toBeDefined();

// Call the actual retry function — it should replace the fallback in the DOM
retryFn?.();
expect(container.textContent).toBe('success');
});

test('catches errors from nested children', () => {
const result = ErrorBoundary({
children: () => {
// Simulate a deeply nested error
const inner = () => {
throw new TypeError('deep error');
};
inner();
return document.createElement('div');
},
fallback: (error) => {
const el = document.createElement('span');
el.textContent = error.message;
return el;
},
});
expect(result.textContent).toBe('deep error');
});

test('non-Error throws are wrapped in Error', () => {
let capturedError: Error | undefined;
ErrorBoundary({
children: () => {
throw 'string error'; // eslint-disable-line no-throw-literal
},
fallback: (error) => {
capturedError = error;
return document.createElement('div');
},
});
expect(capturedError).toBeInstanceOf(Error);
expect(capturedError?.message).toBe('string error');
});
});
Loading