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
114 changes: 114 additions & 0 deletions apps/web/__tests__/lib/simulation/gallery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import {
filterGalleryWorksheets,
isSimulationCellData,
simKindsOf,
} from '@/lib/simulation/gallery';

interface Worksheet {
id: string;
content: unknown;
}

describe('isSimulationCellData', () => {
it('accepts a simulation cell shape', () => {
expect(isSimulationCellData({ kind: 'simulation', sim: 'lorenz' })).toBe(true);
});

it('rejects non-simulation cell kinds', () => {
expect(isSimulationCellData({ kind: 'math', input: '2+2' })).toBe(false);
expect(isSimulationCellData({ kind: 'text', content: 'hi' })).toBe(false);
});

it('rejects malformed / non-object values', () => {
expect(isSimulationCellData(null)).toBe(false);
expect(isSimulationCellData(undefined)).toBe(false);
expect(isSimulationCellData('simulation')).toBe(false);
expect(isSimulationCellData(42)).toBe(false);
expect(isSimulationCellData({ kind: 'simulation' })).toBe(false); // missing sim
expect(isSimulationCellData({ sim: 'lorenz' })).toBe(false); // missing kind
expect(isSimulationCellData({ kind: 'simulation', sim: 5 })).toBe(false); // sim not a string
});
});

describe('simKindsOf', () => {
it('collects unique simulation kinds from a cell array', () => {
const content = [
{ kind: 'math', input: 'x=1' },
{ kind: 'simulation', sim: 'pde-heat' },
{ kind: 'simulation', sim: 'lorenz' },
{ kind: 'simulation', sim: 'pde-heat' }, // duplicate
];
expect(simKindsOf(content).sort()).toEqual(['lorenz', 'pde-heat']);
});

it('returns an empty array for a worksheet with no simulation cells', () => {
const content = [
{ kind: 'math', input: 'x=1' },
{ kind: 'text', content: 'notes' },
];
expect(simKindsOf(content)).toEqual([]);
});

it('returns an empty array for malformed content (not an array)', () => {
expect(simKindsOf(null)).toEqual([]);
expect(simKindsOf(undefined)).toEqual([]);
expect(simKindsOf('not-an-array')).toEqual([]);
expect(simKindsOf({ kind: 'simulation', sim: 'lorenz' })).toEqual([]);
});

it('skips malformed cells within an otherwise valid array', () => {
const content = [null, 42, { kind: 'simulation', sim: 'lorenz' }, { notACell: true }];
expect(simKindsOf(content)).toEqual(['lorenz']);
});
});

describe('filterGalleryWorksheets', () => {
it('keeps only worksheets containing at least one simulation cell', () => {
const worksheets: Worksheet[] = [
{ id: 'text-only', content: [{ kind: 'text', content: 'hi' }] },
{ id: 'has-sim', content: [{ kind: 'simulation', sim: 'lorenz' }] },
{ id: 'empty', content: [] },
{
id: 'multi-sim',
content: [
{ kind: 'simulation', sim: 'pde-wave' },
{ kind: 'simulation', sim: 'direction-field' },
],
},
];

const result = filterGalleryWorksheets(worksheets);

expect(result.map((item) => item.worksheet.id)).toEqual(['has-sim', 'multi-sim']);
});

it('attaches the distinct simKinds alongside each surviving worksheet', () => {
const worksheets: Worksheet[] = [
{
id: 'multi-sim',
content: [
{ kind: 'simulation', sim: 'pde-wave' },
{ kind: 'simulation', sim: 'direction-field' },
],
},
];

const result = filterGalleryWorksheets(worksheets);

expect(result).toHaveLength(1);
expect(result[0]?.simKinds.sort()).toEqual(['direction-field', 'pde-wave']);
});

it('returns an empty array when nothing qualifies', () => {
const worksheets: Worksheet[] = [
{ id: 'a', content: [{ kind: 'text', content: 'hi' }] },
{ id: 'b', content: [] },
];
expect(filterGalleryWorksheets(worksheets)).toEqual([]);
});

it('returns an empty array for an empty worksheet list', () => {
expect(filterGalleryWorksheets([])).toEqual([]);
});
});
215 changes: 215 additions & 0 deletions apps/web/__tests__/lib/simulation/registry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
import { describe, expect, it } from 'vitest';
import {
DEFAULT_PARAMS,
DEFAULT_PRESET,
DIRECTION_FIELD_PRESETS,
getSimParams,
isSimulationKind,
SIM_REGISTRY,
SIMULATION_KINDS,
sanitizeSimParams,
} from '@/lib/simulation/registry';
import en from '@/messages/en.json';

