Merge pull request #102 from misenhower/add-tests

Add tests
This commit is contained in:
Matt Isenhower 2026-02-16 15:53:24 -08:00 committed by GitHub
commit eba99cbb10
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2336 additions and 1298 deletions

23
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,23 @@
name: Tests
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [22.x]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test

View File

@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('fs/promises', () => ({
default: {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
},
}));
const fs = (await import('fs/promises')).default;
const { default: ValueCache } = await import('./ValueCache.mjs');
describe('ValueCache', () => {
let cache;
beforeEach(() => {
cache = new ValueCache('test-key');
vi.clearAllMocks();
});
describe('path', () => {
it('returns storage/cache/{key}.json', () => {
expect(cache.path).toBe('storage/cache/test-key.json');
});
it('uses the key from constructor', () => {
const other = new ValueCache('other');
expect(other.path).toBe('storage/cache/other.json');
});
});
describe('get', () => {
it('returns parsed JSON when valid and not expired', async () => {
const item = { data: 'hello', expires: Date.now() + 60000, cachedAt: new Date().toISOString() };
fs.readFile.mockResolvedValue(JSON.stringify(item));
const result = await cache.get();
expect(result).toEqual(item);
});
it('returns null when expired', async () => {
const item = { data: 'hello', expires: Date.now() - 60000, cachedAt: new Date().toISOString() };
fs.readFile.mockResolvedValue(JSON.stringify(item));
expect(await cache.get()).toBeNull();
});
it('returns item when no expiry is set', async () => {
const item = { data: 'hello', expires: null, cachedAt: new Date().toISOString() };
fs.readFile.mockResolvedValue(JSON.stringify(item));
const result = await cache.get();
expect(result).toEqual(item);
});
it('returns null when file is missing', async () => {
fs.readFile.mockRejectedValue(new Error('ENOENT'));
expect(await cache.get()).toBeNull();
});
});
describe('getData', () => {
it('returns .data field from cached item', async () => {
const item = { data: { foo: 'bar' }, expires: Date.now() + 60000, cachedAt: new Date().toISOString() };
fs.readFile.mockResolvedValue(JSON.stringify(item));
expect(await cache.getData()).toEqual({ foo: 'bar' });
});
it('returns null when no cache', async () => {
fs.readFile.mockRejectedValue(new Error('ENOENT'));
expect(await cache.getData()).toBeNull();
});
});
describe('getCachedAt', () => {
it('returns Date from cachedAt field', async () => {
const cachedAt = '2024-06-15T10:00:00.000Z';
const item = { data: 'x', cachedAt };
fs.readFile.mockResolvedValue(JSON.stringify(item));
const result = await cache.getCachedAt();
expect(result).toEqual(new Date(cachedAt));
});
it('returns null when no data', async () => {
fs.readFile.mockRejectedValue(new Error('ENOENT'));
expect(await cache.getCachedAt()).toBeNull();
});
});
describe('setData', () => {
it('writes JSON with data, expires, and cachedAt', async () => {
fs.mkdir.mockResolvedValue(undefined);
fs.writeFile.mockResolvedValue(undefined);
await cache.setData({ key: 'value' }, Date.now() + 60000);
expect(fs.mkdir).toHaveBeenCalledWith('storage/cache', { recursive: true });
expect(fs.writeFile).toHaveBeenCalledWith(
'storage/cache/test-key.json',
expect.stringContaining('"key": "value"'),
);
const written = JSON.parse(fs.writeFile.mock.calls[0][1]);
expect(written.data).toEqual({ key: 'value' });
expect(written.expires).toBeDefined();
expect(written.cachedAt).toBeDefined();
});
it('passes null expires when not specified', async () => {
fs.mkdir.mockResolvedValue(undefined);
fs.writeFile.mockResolvedValue(undefined);
await cache.setData('test');
const written = JSON.parse(fs.writeFile.mock.calls[0][1]);
expect(written.expires).toBeNull();
});
});
});

49
app/common/fs.test.mjs Normal file
View File

@ -0,0 +1,49 @@
import { describe, it, expect, vi } from 'vitest';
import { mkdirp, exists, olderThan } from './fs.mjs';
vi.mock('fs/promises', () => ({
default: {
mkdir: vi.fn(),
access: vi.fn(),
stat: vi.fn(),
},
}));
const fs = (await import('fs/promises')).default;
describe('mkdirp', () => {
it('calls fs.mkdir with recursive: true', async () => {
fs.mkdir.mockResolvedValue(undefined);
await mkdirp('/some/path');
expect(fs.mkdir).toHaveBeenCalledWith('/some/path', { recursive: true });
});
});
describe('exists', () => {
it('returns true when fs.access succeeds', async () => {
fs.access.mockResolvedValue(undefined);
expect(await exists('/some/file')).toBe(true);
});
it('returns false when fs.access throws', async () => {
fs.access.mockRejectedValue(new Error('ENOENT'));
expect(await exists('/missing/file')).toBe(false);
});
});
describe('olderThan', () => {
it('returns true when mtime is before cutoff', async () => {
fs.stat.mockResolvedValue({ mtime: new Date('2024-01-01') });
expect(await olderThan('/file', new Date('2024-06-01'))).toBe(true);
});
it('returns false when mtime is after cutoff', async () => {
fs.stat.mockResolvedValue({ mtime: new Date('2024-06-01') });
expect(await olderThan('/file', new Date('2024-01-01'))).toBe(false);
});
it('returns true when file is missing', async () => {
fs.stat.mockRejectedValue(new Error('ENOENT'));
expect(await olderThan('/missing', new Date())).toBe(true);
});
});

