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:
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user