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