174
app/common/util.test.mjs Normal file
View File

@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import {
getTopOfCurrentHour,
getDateParts,
getGearIcon,
normalizeSplatnetResourcePath,
deriveId,
getFestId,
getFestTeamId,
getXRankSeasonId,
calculateCacheExpiry,
} from './util.mjs';
describe('getTopOfCurrentHour', () => {
it('zeros out minutes, seconds, and milliseconds', () => {
const date = new Date('2024-01-15T14:35:22.123Z');
const result = getTopOfCurrentHour(date);
expect(result.getUTCMinutes()).toBe(0);
expect(result.getUTCSeconds()).toBe(0);
expect(result.getUTCMilliseconds()).toBe(0);
expect(result.getUTCHours()).toBe(14);
});
it('returns the mutated date object', () => {
const date = new Date('2024-01-15T14:35:22.123Z');
const result = getTopOfCurrentHour(date);
expect(result).toBe(date);
});
it('uses current date when no argument', () => {
const result = getTopOfCurrentHour();
expect(result.getUTCMinutes()).toBe(0);
expect(result.getUTCSeconds()).toBe(0);
expect(result.getUTCMilliseconds()).toBe(0);
});
});
describe('getDateParts', () => {
it('returns correct parts with leading zeros', () => {
const date = new Date('2024-03-05T08:02:09.000Z');
const parts = getDateParts(date);
expect(parts).toEqual({
year: 2024,
month: '03',
day: '05',
hour: '08',
minute: '02',
second: '09',
});
});
it('handles double-digit values', () => {
const date = new Date('2024-12-25T23:59:45.000Z');
const parts = getDateParts(date);
expect(parts).toEqual({
year: 2024,
month: '12',
day: '25',
hour: '23',
minute: '59',
second: '45',
});
});
});
describe('getGearIcon', () => {
it('returns cap emoji for HeadGear', () => {
expect(getGearIcon({ gear: { __typename: 'HeadGear' } })).toBe('🧢');
});
it('returns shirt emoji for ClothingGear', () => {
expect(getGearIcon({ gear: { __typename: 'ClothingGear' } })).toBe('👕');
});
it('returns shoe emoji for ShoesGear', () => {
expect(getGearIcon({ gear: { __typename: 'ShoesGear' } })).toBe('👟');
});
it('returns null for unknown type', () => {
expect(getGearIcon({ gear: { __typename: 'WeaponGear' } })).toBeNull();
});
});
describe('normalizeSplatnetResourcePath', () => {
it('strips /resources/prod prefix and leading slash', () => {
const url = 'https://api.lp1.av5ja.srv.nintendo.net/resources/prod/v2/weapon_illust/12345.png';
expect(normalizeSplatnetResourcePath(url)).toBe('v2/weapon_illust/12345.png');
});
it('handles URLs without the prefix', () => {
const url = 'https://example.com/images/test.png';
expect(normalizeSplatnetResourcePath(url)).toBe('images/test.png');
});
it('strips query strings', () => {
const url = 'https://api.lp1.av5ja.srv.nintendo.net/resources/prod/v2/image.png?ver=123';
expect(normalizeSplatnetResourcePath(url)).toBe('v2/image.png');
});
});
describe('deriveId', () => {
it('produces consistent hash for the same URL', () => {
const node = { image: { url: 'https://example.com/resources/prod/v2/test.png' }, name: 'Test' };
const result1 = deriveId(node);
const result2 = deriveId(node);
expect(result1.__splatoon3ink_id).toBe(result2.__splatoon3ink_id);
});
it('produces different hashes for different URLs', () => {
const node1 = { image: { url: 'https://example.com/resources/prod/v2/a.png' } };
const node2 = { image: { url: 'https://example.com/resources/prod/v2/b.png' } };
expect(deriveId(node1).__splatoon3ink_id).not.toBe(deriveId(node2).__splatoon3ink_id);
});
it('preserves original node properties', () => {
const node = { image: { url: 'https://example.com/test.png' }, name: 'Gear', rarity: 2 };
const result = deriveId(node);
expect(result.name).toBe('Gear');
expect(result.rarity).toBe(2);
expect(result.image).toBe(node.image);
expect(result.__splatoon3ink_id).toBeDefined();
});
});
describe('getFestId', () => {
it('extracts ID from base64-encoded Fest string', () => {
const encoded = Buffer.from('Fest-US:12345').toString('base64');
expect(getFestId(encoded)).toBe('12345');
});
it('handles multi-region prefix', () => {
const encoded = Buffer.from('Fest-UJEA:99').toString('base64');
expect(getFestId(encoded)).toBe('99');
});
it('returns original value on non-match', () => {
expect(getFestId('not-base64-fest')).toBe('not-base64-fest');
});
});
describe('getFestTeamId', () => {
it('extracts FESTID:TEAMID from base64-encoded string', () => {
const encoded = Buffer.from('FestTeam-US:100:3').toString('base64');
expect(getFestTeamId(encoded)).toBe('100:3');
});
it('returns original value on non-match', () => {
expect(getFestTeamId('invalid')).toBe('invalid');
});
});
describe('getXRankSeasonId', () => {
it('extracts REGION-ID from base64-encoded string', () => {
const encoded = Buffer.from('XRankingSeason-US:5').toString('base64');
expect(getXRankSeasonId(encoded)).toBe('US-5');
});
it('returns original value on non-match', () => {
expect(getXRankSeasonId('invalid')).toBe('invalid');
});
});
describe('calculateCacheExpiry', () => {
it('returns timestamp = now + expiresIn - 5 minutes', () => {
const before = Date.now();
const expiresIn = 3600; // 1 hour in seconds
const result = calculateCacheExpiry(expiresIn);
const after = Date.now();
const fiveMinutes = 5 * 60 * 1000;
expect(result).toBeGreaterThanOrEqual(before + expiresIn * 1000 - fiveMinutes);
expect(result).toBeLessThanOrEqual(after + expiresIn * 1000 - fiveMinutes);
});
});

