diff --git a/AGENTS.md b/AGENTS.md index 923dca9d..09af7508 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,69 +9,38 @@ This file provides guidance to AI Agents like Claude Code (claude.ai/code) or Co ## Commands ```bash -# Development server (HTTPS on localhost:4200) -npm start - -# Build +npm start # Dev server, HTTPS on localhost:4200 npm run build:dev # Development build -npm run build # Production build +npm run build # Production build (runs prerendering) -# Testing -npm test # Run unit tests with Karma (watch mode) -npm run test:ci # CI tests with ChromeHeadless and coverage -npm run test:brief # Token-efficient test output (CI mode, failures/warnings only) +npm test # Karma watch mode +npm run test:ci # ChromeHeadless + coverage +npm run test:brief # Token-efficient: CI mode, failures/warnings only +npm run test:scripts # Node tests for scripts/ (security headers, docs validation) -# Linting -npm run lint # Run ESLint -npm run lint:fix # Auto-fix lint issues -npm run lint:brief # Token-efficient lint output (errors/warnings only) +npm run lint # ESLint +npm run lint:fix +npm run lint:brief # Token-efficient: errors/warnings only -# E2E Tests -npm run e2e # Open Cypress interactively -npx cypress run # Run all E2E tests headlessly -npx cypress run --spec cypress/e2e/prints/print-list-filters.cy.ts # Run a single spec +npm run e2e # Cypress interactive +npx cypress run --spec cypress/e2e/prints/print-list-filters.cy.ts -# Formatting npm run prettier # Check formatting -npm run prettier:fix # Fix formatting +npm run prettier:fix -# Generated screenshots (manual; see "Generated screenshots") -npm run capture:home:all # Home feature images -> src/assets/ +npm run capture:home:all # Home feature images -> src/assets/ (manual; see "Generated screenshots") npm run capture:docs:all # Documentation figures -> src/assets/docs/captures/ ``` -### Token-Efficient Commands - -When communicating with Claude about test or lint failures, use these token-efficient variants to reduce output: +Prefer `test:brief` and `lint:brief` when reporting failures — they are optimized for minimal output while preserving what is actionable. -- **`npm run test:brief`** - Runs tests in CI mode with only failures/warnings displayed -- **`npm run lint:brief`** - Runs linting with only errors/warnings displayed +**Read the last line of `test:brief`, not the exit code.** These are meant to be piped (`npm run test:brief | tail -20`), and a shell pipeline reports the status of its _last_ command — so the exit code you see belongs to `tail`. The script prints `RESULT: PASSED` or `RESULT: FAILED (ng test exited N)` as its final line for exactly this reason. A compile error is the case that bites: Karma never reaches a `TOTAL:` line, so without that verdict the output ends in a blank summary that looks like a clean run. -These commands are optimized for minimal token usage while preserving actionable information about failures and warnings. - -**Read the last line of `test:brief`, not the exit code.** These are meant to be -piped (`npm run test:brief | tail -20`), and a shell pipeline reports the status -of its _last_ command — so the exit code you see belongs to `tail`, not to the -test run. The script prints `RESULT: PASSED` or `RESULT: FAILED (ng test exited -N)` as its final line for exactly this reason. A compile error is the case that -bites: Karma never reaches a `TOTAL:` line, so without that verdict the output -ends in a blank summary that looks like a clean run. +**`lint-staged` formats `*.{js,css,md,ts,scss}` on commit — not `.html`.** Templates are never auto-formatted, so an edit that writes CRLF silently flips a whole file and shows up as a several-hundred-line diff with equal insertions and deletions. Run `npx prettier --write` on every template you touch. `npm run prettier` currently reports one pre-existing warning (`settings.component.html`); anything beyond that is yours. ## Architecture -### Module Structure - -- **app-routing.module.ts** - Main routes with lazy-loaded feature modules -- **core/** - Singleton services, guards, resolvers, HTTP interceptors, and stores -- **shared/** - Reusable components, pipes, and SharedModule (exports Angular Material modules) -- **Feature modules** (lazy-loaded): `print/`, `printer/`, `filament/`, `analytics/`, `users/`, `settings/`, `feed/`, `apikeys/`, `printer-maintenance/`, `documentation/`, `home/` - -### Key Services (in `core/services/`) - -- **auth.service.ts** - Auth0 authentication, user profile management -- **print.service.ts** - CRUD for prints, image uploads, cost calculations -- **printer.service.ts**, **filament.service.ts** - Entity management -- **file-parsers/** - Slicer G-code parsers (Cura, PrusaSlicer, OrcaSlicer, Creality Print, Anycubic) +`core/` holds singleton services, guards, resolvers, HTTP interceptors and stores; `shared/` holds reusable components, pipes and `SharedModule` (which re-exports Angular Material). Feature modules are lazy-loaded and named for their route. Slicer G-code parsers (Cura, PrusaSlicer, OrcaSlicer, Creality Print, Anycubic) live in `core/services/file-parsers/`. ### Authentication Flow @@ -86,6 +55,7 @@ Routes without `AuthGuard` (e.g. `/prints/:id`, public profiles/materials) must - On a public route, resolvers/services must degrade to a default/`null` for anonymous users, never throw. Fix in the **service** (also protects `ngOnInit` callers), and keep settings consumers null-tolerant (`?.value`, `?? default`). Auth-required endpoints reject with `missing_refresh_token` unless the request sets `allow-anonymous-request` **and** the API marks them `[AllowAnonymous]`. - Test logged-out without Auth0: append `?devUserId=anonymous` (dev only; `isDevAnonymous`/`resolveDevUserId` in `core/utils/dev-user.ts`, persisted per-tab in sessionStorage). Regression pattern: `cypress/e2e/prints/public-print-anonymous.cy.ts` (a public-route E2E with no `cy.login()`). +- **Prefer a structural guarantee over a per-surface guard.** Printer photos stay off public print pages because the endpoint feeding them is authenticated-only, so an anonymous visitor's map is empty — not because seven templates each remember to check `isOwner()`. ### Route Preloading @@ -94,43 +64,39 @@ Lazy chunks are preloaded **opt-in**, via `SelectivePreloadStrategy` (`core/rout - A route preloads only when it carries `data: { preload: true }`. Today that is `prints`, `materials`, and `printers` — the sections a signed-in user reaches first. `preload-route-matrix.spec.ts` pins that list, so widening it is a deliberate, reviewed change. - Preloading is skipped entirely when `navigator.connection` reports `saveData` or an effective type of `slow-2g`/`2g`/`3g`, and during prerender (fetching a chunk in Node buys nothing). - Do **not** reach for `PreloadAllModules`. It pulls every feature chunk right after first paint, including the documentation site and the d3-backed analytics bundle. -- `navigator.connection` is read through the `NETWORK_INFORMATION` injection token, which is the one guarded home for that global — see the SSR-safety note above. - -### Environment Configuration - -- `src/environments/environment.ts` - Development (localhost:5001 API) -- `src/environments/environment.prod.ts` - Production -- `src/environments/environment.unittest.ts` - Unit tests +- `navigator.connection` is read through the `NETWORK_INFORMATION` injection token, which is the one guarded home for that global — see the SSR-safety note below. ### Prerendering & Sitemap (SEO) -Marketing/SEO routes are prerendered to static HTML at build time via `@angular/ssr` with `outputMode: "static"` (production config only). In `src/app/app.routes.server.ts`, marketing routes use `RenderMode.Prerender` and everything else uses `RenderMode.Client`. `npm run build` runs the marketing routes through Node to emit static `index.html` files. +Marketing/SEO routes are prerendered to static HTML at build time via `@angular/ssr` with `outputMode: "static"` (production config only). In `src/app/app.routes.server.ts`, marketing routes use `RenderMode.Prerender` and everything else uses `RenderMode.Client`. - **SSR-safety (important):** prerendering executes components in Node, so any browser global (`window`, `document`, `localStorage`, `navigator`) touched during construction/init crashes the build. Guard it with `isPlatformBrowser(inject(PLATFORM_ID))`. - **Marketing routes** are defined once in `scripts/marketing-routes.mjs`. To add a prerendered page, add it there AND in `app.routes.server.ts`. - **Verification:** `scripts/verify-prerender.mjs` runs in CI and gates prerendered output (unique titles/descriptions, OG/Twitter, canonicals, internal link graph, crawl files). -- **Sitemap** is generated at deploy time by `scripts/generate-sitemap.mjs` (fetches public print/user IDs, writes a `` plus chunked child sitemaps into `dist/`). It is not committed; there is no static `src/sitemap.xml`. Unit tests: `npm run test:scripts`. +- **Sitemap** is generated at deploy time by `scripts/generate-sitemap.mjs` (fetches public print/user IDs, writes a `` plus chunked child sitemaps into `dist/`). It is not committed; there is no static `src/sitemap.xml`. - **Deploy** ships the prebuilt `dist` with `skip_app_build: true` (no Oryx rebuild) so the generated sitemap reaches production; `refresh-sitemap.yml` redeploys the latest release tag daily. ### Generated screenshots -Two sets of images are captured from the real app against Cypress fixtures and post-processed into hashed WebP: the home page's three feature images (`src/assets/`) and the documentation figures (`src/assets/docs/captures/`). Both are committed, and **no workflow regenerates them**. Nothing compares them to the current UI either, so they go stale silently: the analytics image once advertised a page that had been deleted. +Two sets of images are captured from the real app against Cypress fixtures and post-processed into hashed WebP: the home page's feature images (`src/assets/`) and the documentation figures (`src/assets/docs/captures/`). Both are committed, and **no workflow regenerates them**. Nothing compares them to the current UI either, so they go stale silently: the analytics image once advertised a page that had been deleted. Both sets run the same harness (`cypress/support/capture.ts`) over a `CaptureSet` declared in `cypress/fixtures/demo/manifest.ts`; the spec files are three lines each. - **If you change a view either set captures, re-run that set's capture in the same change.** Home covers the print list, the materials list and the analytics overview tab (`npm run capture:home:all`); docs covers whatever `DOC_CAPTURE_TARGETS` lists (`npm run capture:docs:all`). Commit the images, plus `src/app/home/home.component.html` for the home set (the processing step rewrites its `ngSrc`/`width`/`height`) or `src/content/docs-captures.json` for the docs set. - **A doc figure is referenced by name, never by path:** `` resolves its src and both intrinsic dimensions from the generated map, so adding a figure never touches a template. `src` remains for hand-placed assets and then requires `width`/`height`. `validate-docs.mjs` fails on both-or-neither, on a `name` with no asset, and on hand-typed dimensions beside a `name`. -- **Callouts on a figure are `` children, never arrows burned into the image:** `` places a numbered disc at a **percentage** of the image box, so a recapture leaves it valid. The number comes from DOM order (a CSS counter), one overlay serves both theme variants, and `label` is the marker's only text in the accessibility tree. Reach for one only when the prose sends the reader hunting for regions the surrounding UI does not distinguish — a tighter capture boundary is the cheaper fix and usually the right one. `validate-docs.mjs` requires `label`, requires `x`/`y` to be numbers in 0-100, and rejects a marker outside a ``. -- Requires **Chrome**; `--force-device-scale-factor=2` is a no-op in Electron. Each capture records its boundary's CSS width, and the processing step refuses anything whose PNG-to-CSS ratio is under `MIN_DEVICE_SCALE` rather than publish a half-resolution image. +- **Callouts on a figure are `` children, never arrows burned into the image:** `` places a numbered disc at a **percentage** of the image box, so a recapture leaves it valid. Reach for one only when the prose sends the reader hunting for regions the surrounding UI does not distinguish — a tighter capture boundary is the cheaper fix and usually the right one. +- **The capture harness is also the only end-to-end check this repo runs by default.** It drives the real app in a real browser against fixtures, so it catches template-level failures no unit test sees. If a target that used to pass starts failing, suspect the app before the fixture. +- **`ready` steps must assert on content, not containers**, and on _decode_ rather than presence for images — `imagesRendered` counts DOM nodes, which a still-loading `` already satisfies; `imagesLoaded` waits for pixels. +- Requires **Chrome**; `--force-device-scale-factor=2` is a no-op in Electron. Each capture records its boundary's CSS width, and the processing step refuses anything whose PNG-to-CSS ratio is under `MIN_DEVICE_SCALE`. - The capture specs are excluded from the normal Cypress config — they are generators, not tests. Do not add them back to the E2E run. -- Full detail, and the traps that have already been hit (minimatch globs vs unencoded `/` in query values, viewport clamping in both axes and stitched screenshots, fixture ordering that must match each list's default sort): `cypress/CLAUDE.md`. +- If a dev server is already running on 4200, run the two steps directly (`npm run capture:docs` then `npm run capture:docs:process`) — the `:all` variants use `wait-on`, which times out against the self-signed cert. +- Full detail and the traps already hit (minimatch globs vs unencoded `/` in query values, viewport clamping, fixture ordering that must match each list's default sort): `cypress/CLAUDE.md`. ### Security Headers & CSP Response headers are served by Azure Static Web Apps from `src/staticwebapp.config.json` (`globalHeaders`), which ships as a build asset — SWA reads it literally, so it stays hand-edited JSON. - **SWA injects its own defaults**, whether or not we declare any: HSTS (with `preload`), `Referrer-Policy: same-origin`, `X-Content-Type-Options: nosniff`, `X-XSS-Protection`, and `X-DNS-Prefetch-Control`. `globalHeaders` **overrides them by name**, so redeclaring one with a laxer value is a silent downgrade. `SWA_DEFAULT_HEADERS` in `scripts/security-headers-lib.mjs` records the platform baseline and a test asserts we never fall below it. Verify with `curl -sI https://www.3dprintlog.com/` before changing a value. - - **Validation:** `scripts/security-headers.test.mjs` parses the checked-in config and asserts the headers and CSP directives the app depends on. It runs in CI and at deploy via `npm run test:scripts`. `REQUIRED_CSP_SOURCES` maps each directive to the sources a real feature needs, and a test proves that removing any one of them fails — so adding a third-party origin means updating the CSP _and_ that map. - **The CSP is report-only.** It ships as `Content-Security-Policy-Report-Only` so a missed origin degrades to a console warning rather than a broken page. - **Two things must be solved before enforcing it**, and neither is done: @@ -154,6 +120,16 @@ Follow the patterns in `.github/copilot-instructions.md`: - Put host bindings in the `host` object of decorators, not `@HostBinding`/`@HostListener` - Use `NgOptimizedImage` for static images +### Signals: never write one while a computed is evaluating + +Angular throws **NG0600** for a signal write during `computed()` evaluation, and the throw propagates out of the template that read it — so the failure is not a console warning, it is _the whole subtree failing to render_. A store whose getter lazily kicks off a fetch is the usual way this happens: setting a `loading` phase inside that getter is a write, and any component reading it from a `computed` takes the error. `PrinterThumbnailStore.thumbnailFor()` defers its fetch to a `queueMicrotask` for exactly this reason; that one cost a fully-green suite and a print list that rendered zero rows. + +If a lazily-loading store must be readable from a computed, keep the read pure and schedule the work. + +### A component input beats a `::ng-deep` override + +A child's own `.thing img` rule and a parent's `::ng-deep .thing img` tie on specificity, so which wins depends on stylesheet order — which is not something a caller can rely on. When a child needs to render differently for one caller, give it an input (see `SignedImageComponent.fit`), not a CSS escape hatch. + ### Loading States (skeletons, spinners, progress bars) **Never render a busy affordance unconditionally.** Most responses land in tens of milliseconds, and a placeholder that appears and vanishes inside two frames reads as a rendering glitch, not as feedback. Every busy affordance goes through `src/app/shared/skeleton/deferred-skeleton.ts`, which enforces two thresholds — show nothing for the first 200ms, and once shown stay up for 400ms so the flash cannot just move to the boundary. @@ -166,105 +142,41 @@ Follow the patterns in `.github/copilot-instructions.md`: ## Testing -- Unit tests use Jasmine + Karma with Chrome -- Test files are co-located with source files (`*.spec.ts`) -- E2E tests use Cypress with base URL `https://localhost:4200` - -### Unit Test Patterns - -**Standalone components** use `imports` in TestBed: +Jasmine + Karma with Chrome, specs co-located with source (`*.spec.ts`), unit-test environment in `src/environments/environment.unittest.ts`. Cypress E2E against `https://localhost:4200`; **E2E needs the API running in `E2ETesting` mode**, so ask before running it. -```typescript -await TestBed.configureTestingModule({ - imports: [MyComponent, NoopAnimationsModule], - providers: [{ provide: MyService, useValue: mockService }], -}).compileComponents(); -``` - -**Module-based components** use `declarations`: - -```typescript -await TestBed.configureTestingModule({ - declarations: [MyComponent], - imports: [MatDialogModule], - providers: [...], -}).compileComponents(); -``` +Standalone components go in TestBed `imports`, module-declared ones in `declarations`. Mock services with `jasmine.createSpyObj`. Async work needs `fixture.detectChanges()` then `await fixture.whenStable()`. -**Mocking services** with Jasmine: +**Stubbing every collaborator can hide the bug that matters.** A component's contract with a service is not just the values it returns — with signals it includes _when_ that service touches reactive state. Every spec around `PrinterAvatarComponent` stubbed the store and passed while the print list rendered nothing in a real browser (see NG0600 above). When a component depends on a service that reads or writes signals, add at least one spec that wires the **real** service with `provideHttpClientTesting`, and assert the first `detectChanges()` does not throw. -```typescript -const mockService = jasmine.createSpyObj('MyService', ['methodName']); -mockService.methodName.and.returnValue(of(mockData)); -``` - -**Async operations** require `fixture.detectChanges()` and `await fixture.whenStable()`: - -```typescript -fixture.detectChanges(); -await fixture.whenStable(); -expect(component.data()).toEqual(expected); -``` +The suite also flakes under Karma's random ordering — re-run and check a failure in isolation before treating it as real. ## Analytics & Metrics -Use `LoggingService` to track user actions and errors. +Track user actions with `loggingService.logEvent(name, properties)` and errors with `logException(error)`. -### Logging Events - -```typescript -private readonly loggingService = inject(LoggingService); - -// Track user action with properties -this.loggingService.logEvent('ComponentName_ActionName', { - property1: value1, - property2: value2, -}); - -// Track exceptions -this.loggingService.logException(error); -``` - -### Naming Convention - -- Event names follow `ComponentName_ActionName` pattern (e.g., `QrLabelDialog_Print`, `FilamentSearchModal_FilamentSelected`) +- Event names follow `ComponentName_ActionName` (e.g. `QrLabelDialog_Print`, `FilamentSearchModal_FilamentSelected`) - Use descriptive action names: `Opened`, `Closed`, `Selected`, `Error`, `Success` - Include relevant context in properties (counts, IDs, settings used) ## Documentation -All user-facing documentation lives in the `src/documentation` directory. +User-facing documentation is authored as Markdown in **`src/content/docs/*.md`**. `src/app/documentation/generated/` is build output — never edit it. -- Each main page should has it's own angular component for documentation (Prints, printers, filaments/materials, etc) -- Each integration should have it's own documentation page (integrations, mobile app, etc) -- Update existing documentation with new functionality -- Documentation should be written in clear english, designed to be understandable by the user. +- Each main page gets its own doc page (Prints, printers, filaments/materials, etc.), as does each integration (mobile app, MCP, etc.) +- Update existing documentation with new functionality; write for the user, not the developer - Screenshots use ``, which resolves a generated capture. See "Generated screenshots" above. +- Validate with `node scripts/validate-docs.mjs` ### Release notes -One Markdown file per release under `src/content/release-notes/.md`, with `version`, `date` -and `title` frontmatter. Adding a release means adding one file — see `/release` step 4.2. +One Markdown file per release under `src/content/release-notes/.md`, with `version`, `date` and `title` frontmatter. Adding a release means adding one file — see `/release` step 4.2. -- **The anchor is generated from `version`, not from the heading.** `1.38.0.md` publishes - `#v1.38.0`. A slugger would mangle the dots, and 97 of these ids are already bookmarked, so - `validate-docs.mjs` fails if a previously published anchor stops being emitted. -- **The page shows the ten newest releases; the rest is a lazily imported chunk** built by - `scripts/release-notes-emit.mjs`. That archive is injected with `[innerHTML]`, so it is rewritten - first: `routerLink` becomes `href` and `` becomes the ligature span, because neither - directive nor component exists in markup Angular never compiled. It also has to survive Angular's - sanitizer — do not add a `bypassSecurityTrust*` call to make some new shape work. -- `scripts/extract-release-notes.mjs` reads these files directly to build the GitHub Release body, - before `npm ci` and before any generation runs, so it must never depend on a generated artifact. +- **The anchor is generated from `version`, not from the heading.** `1.38.0.md` publishes `#v1.38.0`. A slugger would mangle the dots, and many of these ids are already bookmarked, so `validate-docs.mjs` fails if a previously published anchor stops being emitted. +- **The page shows the ten newest releases; the rest is a lazily imported chunk** built by `scripts/release-notes-emit.mjs`. That archive is injected with `[innerHTML]`, so it is rewritten first: `routerLink` becomes `href` and `` becomes the ligature span, because neither directive nor component exists in markup Angular never compiled. It also has to survive Angular's sanitizer — do not add a `bypassSecurityTrust*` call to make some new shape work. +- `scripts/extract-release-notes.mjs` reads these files directly to build the GitHub Release body, before `npm ci` and before any generation runs, so it must never depend on a generated artifact. ## GitHub -Issues and PRs are managed on GitHub. - -- **Org:** `https://github.com/HoffmanEngineering` -- **UI repo:** `https://github.com/HoffmanEngineering/3d-print-log-ui` -- **API repo:** `https://github.com/HoffmanEngineering/3d-print-log-api` - -Use `gh issue list`, `gh pr create`, `gh pr view` for CLI operations. +Issues and PRs are managed on GitHub under `https://github.com/HoffmanEngineering` — UI repo `3d-print-log-ui`, API repo `3d-print-log-api`. Use `gh issue list`, `gh pr create`, `gh pr view`. When asked to "work on issue #N": fetch the issue with `gh issue view N`, implement the feature, open a PR with `gh pr create`. diff --git a/cypress/e2e/prints/public-print-anonymous.cy.ts b/cypress/e2e/prints/public-print-anonymous.cy.ts index 291925b7..9ea995eb 100644 --- a/cypress/e2e/prints/public-print-anonymous.cy.ts +++ b/cypress/e2e/prints/public-print-anonymous.cy.ts @@ -22,6 +22,14 @@ describe('Anonymous public print view', () => { // Registered before the visit so the attachment fetch is captured. cy.intercept('GET', '**/api/Prints/*/files*').as('attachments'); + // The thumbnail map is authenticated-only, so a logged-out visitor must never + // even ask for it - that is what keeps a printer's photos off a public print. + cy.intercept( + 'GET', + '**/api/Printers/thumbnails*', + cy.spy().as('thumbnailRequest') + ); + cy.visit(`/prints/${print.id}?devUserId=anonymous`); // 1) No bounce to home (#66). @@ -41,6 +49,8 @@ describe('Anonymous public print view', () => { cy.get('[data-cy-edit-btn]').should('not.exist'); cy.get('[data-cy-printer-link]').should('not.exist'); cy.get('a[href*="/filament/"]').should('not.exist'); + cy.get('app-printer-avatar img').should('not.exist'); + cy.get('@thumbnailRequest').should('not.have.been.called'); // 4) Cost absence is asserted but is NOT evidence of gating, and must // not be cited as such. Material cost needs a recorded filament price diff --git a/cypress/fixtures/demo/filaments.json b/cypress/fixtures/demo/filaments.json index 6bd232ab..f322282a 100644 --- a/cypress/fixtures/demo/filaments.json +++ b/cypress/fixtures/demo/filaments.json @@ -113,8 +113,8 @@ "loadedInPrinter": { "id": 103, "name": "Resin Station", - "make": "Anycubic", - "model": "Photon Mono M7 Pro", + "make": "HeyGears", + "model": "Reflex RS Turbo", "isActive": true, "category": null }, @@ -200,8 +200,8 @@ "loadedInPrinter": { "id": 101, "name": "Living Room", - "make": "Bambu Lab", - "model": "A1", + "make": "Snapmaker", + "model": "U1", "isActive": true, "category": null }, @@ -247,8 +247,8 @@ "loadedInPrinter": { "id": 102, "name": "Workshop", - "make": "Prusa", - "model": "MK4S", + "make": "Anycubic", + "model": "Kobra S1", "isActive": true, "category": null }, diff --git a/cypress/fixtures/demo/images/README.md b/cypress/fixtures/demo/images/README.md index 8e76ce0a..e6f47cdb 100644 --- a/cypress/fixtures/demo/images/README.md +++ b/cypress/fixtures/demo/images/README.md @@ -15,3 +15,24 @@ and are committed here with permission. Fetched via `node scripts/fetch-demo-images.mjs`, which calls `GET https://api.3dprintlog.com/api/Prints/{id}/image/{imageId}` with header `allow-anonymous-request: true`. + +## Demo printer photos + +Photos of the machines `printers-summary.json` names, shown by `app-printer-avatar` +wherever a printer appears. They belong to the repo owner and are committed here with +permission. + +| File | Printer id | Machine | +| ---------------------------- | ---------- | ------------------------ | +| snapmaker_u1.jpg | 101 | Snapmaker U1 | +| anycubic_kobra_s1.jpg | 102 | Anycubic Kobra S1 | +| heygears_reflex_rs_turbo.jpg | 103 | HeyGears Reflex RS Turbo | + +`fetch-demo-images.mjs` does **not** refetch these - they were placed by hand, downscaled +to the 720px-wide convention the print photos use. `PRINTER_IMAGE_MAP` in +`cypress/fixtures/demo/manifest.ts` maps each printer id to its file, and +`printer-thumbnails.json` points at `/api/Printers/{id}/thumbnail` so the capture harness +serves them from the repo rather than from a real blob host. + +The fixture's makes and models were changed to match these photos. A screenshot showing a +Snapmaker beside "(Bambu Lab A1)" is worse than no photo at all. diff --git a/cypress/fixtures/demo/images/anycubic_kobra_s1.jpg b/cypress/fixtures/demo/images/anycubic_kobra_s1.jpg new file mode 100644 index 00000000..4a3988c1 Binary files /dev/null and b/cypress/fixtures/demo/images/anycubic_kobra_s1.jpg differ diff --git a/cypress/fixtures/demo/images/heygears_reflex_rs_turbo.jpg b/cypress/fixtures/demo/images/heygears_reflex_rs_turbo.jpg new file mode 100644 index 00000000..cdf6dedc Binary files /dev/null and b/cypress/fixtures/demo/images/heygears_reflex_rs_turbo.jpg differ diff --git a/cypress/fixtures/demo/images/snapmaker_u1.jpg b/cypress/fixtures/demo/images/snapmaker_u1.jpg new file mode 100644 index 00000000..d12cfcc4 Binary files /dev/null and b/cypress/fixtures/demo/images/snapmaker_u1.jpg differ diff --git a/cypress/fixtures/demo/manifest.ts b/cypress/fixtures/demo/manifest.ts index 1b181500..262a2ff5 100644 --- a/cypress/fixtures/demo/manifest.ts +++ b/cypress/fixtures/demo/manifest.ts @@ -19,6 +19,7 @@ import { exists, FixtureRoute, imagesRendered, + imagesLoaded, noPlaceholders, rendered, visible, @@ -37,6 +38,13 @@ export const FIXTURE_ROUTES: FixtureRoute[] = [ url: '**/api/printers/summary*', fixture: 'demo/printers-summary.json', }, + // Every surface that names a printer reads this map through PrinterThumbnailStore. + // Without a stub the capture run fails on an unhandled /api/** request. + { + method: 'GET', + url: '**/api/Printers/thumbnails*', + fixture: 'demo/printer-thumbnails.json', + }, { method: 'GET', url: '**/api/Filaments?*', fixture: 'demo/filaments.json' }, // The add-print form offers to attach the print to a project. The demo set has // no projects, and an empty list is the right state for a first-print figure: @@ -91,6 +99,17 @@ export const PRINT_IMAGE_MAP: Record = { '1005': 'demo/images/cat-headbands.jpg', }; +/** + * The three demo printers in printers-summary.json, each with a photo of the machine the + * fixture names. `printer-thumbnails.json` points at `/api/Printers/{id}/thumbnail`, which + * `stubApi` serves from this map. + */ +export const PRINTER_IMAGE_MAP: Record = { + '101': 'demo/images/snapmaker_u1.jpg', + '102': 'demo/images/anycubic_kobra_s1.jpg', + '103': 'demo/images/heygears_reflex_rs_turbo.jpg', +}; + /** The five demo prints in prints-summary.json. */ const DEMO_PRINT_COUNT = 5; @@ -135,6 +154,9 @@ const HOME_CAPTURE_TARGETS: CaptureTarget[] = [ // 959.98px) the card view carrying app-print-card is never rendered. rendered('[cy-print-row]', DEMO_PRINT_COUNT), imagesRendered('app-print-image'), + // Decoded, not merely present: a photo still in flight is exactly what a + // capture loses silently. + imagesLoaded('app-printer-avatar'), rendered('app-filament-color-swatch', DEMO_MATERIAL_COUNT), ], }, @@ -147,6 +169,9 @@ const HOME_CAPTURE_TARGETS: CaptureTarget[] = [ ready: [ rendered('[cy-print-row]', DEMO_PRINT_COUNT), imagesRendered('app-print-image'), + // Decoded, not merely present: a photo still in flight is exactly what a + // capture loses silently. + imagesLoaded('app-printer-avatar'), rendered('app-filament-color-swatch', DEMO_MATERIAL_COUNT), ], }, @@ -183,6 +208,7 @@ export const HOME_CAPTURE_SET: CaptureSet = { targets: HOME_CAPTURE_TARGETS, fixtures: FIXTURE_ROUTES, printImages: PRINT_IMAGE_MAP, + printerImages: PRINTER_IMAGE_MAP, // The filter panel is hidden because the home crops want the data, not the // chrome - at every width, now that these are captured at desktop size. The // analytics tab's lone "Export this tab (CSV)" button goes for the same @@ -223,6 +249,9 @@ const DOC_CAPTURE_TARGETS: CaptureTarget[] = [ ready: [ rendered('[cy-print-row]', DEMO_PRINT_COUNT), imagesRendered('app-print-image'), + // Decoded, not merely present: a photo still in flight is exactly what a + // capture loses silently. + imagesLoaded('app-printer-avatar'), rendered('app-filament-color-swatch', DEMO_MATERIAL_COUNT), ], }), @@ -248,6 +277,9 @@ const DOC_CAPTURE_TARGETS: CaptureTarget[] = [ ready: [ rendered('[cy-print-row]', DEMO_PRINT_COUNT), imagesRendered('app-print-image'), + // Decoded, not merely present: a photo still in flight is exactly what a + // capture loses silently. + imagesLoaded('app-printer-avatar'), // The Materials column, which the caption below this figure describes. rendered('app-filament-color-swatch', DEMO_MATERIAL_COUNT), ], @@ -278,6 +310,9 @@ const DOC_CAPTURE_TARGETS: CaptureTarget[] = [ ready: [ rendered('app-print-card', DEMO_PRINT_COUNT), imagesRendered('app-print-image'), + // Decoded, not merely present: a photo still in flight is exactly what a + // capture loses silently. + imagesLoaded('app-printer-avatar'), rendered('.material-chip', DEMO_MATERIAL_COUNT), ], }), @@ -299,6 +334,7 @@ export const DOC_CAPTURE_SET: CaptureSet = { targets: DOC_CAPTURE_TARGETS, fixtures: FIXTURE_ROUTES, printImages: PRINT_IMAGE_MAP, + printerImages: PRINTER_IMAGE_MAP, // Deliberately no extra CSS. The docs are documenting the filter panel and // the tab actions the home crops hide, so hiding them here would document a // product that does not exist. diff --git a/cypress/fixtures/demo/printer-thumbnails.json b/cypress/fixtures/demo/printer-thumbnails.json new file mode 100644 index 00000000..aaaf7335 --- /dev/null +++ b/cypress/fixtures/demo/printer-thumbnails.json @@ -0,0 +1,5 @@ +[ + { "printerId": 101, "thumbnailUrl": "/api/Printers/101/thumbnail" }, + { "printerId": 102, "thumbnailUrl": "/api/Printers/102/thumbnail" }, + { "printerId": 103, "thumbnailUrl": "/api/Printers/103/thumbnail" } +] diff --git a/cypress/fixtures/demo/printers-summary.json b/cypress/fixtures/demo/printers-summary.json index b3dfa08a..37d15d5e 100644 --- a/cypress/fixtures/demo/printers-summary.json +++ b/cypress/fixtures/demo/printers-summary.json @@ -1,11 +1,16 @@ { - "paging": { "currentPage": 1, "totalPages": 1, "pageSize": 10, "totalCount": 3 }, + "paging": { + "currentPage": 1, + "totalPages": 1, + "pageSize": 10, + "totalCount": 3 + }, "items": [ { "id": 101, "name": "Living Room", - "make": "Bambu Lab", - "model": "A1", + "make": "Snapmaker", + "model": "U1", "isActive": true, "wattageW": 350, "printTimeInSeconds": null, @@ -39,8 +44,8 @@ { "id": 102, "name": "Workshop", - "make": "Prusa", - "model": "MK4S", + "make": "Anycubic", + "model": "Kobra S1", "isActive": true, "wattageW": 120, "printTimeInSeconds": null, @@ -74,8 +79,8 @@ { "id": 103, "name": "Resin Station", - "make": "Anycubic", - "model": "Photon Mono M7 Pro", + "make": "HeyGears", + "model": "Reflex RS Turbo", "isActive": true, "wattageW": 120, "printTimeInSeconds": null, diff --git a/cypress/fixtures/demo/prints-summary.json b/cypress/fixtures/demo/prints-summary.json index 6635290a..b9b06d2f 100644 --- a/cypress/fixtures/demo/prints-summary.json +++ b/cypress/fixtures/demo/prints-summary.json @@ -12,8 +12,8 @@ "printer": { "id": 103, "name": "Resin Station", - "make": "Anycubic", - "model": "Photon Mono M7 Pro", + "make": "HeyGears", + "model": "Reflex RS Turbo", "isActive": true, "wattageW": 120, "printTimeInSeconds": null, @@ -77,8 +77,8 @@ "loadedInPrinter": { "id": 103, "name": "Resin Station", - "make": "Anycubic", - "model": "Photon Mono M7 Pro", + "make": "HeyGears", + "model": "Reflex RS Turbo", "isActive": true, "category": null }, @@ -118,8 +118,8 @@ "printer": { "id": 102, "name": "Workshop", - "make": "Prusa", - "model": "MK4S", + "make": "Anycubic", + "model": "Kobra S1", "isActive": true, "wattageW": 120, "printTimeInSeconds": null, @@ -183,8 +183,8 @@ "loadedInPrinter": { "id": 101, "name": "Living Room", - "make": "Bambu Lab", - "model": "A1", + "make": "Snapmaker", + "model": "U1", "isActive": true, "category": null }, @@ -279,8 +279,8 @@ "printer": { "id": 101, "name": "Living Room", - "make": "Bambu Lab", - "model": "A1", + "make": "Snapmaker", + "model": "U1", "isActive": true, "wattageW": 350, "printTimeInSeconds": null, @@ -344,8 +344,8 @@ "loadedInPrinter": { "id": 102, "name": "Workshop", - "make": "Prusa", - "model": "MK4S", + "make": "Anycubic", + "model": "Kobra S1", "isActive": true, "category": null }, @@ -386,8 +386,8 @@ "printer": { "id": 102, "name": "Workshop", - "make": "Prusa", - "model": "MK4S", + "make": "Anycubic", + "model": "Kobra S1", "isActive": true, "wattageW": 120, "printTimeInSeconds": null, @@ -489,8 +489,8 @@ "printer": { "id": 101, "name": "Living Room", - "make": "Bambu Lab", - "model": "A1", + "make": "Snapmaker", + "model": "U1", "isActive": true, "wattageW": 350, "printTimeInSeconds": null, diff --git a/cypress/support/capture.ts b/cypress/support/capture.ts index c74a19ba..e62b4177 100644 --- a/cypress/support/capture.ts +++ b/cypress/support/capture.ts @@ -65,6 +65,8 @@ export interface CaptureSet { fixtures: FixtureRoute[]; /** defaultPrintImageId -> committed demo image. */ printImages: Record; + /** printerId -> committed demo photo, for the thumbnails the avatars render. */ + printerImages: Record; /** CSS appended to BASE_CAPTURE_CSS for this set only. */ css?: string; } @@ -142,6 +144,23 @@ export function imagesRendered(host: string, timeout = 10000): ReadyStep { }; } +/** + * Every `` under `selector` has DECODED, not merely been created. + * + * `imagesRendered` compares host and `` counts, which a still-loading image + * already satisfies - so it cannot detect the one race that actually loses a photo + * from a capture. This waits on the pixels. + */ +export function imagesLoaded(selector: string, timeout = 10000): ReadyStep { + return (scope) => + cy.get(`${scope} ${selector} img`, { timeout }).should(($imgs) => { + $imgs.each((_, img) => { + expect((img as HTMLImageElement).complete).to.be.true; + expect((img as HTMLImageElement).naturalWidth).to.be.greaterThan(0); + }); + }); +} + // --------------------------------------------------------------------------- // Harness // --------------------------------------------------------------------------- @@ -206,6 +225,25 @@ function stubApi(set: CaptureSet, unhandled: string[]) { } req.reply({ fixture: file }); }); + // Printer avatars. The thumbnails fixture points at these relative paths rather than + // at real blob SAS URLs, so the photos come from the repo and no capture depends on + // an external host. The glob cannot collide with the thumbnails map itself: minimatch + // `*` never crosses a `/`, so `Printers/*/thumbnail` needs the two segments this has + // and `Printers/thumbnails` has only one. + cy.intercept('GET', '**/api/Printers/*/thumbnail*', (req) => { + const id = req.url.match(/\/Printers\/(\d+)\/thumbnail/)?.[1] ?? ''; + const file = set.printerImages[id]; + if (!file) { + // Reported like a missed stub rather than falling back to some other machine's + // photo, which every readiness check would still pass. + unhandled.push( + `${req.method} ${req.url} (no demo photo for printer ${id})` + ); + req.reply({ statusCode: 404, body: {} }); + return; + } + req.reply({ fixture: file }); + }); } /** diff --git a/src/app/analytics/tabs/printers/printer-comparison.component.html b/src/app/analytics/tabs/printers/printer-comparison.component.html index fb011117..2638b931 100644 --- a/src/app/analytics/tabs/printers/printer-comparison.component.html +++ b/src/app/analytics/tabs/printers/printer-comparison.component.html @@ -24,7 +24,12 @@ @for (row of sorted(); track row.printerId) { - {{ row.name }} + + {{ row.name }} + @if (row.isIdle) { No prints in this range @@ -74,7 +79,12 @@