Files

231 lines
7.4 KiB
TypeScript

import { test, expect, afterAll } from 'vitest';
import { writeFileSync, rmSync, existsSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { transform } from '../plugin/compile/transformer.ts';
const HERE = dirname(fileURLToPath(import.meta.url));
// Write gen files directly in the test dir so relative imports `../src/...`
// resolve to serializer/src/index.ts.
const GEN_DIR = HERE;
let counter = 0;
async function transformAndImport(source: string): Promise<Record<string, unknown>> {
const id = ++counter;
const file = join(GEN_DIR, `__gen_${id}.ts`);
const result = transform(source, file, {
importPath: '../plugin/index.ts',
packageAliases: ['../plugin/index.ts'],
});
writeFileSync(file, result.code, 'utf8');
// Use file URL + @vite-ignore so vite passes through to native dynamic import.
const url = `${pathToFileURL(file).href}?t=${Date.now()}`;
const mod = await import(/* @vite-ignore */ url);
return mod as Record<string, unknown>;
}
test('transformer: flat type round-trip', async () => {
const src = `
import { type, u53, f64, str } from '../plugin/index.ts';
export const Ticker = type('TxTicker', {
symbol: str,
last: f64,
volume: f64,
});
`;
const mod = await transformAndImport(src);
const Ticker = mod.Ticker as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
const v = { symbol: 'BTC-USD', last: 100.5, volume: 1234.5 };
expect(Ticker.decode(Ticker.encode(v))).toEqual(v);
});
test('transformer: nested object via local reference', async () => {
const src = `
import { type, u53, f64 } from '../plugin/index.ts';
export const Price = type('TxPrice', { value: f64, scale: u53 });
export const Order = type('TxOrder', { id: u53, price: Price, qty: f64 });
`;
const mod = await transformAndImport(src);
const Order = mod.Order as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
const v = { id: 42, price: { value: 100.5, scale: 2 }, qty: 0.5 };
expect(Order.decode(Order.encode(v))).toEqual(v);
});
test('transformer: combinators (list, opt, enumOf, flags, tuple)', async () => {
const src = `
import { type, u53, f64, str, list, opt, enumOf, flags, tuple } from '../plugin/index.ts';
export const Combo = type('TxCombo', {
tags: list(str),
maybe: opt(f64),
side: enumOf(['buy', 'sell'] as const),
f: flags(['ioc', 'post_only'] as const),
point: tuple(f64, f64),
});
`;
const mod = await transformAndImport(src);
const Combo = mod.Combo as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
const v = {
tags: ['a', 'b'],
maybe: 3.14,
side: 'buy',
f: { ioc: true, post_only: false },
point: [1, 2],
};
expect(Combo.decode(Combo.encode(v))).toEqual(v);
});
test('transformer: anonymous (no name) — uses const name as schema name', async () => {
const src = `
import { type, u53, f64 } from '../plugin/index.ts';
export const TxAnon = type({ x: u53, y: f64 });
`;
const mod = await transformAndImport(src);
const T = mod.TxAnon as {
encode: (v: unknown) => Uint8Array;
decode: (b: Uint8Array) => unknown;
id: number;
name: string;
};
expect(T.name).toBe('TxAnon');
const v = { x: 1, y: 2.5 };
expect(T.decode(T.encode(v))).toEqual(v);
});
test('transformer: array of nested objects (the OrderBook hot path)', async () => {
const src = `
import { type, u53, f64, str, list } from '../plugin/index.ts';
export const Level = type('TxLevel', { p: f64, q: f64 });
export const Book = type('TxBook', {
symbol: str,
ts: u53,
bids: list(Level),
asks: list(Level),
});
`;
const mod = await transformAndImport(src);
const Book = mod.Book as { encode: (v: unknown) => Uint8Array; decode: (b: Uint8Array) => unknown };
const v = {
symbol: 'BTC-USD',
ts: 1700000000000,
bids: Array.from({ length: 100 }, (_, i) => ({ p: 100 - i * 0.1, q: 0.5 + i * 0.01 })),
asks: Array.from({ length: 100 }, (_, i) => ({ p: 100 + i * 0.1, q: 0.5 + i * 0.01 })),
};
const decoded = Book.decode(Book.encode(v)) as typeof v;
expect(decoded.symbol).toBe(v.symbol);
expect(decoded.ts).toBe(v.ts);
expect(decoded.bids.length).toBe(100);
expect(decoded.bids[0]).toEqual(v.bids[0]);
expect(decoded.asks[99]).toEqual(v.asks[99]);
});
test('transformer: file without type() imports — unchanged', () => {
const src = `
import { foo } from 'somewhere';
const x = foo();
`;
const result = transform(src, 'test.ts', { importPath: '../plugin/index.ts' });
expect(result.transformedCount).toBe(0);
expect(result.code).toBe(src);
});
test('transformer: file with type import but no calls — adds nothing', () => {
const src = `
import { type, u53 } from '../plugin/index.ts';
// no type() call here
const x = 1;
`;
const result = transform(src, 'test.ts', {
importPath: '../plugin/index.ts',
packageAliases: ['../plugin/index.ts'],
});
expect(result.transformedCount).toBe(0);
});
test('transformer: replaces call with IIFE (smoke check on output)', () => {
const src = `
import { type, u53, f64 } from '../plugin/index.ts';
export const T = type('TxSmoke', { x: u53, y: f64 });
`;
const result = transform(src, 'test.ts', {
importPath: '../plugin/index.ts',
packageAliases: ['../plugin/index.ts'],
});
expect(result.transformedCount).toBe(1);
expect(result.code).toContain('function encode_TxSmoke');
expect(result.code).toContain('function decode_TxSmoke');
expect(result.code).not.toContain("type('TxSmoke'");
});
test('transformer: class with [Serializable] — decoder returns class instances', async () => {
const src = `
import { type, Serializable, registerClass, f64, enumOf } from '../plugin/index.ts';
export class TxPos {
side;
qty;
entryPrice;
static [Serializable] = type('TxPos', {
side: enumOf(['long', 'short']),
qty: f64,
entryPrice: f64,
});
get notional() { return this.qty * this.entryPrice; }
}
export const TxPosCodec = registerClass(TxPos);
`;
const mod = await transformAndImport(src);
const TxPos = mod.TxPos as new () => {
side: 'long' | 'short';
qty: number;
entryPrice: number;
notional: number;
};
const codec = mod.TxPosCodec as {
encode: (v: unknown) => Uint8Array;
decode: (b: Uint8Array) => { side: 'long' | 'short'; qty: number; entryPrice: number };
};
const v = { side: 'long' as const, qty: 2, entryPrice: 100 };
const bytes = codec.encode(v);
const back = codec.decode(bytes);
// Decoded value must be an actual TxPos instance — methods/getters work.
expect(back instanceof TxPos).toBe(true);
expect((back as InstanceType<typeof TxPos>).notional).toBe(200);
expect(back.side).toBe('long');
expect(back.qty).toBe(2);
});
test('transformer: registerClass(X) is rewritten to X[Serializable]', () => {
const src = `
import { type, Serializable, registerClass, f64 } from '../plugin/index.ts';
export class TxBox {
static [Serializable] = type('TxBox', { v: f64 });
}
export const TxBoxCodec = registerClass(TxBox);
`;
const result = transform(src, 'test.ts', {
importPath: '../plugin/index.ts',
packageAliases: ['../plugin/index.ts'],
});
expect(result.code).toContain('TxBox[Serializable]');
expect(result.code).not.toContain('registerClass(TxBox)');
});
afterAll(() => {
for (let i = 1; i <= counter; i++) {
const file = join(GEN_DIR, `__gen_${i}.ts`);
if (existsSync(file)) rmSync(file, { force: true });
}
});