View File

@ -0,0 +1,43 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
// Mock dependencies that ImageProcessor imports but we don't need for pure method tests
vi.mock('p-queue', () => ({ default: class { add(fn) { return fn(); } onIdle() {} } }));
vi.mock('../common/prefixedConsole.mjs', () => ({ default: () => ({ info: vi.fn(), error: vi.fn() }) }));
vi.mock('../common/fs.mjs', () => ({ exists: vi.fn(), mkdirp: vi.fn() }));
vi.mock('fs/promises', () => ({ default: { writeFile: vi.fn() } }));
const { default: ImageProcessor } = await import('./ImageProcessor.mjs');
describe('ImageProcessor', () => {
let processor;
beforeEach(() => {
delete process.env.SITE_URL;
processor = new ImageProcessor();
});
describe('normalize', () => {
it('delegates to normalizeSplatnetResourcePath', () => {
const result = processor.normalize('https://api.lp1.av5ja.srv.nintendo.net/resources/prod/v2/weapon/image.png');
expect(result).toBe('v2/weapon/image.png');
});
});
describe('localPath', () => {
it('returns dist/assets/splatnet/{file}', () => {
expect(processor.localPath('v2/weapon/image.png')).toBe('dist/assets/splatnet/v2/weapon/image.png');
});
});
describe('publicUrl', () => {
it('returns /{outputDirectory}/{file} without SITE_URL', () => {
expect(processor.publicUrl('v2/weapon/image.png')).toBe('/assets/splatnet/v2/weapon/image.png');
});
it('prepends SITE_URL when set', () => {
process.env.SITE_URL = 'https://splatoon3.ink';
const proc = new ImageProcessor();
expect(proc.publicUrl('v2/weapon/image.png')).toBe('https://splatoon3.ink/assets/splatnet/v2/weapon/image.png');
});
});
});

View File

