- seeded OPORD generator (grid, azimuth, H-HOUR, phase lines, codewords, callsign, frequency) in pure lib src/lib/opord-recall.ts with per-tier memo windows (25/18/12s) and quiz sizes (4/6/8) - server action submitOpordRecallResult validates input, recomputes score server-side, persists to Profiles progression.minigames.opordRecall, broadcasts XP, emits minigame:opord-recall event - UI at /opord: briefing countdown, one-at-a-time quiz, debrief metres, tier radio group, SSE presence, REST leaderboard per tier - profiles migration 20260908_152021 (7 additive columns) + XP hook wiring - nav entries in command palette and NavUser Exercises submenu - e2e spec plays a full round and asserts debrief + leaderboard row
60 lines
2.5 KiB
TypeScript
60 lines
2.5 KiB
TypeScript
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
const QUIZ_OPTION_BUTTON = "button.font-mono";
|
|
|
|
async function login(page: Page): Promise<void> {
|
|
await page.goto("/login");
|
|
await page.fill("#username", "dev");
|
|
await page.fill("#password", "Test123");
|
|
await page.locator('button[type="submit"]').click();
|
|
await page.waitForURL((url) => !url.pathname.startsWith("/login"), { timeout: 30_000 });
|
|
}
|
|
|
|
async function playOpordRound(page: Page): Promise<void> {
|
|
await page.goto("/opord");
|
|
await expect(page.getByRole("heading", { name: "OPORD Recall" })).toBeVisible();
|
|
|
|
await page.getByRole("button", { name: "Receive briefing" }).click();
|
|
await expect(page.getByText("Memorize the order")).toBeVisible();
|
|
|
|
const briefingText = await page.locator("body").innerText();
|
|
const gridMatch = briefingText.match(/grid (\d{4}) (\d{4})/);
|
|
if (!gridMatch) throw new Error("Objective grid not found in briefing text");
|
|
const grid = `${gridMatch[1]} ${gridMatch[2]}`;
|
|
|
|
await page.getByRole("button", { name: "Ready early" }).click();
|
|
|
|
const questionCounter = page.getByText(/Question \d+ of \d+/);
|
|
await expect(questionCounter).toBeVisible();
|
|
await expect(questionCounter).toContainText("Question 1 of 4");
|
|
|
|
// Question 1 is always the objective grid: answer it correctly.
|
|
await page.locator(QUIZ_OPTION_BUTTON).filter({ hasText: grid }).first().click();
|
|
|
|
for (let index = 1; index < 4; index += 1) {
|
|
await expect(questionCounter).toContainText(`Question ${index + 1} of 4`, {
|
|
timeout: 10_000,
|
|
});
|
|
await page.locator(QUIZ_OPTION_BUTTON).first().click();
|
|
}
|
|
|
|
await expect(page.getByRole("heading", { name: "Debrief" })).toBeVisible({ timeout: 15_000 });
|
|
}
|
|
|
|
test.describe("OPORD Recall", () => {
|
|
test("plays a full round and records the score", async ({ page }) => {
|
|
await login(page);
|
|
await playOpordRound(page);
|
|
|
|
// The grid answer alone scores 100; the XP metre shows a positive bonus.
|
|
await expect(page.locator("body")).toContainText(/Recalled/);
|
|
await expect(page.getByText(/^\+\d+$/).first()).toBeVisible();
|
|
|
|
// The leaderboard refetches after the run and shows the recorded run for this user.
|
|
const leaderboard = page.getByRole("table");
|
|
await expect(leaderboard).toBeVisible({ timeout: 15_000 });
|
|
const myRow = leaderboard.locator("tbody tr").filter({ hasText: "(you)" }).first();
|
|
await expect(myRow).toBeVisible({ timeout: 15_000 });
|
|
await expect(myRow.locator("td").last()).toContainText(/\d{3}/);
|
|
});
|
|
});
|