feat: v0.17.0 — ISS-008 toasts for errors/events + browser notifications
CI / build + unit + e2e (pull_request) Failing after 6m59s

This commit is contained in:
2026-09-19 14:52:36 +00:00
parent eb69359a38
commit d8dc785757
12 changed files with 545 additions and 9 deletions
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest';
import type { PackageState, PlaneState, StateSnapshot } from './game/state';
import { diffSnapshots } from './deriveEvents';
// ISS-008 (v0.17.0): snapshot-diffing event derivation — pure core of the toast/notification
// feature, unit-tested in node like advanceSimClock/fetchState.
const CATALOG = [
{ model: 'atr72', label: 'ATR 72-600', price: 200_000, maxFuel: 6300, burnPerKm: 1.75, emptyWeightKg: 9300, speedKmh: 510, capacity: 6 },
];
const basePlane = (id: string): PlaneState => ({
id,
model: 'atr72',
status: 'available',
fuel: 6300,
maxFuel: 6300,
locationIcao: 'EDDF',
locationName: 'Frankfurt',
destIcao: null,
destName: null,
departureSimTime: null,
lat: 50.03,
lon: 8.56,
});
const basePackage = (id: string): PackageState => ({
id,
originIcao: 'EDDF',
originName: 'Frankfurt',
destIcao: 'LEMD',
destName: 'Madrid–Barajas',
reward: 1234.5,
cargoType: 'mail',
cargoCount: 2,
deadlineSimTime: 0,
status: 'open',
planeId: null,
createdSimTime: 0,
});
const snapshot = (over: Partial<StateSnapshot> = {}): StateSnapshot => ({
account: { cash: 20_000, reputation: 10 },
simState: { clockMs: 0, timeScale: 1, paused: false },
planes: [basePlane('plane-starter')],
packages: [],
catalog: CATALOG,
fuelPricePerL: 0.8,
sellFraction: 0.5,
...over,
});
describe('diffSnapshots', () => {
it('returns [] for identical snapshots (only clock/cash drift)', () => {
const prev = snapshot();
const next = snapshot({ account: { cash: 21_000, reputation: 11 }, simState: { clockMs: 60_000, timeScale: 1, paused: false } });
expect(diffSnapshots(prev, next)).toEqual([]);
});
it('returns [] for the first snapshot (no baseline — no "new package" spam on page load)', () => {
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
expect(diffSnapshots(null, next)).toEqual([]);
});
it('emits a landing event when a plane goes flying → available (new locationName is the arrival)', () => {
const prev = snapshot({
planes: [
{
...basePlane('plane-starter'),
status: 'flying',
destIcao: 'LEMD',
destName: 'Madrid–Barajas',
departureSimTime: 0,
},
],
});
const next = snapshot({
planes: [
{ ...basePlane('plane-starter'), locationIcao: 'LEMD', locationName: 'Madrid–Barajas' },
],
});
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'ATR 72-600 landed at Madrid–Barajas' },
]);
});
it('emits a delivery event with the reward when a loaded package is delivered on arrival', () => {
const prev = snapshot({ packages: [{ ...basePackage('pkg-1'), status: 'loaded', planeId: 'plane-starter' }] });
const next = snapshot({ packages: [{ ...basePackage('pkg-1'), status: 'delivered', planeId: 'plane-starter' }] });
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'Package delivered: +€1,234.50' },
]);
});
it('emits an expiry event for open → expired and loaded → expired', () => {
const fromOpen = diffSnapshots(
snapshot({ packages: [basePackage('pkg-1')] }),
snapshot({ packages: [{ ...basePackage('pkg-1'), status: 'expired' }] }),
);
expect(fromOpen).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]);
const fromLoaded = diffSnapshots(
snapshot({ packages: [{ ...basePackage('pkg-2'), status: 'loaded', planeId: 'plane-starter' }] }),
snapshot({ packages: [{ ...basePackage('pkg-2'), status: 'expired', planeId: 'plane-starter' }] }),
);
expect(fromLoaded).toEqual([{ kind: 'info', message: 'Package expired: Frankfurt → Madrid–Barajas' }]);
});
it('emits a new-package event for ids only in the next snapshot', () => {
const prev = snapshot({ packages: [basePackage('pkg-1')] });
const next = snapshot({ packages: [basePackage('pkg-1'), basePackage('pkg-2')] });
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'New package available: Frankfurt → Madrid–Barajas' },
]);
});
it('emits bought/sold events when the plane id set changes (with the model name)', () => {
const bought = diffSnapshots(
snapshot(),
snapshot({ planes: [basePlane('plane-starter'), basePlane('plane-2')] }),
);
expect(bought).toEqual([{ kind: 'info', message: 'Bought ATR 72-600' }]);
const sold = diffSnapshots(
snapshot({ planes: [basePlane('plane-starter'), basePlane('plane-2')] }),
snapshot(),
);
expect(sold).toEqual([{ kind: 'info', message: 'Sold ATR 72-600' }]);
});
it('reports every transition in one diff (planes first, then packages)', () => {
const prev = snapshot({
planes: [
{ ...basePlane('plane-starter'), status: 'flying', destIcao: 'LEMD', destName: 'Madrid–Barajas' },
basePlane('plane-2'),
],
packages: [{ ...basePackage('pkg-1'), status: 'loaded', planeId: 'plane-starter' }],
});
const next = snapshot({
planes: [
{ ...basePlane('plane-starter'), locationIcao: 'LEMD', locationName: 'Madrid–Barajas' },
basePlane('plane-3'),
],
packages: [{ ...basePackage('pkg-1'), status: 'delivered', planeId: 'plane-starter' }],
});
expect(diffSnapshots(prev, next)).toEqual([
{ kind: 'info', message: 'ATR 72-600 landed at Madrid–Barajas' },
{ kind: 'info', message: 'Bought ATR 72-600' },
{ kind: 'info', message: 'Sold ATR 72-600' },
{ kind: 'info', message: 'Package delivered: +€1,234.50' },
]);
});
it('ignores transitions that are not events (open → loaded, available → flying)', () => {
const prev = snapshot({ packages: [basePackage('pkg-1')] });
const next = snapshot({
planes: [{ ...basePlane('plane-starter'), status: 'flying', destIcao: 'LEMD', destName: 'Madrid–Barajas' }],
packages: [{ ...basePackage('pkg-1'), status: 'loaded', planeId: 'plane-starter' }],
});
expect(diffSnapshots(prev, next)).toEqual([]);
});
});
+61
View File
@@ -0,0 +1,61 @@
import type { StateSnapshot } from './game/state';
import { PLANE_TYPES } from './game/catalog';
import { formatCash } from './format';
// ISS-008 (v0.17.0): derive human-readable game events by diffing two consecutive /api/state
// snapshots — there is no server event feed, so the client watches for transitions in the
// fields it already receives. Pure and unit-tested; the shell calls it once per new snapshot
// and turns each result into an info toast (plus a browser notification when hidden).
export interface GameEvent {
kind: 'info';
message: string;
}
const planeName = (model: string): string => PLANE_TYPES[model]?.name ?? model;
export function diffSnapshots(prev: StateSnapshot | null, next: StateSnapshot): GameEvent[] {
// No baseline yet (first snapshot after load) — nothing to compare against. Emitting a
// "new package" event for every existing row would spam the stack on page load.
if (prev === null) return [];
const events: GameEvent[] = [];
// Planes: landed (flying → available, new locationName is the arrival airport), bought or
// sold (id set changed — ids are stable for a plane's whole life).
const prevPlanes = new Map(prev.planes.map((p) => [p.id, p]));
for (const plane of next.planes) {
const before = prevPlanes.get(plane.id);
if (!before) {
events.push({ kind: 'info', message: `Bought ${planeName(plane.model)}` });
continue;
}
if (before.status === 'flying' && plane.status === 'available') {
events.push({ kind: 'info', message: `${planeName(plane.model)} landed at ${plane.locationName}` });
}
}
for (const [id, before] of prevPlanes) {
if (!next.planes.some((p) => p.id === id)) {
events.push({ kind: 'info', message: `Sold ${planeName(before.model)}` });
}
}
// Packages: delivered (loaded → delivered, reward paid out in the same tick), expired
// (open|loaded → expired), newly appeared (id only in next — terminal packages are never
// deleted, so an id appearing is always a genuinely new one).
const prevPackages = new Map(prev.packages.map((p) => [p.id, p]));
for (const pkg of next.packages) {
const before = prevPackages.get(pkg.id);
if (!before) {
events.push({ kind: 'info', message: `New package available: ${pkg.originName} → ${pkg.destName}` });
continue;
}
if (before.status === 'loaded' && pkg.status === 'delivered') {
events.push({ kind: 'info', message: `Package delivered: +${formatCash(pkg.reward)}` });
} else if ((before.status === 'open' || before.status === 'loaded') && pkg.status === 'expired') {
events.push({ kind: 'info', message: `Package expired: ${pkg.originName} → ${pkg.destName}` });
}
}
return events;
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import { MAX_TOASTS, pushToast, type Toast } from './useToasts';
// ISS-008 (v0.17.0): the toast queue's pure core — append + cap at MAX_TOASTS dropping the
// oldest. The hook itself is a thin useState wrapper around this (auto-dismiss timers are
// plain setTimeout, covered by e2e via the visible toasts).
const toast = (id: number, message: string): Toast => ({ id, kind: 'info', message });
describe('pushToast', () => {
it('appends to an empty queue and preserves order', () => {
expect(pushToast([], toast(1, 'a'))).toEqual([toast(1, 'a')]);
expect(pushToast([toast(1, 'a')], toast(2, 'b'))).toEqual([toast(1, 'a'), toast(2, 'b')]);
});
it('keeps the newest MAX_TOASTS entries, dropping the oldest beyond the cap', () => {
let queue: Toast[] = [];
for (let id = 1; id <= MAX_TOASTS + 2; id++) queue = pushToast(queue, toast(id, `m${id}`));
expect(queue).toEqual([toast(MAX_TOASTS - 1, 'm3'), toast(MAX_TOASTS, 'm4'), toast(MAX_TOASTS + 1, 'm5'), toast(MAX_TOASTS + 2, 'm6')]);
});
it('does not mutate the input queue', () => {
const before = [toast(1, 'a')];
pushToast(before, toast(2, 'b'));
expect(before).toEqual([toast(1, 'a')]);
});
});
+50
View File
@@ -0,0 +1,50 @@
'use client';
import { useCallback, useRef, useState } from 'react';
// ISS-008 (v0.17.0): transient toast notifications — a bottom-right stack for action errors
// and game events derived from snapshot diffs. Each toast auto-dismisses after TOAST_TTL_MS;
// the queue is capped at MAX_TOASTS (oldest dropped) so an event burst can't fill the corner.
export type ToastKind = 'error' | 'info';
export interface Toast {
id: number;
kind: ToastKind;
message: string;
}
export const TOAST_TTL_MS = 5_000;
export const MAX_TOASTS = 4;
// Pure core (unit-tested): append a toast, dropping the oldest beyond the cap.
export function pushToast(toasts: Toast[], toast: Toast): Toast[] {
return [...toasts, toast].slice(-MAX_TOASTS);
}
export interface UseToastsResult {
toasts: Toast[];
push: (kind: ToastKind, message: string) => void;
dismiss: (id: number) => void;
}
export function useToasts(): UseToastsResult {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextIdRef = useRef(1);
// Functional update — a late timer for an already-dismissed/dropped id is a no-op.
const dismiss = useCallback((id: number) => {
setToasts((current) => current.filter((t) => t.id !== id));
}, []);
const push = useCallback(
(kind: ToastKind, message: string) => {
const toast: Toast = { id: nextIdRef.current++, kind, message };
setToasts((current) => pushToast(current, toast));
setTimeout(() => dismiss(toast.id), TOAST_TTL_MS);
},
[dismiss],
);
return { toasts, push, dismiss };
}