@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest';
import { LocalizationProcessor } from './LocalizationProcessor.mjs';
function makeProcessor(rulesets) {
return new LocalizationProcessor({ code: 'en-US' }, rulesets);
}
describe('LocalizationProcessor', () => {
describe('filename', () => {
it('returns the correct path for the locale', () => {
const processor = makeProcessor([]);
expect(processor.filename).toBe('dist/data/locale/en-US.json');
});
it('uses the locale code', () => {
const processor = new LocalizationProcessor({ code: 'ja-JP' }, []);
expect(processor.filename).toBe('dist/data/locale/ja-JP.json');
});
});
describe('rulesetIterations', () => {
it('yields one entry for a simple ruleset', () => {
const processor = makeProcessor([
{ key: 'stages', nodes: '$.stages.*', id: 'id', values: 'name' },
]);
const results = [...processor.rulesetIterations()];
expect(results).toEqual([
{ key: 'stages', node: '$.stages.*', id: 'id', value: 'name' },
]);
});
it('yields entries for multiple nodes (array)', () => {
const processor = makeProcessor([
{ key: 'weapons', nodes: ['$.main.*', '$.sub.*'], id: 'id', values: 'name' },
]);
const results = [...processor.rulesetIterations()];
expect(results).toEqual([
{ key: 'weapons', node: '$.main.*', id: 'id', value: 'name' },
{ key: 'weapons', node: '$.sub.*', id: 'id', value: 'name' },
]);
});
it('yields entries for multiple values (array)', () => {
const processor = makeProcessor([
{ key: 'stages', nodes: '$.stages.*', id: 'id', values: ['name', 'description'] },
]);
const results = [...processor.rulesetIterations()];
expect(results).toEqual([
{ key: 'stages', node: '$.stages.*', id: 'id', value: 'name' },
{ key: 'stages', node: '$.stages.*', id: 'id', value: 'description' },
]);
});
it('yields cartesian product for multiple nodes and values', () => {
const processor = makeProcessor([
{ key: 'items', nodes: ['$.a.*', '$.b.*'], id: 'id', values: ['name', 'desc'] },
]);
const results = [...processor.rulesetIterations()];
expect(results).toHaveLength(4);
expect(results).toEqual([
{ key: 'items', node: '$.a.*', id: 'id', value: 'name' },
{ key: 'items', node: '$.a.*', id: 'id', value: 'desc' },
{ key: 'items', node: '$.b.*', id: 'id', value: 'name' },
{ key: 'items', node: '$.b.*', id: 'id', value: 'desc' },
]);
});
it('yields entries across multiple rulesets', () => {
const processor = makeProcessor([
{ key: 'stages', nodes: '$.stages.*', id: 'id', values: 'name' },
{ key: 'weapons', nodes: '$.weapons.*', id: 'id', values: 'name' },
]);
const results = [...processor.rulesetIterations()];
expect(results).toHaveLength(2);
expect(results[0].key).toBe('stages');
expect(results[1].key).toBe('weapons');
});
});
describe('dataIterations', () => {
it('yields entries with correct id, value, and path', () => {
const processor = makeProcessor([
{ key: 'stages', nodes: '$.stages[*]', id: 'id', values: 'name' },
]);
const data = {
stages: [
{ id: 'stage-1', name: 'Scorch Gorge' },
{ id: 'stage-2', name: 'Eeltail Alley' },
],
};
const results = [...processor.dataIterations(data)];
expect(results).toHaveLength(2);
expect(results[0].id).toBe('stage-1');
expect(results[0].value).toBe('Scorch Gorge');
expect(results[0].path).toBe('stages.stage-1.name');
expect(results[1].id).toBe('stage-2');
expect(results[1].value).toBe('Eeltail Alley');
expect(results[1].path).toBe('stages.stage-2.name');
});
it('skips null nodes from jsonpath results', () => {
const processor = makeProcessor([
{ key: 'items', nodes: '$.items[*]', id: 'id', values: 'name' },
]);
const data = {
items: [null, { id: 'item-1', name: 'Test' }, null],
};
const results = [...processor.dataIterations(data)];
expect(results).toHaveLength(1);
expect(results[0].id).toBe('item-1');
});
it('handles nested id paths via lodash get', () => {
const processor = makeProcessor([
{ key: 'gear', nodes: '$.gear[*]', id: 'meta.id', values: 'meta.name' },
]);
const data = {
gear: [{ meta: { id: 'g-1', name: 'Splat Helmet' } }],
};
const results = [...processor.dataIterations(data)];
expect(results).toHaveLength(1);
expect(results[0].id).toBe('g-1');
expect(results[0].value).toBe('Splat Helmet');
expect(results[0].path).toBe('gear.g-1.meta.name');
});
it('returns empty for non-matching jsonpath', () => {
const processor = makeProcessor([
{ key: 'stages', nodes: '$.nonexistent[*]', id: 'id', values: 'name' },
]);
const results = [...processor.dataIterations({})];
expect(results).toHaveLength(0);
});
});
});

2454
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,8 @@
"preview": "vite preview --port 5050",
"lint": "eslint .",
"lint-fix": "npm run lint -- --fix",
"test": "vitest run",
"test:watch": "vitest",
"cron": "node app/index.mjs cron",
"start": "npm run sync:download && npm run splatnet:quick && npm run social && npm run cron",
"social": "node app/index.mjs social",
@ -65,6 +67,7 @@
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-eslint-parser": "^10.4.0"
}
}

View File

