140 lines
5.0 KiB
TypeScript
140 lines
5.0 KiB
TypeScript
import type { Dispatch, PlayerState } from "@deadhead/schema";
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
import { samplePlayerState, shipExtras } from "../test/fixtures";
|
|
|
|
/**
|
|
* A company with one ship in flight (Earth → Moon, 20 minutes left) and one idle
|
|
* at Earth, as the server would return it.
|
|
*/
|
|
function midgame(now: number): PlayerState {
|
|
const base = samplePlayerState({ tutorial: null });
|
|
const flight: Dispatch = {
|
|
id: "d1",
|
|
shipId: "s-fly",
|
|
pilotId: "p2",
|
|
contract: null,
|
|
originId: "earth",
|
|
destinationId: "moon",
|
|
tutorial: false,
|
|
departureAt: now - 60_000,
|
|
arrivalAt: now + 20 * 60_000,
|
|
distanceMillionKm: 0.38,
|
|
fuelCost: 160,
|
|
expectedPayout: 0,
|
|
seed: 1,
|
|
status: "in-flight",
|
|
resolvedAt: null,
|
|
};
|
|
return {
|
|
...base,
|
|
company: { id: "co1", name: "Deadhead Freight", credits: 50_000, loanBalance: 0 },
|
|
bays: [{ id: "b1", locationId: "earth" }],
|
|
ships: [
|
|
{
|
|
id: "s-idle",
|
|
modelId: "courier",
|
|
name: "Dusty Mule",
|
|
status: "idle",
|
|
locationId: "earth",
|
|
dispatchId: null,
|
|
pilotId: "avatar",
|
|
lastArrivalAt: 0,
|
|
...shipExtras("courier"),
|
|
},
|
|
{
|
|
id: "s-fly",
|
|
modelId: "clipper",
|
|
name: "Slow Ox",
|
|
status: "in-flight",
|
|
locationId: null,
|
|
dispatchId: "d1",
|
|
pilotId: "p2",
|
|
lastArrivalAt: 0,
|
|
...shipExtras("clipper"),
|
|
},
|
|
],
|
|
pilots: [
|
|
{ ...base.pilots[0]!, assignedShipId: "s-idle" },
|
|
{
|
|
...base.pilots[0]!,
|
|
id: "p2",
|
|
name: "Rae Okafor",
|
|
isAvatar: false,
|
|
assignedShipId: "s-fly",
|
|
status: "in-flight",
|
|
locationId: null,
|
|
},
|
|
],
|
|
activeDispatches: [flight],
|
|
};
|
|
}
|
|
|
|
/** Stands in for the API server; with `offline` set, every request fails as if unplugged. */
|
|
async function fakeServer(page: Page) {
|
|
const server = { offline: false, state: midgame(Date.now()) };
|
|
await page.route("**/api/**", async (route) => {
|
|
if (server.offline) return route.abort("internetdisconnected");
|
|
const path = new URL(route.request().url()).pathname;
|
|
if (path === "/api/state") return route.fulfill({ json: server.state });
|
|
if (path === "/api/time") return route.fulfill({ json: { realTime: Date.now(), gameTime: 0 } });
|
|
if (path.endsWith("/board")) {
|
|
return route.fulfill({ json: { contracts: [], periodEndsAt: Date.now() + 600_000 } });
|
|
}
|
|
return route.fulfill({
|
|
status: 404,
|
|
json: { error: { code: "not-found", message: "Not in this test" } },
|
|
});
|
|
});
|
|
return server;
|
|
}
|
|
|
|
const progress = async (page: Page) =>
|
|
Number(await page.getByRole("progressbar").first().getAttribute("aria-valuenow"));
|
|
|
|
test.describe("offline shell (Step 11.2)", () => {
|
|
test("opens from the cache with the last state, keeps flights moving, and blocks actions", async ({
|
|
page,
|
|
context,
|
|
}) => {
|
|
const server = await fakeServer(page);
|
|
await page.goto("/fleet");
|
|
await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible();
|
|
await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined));
|
|
await expect(page.getByRole("button", { name: /Deadhead/ })).toBeEnabled();
|
|
|
|
// Network off, then a full reload: everything must come from the device.
|
|
server.offline = true;
|
|
await context.setOffline(true);
|
|
await page.reload();
|
|
|
|
await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible();
|
|
await expect(page.getByText(/Offline — showing last update from/)).toBeVisible();
|
|
await expect(page.getByText("Slow Ox")).toBeVisible();
|
|
|
|
// The flight's progress bar keeps advancing from the cached dispatch.
|
|
const before = await progress(page);
|
|
await expect.poll(() => progress(page), { timeout: 10_000 }).toBeGreaterThan(before);
|
|
|
|
// Server actions are disabled; reading and navigating still work.
|
|
await expect(page.getByRole("button", { name: /Deadhead/ })).toBeDisabled();
|
|
await page.getByRole("link", { name: "Shop" }).first().click();
|
|
await expect(page.getByRole("button", { name: "Buy Courier" })).toBeDisabled();
|
|
|
|
// Settings (CR-011): server-backed ones off, display ones still work.
|
|
await page.goto("/settings").catch(() => undefined);
|
|
await expect(page.getByRole("heading", { level: 1, name: "Settings" })).toBeVisible();
|
|
for (const name of ["Rename", "Back up company", "Restore a company", "Start a new company"]) {
|
|
await expect(page.getByRole("button", { name })).toBeDisabled();
|
|
}
|
|
const clock24 = page.getByRole("radio", { name: "24-hour" });
|
|
await page.getByText("24-hour", { exact: true }).click();
|
|
await expect(clock24).toBeChecked();
|
|
|
|
// Back online: the banner goes and actions return.
|
|
server.offline = false;
|
|
await context.setOffline(false);
|
|
await expect(page.getByText(/Offline — showing last update/)).toHaveCount(0);
|
|
await expect(page.getByRole("button", { name: "Rename" })).toBeEnabled();
|
|
});
|
|
});
|