Merge pull request 'Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)' (#68) from fix/job-list-cargo-tonnage into master

Merge pull request 'Job list shows cargo tonnage instead of count (ISS-066, v0.35.0)' (#68) from fix/job-list-cargo-tonnage into master
This commit is contained in:
2026-09-21 04:58:30 +02:00
6 changed files with 28 additions and 7 deletions
+3
View File
@@ -1,5 +1,8 @@
# Changelog # Changelog
## v0.35.0 — 2026-09-21 02:26
- **Job list shows cargo tonnage instead of count (ISS-066).** The open-package rows in the right pane's Jobs tab (`components/RightPane.tsx`) now show cargo as type + tonnage ("Mail 10.0t") via a new `formatCargo` in `lib/format.ts` — `(kg / 1000).toFixed(1) + 't'`, identical to the loadout display — instead of "type × count"; unit tests added to `lib/format.test.ts`; e2e assertion added to `e2e/right-pane.spec.ts` (the hook-created package is forced to 10 t and its row must contain "10.0t"). No server or OpenAPI changes — `weightKg` was already in the snapshot
## v0.34.0 — 2026-09-21 01:26 ## v0.34.0 — 2026-09-21 01:26
- **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses) - **Duplicate queue legs rejected with 409 (ISS-065).** `queueLeg` (`lib/game/actions.ts`) now refuses to append a destination that is already in the plane's flight queue ("{icao} is already in the flight queue"), and — while the plane is flying — re-queuing the active leg's destination ("already flying to {icao}"). Previously the same route could be queued any number of times, producing pointless repeated/zero-length hops when landing auto-chains `queue[0]`. No UI or OpenAPI changes (the endpoint already documents error responses)
+3 -2
View File
@@ -4,7 +4,7 @@ import { useState } from 'react';
import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state'; import type { PackageState, PlaneState, StateSnapshot } from '@/lib/game/state';
import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog'; import { PLANE_TYPES, SELL_FRACTION } from '@/lib/game/catalog';
import { maxRangeKm } from '@/lib/sim/movement'; import { maxRangeKm } from '@/lib/sim/movement';
import { formatCash, formatDeadlineRelative } from '@/lib/format'; import { formatCargo, formatCash, formatDeadlineRelative } from '@/lib/format';
import { useGameAction } from '@/lib/useGameAction'; import { useGameAction } from '@/lib/useGameAction';
import type { HoverTarget } from './hoverTypes'; import type { HoverTarget } from './hoverTypes';
@@ -225,7 +225,8 @@ export default function RightPane({ state, loading, applySnapshot, onHover, onEr
</div> </div>
<div className="contract-sub"> <div className="contract-sub">
<span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span> <span data-testid={`open-package-reward-${pkg.id}`}>{formatCash(pkg.reward)}</span>
<span>{pkg.cargoType} × {pkg.cargoCount}</span> {/* ISS-066 (v0.35.0): cargo shows as type + tonnage ("Mail 10.0t"), not "type × count". */}
<span data-testid={`open-package-cargo-${pkg.id}`}>{formatCargo(pkg.cargoType, pkg.weightKg)}</span>
<span data-testid={`open-package-deadline-${pkg.id}`}> <span data-testid={`open-package-deadline-${pkg.id}`}>
{formatDeadlineRelative(pkg.deadlineSimTime, clockMs)} {formatDeadlineRelative(pkg.deadlineSimTime, clockMs)}
</span> </span>
+6 -3
View File
@@ -158,9 +158,9 @@ function maxRangeKm(burnPerKm: number, emptyWeightKg: number, maxFuel: number):
} }
// Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route. // Test hook (FLIGHT_TEST_HOOKS=1): create an open package on a known route.
async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number): Promise<PackageState> { async function createPackage(page: Page, fromIcao: string, toIcao: string, deadlineOffsetMs?: number, weightKg?: number): Promise<PackageState> {
const created = await page.request.post('/api/test/create-package', { const created = await page.request.post('/api/test/create-package', {
data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs }, data: { originIcao: fromIcao, destIcao: toIcao, deadlineOffsetMs, weightKg },
}); });
expect(created.status()).toBe(201); expect(created.status()).toBe(201);
return (await created.json()) as PackageState; return (await created.json()) as PackageState;
@@ -213,9 +213,12 @@ test('open packages render with names, reward and deadline; Load is enabled at a
// Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot). // Deterministic target: a hook-created EDDF package — Load is enabled (starter at origin, free slot).
const nearest = await nearestToHub(page); const nearest = await nearestToHub(page);
const pkg = await createPackage(page, HUB, nearest.icao); const pkg = await createPackage(page, HUB, nearest.icao, undefined, 4_000);
const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`); const row = page.locator(`li[data-testid="open-package-${pkg.id}"]`);
await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 }); await expect(row, 'the hook-created package appears in the list').toBeVisible({ timeout: 15_000 });
// ISS-066 (v0.35.0): cargo shows as type + tonnage ("… 4.0t"), not "type × count".
// 4 t is within the starter ATR's 4.8 t maxPayloadKg, so Load stays enabled below.
await expect(page.getByTestId(`open-package-cargo-${pkg.id}`)).toContainText('4.0t');
await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward)); await expect(page.getByTestId(`open-package-reward-${pkg.id}`)).toContainText(eur(pkg.reward));
const loadBtn = page.getByTestId(`load-${pkg.id}`); const loadBtn = page.getByTestId(`load-${pkg.id}`);
await expect(loadBtn).toBeEnabled(); await expect(loadBtn).toBeEnabled();
+10 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format'; import { formatCargo, formatCash, formatDeadlineRelative, formatDuration, formatFuel, formatSimClock } from './format';
describe('formatCash', () => { describe('formatCash', () => {
it('formats EUR with thousands separators and two decimals', () => { it('formats EUR with thousands separators and two decimals', () => {
@@ -21,6 +21,15 @@ describe('formatFuel', () => {
}); });
}); });
describe('formatCargo', () => {
it('shows cargo type + tonnage with one decimal, like the loadout display', () => {
expect(formatCargo('Mail', 10_000)).toBe('Mail 10.0t');
// (250 / 1000).toFixed(1) === '0.3' — half rounds up at this value.
expect(formatCargo('Electronics', 250)).toBe('Electronics 0.3t');
expect(formatCargo('Mail', 40)).toBe('Mail 0.0t');
});
});
describe('formatSimClock', () => { describe('formatSimClock', () => {
it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => { it('formats epoch ms as a UTC wall-clock string with seconds (deterministic across timezones)', () => {
expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC'); expect(formatSimClock(Date.UTC(2026, 8, 17, 14, 5))).toBe('2026-09-17 14:05:00 UTC');
+5
View File
@@ -11,6 +11,11 @@ export function formatFuel(fuel: number, maxFuel: number): string {
return `${Math.round(fuel)} / ${maxFuel} L`; return `${Math.round(fuel)} / ${maxFuel} L`;
} }
// Job-list cargo label: "Mail 10.0t" — type + tonnage, one decimal like the loadout display.
export function formatCargo(cargoType: string, weightKg: number): string {
return `${cargoType} ${(weightKg / 1000).toFixed(1)}t`;
}
// The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with // The sim clock is epoch ms (seeded from real time) — shown as a UTC wall-clock with
// seconds so the display is deterministic regardless of the viewer's timezone. // seconds so the display is deterministic regardless of the viewer's timezone.
export function formatSimClock(clockMs: number): string { export function formatSimClock(clockMs: number): string {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "flight-ops", "name": "flight-ops",
"version": "0.34.0", "version": "0.35.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -p 3000", "dev": "next dev -p 3000",