@ -1,7 +1,7 @@
import { useI18n } from 'vue-i18n';
import { useTimeStore } from '@/stores/time';
function getDurationParts(value) {
export function getDurationParts(value) {
let negative = (value < 0) ? '-' : '';
value = Math.abs(value);

63
src/common/time.test.js Normal file
View File

@ -0,0 +1,63 @@
import { describe, it, expect } from 'vitest';
import { getDurationParts } from './time.js';
describe('getDurationParts', () => {
it('returns all zeros for zero', () => {
expect(getDurationParts(0)).toEqual({
negative: '',
days: 0,
hours: 0,
minutes: 0,
seconds: 0,
});
});
it('computes 1d 1h 1m 1s for 90061', () => {
expect(getDurationParts(90061)).toEqual({
negative: '',
days: 1,
hours: 1,
minutes: 1,
seconds: 1,
});
});
it('sets negative flag for negative values', () => {
const result = getDurationParts(-90061);
expect(result.negative).toBe('-');
expect(result.days).toBe(1);
expect(result.hours).toBe(1);
expect(result.minutes).toBe(1);
expect(result.seconds).toBe(1);
});
it('computes exactly 1 day for 86400', () => {
expect(getDurationParts(86400)).toEqual({
negative: '',
days: 1,
hours: 0,
minutes: 0,
seconds: 0,
});
});
it('computes only seconds for 59', () => {
expect(getDurationParts(59)).toEqual({
negative: '',
days: 0,
hours: 0,
minutes: 0,
seconds: 59,
});
});
it('computes hours and minutes without days', () => {
expect(getDurationParts(3661)).toEqual({
negative: '',
days: 0,
hours: 1,
minutes: 1,
seconds: 1,
});
});
});

24
src/common/util.test.mjs Normal file
View File

@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { br2nl } from './util.mjs';
describe('br2nl', () => {
it('replaces <br> with newline by default', () => {
expect(br2nl('hello<br>world')).toBe('hello\nworld');
});
it('replaces <br/> and <br /> variants', () => {
expect(br2nl('a<br/>b<br />c')).toBe('a\nb\nc');
});
it('is case-insensitive', () => {
expect(br2nl('a<BR>b<Br>c')).toBe('a\nb\nc');
});
it('uses custom replacement string when provided', () => {
expect(br2nl('hello<br>world', ' ')).toBe('hello world');
});
it('returns string unchanged when no br tags present', () => {
expect(br2nl('hello world')).toBe('hello world');
});
});

81
src/stores/gear.test.mjs Normal file
View File

@ -0,0 +1,81 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { useTimeStore } from './time.mjs';
import { useGearDataStore } from './data.mjs';
import { useGearStore } from './gear.mjs';
function setGearData(store, gesotown) {
// The data store has a `d => d.data` transform, so wrap accordingly
store.setData({ data: { gesotown } });
}
describe('useGearStore', () => {
let time;
let gearData;
let gear;
beforeEach(() => {
setActivePinia(createPinia());
time = useTimeStore();
gearData = useGearDataStore();
});
describe('dailyDropBrand', () => {
it('returns brand when saleEndTime is in the future', () => {
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
setGearData(gearData, {
pickupBrand: { saleEndTime: '2024-06-16T00:00:00Z', brandGears: [] },
limitedGears: [],
});
gear = useGearStore();
expect(gear.dailyDropBrand).toBeDefined();
expect(gear.dailyDropBrand.saleEndTime).toBe('2024-06-16T00:00:00Z');
});
it('returns null when saleEndTime is in the past', () => {
time.setNow(Date.parse('2024-06-17T00:00:00Z'));
setGearData(gearData, {
pickupBrand: { saleEndTime: '2024-06-16T00:00:00Z', brandGears: [] },
limitedGears: [],
});
gear = useGearStore();
expect(gear.dailyDropBrand).toBeNull();
});
});
describe('dailyDropGear', () => {
it('filters to only current gear items', () => {
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
setGearData(gearData, {
pickupBrand: {
saleEndTime: '2024-06-16T00:00:00Z',
brandGears: [
{ id: 1, saleEndTime: '2024-06-16T00:00:00Z' },
{ id: 2, saleEndTime: '2024-06-14T00:00:00Z' }, // expired
{ id: 3, saleEndTime: '2024-06-16T00:00:00Z' },
],
},
limitedGears: [],
});
gear = useGearStore();
expect(gear.dailyDropGear).toHaveLength(2);
expect(gear.dailyDropGear.map(g => g.id)).toEqual([1, 3]);
});
});
describe('regularGear', () => {
it('filters limitedGears by current time', () => {
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
setGearData(gearData, {
pickupBrand: { saleEndTime: '2024-06-16T00:00:00Z', brandGears: [] },
limitedGears: [
{ id: 'a', saleEndTime: '2024-06-16T00:00:00Z' },
{ id: 'b', saleEndTime: '2024-06-14T00:00:00Z' }, // expired
],
});
gear = useGearStore();
expect(gear.regularGear).toHaveLength(1);
expect(gear.regularGear[0].id).toBe('a');
});
});
});

View File

@ -0,0 +1,199 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { useTimeStore } from './time.mjs';
import { useSchedulesDataStore } from './data.mjs';
import { useSalmonRunSchedulesStore } from './schedules.mjs';
function makeCoopNode({ startTime, endTime, isBigRun = false, weapons = [] }) {
return {
startTime,
endTime,
setting: { weapons, vsStages: [] },
...(isBigRun ? {} : {}),
};
}
function setSchedulesData(store, { regularNodes = [], bigRunNodes = [] } = {}) {
// The data store has a `d => d.data` transform, so wrap accordingly
store.setData({ data: {
regularSchedules: { nodes: [] },
bankaraSchedules: { nodes: [] },
xSchedules: { nodes: [] },
festSchedules: { nodes: [] },
eventSchedules: { nodes: [] },
coopGroupingSchedule: {
regularSchedules: { nodes: regularNodes },
bigRunSchedules: { nodes: bigRunNodes },
teamContestSchedules: { nodes: [] },
},
vsStages: { nodes: [] },
} });
}
describe('useSalmonRunSchedulesStore', () => {
let time;
let schedulesData;
let salmonRun;
beforeEach(() => {
setActivePinia(createPinia());
time = useTimeStore();
schedulesData = useSchedulesDataStore();
});
describe('merge', () => {
it('combines regular and bigRun schedules sorted by startTime', () => {
setSchedulesData(schedulesData, {
regularNodes: [
makeCoopNode({ startTime: '2024-06-15T10:00:00Z', endTime: '2024-06-15T20:00:00Z' }),
],
bigRunNodes: [
makeCoopNode({ startTime: '2024-06-15T08:00:00Z', endTime: '2024-06-15T18:00:00Z' }),
],
});
time.setNow(0);
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.schedules).toHaveLength(2);
// Should be sorted: bigRun first (08:00), then regular (10:00)
expect(salmonRun.schedules[0].startTime).toBe('2024-06-15T08:00:00Z');
expect(salmonRun.schedules[1].startTime).toBe('2024-06-15T10:00:00Z');
});
});
describe('isBigRun', () => {
it('is true for bigRun nodes', () => {
setSchedulesData(schedulesData, {
bigRunNodes: [
makeCoopNode({ startTime: '2024-06-15T08:00:00Z', endTime: '2024-06-15T18:00:00Z' }),
],
});
time.setNow(0);
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.schedules[0].isBigRun).toBe(true);
});
it('is false for regular nodes', () => {
setSchedulesData(schedulesData, {
regularNodes: [
makeCoopNode({ startTime: '2024-06-15T08:00:00Z', endTime: '2024-06-15T18:00:00Z' }),
],
});
time.setNow(0);
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.schedules[0].isBigRun).toBe(false);
});
});
describe('isMystery', () => {
it('is true when any weapon has name Random', () => {
setSchedulesData(schedulesData, {
regularNodes: [
makeCoopNode({
startTime: '2024-06-15T08:00:00Z',
endTime: '2024-06-15T18:00:00Z',
weapons: [
{ name: 'Splattershot', __splatoon3ink_id: 'abc' },
{ name: 'Random', __splatoon3ink_id: 'def' },
],
}),
],
});
time.setNow(0);
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.schedules[0].isMystery).toBe(true);
});
it('is false when no weapon has name Random', () => {
setSchedulesData(schedulesData, {
regularNodes: [
makeCoopNode({
startTime: '2024-06-15T08:00:00Z',
endTime: '2024-06-15T18:00:00Z',
weapons: [
{ name: 'Splattershot', __splatoon3ink_id: 'abc' },
],
}),
],
});
time.setNow(0);
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.schedules[0].isMystery).toBe(false);
});
});
describe('isGrizzcoMystery', () => {
it('is true for specific __splatoon3ink_id values', () => {
setSchedulesData(schedulesData, {
regularNodes: [
makeCoopNode({
startTime: '2024-06-15T08:00:00Z',
endTime: '2024-06-15T18:00:00Z',
weapons: [
{ name: 'Random', __splatoon3ink_id: '6e17fbe20efecca9' },
],
}),
],
});
time.setNow(0);
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.schedules[0].isGrizzcoMystery).toBe(true);
});
it('is false for other __splatoon3ink_id values', () => {
setSchedulesData(schedulesData, {
regularNodes: [
makeCoopNode({
startTime: '2024-06-15T08:00:00Z',
endTime: '2024-06-15T18:00:00Z',
weapons: [
{ name: 'Random', __splatoon3ink_id: 'other-id' },
],
}),
],
});
time.setNow(0);
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.schedules[0].isGrizzcoMystery).toBe(false);
});
});
describe('time filtering', () => {
it('currentSchedules filters by endTime', () => {
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
setSchedulesData(schedulesData, {
regularNodes: [
makeCoopNode({ startTime: '2024-06-15T08:00:00Z', endTime: '2024-06-15T10:00:00Z' }), // ended
makeCoopNode({ startTime: '2024-06-15T10:00:00Z', endTime: '2024-06-15T20:00:00Z' }), // current
],
});
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.currentSchedules).toHaveLength(1);
expect(salmonRun.currentSchedules[0].startTime).toBe('2024-06-15T10:00:00Z');
});
it('activeSchedule returns schedule where now is between start and end', () => {
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
setSchedulesData(schedulesData, {
regularNodes: [
makeCoopNode({ startTime: '2024-06-15T10:00:00Z', endTime: '2024-06-15T20:00:00Z' }),
],
});
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.activeSchedule).toBeDefined();
expect(salmonRun.activeSchedule.startTime).toBe('2024-06-15T10:00:00Z');
});
it('upcomingSchedules filters to future startTime', () => {
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
setSchedulesData(schedulesData, {
regularNodes: [
makeCoopNode({ startTime: '2024-06-15T10:00:00Z', endTime: '2024-06-15T20:00:00Z' }), // active, not upcoming
makeCoopNode({ startTime: '2024-06-16T10:00:00Z', endTime: '2024-06-16T20:00:00Z' }), // upcoming
],
});
salmonRun = useSalmonRunSchedulesStore();
expect(salmonRun.upcomingSchedules).toHaveLength(1);
expect(salmonRun.upcomingSchedules[0].startTime).toBe('2024-06-16T10:00:00Z');
});
});
});

