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']);
});
});
+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": {
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, it } from 'vitest';
import {
assetRefSchema,
extractedObjectSchema,
itemIdSchema,
searchQuerySchema,
searchResultSchema,
workshopItemSchema,
} from './schemas.js';
describe('itemIdSchema', () => {
it('accepts a run of digits', () => {
expect(itemIdSchema.parse('123456')).toBe('123456');
});
it('rejects empty strings', () => {
expect(itemIdSchema.safeParse('').success).toBe(false);
});
it('rejects non-numeric input', () => {
expect(itemIdSchema.safeParse('abc').success).toBe(false);
expect(itemIdSchema.safeParse('12a34').success).toBe(false);
});
});
describe('searchQuerySchema', () => {
it('coerces page to a number and defaults it', () => {
expect(searchQuerySchema.parse({ q: 'cards' })).toEqual({
q: 'cards',
page: 1,
});
expect(searchQuerySchema.parse({ q: 'cards', page: '3' })).toEqual({
q: 'cards',
page: 3,
});
});
it('rejects a missing or blank query', () => {
expect(searchQuerySchema.safeParse({}).success).toBe(false);
expect(searchQuerySchema.safeParse({ q: ' ' }).success).toBe(false);
});
it('rejects a non-positive page', () => {
expect(searchQuerySchema.safeParse({ q: 'cards', page: 0 }).success).toBe(
false,
);
});
});
describe('assetRefSchema', () => {
it('accepts a valid ref', () => {
const ref = {
kind: 'pdf',
url: 'https://example.com/doc.pdf',
ownerGuid: 'abc',
};
expect(assetRefSchema.parse(ref)).toEqual(ref);
});
it('rejects an invalid URL', () => {
expect(
assetRefSchema.safeParse({
kind: 'pdf',
url: 'not-a-url',
ownerGuid: 'abc',
}).success,
).toBe(false);
});
it('rejects an unknown kind', () => {
expect(
assetRefSchema.safeParse({
kind: 'video',
url: 'https://example.com/a.mp4',
ownerGuid: 'abc',
}).success,
).toBe(false);
});
});
describe('extractedObjectSchema', () => {
it('accepts a minimal object', () => {
const obj = {
guid: 'g1',
name: 'Card',
type: 'Card',
childrenGuids: [],
refs: [],
};
expect(extractedObjectSchema.parse(obj)).toEqual(obj);
});
it('accepts optional parentGuid', () => {
const obj = {
guid: 'g1',
name: 'Card',
type: 'Card',
parentGuid: 'g0',
childrenGuids: ['g2'],
refs: [],
};
expect(extractedObjectSchema.parse(obj).parentGuid).toBe('g0');
});
});
describe('workshopItemSchema', () => {
it('accepts a full item', () => {
const item = {
id: '1',
title: 'Deck',
author: 'someone',
previewImageUrl: 'https://example.com/p.png',
tags: ['card'],
timeCreated: 123,
};
expect(workshopItemSchema.parse(item)).toEqual(item);
});
it('accepts a minimal item', () => {
const item = {
id: '1',
title: 'Deck',
author: 'someone',
previewImageUrl: 'https://example.com/p.png',
};
expect(workshopItemSchema.parse(item)).toEqual(item);
});
});
describe('searchResultSchema', () => {
it('accepts an empty result set', () => {
const result = { items: [], page: 1, hasMore: false };
expect(searchResultSchema.parse(result)).toEqual(result);
});
});
+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": {
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getFileName } from './index.js';
import { ItemNotFoundError, NoFileError, SteamApiError, TtsError } from './errors.js';
afterEach(() => {
vi.unstubAllGlobals();
});
describe('getFileName', () => {
it('parses a quoted content-disposition filename', () => {
expect(
getFileName('https://example.com/save', 'attachment; filename="mod.json"'),
).toBe('mod.json');
});
it('parses an unquoted filename', () => {
expect(
getFileName('https://example.com/save', 'attachment; filename=mod.json'),
).toBe('mod.json');
});
it('falls back to the URL path when there is no disposition', () => {
expect(getFileName('https://example.com/files/mod.json', null)).toBe(
'mod.json',
);
});
it('falls back to a default when the URL has no path', () => {
expect(getFileName('https://example.com', null)).toBe('save.json');
});
});
describe('error classes', () => {
it('ItemNotFoundError carries a 404 status', () => {
const err = new ItemNotFoundError('123');
expect(err).toBeInstanceOf(TtsError);
expect(err.status).toBe(404);
expect(err.message).toContain('123');
});
it('NoFileError carries a 404 status', () => {
const err = new NoFileError('123');
expect(err.status).toBe(404);
expect(err.message).toContain('123');
});
it('SteamApiError defaults to a 502 status', () => {
const err = new SteamApiError('boom');
expect(err.status).toBe(502);
});
it('TtsError defaults to a 500 status', () => {
const err = new TtsError('boom');
expect(err.status).toBe(500);
});
});