Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ production, track egg lots from the hen through to the sale with full
traceability, block medication-restricted lots, manage sales and customers, and
see the numbers that matter (hen-day rate, saleable %, stock on hand).

![The daily entry screen: egg counts and grading for one flock on one day, with the sellable total derived from them](docs/images/daily-entry.png)

- **Backend:** C# / .NET 10 (ASP.NET Core minimal APIs) · **Database:** PostgreSQL (EF Core)
- **Frontend:** React 19 + Vite (TypeScript), served by the API in production

Expand Down Expand Up @@ -62,6 +64,23 @@ fails: [first admin provisioning](docs/runbooks/first-admin-provisioning.md).
| [`docs/runbooks/`](docs/runbooks/) | Operating it: provisioning, break-glass recovery, backup & restore |
| [`specs/product/GLOSSARY.md`](specs/product/GLOSSARY.md) | The domain: flocks, daily entries, egg lots, culls, FIFO allocation |

## More of it

Sales orders through their lifecycle — draft, confirmed, with who did what and
when against each one:

![The sales screen: a filterable order list showing reference, date, customer, status, total, and a history column naming the user who created and confirmed each order](docs/images/sales.png)

The numbers a farm actually runs on — daily production with losses split by
cause, hen-day %, the by-grade breakdown, and the money beside it:

![The reports screen: a seven-day production table with eggs, losses, sellable, condition, deaths, hen-days and hen-day percent, then a money section summarising sales and expenses](docs/images/reports.png)

Screenshots are captured from the real built SPA over the simulation fixture —
[`tools/simulation/ui/specs-screenshots/`](tools/simulation/ui/specs-screenshots/),
refreshed with `npm run screenshots`. Nothing enforces that they match the
current UI; they are refreshed deliberately.

## Architecture

