diff --git a/apps/web/e2e/12-6-pilot-progression.e2e.ts b/apps/web/e2e/12-6-pilot-progression.e2e.ts index 5b5516b..acc9b0a 100644 --- a/apps/web/e2e/12-6-pilot-progression.e2e.ts +++ b/apps/web/e2e/12-6-pilot-progression.e2e.ts @@ -77,7 +77,7 @@ test.describe("end to end (Phase 12)", () => { // The Lighter's pair can take it in the dispatch sheet. await page.goto("/contracts?location=moon"); const card = page - .getByRole("listitem") + .getByRole("row") .filter({ hasText: content.good(job!.goodId)!.name }) .filter({ hasText: `${job!.massTonnes} t` }); await card.getByRole("button", { name: "Dispatch" }).first().click(); diff --git a/apps/web/src/game.ts b/apps/web/src/game.ts index de8e204..50ec079 100644 --- a/apps/web/src/game.ts +++ b/apps/web/src/game.ts @@ -9,6 +9,7 @@ import { MS_PER_HOUR, routeDistance, travelTimeGameHours, + pilotShare, tripFuel, type EffectiveStats, type ShipState, @@ -193,6 +194,27 @@ export function readyShips( ); } +/** + * What a job leaves the company on one ship (decision 0.5.169): the fuel at the + * tank's average price (0.5.98) and a hired pilot's share (CR-010). + */ +export function jobMoney( + ship: Pick, + pilot: Pick | null | undefined, + contract: Pick, +) { + const fuelFu = tripFuel( + shipStatsOf(ship), + location(contract.originId), + location(contract.destinationId), + content.config, + ); + const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice); + const pilotPay = + pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0; + return { fuelFu, fuelCost, pilotPay, netProfit: contract.payout - fuelCost - pilotPay }; +} + /** * How long a job takes in game hours (the trip's travel and handling), for the * player's fastest ship that could take it: one at the job's origin if any, diff --git a/apps/web/src/screens/Contracts.tsx b/apps/web/src/screens/Contracts.tsx index 96cb2fe..b106d24 100644 --- a/apps/web/src/screens/Contracts.tsx +++ b/apps/web/src/screens/Contracts.tsx @@ -15,7 +15,7 @@ export function Contracts() { {state.tutorial ? : } diff --git a/apps/web/src/screens/Dashboard.tsx b/apps/web/src/screens/Dashboard.tsx index 98fef06..1ddf486 100644 --- a/apps/web/src/screens/Dashboard.tsx +++ b/apps/web/src/screens/Dashboard.tsx @@ -16,18 +16,7 @@ import { formatGameHours, locationName, shipModelName } from "../format"; import { lowFuel } from "../game"; import { ShopRest } from "../components/ShopRest"; import { useLoadedPlayer } from "../state/actions"; -import { - Art, - buttonClass, - Eyebrow, - Icon, - Meter, - Panel, - Pill, - Screen, - ScreenHeader, - StatTile, -} from "../ui"; +import { Art, buttonClass, Eyebrow, Icon, Meter, Panel, Pill, Screen, ScreenHeader } from "../ui"; /** What the player needs to do next to reach the deal (decision 0.5.30). */ export function tutorialHint(tutorial: TutorialState, runsBeforeDeal: number): string { diff --git a/apps/web/src/screens/DispatchSheet.tsx b/apps/web/src/screens/DispatchSheet.tsx index 5d46ad9..b2abf8a 100644 --- a/apps/web/src/screens/DispatchSheet.tsx +++ b/apps/web/src/screens/DispatchSheet.tsx @@ -5,12 +5,10 @@ import { contractTripGameHours, isInShop, rangeFrom, - pilotShare, dutyAfterTrip, LONG_HAUL_SKILL_ID, needsRest, realToGame, - tripFuel, tripGameHours, validateDispatch, wouldStrand, @@ -31,7 +29,7 @@ import { skillName, } from "../format"; import { requirementsText } from "./LocationBoards"; -import { location, tripRealMs, shipStatsOf, simShip } from "../game"; +import { jobMoney, location, tripRealMs, shipStatsOf, simShip } from "../game"; import { errorMessage, useGameAction, useLoadedPlayer } from "../state/actions"; import { RouteStrip } from "../components/RouteStrip"; import { Art, Button, Sheet } from "../ui"; @@ -221,17 +219,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) contract.destinationId, contract.massTonnes, ); - const fuelFu = tripFuel( - model, - location(contract.originId), - location(contract.destinationId), - content.config, - ); - // The fuel used, at the tank's average price (decision 0.5.98). - const fuelCost = Math.round(fuelFu * ship.fuelAvgPrice); - // Profit-share pay for a hired pilot; the avatar is exempt (CR-010). - const pilotPay = - pilot && !pilot.isAvatar ? pilotShare(contract.payout, fuelCost, content.config) : 0; + const { fuelFu, fuelCost, pilotPay, netProfit } = jobMoney(ship, pilot, contract); let problems: string[] = [INELIGIBLE_REASONS["pilot-not-assigned"]]; let shortOfFuel = false; @@ -292,7 +280,7 @@ export function pairOptions(state: PlayerState, contract: Contract, now: number) fuel: { neededFu: fuelFu, onBoardFu: ship.fuelFu, leftFu: ship.fuelFu - fuelFu }, shortOfFuel, pilotPay, - netProfit: contract.payout - fuelCost - pilotPay, + netProfit, arrivalAt: now + tripMs, duty: pilot ? dutyOutlook(pilot, now, now + tripMs) : null, priority: diff --git a/apps/web/src/screens/JobsTable.tsx b/apps/web/src/screens/JobsTable.tsx new file mode 100644 index 0000000..14dc060 --- /dev/null +++ b/apps/web/src/screens/JobsTable.tsx @@ -0,0 +1,216 @@ +import type { Contract } from "@deadhead/schema"; +import { Countdown } from "../components/Countdown"; +import { ClassChip, goodName, PriorityChip } from "../components/Goods"; +import { + formatClock, + formatCredits, + formatGameHours, + formatMillionKm, + locationName, +} from "../format"; +import { distanceAt } from "../game"; +import { useClock } from "../clock/clock"; +import { useLoadedPlayer } from "../state/actions"; +import { usePreferences, type JobSort } from "../state/preferences"; +import { Pill } from "../ui"; +import { jobFigures, type JobFigures } from "./jobFigures"; +import { requirementsText } from "./LocationBoards"; + +const COLUMNS = "hidden px-2 text-[13px] text-ink-300 @2xl:table-cell"; + +const money = (n: number) => n.toLocaleString("en-US"); + +function netLabel({ net, estimate }: JobFigures): string { + if (net === null) return "—"; + return `${estimate ? "≈" : ""}${net >= 0 ? "+" : "−"}${money(Math.abs(net))}`; +} + +/** Jobs at a place, sortable by net or credits per hour (decision 0.5.169). */ +export function JobsTable({ + contracts, + fresh, + firstLookMs, + pilotsOnTheirOwn, + onSelect, +}: { + contracts: Contract[]; + fresh: ReadonlySet; + firstLookMs: number; + pilotsOnTheirOwn: boolean; + onSelect?: (contract: Contract) => void; +}) { + const state = useLoadedPlayer(); + const now = useClock((s) => s.now); + const sort = usePreferences((p) => p.jobSort); + const setPreference = usePreferences((p) => p.set); + const value = (f: JobFigures) => (sort === "net" ? f.net : f.perHour) ?? -Infinity; + + const rows = contracts + .map((contract) => ({ contract, figures: jobFigures(state, contract, now) })) + .sort((a, b) => value(b.figures) - value(a.figures)); + const ready = rows.filter((r) => r.figures.ready); + const blocked = rows.filter((r) => !r.figures.ready); + + const header = (key: JobSort, label: string, width: string) => ( + + + + ); + + const row = ({ contract, figures }: (typeof rows)[number]) => { + const requirements = requirementsText(contract); + const firstLook = + pilotsOnTheirOwn && !contract.firstContact && now < contract.offeredAt + firstLookMs; + const hours = figures.hours === null ? null : formatGameHours(figures.hours); + const destination = `To ${locationName(contract.destinationId)}`; + return ( + + +
+ + {goodName(contract)} + + {fresh.has(contract.id) && ( + + New + + )} + + {contract.priority && contract.deliveryWindowGameHours !== null && ( + + )} + {contract.firstContact && ( + + First contact + + )} + {firstLook && ( + + + Yours for{" "} + + + + )} +
+
+ {destination} + + {" "} + · {contract.massTonnes} t + {hours && · {hours}} + + {requirements && · Needs {requirements}} + {figures.note && · {figures.note}} +
+ {/* The rest of the job: beside the columns on desktop, in the sheet on phones. */} +
+ Pays {formatCredits(contract.payout)} ·{" "} + {formatMillionKm(distanceAt(contract.originId, contract.destinationId))} ·{" "} + {requirements ? `Needs ${requirements}` : "No skills needed"} · expires in{" "} + +
+ + {contract.shipper} + {contract.massTonnes} t + {hours ?? "—"} + + {netLabel(figures)} + + + {figures.perHour === null ? "—" : money(figures.perHour)} + + {onSelect && ( + + {/* The whole row opens the job: the button stretches over it. */} + - )} - - - ); - })} - + ); } -/** " · 5.4 h": the job's length in game hours for the fastest ship that could take it. */ -function TripHours({ contract }: { contract: Contract }) { - const state = useLoadedPlayer(); - const hours = jobGameHours(state, contract); - if (hours === null) return null; - return ( - - {" "} - · {formatGameHours(hours)} - - ); -} - /** * Why no ship here can take a job now (decision 0.5.134): none docked, or the * pilots here resting (with who wakes first), or none crewed for the player. @@ -329,8 +226,8 @@ function NoneReady({ locationId, now }: { locationId: string; now: number }) { .flatMap((r) => (r?.reason === "resting" ? [r.pilot] : [])) .sort((a, b) => a.restEndsAt! - b.restEndsAt!)[0]; return ( -

- +

+ {idle.length === 0 ? ( "No idle ships here. Deadhead one in from the Fleet screen to take these jobs." ) : waking ? ( diff --git a/apps/web/src/screens/jobFigures.ts b/apps/web/src/screens/jobFigures.ts new file mode 100644 index 0000000..b680516 --- /dev/null +++ b/apps/web/src/screens/jobFigures.ts @@ -0,0 +1,82 @@ +import { content } from "@deadhead/content"; +import type { Contract, PlayerState } from "@deadhead/schema"; +import { contractTripGameHours, permanentFailure } from "@deadhead/sim"; +import { locationName } from "../format"; +import { jobMoney, shipStatsOf } from "../game"; +import { pairOptions } from "./DispatchSheet"; + +/** What a job row shows (decision 0.5.169). */ +export interface JobFigures { + /** Payout minus fuel minus the pilot's share, as in the dispatch sheet. Null: no ship could. */ + net: number | null; + /** Net per game hour of the job. */ + perHour: number | null; + /** The job's game hours for the ship the figures are for. */ + hours: number | null; + /** No ship can take it now: figures are for the best ship the company owns. */ + estimate: boolean; + /** A ship can take it now (refuelling first counts: the sheet offers it). */ + ready: boolean; + /** Why it can't go now, or what it needs first. */ + note: string | null; +} + +const perHourOf = (net: number, hours: number) => (hours > 0 ? Math.round(net / hours) : null); + +/** The best ship's figures for a job: one that can take it now, else the best the fleet has. */ +export function jobFigures(state: PlayerState, contract: Contract, now: number): JobFigures { + const options = pairOptions(state, contract, now); + const able = options.filter((o) => o.problems.length === 0 || o.shortOfFuel); + if (able.length > 0) { + const best = able.reduce((a, b) => (b.netProfit > a.netProfit ? b : a)); + const ship = state.ships.find((s) => s.id === best.shipId)!; + const hours = contractTripGameHours(shipStatsOf(ship), contract, content); + return { + net: best.netProfit, + perHour: perHourOf(best.netProfit, hours), + hours, + estimate: false, + ready: true, + note: able.every((o) => o.shortOfFuel) ? "Refuel first" : null, + }; + } + + // A problem that time will fix says so; a ship here that could never take it isn't the job's + // problem (CR-019: the board only shows jobs the fleet can complete). + const waiting = options.find((o) => { + const ship = state.ships.find((s) => s.id === o.shipId)!; + const pilot = state.pilots.find((p) => p.assignedShipId === ship.id); + if (!pilot) return true; + const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades }; + return permanentFailure(contract, member, undefined, content) === null; + }); + const note = + waiting?.problems[0] ?? + (options.length > 0 + ? `Needs another ship at ${locationName(contract.originId)}` + : `No idle ship at ${locationName(contract.originId)}`); + const candidates = state.ships.flatMap((ship) => { + const pilot = state.pilots.find((p) => p.assignedShipId === ship.id); + if (!pilot) return []; + const member = { modelId: ship.modelId, skills: pilot.skills, upgrades: ship.upgrades }; + if (permanentFailure(contract, member, undefined, content) !== null) return []; + return [ + { + net: jobMoney(ship, pilot, contract).netProfit, + hours: contractTripGameHours(shipStatsOf(ship), contract, content), + }, + ]; + }); + if (candidates.length === 0) { + return { net: null, perHour: null, hours: null, estimate: false, ready: false, note }; + } + const best = candidates.reduce((a, b) => (b.net > a.net ? b : a)); + return { + net: best.net, + perHour: perHourOf(best.net, best.hours), + hours: best.hours, + estimate: true, + ready: false, + note, + }; +} diff --git a/apps/web/src/state/preferences.ts b/apps/web/src/state/preferences.ts index e9d0d72..2d41f18 100644 --- a/apps/web/src/state/preferences.ts +++ b/apps/web/src/state/preferences.ts @@ -11,6 +11,8 @@ import type { Camera } from "../map/camera"; export type ClockFormat = "locale" | "12h" | "24h"; export type CountdownFormat = "real" | "game" | "both"; +export type JobSort = "net" | "rate"; + export interface Preferences { clock: ClockFormat; countdown: CountdownFormat; @@ -20,6 +22,8 @@ export interface Preferences { dismissedTips: string[]; /** The map view to restore; null for the default view. */ mapCamera: Camera | null; + /** How the jobs table sorts (decision 0.5.169). */ + jobSort: JobSort; /** Local notifications on iOS and Android (decision 0.5.91). */ notifications: boolean; } @@ -30,6 +34,7 @@ export const DEFAULT_PREFERENCES: Preferences = { reducedMotion: null, dismissedTips: [], mapCamera: null, + jobSort: "net", notifications: true, }; @@ -67,6 +72,7 @@ export function loadPreferences(storage: PreferenceStorage | null): Preferences dismissedTips: Array.isArray(saved.dismissedTips) ? saved.dismissedTips.filter((t): t is string => typeof t === "string") : [], + jobSort: saved.jobSort === "rate" ? "rate" : "net", mapCamera: saved.mapCamera && [saved.mapCamera.zoom, saved.mapCamera.panX, saved.mapCamera.panY].every(Number.isFinite) @@ -111,7 +117,7 @@ export function createPreferencesStore( ): UseBoundStore> { return create((setState, getState) => { const persist = () => { - const { clock, countdown, reducedMotion, dismissedTips, mapCamera, notifications } = + const { clock, countdown, reducedMotion, dismissedTips, mapCamera, jobSort, notifications } = getState(); savePreferences(storage, { clock, @@ -119,6 +125,7 @@ export function createPreferencesStore( reducedMotion, dismissedTips, mapCamera, + jobSort, notifications, }); }; diff --git a/apps/web/test/board-cards.test.tsx b/apps/web/test/board-cards.test.tsx index d248f8b..bd737b0 100644 --- a/apps/web/test/board-cards.test.tsx +++ b/apps/web/test/board-cards.test.tsx @@ -83,7 +83,7 @@ describe("board cards and the empty board (Step 19.4, decision 0.5.114)", () => }), ]); for (const payout of ["9,001 cr", "9,002 cr", "9,003 cr"]) { - const card = (await screen.findByText(payout)).closest("li")!; + const card = (await screen.findByText(payout)).closest("tr")!; expect(card).not.toHaveTextContent(/too heavy/i); expect(card).not.toHaveTextContent(/too slow/i); expect(card).not.toHaveTextContent(/skill too low/i); diff --git a/apps/web/test/board-logbook.test.tsx b/apps/web/test/board-logbook.test.tsx index 8f89812..efc3c29 100644 --- a/apps/web/test/board-logbook.test.tsx +++ b/apps/web/test/board-logbook.test.tsx @@ -91,7 +91,7 @@ describe("first-look chip (Step 21.7, decision 0.5.126)", () => { it("counts down the player's first look, then disappears", async () => { show(owner([pilot("p1", "Ada")]), [job()]); - const card = (await screen.findByText("1,234 cr")).closest("li")!; + const card = (await screen.findByText("1,234 cr")).closest("tr")!; expect(within(card).getByLabelText("Yours for")).toHaveTextContent("Yours for 02:31"); act(() => { @@ -103,13 +103,13 @@ describe("first-look chip (Step 21.7, decision 0.5.126)", () => { it("isn't shown without Independent pilots", async () => { show(owner([pilot("p1", "Ada", { mode: "manual" })]), [job()]); - const card = (await screen.findByText("1,234 cr")).closest("li")!; + const card = (await screen.findByText("1,234 cr")).closest("tr")!; expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument(); }); it("isn't shown on a first contact, which is always the player's", async () => { show(owner([pilot("p1", "Ada")]), [job({ firstContact: true, destinationId: "mars" })]); - const card = (await screen.findByText("1,234 cr")).closest("li")!; + const card = (await screen.findByText("1,234 cr")).closest("tr")!; expect(within(card).queryByLabelText("Yours for")).not.toBeInTheDocument(); }); }); diff --git a/apps/web/test/contract-card.test.tsx b/apps/web/test/contract-card.test.tsx index 5723c97..66ac1dd 100644 --- a/apps/web/test/contract-card.test.tsx +++ b/apps/web/test/contract-card.test.tsx @@ -49,15 +49,16 @@ function render(contracts: Contract[], ships: Ship[]) { ); } -const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!; +const card = async (payout: string) => (await screen.findByText(payout)).closest("tr")!; describe("contract card (CR-007)", () => { it("shows the good, its class and tier, the shipper, mass and requirement", async () => { render([job({})], [ship("courier")]); const c = await card("3,210 cr"); - expect(within(c).getByRole("heading", { name: "Medical Supplies" })).toBeInTheDocument(); + expect(within(c).getByText("Medical Supplies")).toBeInTheDocument(); expect(c).toHaveTextContent("High-Value · T1"); - expect(c).toHaveTextContent("Meridian Health · 6 t"); + expect(c).toHaveTextContent("Meridian Health"); + expect(c).toHaveTextContent("6 t"); expect(c).toHaveTextContent("Needs Expensive Cargo 1"); expect(c).not.toHaveTextContent("Priority"); }); @@ -76,7 +77,7 @@ describe("contract card (CR-007)", () => { ); const c = await card("3,210 cr"); // A Priority good can have its own name. - expect(within(c).getByRole("heading", { name: "Emergency Parts" })).toBeInTheDocument(); + expect(within(c).getByText("Emergency Parts")).toBeInTheDocument(); // The window follows the countdown preference: real time by default (CR-011). expect(c).toHaveTextContent(`Priority · within ${realTimeOf(5)}`); expect(c).not.toHaveTextContent("Too slow"); diff --git a/apps/web/test/display-options.test.tsx b/apps/web/test/display-options.test.tsx index 607244b..39e92d7 100644 --- a/apps/web/test/display-options.test.tsx +++ b/apps/web/test/display-options.test.tsx @@ -104,7 +104,7 @@ describe("display options in the app (Step 10K.3)", () => { }), "/contracts?location=earth", ); - const card = (await screen.findByText("1,234 cr")).closest("li")!; + const card = (await screen.findByText("1,234 cr")).closest("tr")!; expect(card).toHaveTextContent("expires in 4m 16s"); act(() => usePreferences.getState().set({ countdown: "both" })); expect(card).toHaveTextContent("expires in 4m 16s · 4.3 game h"); diff --git a/apps/web/test/fresh-board.test.tsx b/apps/web/test/fresh-board.test.tsx index 054e3a7..a280ddc 100644 --- a/apps/web/test/fresh-board.test.tsx +++ b/apps/web/test/fresh-board.test.tsx @@ -40,7 +40,7 @@ function render(boards: Contract[][], periodMs = 600_000) { const remember = (ids: string[]) => localStorage.setItem("deadhead:seen-board:earth", JSON.stringify(ids)); -const card = async (payout: string) => (await screen.findByText(payout)).closest("li")!; +const card = async (payout: string) => (await screen.findByText(payout)).closest("tr")!; describe("visible board refresh (CR-008)", () => { it("tags contracts that weren't on the last board seen here, and says so", async () => { diff --git a/apps/web/test/job-hours.test.tsx b/apps/web/test/job-hours.test.tsx index d8074f5..6a73197 100644 --- a/apps/web/test/job-hours.test.tsx +++ b/apps/web/test/job-hours.test.tsx @@ -1,7 +1,7 @@ import { content } from "@deadhead/content"; import type { Contract, Pilot, Ship } from "@deadhead/schema"; import { contractTripGameHours, effectiveStats } from "@deadhead/sim"; -import { screen, within } from "@testing-library/react"; +import { screen } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { useClock } from "../src/clock/clock"; import { formatGameHours } from "../src/format"; @@ -84,9 +84,10 @@ describe("job length on the board", () => { ); const hours = formatGameHours(hoursFor("courier", contract)); const time = await screen.findByLabelText(`Takes ${hours} of game time`); - const line = time.parentElement!; - expect(line).toHaveTextContent(`Pacific Orbital Logistics · 12 t · 0.38M km · ${hours}`); - expect(within(line).getByText(/12 t/)).toBeInTheDocument(); + const row = time.closest("tr")!; + expect(row).toHaveTextContent("Pacific Orbital Logistics"); + expect(row).toHaveTextContent("0.38M km"); + expect(time.parentElement).toHaveTextContent(`12 t · ${hours}`); }); it("times the job for the fastest ship that could take it, preferring one at the origin", () => { diff --git a/apps/web/test/jobs-table.test.tsx b/apps/web/test/jobs-table.test.tsx new file mode 100644 index 0000000..fe2721a --- /dev/null +++ b/apps/web/test/jobs-table.test.tsx @@ -0,0 +1,126 @@ +import type { Contract, Ship } from "@deadhead/schema"; +import { screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it } from "vitest"; +import { usePreferences } from "../src/state/preferences"; +import { pairOptions } from "../src/screens/DispatchSheet"; +import { jobFigures } from "../src/screens/jobFigures"; +import { fakeApi, renderApp, samplePlayerState, shipExtras } from "./render"; + +const NOW = Date.now(); + +const job = (overrides: Partial): Contract => + ({ + id: "j1", + locationId: "earth", + originId: "earth", + destinationId: "moon", + goodId: "consumer-goods", + cargoTypeId: "standard", + massTonnes: 10, + payout: 2000, + shipper: "Pacific Orbital Logistics", + offeredAt: NOW, + expiresAt: NOW + 3_600_000, + priority: false, + deliveryWindowGameHours: null, + tier: null, + requiredSkill: null, + requiredLongHaulLevel: 0, + firstContact: false, + ...overrides, + }) as Contract; + +const ship = (id: string, overrides: Partial = {}): Ship => ({ + id, + modelId: "courier", + name: id, + status: "idle", + locationId: "earth", + dispatchId: null, + lastArrivalAt: 0, + ...shipExtras("courier"), + pilotId: "avatar", + ...overrides, +}); + +function owner(ships: Ship[]) { + const base = samplePlayerState(); + return samplePlayerState({ + tutorial: null, + bays: [{ id: "b", locationId: "earth" }], + ships, + pilots: [{ ...base.pilots[0]!, assignedShipId: ships[0]?.id ?? null }], + }); +} + +function show(contracts: Contract[], ships: Ship[]) { + renderApp( + fakeApi({ + getPlayerState: () => owner(ships), + getContractBoard: () => ({ contracts, periodEndsAt: NOW + 3_600_000 }), + }), + "/contracts?location=earth", + ); +} + +const rowNames = () => + screen + .getAllByRole("row") + .map((r) => r.getAttribute("aria-label")) + .filter(Boolean); + +describe("jobs table (decision 0.5.169)", () => { + beforeEach(() => usePreferences.setState({ jobSort: "net" })); + + it("shows the same net as the dispatch sheet", () => { + const state = owner([ship("s1")]); + const contract = job({}); + const figures = jobFigures(state, contract, NOW); + expect(figures.ready).toBe(true); + expect(figures.estimate).toBe(false); + expect(figures.net).toBe(pairOptions(state, contract, NOW)[0]!.netProfit); + expect(figures.perHour).toBe(Math.round(figures.net! / figures.hours!)); + }); + + it("sorts by net, then by credits per hour, and remembers the choice", async () => { + const big = job({ + id: "big", + goodId: "packaged-food", + payout: 3000, + destinationId: "moon", + massTonnes: 18, + }); + const small = job({ id: "small", goodId: "mail", payout: 2000, massTonnes: 1 }); + show([small, big], [ship("s1")]); + await screen.findByRole("table"); + expect(rowNames()).toEqual(["Packaged Food", "Mail & Parcels"]); + const net = screen.getByRole("columnheader", { name: /Net/ }); + expect(net).toHaveAttribute("aria-sort", "descending"); + + await userEvent.click(within(screen.getByRole("table")).getByRole("button", { name: /cr\/h/ })); + expect(screen.getByRole("columnheader", { name: /cr\/h/ })).toHaveAttribute( + "aria-sort", + "descending", + ); + expect(net).toHaveAttribute("aria-sort", "none"); + expect(usePreferences.getState().jobSort).toBe("rate"); + }); + + it("keeps jobs it can't take below, with the reason and an estimate", async () => { + // The only ship is away: the figures are an estimate, and the row says why. + const away = ship("s1", { status: "in-flight", locationId: null, dispatchId: "d" }); + show([job({})], [away]); + await screen.findByRole("table"); + expect(screen.getByText("Can't take yet")).toBeInTheDocument(); + const row = screen.getByRole("row", { name: "Consumer Goods" }); + expect(row).toHaveTextContent("No idle ship at Earth"); + expect(within(row).getByLabelText("Net, estimated")).toHaveTextContent(/^≈/); + }); + + it("opens the job from its row", async () => { + show([job({})], [ship("s1")]); + await userEvent.click(await screen.findByRole("button", { name: "Dispatch" })); + await waitFor(() => expect(screen.getByRole("dialog")).toBeInTheDocument()); + }); +}); diff --git a/apps/web/test/location-boards.test.tsx b/apps/web/test/location-boards.test.tsx index 95c6fae..e024181 100644 --- a/apps/web/test/location-boards.test.tsx +++ b/apps/web/test/location-boards.test.tsx @@ -156,21 +156,20 @@ describe("location contract boards", () => { renderApp(boardApi(), "/contracts?location=earth"); await screen.findByText("1,999 cr"); - const items = screen.getAllByRole("listitem"); - const [plain, special] = items; - // CR-007 card: good headline, class chip, shipper · mass · distance. - expect(within(plain!).getByRole("heading", { name: "Consumer Goods" })).toBeInTheDocument(); + const plain = screen.getByRole("row", { name: "Consumer Goods" }); + const special = screen.getByRole("row", { name: "Machine Parts" }); + // CR-007: good, class chip, shipper, mass and distance. expect(plain).toHaveTextContent("Standard"); - expect(plain).toHaveTextContent("Pacific Orbital Logistics · 50 t"); + expect(plain).toHaveTextContent("Pacific Orbital Logistics"); + expect(plain).toHaveTextContent("50 t"); expect(plain).toHaveTextContent("No skills needed"); expect(plain).toHaveTextContent("expires in 30m 00s"); - expect(within(plain!).queryByText("First contact")).not.toBeInTheDocument(); + expect(within(plain).queryByText("First contact")).not.toBeInTheDocument(); - expect(special).toHaveTextContent("Earth → Mars"); - expect(within(special!).getByRole("heading", { name: "Machine Parts" })).toBeInTheDocument(); + expect(special).toHaveTextContent("To Mars"); expect(special).toHaveTextContent("Heavy · T2"); expect(special).toHaveTextContent("Needs Heavy Cargo 2, Long Haul 1"); expect(special).toHaveTextContent(/M km/); - expect(within(special!).getByText("First contact")).toBeInTheDocument(); + expect(within(special).getByText("First contact")).toBeInTheDocument(); }); });