mirror of
https://github.com/misenhower/splatoon3.ink.git
synced 2026-03-21 17:54:13 -05:00
commit
eba99cbb10
23
.github/workflows/test.yml
vendored
Normal file
23
.github/workflows/test.yml
vendored
Normal 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
|
||||
118
app/common/ValueCache.test.mjs
Normal file
118
app/common/ValueCache.test.mjs
Normal 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
49
app/common/fs.test.mjs
Normal 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
174
app/common/util.test.mjs
Normal 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);
|
||||
});
|
||||
});
|
||||
43
app/data/ImageProcessor.test.mjs
Normal file
43
app/data/ImageProcessor.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
149
app/data/LocalizationProcessor.test.mjs
Normal file
149
app/data/LocalizationProcessor.test.mjs
Normal 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
2454
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
63
src/common/time.test.js
Normal 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
24
src/common/util.test.mjs
Normal 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
81
src/stores/gear.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
199
src/stores/schedules.test.mjs
Normal file
199
src/stores/schedules.test.mjs
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
151
src/stores/splatfests.test.mjs
Normal file
151
src/stores/splatfests.test.mjs
Normal 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
88
src/stores/time.test.mjs
Normal 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
13
vitest.config.mjs
Normal 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}'],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user