127 lines
9.2 KiB
Markdown
127 lines
9.2 KiB
Markdown
# QA: Editor screen — load, edit and save (core)
|
||
|
||
## Implementation note for coder (informational, not a QA assertion)
|
||
|
||
`docs/design/components.md`'s `ErrorView` only documents two fixed copies (`kind: 'not-found'` →
|
||
"Page not found" / Home, and `kind: 'error'` → the generic retry copy). The editor's own 404-on-
|
||
load state needs different copy ("This article doesn't exist." / "All articles" → /article),
|
||
per `docs/design/states.md`'s HTTP-status-to-copy table. Either widen `ErrorView` with an optional
|
||
title/description/action override, or add a third `kind`; this is a same-layer prop widening, not
|
||
a new component, so it doesn't need its own slice.
|
||
|
||
Known limitation, not a bug to silently fix: per `features/public_article_read.feature`,
|
||
`GET /v1/article/{id}` falls back to public online-only visibility on a malformed/expired jwt
|
||
(no 401). So if a session is already stale when the owner opens an *offline* article, the GET
|
||
answers 404 exactly like a truly deleted article — the editor cannot tell the two apart from this
|
||
response alone, and correctly shows the "This article doesn't exist." state.
|
||
|
||
## Automated (Vitest + Vue Test Utils, `task test:web`)
|
||
|
||
Mock `useApi`'s `request` for `GET /v1/article/{id}` and `PATCH /v1/article/{id}` per case; no
|
||
real backend, no httptest server. Mount EditorPage inside DashboardShell with a resolved verified
|
||
session (username "marta") and a test router at `/article/12/edit`. Use fake timers for the
|
||
150 ms skeleton delay and the relative-time SaveStatus format. Use `Object.defineProperty`/mocked
|
||
`navigator.onLine` and dispatched `window` `online`/`offline` events for the offline scenarios.
|
||
|
||
Fixture article (reused across cases unless a scenario overrides a field): id 12, title
|
||
"How we price a bathroom renovation", is_online false, body length 4318, created_at
|
||
"2026-03-12T09:14:00Z", updated_at "2026-09-26T11:02:00Z" (fixed `now`
|
||
"2026-09-26T11:02:00Z" unless a scenario advances it).
|
||
|
||
1. **Skeleton only after 150 ms** — mock `GET /v1/article/12` unresolved; assert no skeleton
|
||
before 150 ms; advance past it; assert skeleton placeholders for title/content/details;
|
||
resolve 200; assert the Saved state renders. Separately, resolve under 150 ms; assert no
|
||
skeleton ever renders.
|
||
2. **Saved state, draft and online** — Examples-driven on `is_online`: mock `GET
|
||
/v1/article/12` resolving 200 with the fixture at each `is_online` value; assert the
|
||
breadcrumb, ArticleStatusBadge ("Draft"/"Online"), SaveStatus "Saved at 11:02", Save
|
||
disabled, PublishControl ("Publish"/"Unpublish"), and the neutral count ending "4,318 characters".
|
||
3. **Unsaved changes** — from the Saved state, type into the body; assert SaveStatus "Unsaved
|
||
changes", Save enabled, PublishControl now "Save and publish".
|
||
4. **Saving via click and via Ctrl/⌘ S** — from Unsaved changes, (a) click Save, (b) press
|
||
Ctrl+S; in each, assert `PATCH /v1/article/12` is called with the edited body, SaveStatus
|
||
"Saving…", Save disabled while unresolved.
|
||
5. **Successful save** — from Saving, resolve `PATCH` 200 with the updated article; assert
|
||
SaveStatus "Saved at {time}" and Save disabled again.
|
||
6. **Relative save time after an hour** — after a 200 save at 11:02, advance the fake clock past
|
||
one hour with no further edit; assert SaveStatus switches to "Saved {n} min ago".
|
||
7. **Save failed, Examples-driven** — (a) `PATCH` resolves 500, (b) `PATCH` rejects (network);
|
||
in each, assert SaveStatus "Save failed" with a visible Retry button, and the editor still
|
||
shows the typed body.
|
||
8. **Retry recovers** — from Save failed (500), click Retry; assert `PATCH` is called again with
|
||
the same body; resolve 200; assert SaveStatus "Saved at {time}".
|
||
9. **404 on save shows a toast, not the error SaveStatus** — from Unsaved changes, save; resolve
|
||
`PATCH` 404; assert a toast "That article no longer exists." appears and SaveStatus never
|
||
reads "Save failed".
|
||
10. **Very long body** — set the body's HTML length to 120000 (via a fixture string of that
|
||
length); assert the count "19,845 words · 120,000 characters" with no warning styling, Save
|
||
and PublishControl enabled.
|
||
11. **No limit states** — assert no "Near the limit" or "Too long" text is ever rendered.
|
||
12. **Preview toggle** — from a loaded article with body content, switch Preview on; assert the
|
||
toolbar and editable surface are gone and a read-only rendering of title+body appears;
|
||
switch off; assert the editable surface returns with the same content.
|
||
13. **Offline blocks and recovers** — from Unsaved changes, set `navigator.onLine` false and
|
||
dispatch `window` `offline`; assert SaveStatus shows the wifi-off icon and reads "Waiting
|
||
for connection", Save and PublishControl disabled with a tooltip; set `navigator.onLine`
|
||
true and dispatch `online`; assert Save re-enables and SaveStatus reads "Unsaved changes"
|
||
again.
|
||
14. **Leave guard: Discard changes proceeds** — from Unsaved changes, click the "Articles"
|
||
breadcrumb link; assert ConfirmDialog opens with the exact title/text/button labels; click
|
||
"Discard changes"; assert the router navigates to `/article` and no further `PATCH` fires.
|
||
15. **Leave guard: Keep editing stays** — same setup; click "Keep editing"; assert the dialog
|
||
closes and the router does not navigate, and the unsaved body is still shown.
|
||
16. **No leave guard when clean** — from the Saved state, click the "Articles" breadcrumb link;
|
||
assert the router navigates to `/article` with no ConfirmDialog ever rendered.
|
||
17. **Session expiry mid-save mirrors and restores the draft** — from Unsaved changes with body
|
||
"Paragraph added just before the session expired.", save; resolve `PATCH` 401; assert
|
||
`sessionStorage` holds that body keyed by article id 12, and the router navigates to
|
||
`/login?redirect=/article/12/edit&reason=expired`. Then, simulating the return trip, mount
|
||
EditorPage again at `/article/12/edit` with `GET /v1/article/12` resolving 200 with a body
|
||
that lacks that paragraph; assert the rendered body is the restored sessionStorage draft
|
||
(with the paragraph) and SaveStatus reads "Unsaved changes".
|
||
18. **Not found on load** — mock `GET /v1/article/9999` resolving 404; mount at
|
||
`/article/9999/edit`; assert only the error state renders (title "This article doesn't
|
||
exist.", action "All articles"), with no breadcrumb-title/SaveStatus/Save/PublishControl/
|
||
ArticleEditor; click "All articles"; assert the router navigates to `/article`.
|
||
|
||
## Visual (manual verification — desktop and mobile, light and dark)
|
||
|
||
Reference: `docs/design/screens/editor.md` and `docs/design/source/RenewCMS Screens
|
||
Back-office.dc.html`, group "Editor". Run `task visual:check -- editor` for each state below and
|
||
compare against the board frame; add each state's mock setup to `web/visual/states.ts` first,
|
||
reusing the sample data below and a fixed `now` of "2026-09-26T11:02:00Z" (updated_at "Today,
|
||
11:02"; created_at "12 Mar 2026, 09:14"; id 12; title "How we price a bathroom renovation";
|
||
3 images).
|
||
|
||
- [ ] Loading: skeletons for title, content and details, shaped like the real layout.
|
||
- [ ] Saved: breadcrumb, "Draft" badge, "Saved at 11:02", disabled Save, "Publish", counter
|
||
"N words · 4,318 characters", details panel (Created/Updated/ID/Images/Public API URL).
|
||
- [ ] Unsaved changes: "Unsaved changes" SaveStatus, enabled Save, "Save and publish".
|
||
- [ ] Saving: "Saving…" SaveStatus, disabled Save.
|
||
- [ ] Save failed: "Save failed" with Retry, per the SaveStatus error spec.
|
||
- [ ] Very long article: count "19,845 words · 120,000 characters", neutral, Save/Publish enabled.
|
||
- [ ] Preview: read-only rendered title and body in the content scale, toolbar hidden.
|
||
- [ ] Offline: SaveStatus shows the wifi-off icon and reads "Waiting for connection", disabled
|
||
Save/Publish, OfflineBanner visible.
|
||
- [ ] Not found (404): the standalone error state, no editor chrome.
|
||
- [ ] Mobile (all of the above at 390×844): sticky bottom bar for Save/PublishControl, details
|
||
panel as a slideover, single "Edit article" mobile top-bar title instead of the breadcrumb.
|
||
- [ ] Keyboard/focus: Tab reaches the breadcrumb, Save, Preview switch and PublishControl in a
|
||
sensible order; Ctrl/⌘ S saves from anywhere in the editor surface; the leave-guard
|
||
ConfirmDialog traps focus starting on "Discard changes" per ConfirmDialog's a11y spec, and
|
||
Esc behaves like dismissing (equivalent to "Keep editing"); the character-counter live
|
||
region announces only when crossing the warn/over thresholds, not on every keystroke.
|
||
- [ ] Motion: modal open/close and skeleton pulse follow the standard 240 ms / 1.6 s timings and
|
||
stop under reduced motion.
|
||
|
||
## Manual verification
|
||
|
||
- **Real GET/PATCH round trip against a running server** — Vitest only mocks `useApi`; confirm
|
||
the real 200/404/401/500 responses and the real `sessionStorage` restoration across an actual
|
||
session expiry (`JWT_SECRET`-signed cookie timing out) against `task dev` with a live SQLite
|
||
file, since automated tests never exercise the real Go handler, `UpdateArticleUseCase`, or a
|
||
real cookie's `exp` claim.
|
||
- **Real `beforeunload` prompt** — Vitest can assert `onBeforeRouteLeave`'s ConfirmDialog, but a
|
||
real browser tab-close prompt (native `beforeunload`) needs manual confirmation, since jsdom
|
||
doesn't model it.
|