/** Resolve a dotted key path against the (typed) en.json import at runtime. */
function hasStringAt(obj: unknown, path: readonly string[]): boolean {
let cur: unknown = obj;
for (const key of path) {
if (typeof cur !== 'object' || cur === null || !(key in cur)) return false;
cur = (cur as Record<string, unknown>)[key];
}
return typeof cur === 'string';
}

/**
* Extract the string param keys a direction-field f/g closure reads via
* `p['paramN']` — used to assert presets never read an undeclared param key.
*/
function referencedParamKeys(fn: (...args: never[]) => number): string[] {
const matches = fn.toString().matchAll(/p\[['"](\w+)['"]\]/g);
return [...matches].map((m) => m[1] as string);
}

describe('SIM_REGISTRY', () => {
it('declares exactly the kinds in SIMULATION_KINDS', () => {
expect(Object.keys(SIM_REGISTRY).sort()).toEqual([...SIMULATION_KINDS].sort());
});

describe.each(SIMULATION_KINDS)('%s', (kind) => {
it('has a label key that resolves in en.json under worksheet.simulation', () => {
const entry = SIM_REGISTRY[kind];
expect(hasStringAt(en, ['worksheet', 'simulation', entry.labelKey])).toBe(true);
});

it('declares at least one parameter spec', () => {
expect(SIM_REGISTRY[kind].params.length).toBeGreaterThan(0);
});

it('every param spec satisfies min <= default <= max and min < max', () => {
for (const spec of SIM_REGISTRY[kind].params) {
expect(spec.min).toBeLessThan(spec.max);
expect(spec.min).toBeLessThanOrEqual(spec.default);
expect(spec.default).toBeLessThanOrEqual(spec.max);
}
});

it('every param spec has a label key that resolves under worksheet.simulation.params', () => {
for (const spec of SIM_REGISTRY[kind].params) {
expect(hasStringAt(en, ['worksheet', 'simulation', 'params', spec.labelKey])).toBe(true);
}
});

it('declares at least one preset, each with a resolvable label key', () => {
const presets = SIM_REGISTRY[kind].presets;
expect(presets.length).toBeGreaterThan(0);
for (const preset of presets) {
expect(hasStringAt(en, ['worksheet', 'simulation', 'presets', preset])).toBe(true);
}
});

it('DEFAULT_PARAMS produces exactly the declared keys, each within [min, max]', () => {
const preset = DEFAULT_PRESET(kind);
const specs = getSimParams(kind, preset);
const params = DEFAULT_PARAMS(kind, preset);

expect(Object.keys(params).sort()).toEqual(specs.map((s) => s.key).sort());
for (const spec of specs) {
const value = params[spec.key];
expect(value).toBeGreaterThanOrEqual(spec.min);
expect(value).toBeLessThanOrEqual(spec.max);
}
});
});

describe('direction-field presets', () => {
it('every preset name declared on the kind maps to a DIRECTION_FIELD_PRESETS entry', () => {
for (const preset of SIM_REGISTRY['direction-field'].presets) {
expect(DIRECTION_FIELD_PRESETS[preset]).toBeDefined();
}
});

it('every preset param spec satisfies min <= default <= max', () => {
for (const preset of Object.values(DIRECTION_FIELD_PRESETS)) {
expect(preset.params.length).toBeGreaterThan(0);
for (const spec of preset.params) {
expect(spec.min).toBeLessThan(spec.max);
expect(spec.min).toBeLessThanOrEqual(spec.default);
expect(spec.default).toBeLessThanOrEqual(spec.max);
}
}
});

it('every preset declares a non-degenerate domain', () => {
for (const preset of Object.values(DIRECTION_FIELD_PRESETS)) {
expect(preset.xMin).toBeLessThan(preset.xMax);
expect(preset.yMin).toBeLessThan(preset.yMax);
}
});

it('f/g only ever read param keys declared on that preset', () => {
for (const [name, preset] of Object.entries(DIRECTION_FIELD_PRESETS)) {
const declared = new Set(preset.params.map((s) => s.key));
const used = new Set([...referencedParamKeys(preset.f), ...referencedParamKeys(preset.g)]);
for (const key of used) {
expect(declared.has(key), `${name} references undeclared param "${key}"`).toBe(true);
}
}
});

it('f/g return finite numbers for default params at the domain midpoint', () => {
for (const preset of Object.values(DIRECTION_FIELD_PRESETS)) {
const params: Record<string, number> = {};
for (const spec of preset.params) params[spec.key] = spec.default;
const midX = (preset.xMin + preset.xMax) / 2;
const midY = (preset.yMin + preset.yMax) / 2;
expect(Number.isFinite(preset.f(midX, midY, params))).toBe(true);
expect(Number.isFinite(preset.g(midX, midY, params))).toBe(true);
}
});

it('getSimParams returns the active preset params, not the registry default entry', () => {
const pendulumParams = getSimParams('direction-field', 'pendulum');
expect(pendulumParams).toBe(DIRECTION_FIELD_PRESETS['pendulum']?.params);
expect(pendulumParams).not.toBe(SIM_REGISTRY['direction-field'].params);
});

it('falls back to the registry default params for an unrecognized preset name', () => {
const params = getSimParams('direction-field', 'not-a-real-preset');
expect(params).toBe(SIM_REGISTRY['direction-field'].params);
});
});

describe('isSimulationKind', () => {
it('accepts every declared kind', () => {
for (const kind of SIMULATION_KINDS) {
expect(isSimulationKind(kind)).toBe(true);
}
});

it('rejects strings that are not declared kinds', () => {
expect(isSimulationKind('not-a-kind')).toBe(false);
expect(isSimulationKind('')).toBe(false);
});
});

describe('DEFAULT_PRESET', () => {
it('returns van-der-pol for direction-field', () => {
expect(DEFAULT_PRESET('direction-field')).toBe('van-der-pol');
});

it('returns the first registry preset for non-direction-field kinds', () => {
expect(DEFAULT_PRESET('lorenz')).toBe(SIM_REGISTRY['lorenz'].presets[0]);
expect(DEFAULT_PRESET('pde-heat')).toBe(SIM_REGISTRY['pde-heat'].presets[0]);
});
});

describe('sanitizeSimParams', () => {
// Worksheet content is untrusted: forked/public gallery worksheets are
// author-controlled JSON, so stored params must be clamped before any
// compute (a crafted lorenz `steps` would otherwise drive an unbounded
// CPU trajectory loop — the sliders only clamp locally-written values).
it('clamps out-of-range values to the spec bounds', () => {
const safe = sanitizeSimParams('lorenz', 'classic', {
sigma: -100,
rho: 1e9,
beta: 2,
steps: 1e9,
});
expect(safe['sigma']).toBe(0);
expect(safe['rho']).toBe(60);
expect(safe['beta']).toBe(2);
expect(safe['steps']).toBe(5000);
});

it('replaces non-finite and non-numeric values with the spec default', () => {
const safe = sanitizeSimParams('lorenz', 'classic', {
sigma: Number.NaN,
rho: Number.POSITIVE_INFINITY,
steps: 'evil' as unknown as number,
});
expect(safe['sigma']).toBe(10);
expect(safe['rho']).toBe(28);
expect(safe['beta']).toBe(8 / 3);
expect(safe['steps']).toBe(2000);
});

it('drops unknown keys and fills every declared spec key', () => {
const safe = sanitizeSimParams('pde-heat', 'center', { __proto__evil: 1 });
expect(Object.keys(safe).sort()).toEqual(
getSimParams('pde-heat', 'center')
.map((s) => s.key)
.sort(),
);
});

it('uses the active direction-field preset spec, not the registry default', () => {
const specs = getSimParams('direction-field', 'van-der-pol');
const firstKey = specs[0]?.key;
if (!firstKey) throw new Error('van-der-pol preset declares no params');
const safe = sanitizeSimParams('direction-field', 'van-der-pol', {
[firstKey]: Number.MAX_VALUE,
});
expect(safe[firstKey]).toBe(specs[0]?.max);
});
});
});
16 changes: 16 additions & 0 deletions apps/web/app/[locale]/gpu-lab/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default function Loading() {
return (
<div className="container mx-auto max-w-6xl animate-pulse px-4 py-10">
<div className="mb-2 flex items-center gap-3">
<div className="size-10 rounded-xl bg-muted" />
<div className="h-8 w-48 rounded bg-muted" />
</div>
<div className="mb-8 h-4 w-2/3 max-w-lg rounded bg-muted" />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-40 rounded-xl border border-border/40 bg-muted/40" />
))}
</div>
</div>
);
}
Loading