feat: bootstrap walking skeleton
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { createGameState, startAction } from '../../engine/game';
|
||||
import { createSave, serializeSave } from '../../engine/save';
|
||||
import { createMemoryBackend, loadGame, saveGame } from '../persistence';
|
||||
|
||||
function testContent() {
|
||||
return buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 0 }],
|
||||
actions: [
|
||||
{ id: 'forage', name: 'Forage', durationMs: 3000, yields: { resourceId: 'gold', amount: 1 } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('loadGame()', () => {
|
||||
it('returns a fresh game when no save exists', async () => {
|
||||
const content = testContent();
|
||||
const result = await loadGame(content, createMemoryBackend(), 1000);
|
||||
expect(result.state.resources.gold).toBe(0);
|
||||
expect(result.offlineMs).toBe(0);
|
||||
});
|
||||
|
||||
it('round-trips a saved game and credits offline progress', async () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
state.resources.gold = 10;
|
||||
startAction(state, content, 'forage');
|
||||
const backend = createMemoryBackend();
|
||||
await saveGame(state, backend, 1000);
|
||||
|
||||
const result = await loadGame(content, backend, 1000 + 9000); // 9s offline
|
||||
expect(result.offlineMs).toBe(9000);
|
||||
expect(result.state.resources.gold).toBe(13); // 10 + 9000/3000
|
||||
expect(result.state.activeActionId).toBe('forage');
|
||||
});
|
||||
|
||||
it('falls back to a fresh game on a corrupt save instead of throwing', async () => {
|
||||
const content = testContent();
|
||||
const result = await loadGame(content, createMemoryBackend('@@@garbage@@@'), 1000);
|
||||
expect(result.state.resources.gold).toBe(0);
|
||||
expect(result.offlineMs).toBe(0);
|
||||
});
|
||||
|
||||
it('drops an active action that no longer exists in content', async () => {
|
||||
const content = testContent();
|
||||
const stale = serializeSave(
|
||||
createSave(
|
||||
{ resources: { gold: 1 }, activeActionId: 'ghost-action', actionElapsedMs: 0 },
|
||||
1000,
|
||||
),
|
||||
);
|
||||
const result = await loadGame(content, createMemoryBackend(stale), 1000);
|
||||
expect(result.state.activeActionId).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildContent } from '../../content/schema';
|
||||
import { createGameState, startAction } from '../../engine/game';
|
||||
import { formatOfflineDuration, toView } from '../viewModel';
|
||||
|
||||
function testContent() {
|
||||
return buildContent({
|
||||
resources: [{ id: 'gold', name: 'Gold', startAmount: 4 }],
|
||||
actions: [
|
||||
{ id: 'forage', name: 'Forage', durationMs: 200, yields: { resourceId: 'gold', amount: 1 } },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('toView()', () => {
|
||||
it('maps resources with their names and amounts', () => {
|
||||
const content = testContent();
|
||||
const view = toView(createGameState(content), content);
|
||||
expect(view.resources).toEqual([{ id: 'gold', name: 'Gold', amount: 4 }]);
|
||||
});
|
||||
|
||||
it('reports no progress and no action name when idle', () => {
|
||||
const content = testContent();
|
||||
const view = toView(createGameState(content), content);
|
||||
expect(view.activeActionId).toBeNull();
|
||||
expect(view.actionName).toBeNull();
|
||||
expect(view.actionProgress).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the active action name and fractional progress', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage');
|
||||
state.actionElapsedMs = 50; // of 200ms
|
||||
const view = toView(state, content);
|
||||
expect(view.actionName).toBe('Forage');
|
||||
expect(view.actionProgress).toBeCloseTo(0.25);
|
||||
});
|
||||
|
||||
it('clamps progress to at most 1', () => {
|
||||
const content = testContent();
|
||||
const state = createGameState(content);
|
||||
startAction(state, content, 'forage');
|
||||
state.actionElapsedMs = 999;
|
||||
expect(toView(state, content).actionProgress).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatOfflineDuration()', () => {
|
||||
it('formats sub-minute durations in seconds', () => {
|
||||
expect(formatOfflineDuration(0)).toBe('0s');
|
||||
expect(formatOfflineDuration(45_000)).toBe('45s');
|
||||
});
|
||||
|
||||
it('formats minutes with seconds', () => {
|
||||
expect(formatOfflineDuration(90_000)).toBe('1m 30s');
|
||||
expect(formatOfflineDuration(120_000)).toBe('2m');
|
||||
});
|
||||
|
||||
it('formats hours with minutes', () => {
|
||||
expect(formatOfflineDuration(3_660_000)).toBe('1h 1m');
|
||||
expect(formatOfflineDuration(7_200_000)).toBe('2h');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user