test: add vitest unit tests across packages

Cover shared zod schemas, extract traversal/refs/downloads, and tts filename/error handling. Document the test setup in the README and docs.
This commit is contained in:
2026-08-08 11:13:14 +08:00
parent 1de559c321
commit 92f11db415
15 changed files with 1185 additions and 3 deletions
+1
View File
@@ -14,6 +14,7 @@
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "vitest run",
"lint": "echo \"no lint configured\""
},
"dependencies": {
+92
View File
@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { AssetRef } from '@tts/shared';
import { downloadAll, downloadAsset, guessMimeType } from './download.js';
const ref = (url: string): AssetRef => ({
kind: 'image',
url,
ownerGuid: 'g1',
});
function blobResponse(): Response {
return new Response(new Blob(['data']), { status: 200 });
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe('guessMimeType', () => {
it('maps common image extensions', () => {
expect(guessMimeType('https://example.com/a.png')).toBe('image/png');
expect(guessMimeType('https://example.com/a.jpg')).toBe('image/jpeg');
expect(guessMimeType('https://example.com/a.jpeg')).toBe('image/jpeg');
expect(guessMimeType('https://example.com/a.gif')).toBe('image/gif');
expect(guessMimeType('https://example.com/a.webp')).toBe('image/webp');
expect(guessMimeType('https://example.com/a.pdf')).toBe('application/pdf');
});
it('ignores query strings when reading the extension', () => {
expect(guessMimeType('https://example.com/a.png?v=2')).toBe('image/png');
});
it('falls back to octet-stream for unknown extensions', () => {
expect(guessMimeType('https://example.com/a.xyz')).toBe(
'application/octet-stream',
);
expect(guessMimeType('https://example.com/noext')).toBe(
'application/octet-stream',
);
});
});
describe('downloadAsset', () => {
it('returns a blob on success', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(blobResponse()));
const blob = await downloadAsset('https://example.com/a.png');
expect(blob).toBeInstanceOf(Blob);
});
it('throws on a non-OK response', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(null, { status: 404 })),
);
await expect(downloadAsset('https://example.com/a.png')).rejects.toThrow(
'Failed to download asset (404)',
);
});
});
describe('downloadAll', () => {
it('downloads every ref and reports progress', async () => {
vi.stubGlobal('fetch', vi.fn().mockImplementation(() => blobResponse()));
const refs = [ref('https://example.com/1.png'), ref('https://example.com/2.png')];
const progress: number[] = [];
const results = await downloadAll(refs, {
concurrency: 2,
onProgress: (p) => progress.push(p.done),
});
expect(results).toHaveLength(2);
expect(progress).toEqual([1, 2]);
});
it('respects the concurrency limit', async () => {
let inFlight = 0;
let maxInFlight = 0;
const fetchMock = vi.fn().mockImplementation(async () => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((r) => setTimeout(r, 5));
inFlight -= 1;
return blobResponse();
});
vi.stubGlobal('fetch', fetchMock);
const refs = Array.from({ length: 6 }, (_, i) =>
ref(`https://example.com/${i}.png`),
);
await downloadAll(refs, { concurrency: 2 });
expect(maxInFlight).toBeLessThanOrEqual(2);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import type { TTSMod, TTSObject } from '@tts/shared';
import { filterObjects, findObject, flattenObjects } from './objects.js';
function card(guid: string, name = 'Card'): TTSObject {
return { Name: name, GUID: guid, Description: '' };
}
function bag(guid: string, contained: TTSObject[]): TTSObject {
return { Name: 'Bag', GUID: guid, Description: '', ContainedObjects: contained };
}
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [
card('a', 'Deck'),
bag('b', [card('c', 'Card'), card('d', 'Token')]),
card('e', 'Deck'),
],
};
describe('flattenObjects', () => {
it('returns every object including those inside bags', () => {
const guids = flattenObjects(mod).map((o) => o.GUID);
expect(guids).toEqual(['a', 'c', 'd', 'e']);
});
it('sets Parent links before returning', () => {
const [a, c] = flattenObjects(mod);
expect(a!.Parent).toBeUndefined();
expect(c!.Parent!.GUID).toBe('b');
});
});
describe('filterObjects', () => {
it('filters by predicate', () => {
const decks = filterObjects(mod, (o) => o.Name === 'Deck');
expect(decks.map((o) => o.GUID)).toEqual(['a', 'e']);
});
});
describe('findObject', () => {
it('finds an object by GUID', () => {
expect(findObject(mod, 'd')?.Name).toBe('Token');
});
it('returns undefined for a missing GUID', () => {
expect(findObject(mod, 'zzz')).toBeUndefined();
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import type { TTSMod, TTSObject } from '@tts/shared';
import { collectRefs, extractRefs } from './refs.js';
function card(guid: string): TTSObject {
return { Name: 'Card', GUID: guid, Description: '' };
}
describe('extractRefs', () => {
it('extracts a PDF reference', () => {
const o: TTSObject = {
...card('g1'),
CustomPDF: { PDFUrl: 'https://example.com/rules.pdf' },
};
expect(extractRefs(o)).toEqual([
{ kind: 'pdf', url: 'https://example.com/rules.pdf', ownerGuid: 'g1' },
]);
});
it('extracts deck face and back references', () => {
const o: TTSObject = {
...card('g2'),
CustomDeck: {
1: {
FaceURL: 'https://example.com/face.png',
BackURL: 'https://example.com/back.png',
UniqueBack: true,
NumHeight: 1,
NumWidth: 1,
},
},
};
expect(extractRefs(o)).toEqual([
{ kind: 'deckFace', url: 'https://example.com/face.png', ownerGuid: 'g2' },
{ kind: 'deckBack', url: 'https://example.com/back.png', ownerGuid: 'g2' },
]);
});
it('extracts image and secondary image references', () => {
const o: TTSObject = {
...card('g3'),
CustomImage: {
ImageURL: 'https://example.com/a.png',
ImageSecondaryURL: 'https://example.com/b.png',
},
};
expect(extractRefs(o)).toEqual([
{ kind: 'image', url: 'https://example.com/a.png', ownerGuid: 'g3' },
{
kind: 'imageSecondary',
url: 'https://example.com/b.png',
ownerGuid: 'g3',
},
]);
});
it('returns an empty array for an object with no refs', () => {
expect(extractRefs(card('g4'))).toEqual([]);
});
});
describe('collectRefs', () => {
it('collects refs across the whole save', () => {
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [
{ ...card('g1'), CustomPDF: { PDFUrl: 'https://example.com/a.pdf' } },
{
...card('g2'),
CustomImage: {
ImageURL: 'https://example.com/a.png',
ImageSecondaryURL: 'https://example.com/b.png',
},
},
],
};
const refs = collectRefs(mod);
expect(refs).toHaveLength(3);
expect(refs.map((r) => r.kind).sort()).toEqual([
'image',
'imageSecondary',
'pdf',
]);
});
it('dedupes refs by URL', () => {
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [
{ ...card('g1'), CustomPDF: { PDFUrl: 'https://example.com/a.pdf' } },
{ ...card('g2'), CustomPDF: { PDFUrl: 'https://example.com/a.pdf' } },
],
};
const refs = collectRefs(mod);
expect(refs).toHaveLength(1);
expect(refs[0]!.ownerGuid).toBe('g1');
});
});
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest';
import type { TTSMod, TTSObject } from '@tts/shared';
import { markParent, traverseMod } from './traverse.js';
function card(guid: string, name = 'Card'): TTSObject {
return { Name: name, GUID: guid, Description: '' };
}
function bag(guid: string, contained: TTSObject[]): TTSObject {
return { Name: 'Bag', GUID: guid, Description: '', ContainedObjects: contained };
}
const mod: TTSMod = {
GameMode: 'Tabletop',
Date: '2024-01-01',
ObjectStates: [
card('a'),
bag('b', [card('c'), bag('d', [card('e')])]),
card('f'),
],
};
describe('markParent', () => {
it('sets Parent on direct children', () => {
const root = bag('b', [card('c')]);
markParent(root);
expect(root.ContainedObjects![0]!.Parent).toBe(root);
});
it('sets Parent recursively', () => {
const root = bag('b', [bag('d', [card('e')])]);
markParent(root);
const d = root.ContainedObjects![0]!;
expect(d.Parent).toBe(root);
expect(d.ContainedObjects![0]!.Parent).toBe(d);
});
it('leaves leaf objects untouched', () => {
const leaf = card('a');
markParent(leaf);
expect(leaf.Parent).toBeUndefined();
});
});
describe('traverseMod', () => {
it('yields every non-bag object in order', () => {
const guids = [...traverseMod(mod)].map((o) => o.GUID);
expect(guids).toEqual(['a', 'c', 'e', 'f']);
});
it('descends into nested bags', () => {
const guids = [...traverseMod(mod)].map((o) => o.GUID);
expect(guids).toContain('e');
});
it('accepts a single object', () => {
const guids = [...traverseMod(bag('b', [card('c')]))].map((o) => o.GUID);
expect(guids).toEqual(['c']);
});
});