feat: v0.17.0 — ISS-008 toasts for errors/events + browser notifications
CI / build + unit + e2e (pull_request) Failing after 6m59s
CI / build + unit + e2e (pull_request) Failing after 6m59s
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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')]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user