View File

@ -0,0 +1,151 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { useTimeStore } from './time.mjs';
import { useFestivalsDataStore } from './data.mjs';
import { useUSSplatfestsStore, STATUS_ACTIVE, STATUS_UPCOMING, STATUS_PAST } from './splatfests.mjs';
function makeFestNode({ id = 'UJ-1', startTime, endTime, teams = [], results = false }) {
return {
__splatoon3ink_id: id,
startTime,
endTime,
teams: teams.length ? teams : [
{ result: results ? {} : null },
{ result: results ? {} : null },
{ result: results ? {} : null },
],
};
}
function setFestivalsData(store, nodes) {
store.setData({
US: { data: { festRecords: { nodes } } },
});
}
describe('splatfests store', () => {
let time;
let festivalsData;
let splatfests;
beforeEach(() => {
setActivePinia(createPinia());
time = useTimeStore();
festivalsData = useFestivalsDataStore();
});
describe('status detection', () => {
it('marks festival as active when between start and end', () => {
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
setFestivalsData(festivalsData, [
makeFestNode({ startTime: '2024-06-15T00:00:00Z', endTime: '2024-06-16T00:00:00Z' }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.festivals[0].status).toBe(STATUS_ACTIVE);
});
it('marks festival as upcoming when before start', () => {
time.setNow(Date.parse('2024-06-14T00:00:00Z'));
setFestivalsData(festivalsData, [
makeFestNode({ startTime: '2024-06-15T00:00:00Z', endTime: '2024-06-16T00:00:00Z' }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.festivals[0].status).toBe(STATUS_UPCOMING);
});
it('marks festival as past when after end', () => {
time.setNow(Date.parse('2024-06-17T00:00:00Z'));
setFestivalsData(festivalsData, [
makeFestNode({ startTime: '2024-06-15T00:00:00Z', endTime: '2024-06-16T00:00:00Z' }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.festivals[0].status).toBe(STATUS_PAST);
});
});
describe('region parsing', () => {
it('parses U as NA', () => {
time.setNow(0);
setFestivalsData(festivalsData, [
makeFestNode({ id: 'U-1', startTime: '2020-01-01T00:00:00Z', endTime: '2020-01-02T00:00:00Z' }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.festivals[0].regions).toEqual(['NA']);
});
it('parses multi-region UJEA correctly', () => {
time.setNow(0);
setFestivalsData(festivalsData, [
makeFestNode({ id: 'UJEA-1', startTime: '2020-01-01T00:00:00Z', endTime: '2020-01-02T00:00:00Z' }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.festivals[0].regions).toEqual(['NA', 'JP', 'EU', 'AP']);
});
it('parses J as JP', () => {
time.setNow(0);
setFestivalsData(festivalsData, [
makeFestNode({ id: 'J-1', startTime: '2020-01-01T00:00:00Z', endTime: '2020-01-02T00:00:00Z' }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.festivals[0].regions).toEqual(['JP']);
});
it('parses E as EU', () => {
time.setNow(0);
setFestivalsData(festivalsData, [
makeFestNode({ id: 'E-1', startTime: '2020-01-01T00:00:00Z', endTime: '2020-01-02T00:00:00Z' }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.festivals[0].regions).toEqual(['EU']);
});
it('parses A as AP', () => {
time.setNow(0);
setFestivalsData(festivalsData, [
makeFestNode({ id: 'A-1', startTime: '2020-01-01T00:00:00Z', endTime: '2020-01-02T00:00:00Z' }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.festivals[0].regions).toEqual(['AP']);
});
});
describe('recentFestival', () => {
it('returns past fest within 3 days', () => {
const endTime = new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(); // 1 day ago
const startTime = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString();
time.setNow(Date.now());
setFestivalsData(festivalsData, [
makeFestNode({ startTime, endTime }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.recentFestival).not.toBeNull();
});
it('returns null for fest ended more than 3 days ago', () => {
const endTime = new Date(Date.now() - 4 * 24 * 60 * 60 * 1000).toISOString();
const startTime = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString();
time.setNow(Date.now());
setFestivalsData(festivalsData, [
makeFestNode({ startTime, endTime }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.recentFestival).toBeUndefined();
});
});
describe('activeFestival / upcomingFestival', () => {
it('returns correct fest based on time', () => {
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
setFestivalsData(festivalsData, [
makeFestNode({ id: 'UJ-1', startTime: '2024-06-15T00:00:00Z', endTime: '2024-06-16T00:00:00Z' }),
makeFestNode({ id: 'UJ-2', startTime: '2024-07-15T00:00:00Z', endTime: '2024-07-16T00:00:00Z' }),
]);
splatfests = useUSSplatfestsStore();
expect(splatfests.activeFestival).toBeDefined();
expect(splatfests.activeFestival.__splatoon3ink_id).toBe('UJ-1');
expect(splatfests.upcomingFestival).toBeDefined();
expect(splatfests.upcomingFestival.__splatoon3ink_id).toBe('UJ-2');
});
});
});

88
src/stores/time.test.mjs Normal file
View File

@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { useTimeStore } from './time.mjs';
describe('useTimeStore', () => {
let time;
beforeEach(() => {
setActivePinia(createPinia());
time = useTimeStore();
});
describe('isCurrent', () => {
it('returns true when endTime is in the future', () => {
time.setNow(1000);
expect(time.isCurrent('2099-01-01T00:00:00Z')).toBe(true);
});
it('returns false when endTime is in the past', () => {
time.setNow(Date.parse('2099-01-01T00:00:00Z') + 1);
expect(time.isCurrent('2099-01-01T00:00:00Z')).toBe(false);
});
it('returns false when endTime is null', () => {
expect(time.isCurrent(null)).toBe(false);
});
});
describe('isActive', () => {
it('returns true when now is between start and end', () => {
time.setNow(Date.parse('2024-06-15T12:00:00Z'));
expect(time.isActive('2024-06-15T00:00:00Z', '2024-06-16T00:00:00Z')).toBe(true);
});
it('returns false when now is before start', () => {
time.setNow(Date.parse('2024-06-14T00:00:00Z'));
expect(time.isActive('2024-06-15T00:00:00Z', '2024-06-16T00:00:00Z')).toBe(false);
});
it('returns false when now is after end', () => {
time.setNow(Date.parse('2024-06-17T00:00:00Z'));
expect(time.isActive('2024-06-15T00:00:00Z', '2024-06-16T00:00:00Z')).toBe(false);
});
it('returns false when startTime is null', () => {
time.setNow(1000);
expect(time.isActive(null, '2099-01-01T00:00:00Z')).toBe(false);
});
it('returns false when endTime is null', () => {
time.setNow(1000);
expect(time.isActive('2000-01-01T00:00:00Z', null)).toBe(false);
});
});
describe('isUpcoming', () => {
it('returns true when startTime is in the future', () => {
time.setNow(1000);
expect(time.isUpcoming('2099-01-01T00:00:00Z')).toBe(true);
});
it('returns false when startTime is in the past', () => {
time.setNow(Date.parse('2099-01-01T00:00:00Z') + 1);
expect(time.isUpcoming('2099-01-01T00:00:00Z')).toBe(false);
});
it('returns false when startTime is null', () => {
expect(time.isUpcoming(null)).toBe(false);
});
});
describe('setNow', () => {
it('sets the now value directly', () => {
time.setNow(42000);
expect(time.now).toBe(42000);
});
});
describe('setOffset', () => {
it('adjusts now by offset amount', () => {
const before = time.now;
time.setOffset(60000);
expect(time.offset).toBe(60000);
// now should be approximately before + 60000 (within a second tolerance)
expect(time.now).toBeGreaterThanOrEqual(before + 59000);
});
});
});

13
vitest.config.mjs Normal file
View File

@ -0,0 +1,13 @@
import { fileURLToPath, URL } from 'url';
import { defineConfig } from 'vitest/config';
export default defineConfig({
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
include: ['app/**/*.test.mjs', 'src/**/*.test.{js,mjs}'],
},
});