Multi-tenant from the root, so the system scales past a single farm:
Expand Down
Binary file added docs/images/daily-entry.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/reports.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/sales.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
9 changes: 9 additions & 0 deletions tools/simulation/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ Two modes:
| --- | --- | --- |
| **Smoke** (#385) | Key flow per persona + cross-cutting session/i18n/PWA guarantees | `npm test` |
| **Canary** (#386) | 1–2 browsers recording Core Web Vitals, optionally while k6 loads the backend | `bash run-canary.sh [--with-load]` |
| **Screenshots** (#549) | Captures the root README's three images into `docs/images/` | `npm run screenshots` |

**Screenshots are a third config on purpose.** That run WRITES FILES into the
repo, so folding it into `npm test` would rewrite committed images on every
smoke run and turn an unrelated green run into a dirty working tree. It is not
visual regression either — nothing asserts on pixels, and no gate checks the
images are current. See the header of `specs-screenshots/screenshots.spec.ts`
for the staleness contract and for why the Dashboard is deliberately not among
them.

**Playwright is never the load generator.** k6 stays the crowd.

Expand Down
1 change: 1 addition & 0 deletions tools/simulation/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"report": "playwright show-report",
"typecheck": "tsc --noEmit",
"canary": "playwright test --config playwright.canary.config.ts",
"screenshots": "playwright test --config playwright.screenshots.config.ts",
"canary:report": "playwright show-report playwright-report-canary",
"mutation": "bash mutation-check.sh"
},
Expand Down
76 changes: 76 additions & 0 deletions tools/simulation/ui/playwright.screenshots.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// tools/simulation/ui/playwright.screenshots.config.ts — #549.
//
// Captures the README's screenshots from the REAL built SPA over the same
// `seed --profile simulation` fixture every other spec here uses. Deliberately
// a THIRD config rather than a spec in `specs/`: this run WRITES FILES into the
// repo (docs/images/), and a capture that rides along with `npm test` would
// rewrite committed images on every smoke run — turning an unrelated green run
// into a dirty working tree.
//
// NOT a visual-regression suite. Nothing here asserts on pixels; rendering is
// not byte-deterministic across fonts and antialiasing, so a byte-diff gate
// would flake. The images are documentation artefacts with a manual refresh:
// see specs-screenshots/screenshots.spec.ts for the staleness contract.

import { defineConfig, devices } from "@playwright/test";
import { resolveBrowser } from "./src/browser";
import { BASE_URL } from "./src/env";

const { executablePath } = resolveBrowser();

export default defineConfig({
testDir: "./specs-screenshots",
Comment on lines +21 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include the screenshot suite in TypeScript checks

The new config points at specs-screenshots, but this project’s tsconfig.json includes only specs/**/*.ts, specs-canary/**/*.ts, and the two existing config files. Consequently, npm run typecheck does not examine either this config or the new screenshot spec, so TypeScript-only regressions in the documented capture command can land while the advertised validation remains green; add both paths to the TypeScript project.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 25cfb5b. specs-screenshots/**/*.ts and playwright.screenshots.config.ts are both in the project now.

You were right that the advertised validation was green while proving nothing about the new files — and the fix immediately proved it, twice:

  1. Coverage check. A deliberate const __mutant: number = "not a number" in the spec now fails the typecheck (TS2322 at that line); removing it restores green. Before the tsconfig change the same mutant compiled clean.
  2. A real defect the vacuous check had hidden. The same run reported TS6133: 'prefixOf' is declared but its value is never read — a leftover import from an assertion I refuted while writing the spec. It would have shipped.

"npm run typecheck clean" in the PR body was therefore a claim about files the compiler never opened. Corrected.


// Same reasoning as the smoke config: the stack is owned by reset.sh, not by
// Playwright. No webServer.
globalSetup: "./src/preflight.ts",

// One worker: the captures sign three personas in and out of one seeded
// database, and a second worker would interleave those sessions.
workers: 1,
fullyParallel: false,
retries: 0,
forbidOnly: true,

reporter: [["list"]],

timeout: 60_000,
expect: { timeout: 15_000 },

use: {
baseURL: BASE_URL,
trace: "retain-on-failure",
video: "off",
ignoreHTTPSErrors: true,
},

projects: [
{
name: "chromium",
use: {
...devices["Desktop Chrome"],

// AFTER the spread, not before, and not in the top-level `use`:
// `devices["Desktop Chrome"]` carries its own 1280x720 @1, and a
// project's `use` beats the top-level one. Setting the frame above the
// spread silently captures at the device's size instead of this one.
//
// A fixed frame means a refreshed image differs from its predecessor
// only where the app changed. 1280x800 is the widest that still reads
// at GitHub's rendered README width without shrinking the type to
// noise.
//
// Scale 1, measured rather than assumed: at scale 2 the three images
// are 910 KB, at scale 1 they are 382 KB, and GitHub renders a README
// image into roughly 890 CSS pixels — so a 1280-wide capture still has
// ~1.4x the pixels of its slot and stays sharp on a HiDPI display.
// Every regeneration adds its bytes to git history permanently, which
// is what makes the 2.4x worth avoiding.
viewport: { width: 1280, height: 800 },
deviceScaleFactor: 1,

launchOptions: executablePath ? { executablePath } : {},
},
},
],
});
193 changes: 193 additions & 0 deletions tools/simulation/ui/specs-screenshots/screenshots.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
// tools/simulation/ui/specs-screenshots/screenshots.spec.ts — #549.
//
// Captures the three images the root README embeds, from the REAL built SPA
// over the #243 simulation fixture. Run it deliberately:
//
// npm run screenshots # from tools/simulation/ui, with the sim stack up
//
// ================== THE STALENESS CONTRACT ==================
//
// These images are DOCUMENTATION ARTEFACTS, not test baselines, and **nothing
// enforces that they match the current UI**. A change to any of the three
// screens below silently invalidates them; a reviewer looking at a stale
// screenshot cannot tell. The honest mitigation is the one this file can
// actually keep: the capture is scripted, so refreshing is one command rather
// than a hunt for whoever took the last one. Re-run it when a captured screen
// changes, and at each release.
//
// Deliberately NOT a visual-regression gate. Rendering is not byte-deterministic
// across fonts, antialiasing and GPU paths, so a byte-diff check in the shape of
// the #417 schema-docs gate would flake — and a flaky gate gets disabled, which
// is worse than an honest "no gate".
//
// ================== WHY IT ASSERTS BEFORE IT CAPTURES ==================
//
// A screenshot of a half-rendered page is worse than no screenshot: it is a
// published claim that the app looks like that. Every capture below waits for a
// signal that the screen's DATA has arrived — not merely that the route
// rendered — because the panels fetch independently and each degrades to its
// own error text. `page.screenshot` also waits for fonts, so the assertions are
// the only part that needs saying out loud.
//
// The suite's three standing rules (#277/#385) apply here as they do to every
// other spec: no hardcoded credential (personas come from the git-ignored
// `.sim-cast.json`), no hardcoded English (selector text resolves through the
// SPA's own catalogs), and respect the farm clock.

import { mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { expect, test, type Page } from "../src/fixtures";
import { castMember } from "../src/cast";
import { daysBefore, farmToday } from "../src/farm";
import { tEn } from "../src/i18n";

// tools/simulation/ui/specs-screenshots/ -> repo root -> docs/images/
const IMAGE_DIR = fileURLToPath(new URL("../../../../docs/images/", import.meta.url));

function imagePath(name: string): string {
mkdirSync(IMAGE_DIR, { recursive: true });
return `${IMAGE_DIR}${name}`;
}

// `animations: "disabled"` finishes CSS transitions instead of freezing them
// mid-flight, so a capture cannot land on a half-faded panel.
//
// Blurring first is not cosmetic pedantry: a field this spec typed into keeps
// the focus ring AND its text selection, so the published image shows a form
// mid-edit with half a date highlighted — which reads as a screenshot somebody
// took by accident.
async function capture(page: Page, name: string): Promise<void> {
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur());
await page.screenshot({ path: imagePath(name), animations: "disabled" });
}

test.describe("README screenshots", () => {
// ================== WHY THERE IS NO DASHBOARD SHOT ==================
//
// The obvious hero image is the Dashboard, and it was captured first. Its
// "Today" panel is EMPTY in every fixture capture — the simulation seeds
// history up to a few days back, so both flocks read "no entry" and "eggs
// collected today" is 0. True of the fixture, misleading about the product,
// and unfixable from here without the capture writing today's entries into
// the shared database. The three screens below each evidence a specific
// claim the README makes, with data.

test("daily entry — a recorded day, by grade", async ({ page, signIn, farm }) => {
await signIn(castMember("Manager"));

// YESTERDAY, not today, and this is the whole point of the capture. The
// fixture seeds history up to the previous farm-local day, so today's form
// is an empty one — a screenshot of five zeroes that says nothing about
// what the screen is for. Yesterday has a real submitted entry: counts,
// grades, and the sellable arithmetic that makes the screen legible.
//
// Farm-local, via the farm fixture: `America/Chicago` is behind UTC, so a
// UTC "yesterday" is sometimes today's date on the farm and the capture
// would silently be the empty form again.
const day = daysBefore(farmToday(farm.timeZoneId), 1);

// #446: the page fires a prefill that RESETS every count when it settles.
// Capturing before that lands photographs numbers the app is about to throw
// away. Register the wait BEFORE the navigation that triggers it.
const prefill = page.waitForResponse((r) => r.url().includes("/daily-entries") && r.ok());
await page.goto("/daily-entry");
await page.getByLabel(tEn("dailyEntry:dateLabel")).fill(day);
await prefill;

// The assertion IS the point of choosing yesterday: a non-zero total proves
// the seeded day loaded, which is the whole difference between this capture
// and a screenshot of an empty form. Waiting on the route or on a label
// would pass just as happily on the empty one.
//
// Three other signals were refuted first, and each failure mode is worth
// knowing: "Sellable" resolves to two elements (totals label and summary
// line — strict-mode violation); `allGradedShort` is present but CSS-hidden
// here, so waiting on it times out claiming the data never arrived when it
// had; and `entryLockedBanner` never renders for this day at all.
await expect(
page.getByRole("spinbutton", { name: tEn("dailyEntry:totalEggsLabel") }),
).not.toHaveValue("0");

// Read-only on purpose: this capture must not submit, adjust or void
// anything. It shares one seeded database with every other spec here.
await capture(page, "daily-entry.png");
});

test("reports — production and money over the seeded period", async ({ page, signIn, farm }) => {
await signIn(castMember("Owner"));
await page.goto("/reports");

// The default range ends TODAY, and the fixture's most recent days are
// drafts — which a report does not count. The default view is therefore
// three trailing rows of zeroes, which is a true statement about the
// fixture and a misleading one about the product.
//
// So the range is set explicitly to a window that is entirely submitted
// days. The offsets are fixture knowledge, not a general truth: the seeder
// leaves roughly the last few days as drafts. If that changes, the
// assertion below is what fails.
const today = farmToday(farm.timeZoneId);
// `exact` on both, and the reason is the SECOND one: the sidebar's theme
// toggle is labelled "Switch to night mode", which contains "To" — so a
// substring match on the To field is ambiguous with a button that changes
// the whole page's colours.
await page.getByLabel(tEn("reports:fromLabel"), { exact: true }).fill(daysBefore(today, 9));
await page.getByLabel(tEn("reports:toLabel"), { exact: true }).fill(daysBefore(today, 3));

await expect(page.getByRole("heading", { name: tEn("reports:productionHeading") })).toBeVisible();
await expect(page.getByRole("heading", { name: tEn("reports:moneyHeading") })).toBeVisible();

// Renders only when the period produced something (ReportsPage guards on
// `gradeTotals.length > 0`), so it proves the window is not empty.
await expect(page.getByText(tEn("reports:gradeTotalsLabel"))).toBeVisible();

// ...but a non-empty PERIOD can still end in zero rows, which is exactly
// the thing being avoided. The last row's Eggs cell is the specific check:
// the window's final day must itself have production.
const lastRow = page.getByRole("table").first().locator("tbody tr").last();
await expect(lastRow.locator("td").nth(1)).not.toHaveText("0");

await capture(page, "reports.png");
});

test("sales — orders across their lifecycle", async ({ page, signIn }) => {
await signIn(castMember("Sales"));
await page.goto("/sales");

await expect(page.getByRole("heading", { name: tEn("sales:ordersHeading") })).toBeVisible();

// The heading and the absence of "Loading…" are NOT enough, and the failure
// they miss is the one that matters: when the initial /sales request fails,
// `usePagedList` empties the rows and clears its loading flag, so the page
// settles into an error plus the empty-state message with the heading still
// there. Both weak signals pass, and the run overwrites the committed image
// with a picture of an error while reporting success.
//
// So: the empty state must be absent, and the table must actually hold
// orders in the two lifecycle states this capture exists to show.
// `sales:noOrdersMatch`, not `dashboard:noOrdersMessage` — two different
// empty states in two namespaces, and this page renders the former. The
// wrong one is not a silent miss: src/i18n.ts throws on an absent key
// rather than falling back, which is how this was caught.
await expect(page.getByText(tEn("sales:noOrdersMatch"))).toHaveCount(0);
const orders = page.getByRole("table").first();
await expect(orders.getByRole("row").filter({ hasText: tEn("enums:status.Confirmed") }))
.not.toHaveCount(0);
await expect(orders.getByRole("row").filter({ hasText: tEn("enums:status.Draft") }))
.not.toHaveCount(0);

Comment on lines +175 to +178

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Wait for sales setup data before capturing

Fresh evidence beyond the earlier order-loading fix is that SalesPage loads the order list independently from its customer/product/grade setup request. When /sales returns first, these row assertions pass while customer cells still show ID prefixes and the New order control is absent; if setup subsequently fails, the page can even switch to its setup-error screen before capture while the test remains green. Wait for a setup-derived signal such as the localized New order button before writing the image.

AGENTS.md reference: AGENTS.md:L188-L191

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 151d4ed. Verified in SalesPage.tsx: customerName is customers.find(...)?.name ?? id.slice(0, 8) (line 175), and setupError replaces the entire screen (line 572) — so both halves of what you describe are real.

The capture now waits for the New order button, which renders only past the setup gate.

Mutation-checked in both directions, and they do not both land on the new line — worth stating rather than claiming the one assertion covers everything:

  • Setup delayed past the expect timeout (page.route on /customers with a 20s delay): the rows render, every earlier assertion passes, and the run fails on getByRole('button', { name: 'New order' }) — the new guard, by name. This is the mid-load GUID-column case.
  • Setup failing outright (r.abort()): the page becomes the setupError screen, and the run fails on the Orders heading — the first assertion, not the new one. Already covered, just not by the line you asked for.

Both restored; all three captures green.

// The orders list and the page's SETUP data (customers, products, grades)
// are two independent loads, and the rows render either way: `customerName`
// falls back to `id.slice(0, 8)`, so a capture taken between them shows a
// column of GUID fragments where customer names belong. Worse, a setup
// failure replaces the whole screen with `setupError` — after these row
// assertions have already passed.
//
// The New order button is the setup-derived signal: it renders only past
// that gate, so it is absent in both the mid-load and the error state.
await expect(page.getByRole("button", { name: tEn("sales:newOrder") })).toBeVisible();

await capture(page, "sales.png");
});
});

2 changes: 2 additions & 0 deletions tools/simulation/ui/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@
"src/**/*.ts",
"specs/**/*.ts",
"specs-canary/**/*.ts",
"specs-screenshots/**/*.ts",
"playwright.config.ts",
"playwright.canary.config.ts",
"playwright.screenshots.config.ts",
"../../../web/src/i18n/en.ts",
"../../../web/src/i18n/es.ts",
"../../../web/src/i18n/tl.ts"
Expand Down
Loading