feat(career): migrate overview and factual evidence
This commit is contained in:
@@ -10,7 +10,9 @@ Entry is the completed Phase6 successor receipt `b5319981ad3d1bd635daff861453d23
|
||||
| P7-01 public authentication sub-slice | `8ad720da5db5999ecda787575dc45621d18a5498` | [Public report](p7-01/README.md#public-authentication-implementation-and-acceptance), [immutable-source/runtime evidence](p7-01/evidence/public-auth-results.json): affected61/61, native22/22, isolated SMTP2/2, SMTP helper6/6 and unchanged-backend auth subset87/87; final types/lint/build pass. Public slice CONDITIONAL GO with31 contrast incompletes/manual/provider conditions. Normal push verified2026-10-11 18:50:24UTC: localHEAD=origin=live development8ad720d; local/origin/live main27657f3 unchanged. No completed public work remains uncommitted; independent account work in flight. Whole P7-01 remains PENDING |
|
||||
| P7-01 account identity/security and final checkpoint outcome | `ad7b4196af638f3a8c79e261ffd96507bd002835` | [Account report](p7-01/README.md#account-identity-and-security-implementation), [frozen account source/runtime](p7-01/evidence/account-results.json): full573/89 (+52/+4 from521/85), native account6/public22/auth-boundaries10, types/lint/build all pass; actual eight final PNGs inspected. Implemented P7-01 scope CONDITIONAL GO, account85/public31 incompletes and manual/provider/production gates retained;57/32 statuses unchanged. Final staged secret/whitespace/docs/matrix checks PASS (303 local links,57 groups/32 routes); normal push verified2026-10-11 19:16:15UTC: localHEAD=origin=live developmentad7b419, local/origin/live main27657f3 unchanged, worktree clean at observation. Actual receipt appended by successor; P7-02 entry satisfied. Source/export frozen independently from public8ad720d |
|
||||
| P7-02 onboarding guidance sub-slice | `d21501aa7e5f9040937fa821d6a77d823b7e08eb` | [Report](p7-02/README.md), [frozen source/runtime](p7-02/evidence/onboarding-results.json): affected32/2 (+23 net over nine inherited subset cases), ordinary native13/regression4, app/browser types/lint/build and actual13-image review pass. BuildbwZtqlczGqVRJ0J01IxVK; thirteen all-enabled scans0/13 incompletes with M11/manual/provider conditions. Retry-focus unit/native RED and earlier12-pass history retained; latest full573/89 inherited,596 derived only. Implemented onboarding CONDITIONAL GO; slice-specific hygiene PASS (four commands0,9docs/329links/0errors,57/32 retained) and normal development push directly verified2026-10-11 23:55:12.614723UTC: localHEAD=origin=live developmentd21501aa; local/origin/live main27657f3 unchanged; clean worktree at observation. Actual receipt appended by successor. Career overview/evidence and whole P7-02 pending;57/32 unchanged/main NO-GO |
|
||||
| P7-02 SQLite evidence-list compatibility repair | PENDING actual commit/push | [Separate repair](p7-02/README.md#separate-evidence-list-provider-repair), [frozen backend/HTTP evidence](p7-02/evidence/evidence-list-repair.json): actual SQLite/HTTP RED0/1 each; new SQLite1/1, fresh guards91/91 and Release0warnings/errors plus ordinary HTTP1/1 GREEN. SQL owner filter/other-provider ordering unchanged; no schema/action/payload change, owned list remainsO(N). Fullbackend1,034 derived only from inherited1,033+1. Bounded local repair GO; slice-specific hygiene PASS00:05:02UTC (9docs337links/0errors,57/32 retained), direct normal push pending; Career UI/whole P7-02 pending; in-flight frontend excluded |
|
||||
| P7-02 SQLite evidence-list compatibility repair | `01281f52d9279ecdbd3363a11d2e9cb71230bf49` | [Separate repair](p7-02/README.md#separate-evidence-list-provider-repair), [frozen backend/HTTP evidence](p7-02/evidence/evidence-list-repair.json): actual SQLite/HTTP RED0/1 each; new SQLite1/1, fresh guards91/91 and Release0warnings/errors plus ordinary HTTP1/1 GREEN. SQL owner filter/other-provider ordering unchanged; no schema/action/payload change, owned list remainsO(N). Fullbackend1,034 derived only from inherited1,033+1. Bounded local repair GO; slice-specific hygiene PASS00:05:02UTC (9docs337links/0errors,57/32 retained), normal push directly verified2026-10-12 00:06:47.145743UTC: localHEAD=origin=live development01281f52, local/origin/live main27657f3 unchanged; five unfinished Career paths retained/worktree not clean. Actual receipt appended by successor; Career UI/whole P7-02 pending; in-flight frontend excluded |
|
||||
|
||||
| P7-02 Career overview/evidence and final implemented checkpoint outcome | PENDING normal commit/push | [Report](p7-02/README.md#career-overviewevidence-commit), [frozen finite evidence](p7-02/evidence/career-results.json): affected46/3 (+14 cases), native14 and fresh ordinary P2-D01/mobile-override2, types/lint/build pass; all22 final PNGs actually inspected. BuildqG82wPnFf-IsAkFvXkmn6,75-file export;0 violations/99 incompletes and M09/M11/manual/provider qualifications retained. Actual Delete-cancel RED/repair and Windows CRLF test correction retained. Implemented P7-02 CONDITIONAL GO; [final Career hygiene](p7-02/evidence/career-static-checks.json) PASS05:48:32UTC (four commands0,9docs347links0errors/57+32 retained); normal push pending. Fullfrontend610/backend1,034 derived only;57/32 unchanged/main NO-GO |
|
||||
|
||||
Hashes and remote receipts are appended by a successor to avoid self-referential commit hashes. Each row records actual commands/results, source scope, remaining conditions, normal push and immediate direct remote identity; partial or blocked evidence is recorded without inventing success. Subsequent receipt metadata is a separately reviewable documentation unit, with its own hash reported after push rather than an endless receipt chain.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Production remains **NO-GO**, main unchanged. Independent bounded development ca
|
||||
| RD18 | Board/CV physical selection/drag/drop/cancel/browser variants NOT VERIFIED | Applicable M03/M06/M12 physical evidence and named keyboard alternatives; no invented cross-section feature |
|
||||
| RD19 | `followMode` producers/query metadata retained; reviewed consumer remains DEFERRED | P7-06F requires explicit mode→draft/review action/fallback before implementation; no auto-send/resend |
|
||||
| RD20 | P5-05 optional read-only quick preview remains DEFERRED; unsupported NEW/OD and Phase8 scope remain reserved | No second full editor or early Dashboard order/resize/reset/cross-device widget framework |
|
||||
| RD21 | Canonical profile/job stale-save and creation-intent idempotency remain OPEN, separate from verified CV revision | Resolve or explicitly bound concurrency before save/history owner expansion; P7-03 retains current presentation-only behavior |
|
||||
| RD21 | Canonical profile/job stale-save and creation-intent idempotency remain OPEN, separate from verified CV revision | Resolve or explicitly bound concurrency before save/history expansion. Separate P7-03 entry: reproduce/repair optional stable source-ID preservation, then dirty-locale and held-PUT/pending-write ownership/recovery proof before structured presentation |
|
||||
| RD22 | F20 enabled-worker outage recovery/durability remains OPEN | Inspect intended gates; recovery/no-loss/no-duplicate proof before new enabled producers; no worker activation |
|
||||
| RD23 | F21 calendar offset/DST/date-only semantics remain OPEN | P7-05/06 retains existing links; dedicated contract/provider proof before semantic changes or scheduling claims |
|
||||
| RD24 | Billing/deletion/retention/tombstone/provider-cleanup policy/configuration UNVERIFIED | P7-09 presentation only; no checkout/deletion/restore activation or guessed policy |
|
||||
@@ -83,7 +83,7 @@ Copy fixes following actual visual review truthfully describe stored profile CV
|
||||
|
||||
## Evidence and production limits
|
||||
|
||||
The [separate P7-02 SQLite evidence-list repair](p7-02/evidence/evidence-list-repair.json) reproduces actual provider/HTTP failures and passes the new ordinary SQLite case,91 affected guards, Release build and HTTP readback after owner-local materialization/sort. It changes no schema, SQL owner filter or other-provider ordering. Fullbackend1,034 is derived only; full regression remains P7-12. The list remains O(N), and HTTP proof does not establish Career presentation/reader/production acceptance. Source/DLL/RED/GREEN/spec/raw traces are frozen with34 references verified; slice-specific hygiene PASS00:05:02UTC (9docs337links/0errors,57/32 retained), normal push pending. No RD production/manual/concurrency/provider closure follows from this bounded compatibility repair.
|
||||
The [separate P7-02 SQLite evidence-list repair](p7-02/evidence/evidence-list-repair.json) reproduces actual provider/HTTP failures and passes the new ordinary SQLite case,91 affected guards, Release build and HTTP readback after owner-local materialization/sort. It changes no schema, SQL owner filter or other-provider ordering. Fullbackend1,034 is derived only; full regression remains P7-12. The list remains O(N), and HTTP proof does not establish Career presentation/reader/production acceptance. Source/DLL/RED/GREEN/spec/raw traces are frozen with34 references verified; slice-specific hygiene PASS00:05:02UTC (9docs337links/0errors,57/32 retained), normal push01281f52 directly verified00:06:47UTC/main unchanged, five unfinished Career paths retained. No RD production/manual/concurrency/provider closure follows from this bounded compatibility repair.
|
||||
|
||||
### P7-02 onboarding conditional disposition
|
||||
|
||||
@@ -91,6 +91,14 @@ The [separate P7-02 SQLite evidence-list repair](p7-02/evidence/evidence-list-re
|
||||
|
||||
Actual review demonstrated lost focus when Retry disappeared. Five strengthened existing unit cases and one focused native case reproduced it before the bounded Dismiss-ref/focus correction; final native13 proves retry focus and next action after dismissal/complete recovery. Existing Dashboard summary Retry recovery still captures focus on the shell Skip link with altered scroll; its owner focus restoration remains unqualified. Presentation retry does not diagnose historical Dashboard/Board500 causes or close RD08–RD14. Latest full573/89 is inherited;596/89 is derived573+23 only. Exact source/test/build/export/runner/raw/unit/history are frozen under `.artifacts/phase7/p7-02/onboarding-final/`,154 file-reference hashes verified; initial pre-focus source/export observations are historical identities rather than claimed snapshots. Implemented onboarding CONDITIONAL GO is final hygiene PASS and normal pushd21501aa/direct receipt23:55:12UTC; Career overview/evidence and whole P7-02 remain pending,57/32 statuses unchanged.
|
||||
|
||||
### P7-02 Career conditional disposition and P7-03 dependency
|
||||
|
||||
[Career results](p7-02/evidence/career-results.json) retain affected46/3, native14 and fresh existing P2-D01/mobile-override2, types/lint/build and actual22-image review passing. Delete-cancel focus loss was reproduced in native and strengthened unit cases before a minimal state-order correction; the shared confirmation and duplicate guard remain. The separate clipboard failure was native Windows LF→CRLF conversion, with only CRLF→LF test normalization and raw codepoint retention. All prior failures stay in immutable history; no product clipboard or byte-exact OS claim is made.
|
||||
|
||||
Twenty-two all-enabled scans retain0 violations/**99 incompletes**: contrast74 `bgOverlap`+22 invisiblebadge `shortTextContent`, plus3 hidden-focus `#menu-` MUI select portals. Actual section/form keyboard and corrected cancel/retry focus pass; M09/M11 contextual contrast/reader and RD06/RD07 human/device/zoom remain open. Error captures retain inherited shell Skip-link/scroll focus qualification and controlled English server-fixture detail. No density/provider/production acceptance is inferred. Source/test/export/runner/raw/unit history is frozen in `.artifacts/phase7/p7-02/career-final/`,152 refs verified; final buildqG82wPnFf-IsAkFvXkmn6. Implemented P7-02 is CONDITIONAL GO; [Career final hygiene](p7-02/evidence/career-static-checks.json) PASS (four commands0,9docs347links0errors), normal push pending. Latest full573/89 remains inherited;610/89 and backend1,034 are derived expectations only.
|
||||
|
||||
Two independent read reviews identify the existing normalizer dropping job/education/certification/project stable IDs while overrides reference those IDs. P7-03 must separately reproduce and repair optional-ID preservation before structured presentation, with canonical/sibling native and payload proof. This inherited risk is distinct from RD21 stale-save/idempotency and is neither introduced nor closed by P7-02. No date-loss claim is made. Prove dirty-locale/pending-write draft ownership and recovery, and resolve or explicitly bound RD21 before changing save/history owners. Other manual/provider/production and C11/Phase8 gates remain unchanged.
|
||||
|
||||
The early Phase6 runner retained exact failure commands/reports/logs but reused result folders, so not all old screenshots/traces/error-context paths remain resolvable. Later runs archive the whole previous output; current successful attachments have exact provenance. Preserve that limit rather than claiming complete old failure-trace retention. Native Windows Chromium/local SQLite/synthetic fixtures and finite PDF oracles do not establish deployed Linux, human semantics, physical devices, real provider quality or production load.
|
||||
|
||||
API transferSize0 is unavailable cross-origin timing information, not zero network bytes. CDP document/node/heap snapshots can retain earlier navigation/CV iframe documents and do not prove leak/growth. The existing measured Board/Dashboard candidates require baseline/comparative evidence before a repair or capacity claim.
|
||||
|
||||
@@ -99,6 +99,20 @@ The22 public native cases and two isolated recovery cases are new separate packs
|
||||
|
||||
Full frontend521→573 adds52 cases across four new suites with no removed/renamed cases: public23 and account29 (providers12, card presentation7, flows5, Profile5). Focused subsets and native packs overlap and are not added to that total. Earlier571 frontend and pre-copy native runs remain historical; the exact post-copy build/source identity is in the account manifest. P2-D01/sidecar/backend/rendering execution remains inherited here.
|
||||
|
||||
### P7-02 reconciliation, no full-group upgrade
|
||||
|
||||
The [onboarding](p7-02/evidence/onboarding-results.json), [separate provider repair](p7-02/evidence/evidence-list-repair.json) and [Career](p7-02/evidence/career-results.json) records retain distinct executed scopes and failures. All57 remain **56 PARTIAL /1 DEFERRED**, with0 PASS/FAIL, and all32 routes remain retained. Career runtime/actual visual review is conditional; [final slice hygiene](p7-02/evidence/career-static-checks.json) PASS (9docs347links0errors/four commands0); normal push pending.
|
||||
|
||||
| Protected groups | Fresh finite P7-02 proof | Remaining boundary |
|
||||
|---|---|---|
|
||||
| S02/E08/E09 | Onboarding affected32/native13/regression4; four observable steps, nullable unknown, five read-only source facts, existing per-user key and ownerA/B dismiss/reload/settled no-read proof. Existing four destinations/Back, retry and next-action focus pass; no forced guidance | Real grants/human/device/zoom, notification/full flags and historical Dashboard500 causes; Phase8 customization separate |
|
||||
| C01/C02/C05/E08/E09 | Career affected46/native14: independent known/unknown/error reads, overview provenance, source/raw-copy/history destinations, three unique existing CV links and canonical/sibling readback/no unintended writes | Structured save/import/history and full library/publication migration remain P7-03/04; Windows clipboard content is LF-normalized with raw codepoints, not exact OS bytes |
|
||||
| C04/E08/E09 | Actual evidence create/delete/readback, canceled confirmation/no write, retained draft/row after failures, scoped requests, explicit retry/reconfirm, keyboard and corrected cancel-focus. Twenty-two inspected geometry/axe states retain0 violations/99 incompletes | Full reader/contextual contrast/device/zoom, all metadata/long-tail combinations and production/provider qualification |
|
||||
| C04 | Separate real SQLite/HTTP RED→GREEN repair; owner SQL filter/AsNoTracking and foreign-owner denial preserved; differently-offset instants sorted correctly;91 affected guards/Release pass | No live MySQL/production proof; owner-local list remainsO(N), no scaling/schema/payload/auth change |
|
||||
| W05/W07/C07/E09 | Existing ordinary P2-D01 frozen-workspace and mobile-override cases freshly pass2/2 on final Career export, without skip/retry/expected failure | Broader frozen/render/CV revision/manual/deployed combinations remain their original gates |
|
||||
|
||||
Stable job/education/certification/project IDs dropped by the existing normalizer are an inherited **P7-03 entry risk** requiring separate RED/payload/native proof and optional-ID repair before structured editing. This is distinct from RD21 concurrency and is not a P7-02 repair claim. Dirty-locale and pending-write ownership/recovery must also be qualified before save/history expansion.
|
||||
|
||||
[INVENTORY.md](p7-00/INVENTORY.md#current-route-coverage) reconciles exactly32 current App patterns against [route acceptance](../phase-5/p5-15/ROUTE_ACCEPTANCE.md); all whole-route groups remain PARTIAL. Preserve Board/Application/mail aliases and actual query/hash/state behavior, section/sub precedence, safe internal from, numeric navigation-helper output, capture consumption, focusJobId and provider/token destinations. No Documents/Notifications/aggregate Admin/new Career destination is inferred without contract/content evidence.
|
||||
|
||||
P2-D01 remains the ordinary passing [frozen-workspace test](../../../job-tracker-ui/e2e/redesign-preservation.spec.ts#L73), retaining native capture/readback/archive bytes/canonical/sibling proof. No skip, expected-failure marker or relaxed oracle may replace it. Preserve server roles/account/tenant/owned-resource denial; canonical Career versus CV overrides; revision/conflict/recoverable drafts; private/public PDF admission/revocation; unchanged physical pagination; immutable packages; existing grounding/proposal/stale guards and Production C11 withholding.
|
||||
|
||||
@@ -10,8 +10,8 @@ The entry commit is `b5319981ad3d1bd635daff861453d23c0de5320b` on `codex/ui-rede
|
||||
|---|---|---|
|
||||
| [P7-00](p7-00/README.md) | Fresh scope inventory and fixture baseline | Complete finite identity/route/matrix/static checks; inherited runtime separated. Push receipt recorded by the successor before page changes |
|
||||
| [P7-01](p7-01/README.md) | Authentication and account-security presentation | Public sub-slice CONDITIONAL GO, pushed8ad720d with direct receipt; account implementation/runtime/actual8-image review CONDITIONAL GO (573/89 +6/22/10); pushedad7b419 with final static PASS/direct receipt19:16:15UTC; main unchanged |
|
||||
| [P7-02](p7-02/README.md) | Onboarding and Career overview/evidence | Entryad7b419 pushed/verified; bounded onboarding CONDITIONAL GO: affected32/native13/regression4, types/lint/build and13 actual PNG inspections pass; final hygiene PASS/pushedd21501aa, direct receipt23:55:12UTC/main unchanged. Career overview/evidence and whole checkpoint remain pending |
|
||||
| P7-03 | Career structured editing and import review | P7-02 fixtures; resolve or explicitly bound concurrency before changing save/history ownership; planned |
|
||||
| [P7-02](p7-02/README.md) | Onboarding and Career overview/evidence | Entryad7b419 pushed/verified; bounded onboarding CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections, hygiene PASS and d21501aa push verified23:55:12UTC. Separate SQLite evidence-list repair91 guards/HTTP1/Release passed, pushed01281f52/direct receipt00:06:47UTC with unfinished Career work preserved. Career affected46/3/native14/preservation2/types/lint/build and actual22-image review pass; implemented checkpoint CONDITIONAL GO with99 incompletes/manual gates. Career final hygiene PASS (four commands0,9docs347links0errors); normal push pending; main unchanged |
|
||||
| P7-03 | Career structured editing and import review | P7-02 normal push; separately reproduce/repair stable source-ID preservation before structured presentation, then prove dirty-locale/pending-write ownership and resolve or bound RD21 concurrency; planned |
|
||||
| P7-04 | CV library and public entry | Existing gallery/editor and P7-03 source language; planned |
|
||||
| P7-05 | Discovery, Companies, Offers and Reminders | Existing domain owners and Jobs return context; planned |
|
||||
| P7-06 | Remaining Application forms and global correspondence | Existing seven-section workspace, immutable packages and draft/send owners; planned |
|
||||
@@ -39,7 +39,7 @@ The separate [account runtime manifest](p7-01/evidence/account-results.json) rec
|
||||
|
||||
## Working constraints
|
||||
|
||||
The [P7-02 onboarding slice](p7-02/evidence/onboarding-results.json) adds actual fresh affected32/2 (+23 cases over its nine-case inherited subset), ordinary native13 and separate Dashboard/Analytics4, app/browser types/lint/build and13 inspected final PNGs. Its13 all-enabled scans retain0 violation nodes/13 invisible-zero notification-badge contrast incompletes; no manual conformance or real provider grant is inferred. The retry-focus defect was reproduced by five strengthened unit cases and a focused native failure before correction. Latest full frontend573/89 remains inherited;596/89 is only a derived expectation. Onboarding implemented scope is CONDITIONAL GO final hygiene PASS and normal pushd21501aa/direct receipt23:55:12UTC; Career overview/evidence remains pending,57 groups/32 routes unchanged and production NO-GO.
|
||||
The [P7-02 onboarding slice](p7-02/evidence/onboarding-results.json) adds actual fresh affected32/2 (+23 cases over its nine-case inherited subset), ordinary native13 and separate Dashboard/Analytics4, app/browser types/lint/build and13 inspected final PNGs. Its13 all-enabled scans retain0 violation nodes/13 invisible-zero notification-badge contrast incompletes; no manual conformance or real provider grant is inferred. The retry-focus defect was reproduced by five strengthened unit cases and a focused native failure before correction. Latest full frontend573/89 remains inherited;596/89 is only a derived expectation. Onboarding implemented scope is CONDITIONAL GO final hygiene PASS and normal pushd21501aa/direct receipt23:55:12UTC; Career successor affected46/3/native14/preservation2 and22 actual image inspections now pass; implemented P7-02 scope is CONDITIONAL GO with final Career hygiene PASS; normal push pending. Its22 all-enabled scans retain0 violations/99 incompletes;610/89 is derived only, latest full573/89 unchanged. Frozen [Career results](p7-02/evidence/career-results.json) retain failures/source/export/manual qualifications;57 groups/32 routes unchanged and production NO-GO.
|
||||
|
||||
Use the current MUI/Emotion theme, PageLayout/section primitives, ViewStateNotice/useViewResource, Router, i18n, notification and save owners. Preserve query/history/aliases, account/tenant/role boundaries, canonical Career facts versus CV overrides, revision recovery, frozen packages and original PDF pagination/oracles. Do not infer new Documents/Notifications/aggregate Admin routes from illustrative navigation. No new hard-coded user-facing strings.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# P7-02 — Onboarding and Career overview/evidence
|
||||
|
||||
**Status: implemented onboarding guidance CONDITIONAL GO; final hygiene PASS, normally pushed d21501aa with direct receipt. Career overview/evidence and whole P7-02 remain pending.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries). This checkpoint has two bounded commits: onboarding guidance first, then Career overview/evidence. Structured Career editing/import/history remains P7-03; no alternative checkpoint order or new route is introduced.
|
||||
**Status: implemented P7-02 scope CONDITIONAL GO. Onboarding and the separate SQLite repair are pushed; Career runtime/visual acceptance and final slice hygiene PASS; normal push pending.** Follow the exact [approved handoff](../../phase-6/p6-05/PHASE7_HANDOFF.md#migration-order-and-commit-boundaries). This checkpoint has two bounded commits: onboarding guidance first, then Career overview/evidence. Structured Career editing/import/history remains P7-03; no alternative checkpoint order or new route is introduced.
|
||||
|
||||
## Accepted entry baseline
|
||||
|
||||
@@ -52,31 +52,45 @@ The bounded [controller](../../../../JobTrackerApi/Controllers/CareerEvidenceCon
|
||||
|
||||
Dedicated SQLite1/1 and fresh affected guards91/91 passed with zero failed/not-executed cases. Release test-project/API build passed00:01:26–00:01:28UTC with zero warnings/errors. Actual real-HTTP GREEN passed1/1 at00:01:48–00:01:58UTC, zero skip/flaky/unexpected/retry. The added ordinary backend case makes1,034 the **derived** future full expectation; only the91 affected guards have freshly executed. The dedicated1/HTTP1 overlap this coverage and later Career native coverage; do not add them to full totals.
|
||||
|
||||
Source/DLL, backend command/log/TRX and HTTP RED/GREEN report/log/result/trace/spec snapshots are immutable under `.artifacts/phase7/p7-02/evidence-list-repair/`, with34 file/hash references verified. The HTTP case executes local API contracts only; its uncommitted native-spec snapshot will be committed and rerun in the later Career slice. Existing onboarding frontend export is not a Career build or UI acceptance. Slice-specific [repair static checks](evidence/evidence-list-repair-static-checks.json) passed2026-10-12 00:04:59–00:05:02UTC: all four secret/working/staged/entry-whitespace commands exit0, nine Markdown documents/337 local links checked with0errors,57 groups/32routes retained, main unchanged. **Bounded local repair GO; final hygiene PASS, separate commit/push pending. Career UI/whole P7-02 remain pending.** This repair is committed separately before Career UI; no in-flight frontend implementation is included. A repair push observation may include the independent unfinished Career worktree; it must not claim that work complete or clean.
|
||||
Source/DLL, backend command/log/TRX and HTTP RED/GREEN report/log/result/trace/spec snapshots are immutable under `.artifacts/phase7/p7-02/evidence-list-repair/`, with34 file/hash references verified. The HTTP case executes local API contracts only; its uncommitted native-spec snapshot will be committed and rerun in the later Career slice. Existing onboarding frontend export is not a Career build or UI acceptance. Slice-specific [repair static checks](evidence/evidence-list-repair-static-checks.json) passed2026-10-12 00:04:59–00:05:02UTC: all four secret/working/staged/entry-whitespace commands exit0, nine Markdown documents/337 local links checked with0errors,57 groups/32routes retained, main unchanged. **Bounded local repair GO; final hygiene PASS, normally pushed01281f52 and directly verified. Career UI acceptance was pending at this repair gate; its successor result follows.** This repair is committed separately before Career UI; no in-flight frontend implementation is included. Actual receipt2026-10-12 00:06:47.145743UTC records development localHEAD=origin=live01281f52, local/origin/live main27657f3 unchanged and five independent unfinished Career paths. The worktree was explicitly not clean; the completed repair was normally pushed without discarding that work.
|
||||
|
||||
## Career overview/evidence commit
|
||||
|
||||
Entry is the normally pushed onboarding `d21501aa7e5f9040937fa821d6a77d823b7e08eb`, directly verified23:55:12.614723UTC with development local/origin/live equality, main unchanged and clean worktree. Its final32/13/4/source/build/axe evidence remains frozen and historical at that commit; later Career translation changes cannot be validated against the earlier workspace. Latest fresh full frontend remains inherited573/89; onboarding adds23 verified affected cases, giving only a derived596/89 expectation. In the frozen573 report, the existing Career workspace suite has7 cases and evidence suite1, **eight-case affected baseline**. This is inherited baseline evidence, not a new Career execution.
|
||||
Entry includes the normally pushed onboarding `d21501aa7e5f9040937fa821d6a77d823b7e08eb` and subsequent separate provider repair `01281f52d9279ecdbd3363a11d2e9cb71230bf49`. Each has its actual direct development/main receipt above. Onboarding's final32/13/4/source/build/axe evidence remains frozen and historical atd215; later Career translation changes cannot be validated against the earlier workspace. Latest fresh full frontend remains inherited573/89; onboarding adds23 verified affected cases, giving only a derived596/89 expectation. In the frozen573 report, the existing Career workspace suite has7 cases and evidence suite1, **eight-case affected baseline**. This is inherited baseline evidence, not a new Career execution. Fresh backend91 affected guards/Release/HTTP1 are from the separately pushed repair; fullbackend1,034 is derived only.
|
||||
|
||||
Existing [CareerWorkspacePage](../../../../job-tracker-ui/src/views/CareerWorkspacePage.tsx) delegates to [CareerProfilePage](../../../../job-tracker-ui/src/views/CareerProfilePage.tsx). Preserve the actual Overview/Profile/Evidence/Import section owner and query/history behavior, including removal of the default overview query. Scope the residual presentation to [CareerWorkspaceOverview](../../../../job-tracker-ui/src/views/career/CareerWorkspaceOverview.tsx) and [CareerEvidenceBank](../../../../job-tracker-ui/src/views/career/CareerEvidenceBank.tsx), with existing [workspace](../../../../job-tracker-ui/src/career-workspace-page.test.tsx) and [evidence](../../../../job-tracker-ui/src/career-evidence.test.tsx) tests.
|
||||
|
||||
Keep canonical `/career/profile` data and existing server-provided completeness labels/provenance, source/raw-copy/history links, `/career/evidence` list/create and owned delete contracts. Evidence creation keeps existing category/title/statement/tags/source semantics and explicit manual review; presentation does not invent achievements or change persistence. Improve bounded headings/state/form/retry/reflow using existing owners after reproduction. No structured editor/save/import/history owner or CV renderer change belongs here.
|
||||
|
||||
Required native evidence covers populated/empty/loading/error/retry, raw-copy/source/history destinations, actual evidence create/delete confirmation/no-write cancellation/readback, long/many items and keyboard/EN/NB/theme/narrow-screen states. Canonical/sibling/frozen boundaries and foreign-owner denial remain applicable. Any concurrency/save-owner change requires the separate P7-03 disposition; unavailable human/device evidence is carried explicitly. **Implementation/results/commit/push for this second boundary are PENDING.**
|
||||
The [finite Career manifest](evidence/career-results.json) records actual affected **46/46 in three suites**, ordinary Career **14/14** and separate existing preservation **2/2** passing, with zero skipped/flaky/unexpected/retry native cases. Final app/browser TypeScript, lint and build passed2026-10-12 00:18:30–00:19:05UTC. Career14 executed00:19:05–00:20:04UTC; the ordinary P2-D01 frozen-workspace and mobile-override cases executed00:20:04–00:20:19UTC. These two are freshly executed preservation proof, not inherited acceptance. The API-only provider case overlaps the separate repair HTTP case and is not added to totals.
|
||||
|
||||
| Career proof | Current evidence / required outcome |
|
||||
The source-worker RED passed8/22 and failed14 added assertions. First affected GREEN passed45/46; its remaining required-label selector harness error was corrected without changing the product contract or removing assertions. The initial native pack passed12/14 on build `KKwBMDeMkaRLM-O5e2Fb-`. One failure exposed genuine Delete-cancel focus loss: disabling the trigger before confirmation prevented restoration. The stronger unit RED passed7/8, then the minimal state-order correction passed46/46 at00:13:56UTC; the duplicate-operation guard and shared confirmation owner remain. The earlier jsdom focus-only8/8 diagnostic did not reproduce native disabled-element blur and stays historical. The other native failure was Windows clipboard LF→CRLF conversion. The final assertion normalizes only CRLF→LF and retains actual code points; no clipboard product change, trimming or byte-identical platform output claim follows.
|
||||
|
||||
Final workspace14/evidence8 replace inherited7+1, adding **14 net cases**; the existing Profile24 consumer cases also pass unchanged. Onboarding adds23 separately. Latest fresh full frontend remains P7-01 **573/89**; **610/89 is derived573+23+14**, not a new full run. Full backend1,034 is likewise derived from1,033+the new SQLite case; the fresh affected91 guard run is separate. Final full regression remains P7-12.
|
||||
|
||||
| Career proof | Actual finite outcome / remaining boundary |
|
||||
|---|---|
|
||||
| Reproduced presentation defects | Source-owner RED cases and exact failure causes **PENDING**; retain original operation/save/navigation semantics |
|
||||
| Affected unit regressions | Fresh workspace/evidence packet **PENDING**; reconcile names and net case changes against inherited7+1 without treating derived totals as a fresh full run |
|
||||
| Real local workflows and boundaries | Native actual create/delete/readback/cancel/retry/source/history/section-query flows and canonical/sibling/frozen no-write/foreign-owner assertions **PENDING**; provider/model execution excluded |
|
||||
| Finite responsive/localised/accessibility proof | EN/NB, light/dark, narrow/tablet/wide/short landscape, long/many items, semantic headings/forms, keyboard trap/restore, geometry and all axe findings **PENDING**; density only if an actual control exists |
|
||||
| Backend constraints | Targeted existing Career/evidence authorization and real SQLite endpoint guards **PENDING**; no schema/endpoint/data-save expansion implied |
|
||||
| Final source/export and exit | Fresh app/browser types/lint/build, exact measured source/export identity, actual image inspection, compact raw-report/hash evidence and slice-specific `career-static-checks.json` **PENDING**; normal push after passing bounded checks |
|
||||
| Truthful resource states and navigation | Populated/empty/pending/failed independent profile/run/variant reads, deliberate retry, section query/Back, existing source/history destinations and three unique CV links pass; structured editor/import/history remains P7-03 |
|
||||
| Evidence CRUD and isolation | Actual create, canceled delete/no write, failed save retaining draft, failed delete retaining row, deliberate retry/reconfirm and successful readback pass. Canonical Career and sibling CV readback after writes, owner denial and scoped requests remain asserted |
|
||||
| Keyboard/focus | Retry focus, actual section selection/subsequent form traversal and corrected cancel-trigger restoration pass. Failed-save/delete captures retain inherited shell Skip-link/scroll context; complete human focus/spoken-state acceptance remains unverified |
|
||||
| Responsive/localised visual review | Parent actually opened all22 final raw PNGs: eight EN/NB/theme samples320/360/390/768/1024/1280 short landscape/1440/1920 plus six pending/error states. Fields, titles and actions wrap without observed clipping; all22 recorded root-overflow values are0; the1440 many-item fixture has11 rows |
|
||||
| Accessibility | Twenty-two all-enabled scans retain0 violation nodes/99 incomplete occurrences. No automated result establishes reader/device/zoom conformance; density was not varied and Career has no global density control |
|
||||
| Exit | Runtime and actual22-image review support implemented-scope CONDITIONAL GO. Slice-specific final hygiene PASS; normal commit/push remains pending; main/production stays NO-GO |
|
||||
|
||||
Record final compact outcomes in `evidence/career-results.json` only after actual executions. Full axe node/check vectors remain in the identified immutable raw report; the portable record must retain every rule count and contextual triage, including incomplete results and errors. Keep source-worker/native failures, fixture corrections and exact count deltas; no skips/expected failures/assertion relaxation. The accepted onboarding evidence and `onboarding-static-checks.json` remain independent. Career/whole P7-02 acceptance, source/build identity and push are not claimed by this plan.
|
||||
All99 incomplete contexts remain recorded:96 `color-contrast` (74 `bgOverlap` plus22 invisible notification-badge `0`/`shortTextContent`) and3 `aria-hidden-focus` targeting `#menu-`, the hidden MUI section-select portal in narrow evidence states. The actual section and subsequent form keyboard workflows pass; **M09/M11** reader/contextual contrast/forced-colors requirements remain pending. Complete HTML/data/related-node/check vectors remain in the exact hashed raw report, with every rule/target/check context summarized in the portable manifest. Controlled English500 details are server-fixture text, distinct from translated product fallback. Sticky appbar/Skip-link placement in error captures reflects recorded scroll/focus context, not a new demonstrated overflow defect.
|
||||
|
||||
Final four-source LF digest is `190d0a24368bfc3cc76550ff0f74c272200d3a30f61218a86628449ac83735ce`; build `qG82wPnFf-IsAkFvXkmn6`; **75 exported files**, digest `50896e8ad6934b909b83cc487eaf3579516921fde54b19e50a9039db68e04869`. Final native report SHA256 is `f7a166ec33ecdedd9f0374290961c0daaf1279d8c89d3425fbe3b3a66c4a2360`. Source/test/build/export/runner/config, whole runtime/attempt tree, unit history and entry manifests are frozen under `.artifacts/phase7/p7-02/career-final/`:152 file-reference hashes verified,428 runtime files and81 unit files retained. Parent independently verified152 references/84 unique referenced files with0 errors. Raw hashes/counts remain measured bytes; portable JSON/source canonical-LF policies are explicit. Earlier first-build75-file export/report and failed unit/native attempts stay historical; the first source had already been corrected before export archival, so no original-source snapshot is claimed.
|
||||
|
||||
Slice-specific [Career static checks](evidence/career-static-checks.json) passed2026-10-12 05:48:28–05:48:32UTC: four secret/working/staged/entry-whitespace commands exit0; nine Markdown documents/347 local links checked with0errors at that observation;57 qualified groups/32 routes retained and main27657f3 unchanged. Canonical UTF-8/LF static SHA256 is `f575a6304bb4d00e11ee77270d60ebdee11f1cd8faf9692adb4c473961b4d8f4`. Final hygiene PASS; normal commit/push receipt remains pending. Runtime/source/export/manual qualifications are unchanged.
|
||||
|
||||
## Preservation and exit
|
||||
|
||||
S02/C02/C04/E09 and shared E08/S03 are the finite scope in the [57 matrix](../PRESERVATION.md). Retain all [debt](../DEBT.md), established auth/private-tree behavior, original routes and semantic action boundaries. Scoped truthful loading/retry does not establish the root cause of historical Dashboard/Board500 observations.
|
||||
S02/C01/C02/C04/C05/E09 and shared E08/S03 are the finite scope in the [57 matrix](../PRESERVATION.md). Retain all [debt](../DEBT.md), established auth/private-tree behavior, original routes and semantic action boundaries. Scoped truthful loading/retry does not establish the root cause of historical Dashboard/Board500 observations.
|
||||
|
||||
Each completed logical boundary must include reproduction/baseline, actual changes, automated and visual evidence, exact count explanation, source/export identity, manual qualifications and normal commit/push with remote=HEAD. Record observed results here after execution; no skips, expected failures or relaxed assertions can substitute for passing ordinary cases. P7-03 starts only after the applicable P7-02 fixtures/entry are verified. Production main/deployment, P5-13 activation and Phase8 Dashboard customization remain outside this checkpoint.
|
||||
|
||||
### Mandatory P7-03 entry conditions
|
||||
|
||||
Two independent source reviews identify an inherited stable-ID risk in [normalizeStructuredCv](../../../../job-tracker-ui/src/profileCv.ts): job/education/certification/project IDs are dropped, while CV overrides refer to source IDs and the API may assign replacements. **Before structured-edit presentation, separately reproduce and repair optional-ID preservation with normalizer/payload/native canonical-and-sibling proof.** This is a P7-03 entry condition, not a P7-02 regression or completed repair. No date-loss claim is made; server StartDate derives from Start.
|
||||
|
||||
Retain RD21 concurrency as a separate obligation. Prove dirty drafts survive locale/navigation/read-retry boundaries and held-PUT/pending-write ownership/recovery before changing save/history behavior; resolve or explicitly bound stale profile/job saves and creation-intent behavior using the existing contracts. Known canonical/sibling fixtures and actual server IDs remain the basis; no guessed revision/schema/provider behavior. P7-03 begins after this slice's successful normal push. Import/history remains its approved second boundary; P5-13 and Phase8 remain deferred.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,104 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "p7-02",
|
||||
"status": "PASS",
|
||||
"checkedUtc": "2026-10-12T05:48:32.289310+00:00",
|
||||
"commands": [
|
||||
{
|
||||
"name": "secret-scan",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"C:\\Users\\Connor\\AppData\\Local\\Programs\\Python\\Python312\\python.exe",
|
||||
"-X",
|
||||
"utf8",
|
||||
"scripts/supply-chain.py",
|
||||
"scan-secrets"
|
||||
],
|
||||
"startedUtc": "2026-10-12T05:48:28.856155+00:00",
|
||||
"finishedUtc": "2026-10-12T05:48:31.685439+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T054828Z/secret-scan.log",
|
||||
"logSha256Raw": "c5a88d5ebdc042adba125c1eaacbe44f9f4a5c19898c0f195573c00d511aa28d"
|
||||
},
|
||||
{
|
||||
"name": "working-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T05:48:31.685929+00:00",
|
||||
"finishedUtc": "2026-10-12T05:48:31.771415+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T054828Z/working-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "staged-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--cached",
|
||||
"--check"
|
||||
],
|
||||
"startedUtc": "2026-10-12T05:48:31.771415+00:00",
|
||||
"finishedUtc": "2026-10-12T05:48:31.861097+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T054828Z/staged-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
},
|
||||
{
|
||||
"name": "phase7-whitespace",
|
||||
"command": [
|
||||
"rtk",
|
||||
"proxy",
|
||||
"git",
|
||||
"diff",
|
||||
"--check",
|
||||
"b5319981ad3d1bd635daff861453d23c0de5320b"
|
||||
],
|
||||
"startedUtc": "2026-10-12T05:48:31.861097+00:00",
|
||||
"finishedUtc": "2026-10-12T05:48:31.981627+00:00",
|
||||
"exitCode": 0,
|
||||
"log": ".artifacts/phase7/current/static/20261012T054828Z/phase7-whitespace.log",
|
||||
"logSha256Raw": "d2993bc68ad9a63f711c8f5473155e2ba00fdcd461220cc3849185f2f9ad93e6"
|
||||
}
|
||||
],
|
||||
"untrackedSecretScan": {
|
||||
"files": 0,
|
||||
"findings": 0
|
||||
},
|
||||
"documentation": {
|
||||
"files": 9,
|
||||
"relativeLinks": 347,
|
||||
"errors": []
|
||||
},
|
||||
"preservation": {
|
||||
"groups": 57,
|
||||
"statuses": {
|
||||
"PARTIAL": 56,
|
||||
"PASS": 0,
|
||||
"FAIL": 0,
|
||||
"DEFERRED": 1
|
||||
},
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 32
|
||||
},
|
||||
"production": {
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"status": "NO-GO",
|
||||
"qualification": "Local fetched refs; direct push observation recorded separately."
|
||||
},
|
||||
"hashPolicy": "Portable JSON UTF8 LF; raw log hashes measure exact saved bytes.",
|
||||
"limits": [
|
||||
"Pattern scanner skips binary and files over2MiB; not historical Git/production secrets.",
|
||||
"Local links check target existence, not URL/heading anchors.",
|
||||
"Static evidence is not runtime/manual/device/provider/production acceptance."
|
||||
]
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"schemaVersion": 1,
|
||||
"checkpoint": "P7-02",
|
||||
"slice": "SQLite evidence-list compatibility repair only",
|
||||
"acceptance": "GO for bounded local repair; final hygiene PASS, actual push pending",
|
||||
"acceptance": "GO for bounded local repair; hygiene PASS, normally pushed and directly verified",
|
||||
"entryCommit": "d21501aa7e5f9040937fa821d6a77d823b7e08eb",
|
||||
"problem": "EF SQLite9.0.14 cannot translate DateTimeOffset ORDER BY; owned evidence POST200 was followed by list GET500 and actual provider NotSupportedException.",
|
||||
"change": "Keep SQL owner filtering and AsNoTracking. SQLite materializes only owned rows then orders actual DTO instants; other providers retain existing SQL ordering.",
|
||||
@@ -591,7 +591,32 @@
|
||||
"qualification": "Exact backend command/log/TRX, frozen source/DLL, RED/GREEN HTTP raw reports/logs/result trees/traces and runner/spec snapshots; internal execution paths unchanged."
|
||||
},
|
||||
"pushReceipt": {
|
||||
"status": "PENDING_ACTUAL_PUSH"
|
||||
"checkpoint": "P7-02",
|
||||
"slice": "evidence list SQLite compatibility repair",
|
||||
"status": "PUSHED_AND_VERIFIED",
|
||||
"checkedUtc": "2026-10-12T00:06:47.145743+00:00",
|
||||
"head": "01281f52d9279ecdbd3363a11d2e9cb71230bf49",
|
||||
"originDevelopment": "01281f52d9279ecdbd3363a11d2e9cb71230bf49",
|
||||
"liveDevelopment": "01281f52d9279ecdbd3363a11d2e9cb71230bf49",
|
||||
"main": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"originMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"liveMain": "27657f31131cd95d42f0e1290853ec9c9b3959a4",
|
||||
"workingTreeClean": false,
|
||||
"pendingPaths": [
|
||||
"job-tracker-ui/src/career-evidence.test.tsx",
|
||||
"job-tracker-ui/src/career-workspace-page.test.tsx",
|
||||
"job-tracker-ui/src/i18n/translations.ts",
|
||||
"job-tracker-ui/src/views/career/CareerWorkspaceOverview.tsx",
|
||||
"job-tracker-ui/e2e/phase7-career-overview.spec.ts"
|
||||
],
|
||||
"qualification": "Only in-progress Career frontend source/tests remain uncommitted; repair is complete and normally pushed. No other work discarded.",
|
||||
"production": "NO-GO",
|
||||
"pushExitCode": 0,
|
||||
"raw": {
|
||||
"path": ".artifacts/phase7/p7-02/evidence-list-repair/push-receipt.json",
|
||||
"sha256": "4b673fb11248d185fd3d2b635a62d18d0423ea36dd7e4655fa0bb6279663a71b",
|
||||
"hashPolicy": "Exact observed raw bytes"
|
||||
}
|
||||
},
|
||||
"staticGate": {
|
||||
"evidence": {
|
||||
@@ -623,5 +648,6 @@
|
||||
"originalRoutesPreserved": true,
|
||||
"currentConcreteRoutes": 32
|
||||
}
|
||||
}
|
||||
},
|
||||
"sourceApplicability": "Frozen canonical source identities apply at01281f52d9279ecdbd3363a11d2e9cb71230bf49; sourceAndBinarySnapshots preserve exact measured bytes. API/test DLLs are local build provenance, not deployed production identities."
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ Follow the approved [Phase 7 handoff](../redesign/phase-6/p6-05/PHASE7_HANDOFF.m
|
||||
|
||||
- [x] P7-00 fresh scope inventory and unchanged-source fixture baseline; finite static checks pass. Commit and remote receipt in the Phase 7 ledger.
|
||||
- [x] P7-01 public authentication and separate account/security presentation (implemented scope CONDITIONAL GO; account pushedad7b419/direct receipt19:16:15UTC): public sub-slice CONDITIONAL GO, pushed `8ad720d`, direct remote=HEAD verified2026-10-11 18:50:24UTC, main unchanged; affected61/native22/isolated SMTP2/helper6/auth subset87 pass. [Checkpoint record](../redesign/phase-7/p7-01/README.md) retains manual/provider/production gates. Fresh account full573/89, native6/public22/auth-boundaries10 and types/lint/build pass; all eight final post-copy PNGs actually inspected and source/context evidence frozen. Account85 and public31 current axe incompletes remain qualified; final staged finite hygiene passed; normal account pushad7b419 directly verified2026-10-11 19:16:15UTC with localHEAD=origin=live equality and clean worktree at observation, main unchanged.
|
||||
- [ ] P7-02 [onboarding guidance and Career overview/evidence](../redesign/phase-7/p7-02/README.md): entryad7b419 pushed/verified; onboarding implemented-scope CONDITIONAL GO with fresh affected32/native13/regression4/types/lint/build and13 actual PNG inspections. Legacy dismissal/routes/data and unknown-summary state preserved; final onboarding hygiene PASS and normally pushedd21501aa/direct receipt23:55:12UTC, main unchanged. Career overview/evidence second boundary and whole P7-02 remain pending;596 full frontend is derived only.
|
||||
- [ ] P7-03 Career structured editor and import/history presentation.
|
||||
- [ ] P7-02 [onboarding guidance and Career overview/evidence](../redesign/phase-7/p7-02/README.md): entryad7b419 pushed/verified; onboarding implemented-scope CONDITIONAL GO32/13/4+types/lint/build/13 actual PNG inspections/hygiene, pushedd21501aa/direct receipt23:55:12UTC. Separate actual SQLite evidence-list repair91 guards/HTTP1/Release passed and pushed01281f52/direct receipt00:06:47UTC; unfinished Career work preserved/main unchanged. Career affected46/3/native14/fresh preservation2/types/lint/build and22 actual PNG inspections pass; implemented P7-02 CONDITIONAL GO, final Career hygiene PASS (four commands0,9docs347links0errors) and normal push pending. Career0/99 axe incompletes/manual gates retained; fullfrontend610/backend1,034 are derived only, latest fresh full573/89 unchanged.
|
||||
- [ ] P7-03 Career structured editor and import/history: after P7-02 push, separately RED/prove/repair optional stable source-ID preservation before presentation; prove dirty-locale/held-PUT pending-write ownership/recovery and resolve or bound RD21 concurrency before save/history expansion.
|
||||
- [ ] P7-04 CV library and public entry.
|
||||
- [ ] P7-05 Discovery, Companies, Offers and Reminders.
|
||||
- [ ] P7-06 remaining Application forms and global correspondence.
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
import { expect, test as base, type APIRequestContext, type Page, type TestInfo } from '@playwright/test';
|
||||
import { translations } from '../src/i18n/translations';
|
||||
import type { CvVariant } from '../src/cvBuilder';
|
||||
|
||||
const apiUrl = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
type Owner = { id: string; email: string; headers: Record<string, string> };
|
||||
type Sample = { language: 'en' | 'nb'; theme: 'light' | 'dark'; width: number; height: number };
|
||||
|
||||
async function login(api: APIRequestContext, email: string): Promise<Owner> {
|
||||
expect((await api.post(`${apiUrl}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const response = await api.get(`${apiUrl}/auth/me`);
|
||||
expect(response.status()).toBe(200);
|
||||
const me = await response.json();
|
||||
const csrf = (await api.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN');
|
||||
expect(csrf).toBeDefined();
|
||||
return { id: me.id, email, headers: { 'X-CSRF-TOKEN': csrf!.value, 'X-Session-Context': me.sessionContext } };
|
||||
}
|
||||
|
||||
async function createOwner(api: APIRequestContext, headers: Record<string, string>) {
|
||||
const email = `p7-career-${Date.now()}-${Math.random().toString(36).slice(2)}@example.test`;
|
||||
expect((await api.post(`${apiUrl}/users`, { headers, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
return email;
|
||||
}
|
||||
|
||||
const test = base.extend<{ owner: Owner }>({
|
||||
owner: async ({ page, context, playwright }, provide) => {
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminOwner = await login(admin, 'e2e@example.test');
|
||||
const owner = await login(page.request, await createOwner(admin, adminOwner.headers));
|
||||
await context.route('**/*', route => {
|
||||
const url = new URL(route.request().url());
|
||||
return ['localhost', '127.0.0.1'].includes(url.hostname) || ['data:', 'blob:', 'about:'].includes(url.protocol)
|
||||
? route.continue() : route.abort();
|
||||
});
|
||||
await context.addInitScript(() => {
|
||||
if (!localStorage.getItem('uiLanguage')) localStorage.setItem('uiLanguage', 'en');
|
||||
if (!localStorage.getItem('jobtracker.themeMode')) localStorage.setItem('jobtracker.themeMode', 'light');
|
||||
});
|
||||
await provide(owner);
|
||||
} finally { await admin.dispose(); }
|
||||
},
|
||||
});
|
||||
|
||||
async function enter(page: Page, owner: Owner, sample: Sample, section = 'overview') {
|
||||
expect((await page.request.put(`${apiUrl}/auth/preferences/language`, { headers: owner.headers,
|
||||
data: { language: sample.language === 'nb' ? 'nb-NO' : 'en' } })).status()).toBe(204);
|
||||
await page.setViewportSize({ width: sample.width, height: sample.height });
|
||||
await page.goto(`/career?section=${section}&source=phase7#preserved`);
|
||||
await page.evaluate(({ language, theme }) => {
|
||||
localStorage.setItem('uiLanguage', language);
|
||||
localStorage.setItem('jobtracker.themeMode', theme);
|
||||
}, sample);
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.reload();
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', sample.language === 'nb' ? 'nb-NO' : 'en');
|
||||
await expect(page.locator('html')).toHaveAttribute('data-color-scheme', sample.theme);
|
||||
}
|
||||
|
||||
async function navigateSection(page: Page, language: Sample['language'], section: 'overview' | 'profile' | 'evidence' | 'import') {
|
||||
const t = translations[language];
|
||||
const label = { overview: t.careerWorkspaceOverviewTab, profile: t.careerWorkspaceProfileTab,
|
||||
evidence: t.careerWorkspaceEvidenceTab, import: t.careerWorkspaceImportTab }[section];
|
||||
const selector = page.getByRole('combobox', { name: t.careerWorkspaceNavigation, exact: true });
|
||||
if (await selector.isVisible()) {
|
||||
await selector.click();
|
||||
await page.getByRole('option', { name: label, exact: true }).click();
|
||||
} else {
|
||||
await page.getByRole('tab', { name: label, exact: true }).click();
|
||||
}
|
||||
const url = new URL(page.url());
|
||||
expect(url.searchParams.get('section')).toBe(section === 'overview' ? null : section);
|
||||
expect(url.searchParams.get('source')).toBe('phase7');
|
||||
expect(url.hash).toBe('#preserved');
|
||||
}
|
||||
|
||||
async function capture(page: Page, info: TestInfo, sample: Sample, state: string) {
|
||||
await page.evaluate(async () => { await document.fonts.ready; await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))); });
|
||||
const geometry = await page.evaluate(() => ({ width: innerWidth, height: innerHeight,
|
||||
rootOverflow: Math.max(0, document.documentElement.scrollWidth - document.documentElement.clientWidth) }));
|
||||
const axe = await new AxeBuilder({ page }).options({ rules: { 'target-size': { enabled: true } } }).analyze();
|
||||
const name = `p7-career-${sample.language}-${sample.theme}-${sample.width}-${state}`;
|
||||
await info.attach(`${name}-audit`, { body: JSON.stringify({ sample, state, geometry,
|
||||
ariaSnapshot: await page.getByRole('main').ariaSnapshot(),
|
||||
axe: { scope: 'All enabled rules plus target-size; complete findings retained', violations: axe.violations,
|
||||
incomplete: axe.incomplete, passes: axe.passes.map(rule => ({ id: rule.id, nodes: rule.nodes.length })) },
|
||||
limits: ['No human screen reader or physical touch/software keyboard', 'No actual browser zoom or production font/photo qualification', 'Controlled faults are explicit; no model/provider invocation'],
|
||||
}), contentType: 'application/json' });
|
||||
await info.attach(name, { body: await page.screenshot({ fullPage: true, animations: 'disabled' }), contentType: 'image/png' });
|
||||
expect(geometry.rootOverflow).toBe(0);
|
||||
}
|
||||
|
||||
const matrix: Sample[] = [
|
||||
{ language: 'nb', theme: 'dark', width: 320, height: 844 },
|
||||
{ language: 'en', theme: 'light', width: 360, height: 800 },
|
||||
{ language: 'nb', theme: 'light', width: 390, height: 844 },
|
||||
{ language: 'en', theme: 'dark', width: 768, height: 1024 },
|
||||
{ language: 'nb', theme: 'dark', width: 1024, height: 768 },
|
||||
{ language: 'en', theme: 'light', width: 1280, height: 320 },
|
||||
{ language: 'nb', theme: 'light', width: 1440, height: 900 },
|
||||
{ language: 'en', theme: 'dark', width: 1920, height: 1080 },
|
||||
];
|
||||
|
||||
test('[P7-02 PRES-C04 E09] real SQLite evidence list preserves owner boundaries and existing validation', async ({ page, owner, playwright }, info) => {
|
||||
const created = await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers,
|
||||
data: { category: 'project', title: 'Owner A evidence', statement: 'Recorded, reviewed fact.', tags: [' C# ', 'c#', 'Norsk'], sourceType: 'manual', sourceReference: ' Retrospective ', isVerified: true } });
|
||||
expect(created.status()).toBe(200);
|
||||
const item = await created.json();
|
||||
const listed = await page.request.get(`${apiUrl}/career/evidence`);
|
||||
expect(listed.status(), 'the actual local SQLite HTTP list must succeed').toBe(200);
|
||||
expect(await listed.json()).toEqual([item]);
|
||||
expect(item.tags).toEqual(['C#', 'Norsk']);
|
||||
expect(item.sourceReference).toBe('Retrospective');
|
||||
const admin = await playwright.request.newContext();
|
||||
const other = await playwright.request.newContext();
|
||||
const anonymous = await playwright.request.newContext();
|
||||
try {
|
||||
const adminOwner = await login(admin, 'e2e@example.test');
|
||||
const otherOwner = await login(other, await createOwner(admin, adminOwner.headers));
|
||||
expect(await (await other.get(`${apiUrl}/career/evidence?ownerUserId=${encodeURIComponent(owner.id)}`)).json()).toEqual([]);
|
||||
expect((await other.put(`${apiUrl}/career/evidence/${item.id}`, { headers: otherOwner.headers,
|
||||
data: { title: 'Foreign overwrite', statement: 'Denied' } })).status()).toBe(404);
|
||||
expect((await other.delete(`${apiUrl}/career/evidence/${item.id}`, { headers: otherOwner.headers })).status()).toBe(404);
|
||||
expect((await anonymous.get(`${apiUrl}/career/evidence`)).status()).toBe(401);
|
||||
expect((await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers,
|
||||
data: { title: ' ', statement: 'Not saved' } })).status()).toBe(400);
|
||||
expect((await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers,
|
||||
data: { title: 'Not saved', statement: ' ' } })).status()).toBe(400);
|
||||
expect(await (await page.request.get(`${apiUrl}/career/evidence`)).json()).toEqual([item]);
|
||||
await info.attach('native-evidence-owner-provider-contract', { body: JSON.stringify({ provider: 'Local Playwright SQLite', list200: true,
|
||||
foreignUpdate404: true, foreignDelete404: true, anonymous401: true, invalidCreate400: true, noForeignMutation: true,
|
||||
qualification: 'Actual preceding HTTP assertions; no InMemory or production-provider inference' }), contentType: 'application/json' });
|
||||
} finally { await admin.dispose(); await other.dispose(); await anonymous.dispose(); }
|
||||
});
|
||||
|
||||
for (const sample of matrix) {
|
||||
test(`[P7-02 PRES-C01 C02 C04 C05 E08 E09] ${sample.language}/${sample.theme} ${sample.width}px overview and factual evidence preserve canonical source and recent CV links`, async ({ page, owner }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const profile = { version: '1', contact: { fullName: 'Åse Kunnskapsbank', headline: 'Canonical source' },
|
||||
jobs: [{ title: 'Engineer', company: 'Dokumentasjon AS', bullets: ['Recorded work, no invented outcomes.'] }], skills: ['C#', 'Norsk'] };
|
||||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile } })).status()).toBe(200);
|
||||
const before = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
const cvs: CvVariant[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name: `CV ${i + 1} — ${'Lang dokumenttittel '.repeat(5)}` } });
|
||||
expect(r.status()).toBe(200); cvs.push(await r.json());
|
||||
}
|
||||
const summariesResponse = await page.request.get(`${apiUrl}/cv/variants`);
|
||||
expect(summariesResponse.status()).toBe(200);
|
||||
const authoritativeRecent = (await summariesResponse.json()).slice(0, 3).map((cv: { id: number }) => `/career/builder/${cv.id}`);
|
||||
const item = await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers, data: {
|
||||
title: 'Et dokumentert prosjekt med en lang tittel — æøå '.repeat(4), statement: 'Faktisk dokumentasjon fra et gjennomført prosjekt. '.repeat(8),
|
||||
category: 'project', tags: ['Tilgjengelighet', 'Systematisk dokumentasjon'], sourceType: 'manual', sourceReference: 'Prosjektgjennomgang — æøå', isVerified: true,
|
||||
} });
|
||||
expect(item.status()).toBe(200);
|
||||
const evidence = await item.json();
|
||||
if (sample.width === 1440) {
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
expect((await page.request.post(`${apiUrl}/career/evidence`, { headers: owner.headers, data: {
|
||||
title: `Additional owned evidence ${i}`, statement: 'Another supported statement. '.repeat(8), sourceType: 'manual', isVerified: true,
|
||||
} })).status()).toBe(200);
|
||||
}
|
||||
expect(await (await page.request.get(`${apiUrl}/career/evidence`)).json()).toHaveLength(11);
|
||||
}
|
||||
await enter(page, owner, sample);
|
||||
const t = translations[sample.language];
|
||||
await expect(page.getByRole('heading', { name: t.careerWorkspaceOverviewTab, level: 2 })).toBeVisible();
|
||||
await expect(page.getByRole('progressbar', { name: t.careerOverviewCompletenessLabel })).toHaveAttribute('aria-valuenow', String(before.completeness.percent));
|
||||
const recent = page.getByRole('region', { name: t.careerOverviewRecentCvs, exact: true });
|
||||
await expect(recent.getByRole('link', { name: t.cvDashboardOpen, exact: true })).toHaveCount(3);
|
||||
const links = await recent.getByRole('link', { name: t.cvDashboardOpen, exact: true }).evaluateAll(nodes => nodes.map(node => node.getAttribute('href')));
|
||||
expect(new Set(links).size).toBe(3);
|
||||
expect(links).toEqual(authoritativeRecent);
|
||||
await capture(page, info, sample, 'populated-overview');
|
||||
await navigateSection(page, sample.language, 'evidence');
|
||||
await expect(page.getByRole('heading', { name: t.careerEvidenceTitle, level: 2 })).toBeVisible();
|
||||
await expect(page.getByText(evidence.title, { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel(t.careerEvidenceStatement)).toBeVisible();
|
||||
if (sample.width === 1440) await expect(page.getByText('Additional owned evidence 10', { exact: true })).toBeVisible();
|
||||
await capture(page, info, sample, 'long-evidence');
|
||||
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(before);
|
||||
for (const cv of cvs) expect(await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).toEqual(cv);
|
||||
await page.reload();
|
||||
await expect(page.getByText(evidence.title, { exact: true })).toBeVisible();
|
||||
});
|
||||
}
|
||||
|
||||
for (const failedPath of ['/career/profile', '/profile-cv/runs', '/cv/variants']) {
|
||||
test(`[P7-02 PRES-C02 E09] unavailable ${failedPath} stays unknown and retries its reads without writes or false first-run guidance`, async ({ page, owner }, info) => {
|
||||
const sample: Sample = { language: 'en', theme: 'dark', width: 390, height: 844 };
|
||||
const actualProfile = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
let fail = true;
|
||||
let reads = 0;
|
||||
await page.route(`**/api${failedPath}`, async route => {
|
||||
if (route.request().method() === 'GET') {
|
||||
reads++;
|
||||
if (fail) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled source read unavailable' }) });
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await enter(page, owner, sample);
|
||||
const t = translations.en;
|
||||
const message = failedPath === '/career/profile' ? t.careerOverviewUnavailable : failedPath === '/profile-cv/runs' ? t.careerOverviewImportUnavailable : t.careerOverviewRecentFailed;
|
||||
const alert = page.getByRole('alert').filter({ hasText: message });
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(page.getByText(t.careerOverviewFirstRun, { exact: true })).toHaveCount(0);
|
||||
if (failedPath === '/career/profile') await expect(page.getByRole('progressbar', { name: t.careerOverviewCompletenessLabel })).toHaveCount(0);
|
||||
if (failedPath === '/profile-cv/runs') await expect(page.getByText(t.careerOverviewNoImport, { exact: true })).toHaveCount(0);
|
||||
if (failedPath === '/cv/variants') await expect(page.getByText(t.careerOverviewNoCvs, { exact: true })).toHaveCount(0);
|
||||
await capture(page, info, sample, `unavailable-${failedPath.split('/').pop()}`);
|
||||
const writes: string[] = [];
|
||||
page.on('request', request => { if (request.url().startsWith(apiUrl) && !['GET', 'HEAD', 'OPTIONS'].includes(request.method())) writes.push(request.method() + ' ' + request.url()); });
|
||||
const beforeReads = reads;
|
||||
fail = false;
|
||||
await alert.getByRole('button', { name: t.retry, exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(alert).toHaveCount(0);
|
||||
const focusRegion = failedPath === '/career/profile' ? t.careerOverviewProfileStatus : failedPath === '/profile-cv/runs' ? t.careerOverviewCvImport : t.careerOverviewRecentCvs;
|
||||
const focusLabel = failedPath === '/career/profile' ? t.careerOverviewImproveProfile : failedPath === '/profile-cv/runs' ? t.careerOverviewImportCv : t.careerOverviewViewAll;
|
||||
await expect(page.getByRole('region', { name: focusRegion, exact: true }).getByRole('link', { name: focusLabel, exact: true })).toBeFocused();
|
||||
await expect(page.getByRole('progressbar', { name: t.careerOverviewCompletenessLabel })).toHaveAttribute('aria-valuenow', String(actualProfile.completeness.percent));
|
||||
expect(reads).toBe(beforeReads + 1);
|
||||
expect(writes).toEqual([]);
|
||||
});
|
||||
}
|
||||
|
||||
test('[P7-02 PRES-C04 E09] evidence pending/failure/read retry and failed save preserve drafts across section changes, cancellation and actual deletion', async ({ page, owner }, info) => {
|
||||
test.setTimeout(120_000);
|
||||
const sample: Sample = { language: 'en', theme: 'light', width: 390, height: 844 };
|
||||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: {
|
||||
profile: { version: '1', contact: { fullName: 'Independent canonical source' }, skills: ['Original source fact'] }, cvText: 'Original raw source',
|
||||
} })).status()).toBe(200);
|
||||
const canonicalBefore = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
const retainedCvs: CvVariant[] = [];
|
||||
for (const name of ['Selected CV', 'Sibling CV']) {
|
||||
const response = await page.request.post(`${apiUrl}/cv/variants`, { headers: owner.headers, data: { name } });
|
||||
expect(response.status()).toBe(200); retainedCvs.push(await response.json());
|
||||
}
|
||||
let readFailure = true;
|
||||
let holdRead = false;
|
||||
let releaseRead!: () => void;
|
||||
const delayedRead = new Promise<void>(resolve => { releaseRead = resolve; });
|
||||
let saveFailure = true;
|
||||
const payloads: unknown[] = [];
|
||||
await page.route('**/api/career/evidence', async route => {
|
||||
const request = route.request();
|
||||
if (request.method() === 'GET' && readFailure) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled evidence read unavailable' }) });
|
||||
if (request.method() === 'GET' && holdRead) await delayedRead;
|
||||
if (request.method() === 'POST') {
|
||||
payloads.push(request.postDataJSON());
|
||||
if (saveFailure) return route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled evidence write unavailable' }) });
|
||||
}
|
||||
return route.continue();
|
||||
});
|
||||
await enter(page, owner, sample, 'evidence');
|
||||
const t = translations.en;
|
||||
const readAlert = page.getByRole('alert').filter({ hasText: 'Controlled evidence read unavailable' });
|
||||
await expect(readAlert).toBeVisible();
|
||||
await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toHaveCount(0);
|
||||
readFailure = false;
|
||||
holdRead = true;
|
||||
await readAlert.getByRole('button', { name: t.retry, exact: true }).click();
|
||||
const bank = page.getByRole('region', { name: t.careerEvidenceTitle, exact: true });
|
||||
await expect(bank.getByText(t.loading, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: t.careerEvidenceSave, exact: true })).toBeDisabled();
|
||||
await capture(page, info, sample, 'pending-evidence-read');
|
||||
releaseRead();
|
||||
await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toBeVisible();
|
||||
await page.getByLabel(t.careerEvidenceItemTitle).fill('Reviewed project — æøå');
|
||||
await page.getByLabel(t.careerEvidenceStatement).fill('A fact supplied by the owner.');
|
||||
await page.getByLabel(t.careerEvidenceTags, { exact: true }).fill('C#, Norsk');
|
||||
await page.getByLabel(t.careerEvidenceSource, { exact: true }).fill('Project retrospective');
|
||||
await page.getByRole('button', { name: t.careerEvidenceSave, exact: true }).click();
|
||||
const saveAlert = page.getByRole('alert').filter({ hasText: 'Controlled evidence write unavailable' });
|
||||
await expect(saveAlert).toBeVisible();
|
||||
await expect(saveAlert.getByRole('button', { name: t.retry, exact: true })).toHaveCount(0);
|
||||
await capture(page, info, sample, 'failed-save-retained-draft');
|
||||
await navigateSection(page, 'en', 'overview');
|
||||
await navigateSection(page, 'en', 'evidence');
|
||||
await expect(page.getByLabel(t.careerEvidenceItemTitle)).toHaveValue('Reviewed project — æøå');
|
||||
await expect(page.getByLabel(t.careerEvidenceStatement)).toHaveValue('A fact supplied by the owner.');
|
||||
saveFailure = false;
|
||||
const saved = page.waitForResponse(response => response.url() === `${apiUrl}/career/evidence` && response.request().method() === 'POST');
|
||||
await page.getByRole('button', { name: t.careerEvidenceSave, exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
expect((await saved).status()).toBe(200);
|
||||
expect(payloads).toEqual(Array(2).fill({ category: 'achievement', title: 'Reviewed project — æøå', statement: 'A fact supplied by the owner.', tags: ['C#', ' Norsk'], sourceType: 'manual', sourceReference: 'Project retrospective', isVerified: true }));
|
||||
const [item] = await (await page.request.get(`${apiUrl}/career/evidence`)).json();
|
||||
await expect(page.getByText(item.title, { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel(t.careerEvidenceItemTitle)).toHaveValue('');
|
||||
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(canonicalBefore);
|
||||
for (const cv of retainedCvs) expect(await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).toEqual(cv);
|
||||
const row = page.getByRole('article').filter({ hasText: item.title });
|
||||
const remove = row.getByRole('button', { name: t.careerEvidenceDeleteMessage.replace('{title}', item.title), exact: true });
|
||||
await remove.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
const dialog = page.getByRole('dialog', { name: t.careerEvidenceDeleteTitle, exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole('button', { name: t.cancel, exact: true }).click();
|
||||
await expect(remove).toBeFocused();
|
||||
expect(await (await page.request.get(`${apiUrl}/career/evidence`)).json()).toEqual([item]);
|
||||
let deleteFailure = true;
|
||||
await page.route(`**/api/career/evidence/${item.id}`, route => route.request().method() === 'DELETE' && deleteFailure
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ message: 'Controlled evidence deletion unavailable' }) }) : route.continue());
|
||||
await remove.click();
|
||||
const failedDelete = page.waitForResponse(response => response.url() === `${apiUrl}/career/evidence/${item.id}` && response.request().method() === 'DELETE');
|
||||
await dialog.getByRole('button', { name: t.deleteAction, exact: true }).click();
|
||||
expect((await failedDelete).status()).toBe(500);
|
||||
const deleteAlert = row.getByRole('alert').filter({ hasText: 'Controlled evidence deletion unavailable' });
|
||||
await expect(deleteAlert).toBeVisible();
|
||||
expect(await (await page.request.get(`${apiUrl}/career/evidence`)).json()).toEqual([item]);
|
||||
await capture(page, info, sample, 'failed-delete-retained-row');
|
||||
deleteFailure = false;
|
||||
await deleteAlert.getByRole('button', { name: t.retry, exact: true }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(dialog).toBeVisible();
|
||||
const deleted = page.waitForResponse(response => response.url() === `${apiUrl}/career/evidence/${item.id}` && response.request().method() === 'DELETE');
|
||||
await dialog.getByRole('button', { name: t.deleteAction, exact: true }).click();
|
||||
expect((await deleted).status()).toBe(204);
|
||||
await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toBeVisible();
|
||||
await expect(page.getByLabel(t.careerEvidenceItemTitle)).toBeFocused();
|
||||
await page.reload();
|
||||
await expect(page.getByText(t.careerEvidenceEmpty, { exact: true })).toBeVisible();
|
||||
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(canonicalBefore);
|
||||
for (const cv of retainedCvs) expect(await (await page.request.get(`${apiUrl}/cv/variants/${cv.id}`)).json()).toEqual(cv);
|
||||
});
|
||||
|
||||
test('[P7-02 PRES-C01 C02] existing raw source copy and history entry remain read-only', async ({ page, owner, context }, info) => {
|
||||
const sample: Sample = { language: 'en', theme: 'light', width: 1440, height: 900 };
|
||||
const profile = { version: '1', contact: { fullName: 'Canonical source' }, skills: ['Recorded skill'] };
|
||||
const raw = 'Exact raw source — æøå\nA supported owner claim.\n';
|
||||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile: { ...profile, skills: ['Recorded skill', 'Recorded later skill'] }, cvText: raw } })).status()).toBe(200);
|
||||
expect((await page.request.put(`${apiUrl}/career/profile`, { headers: owner.headers, data: { profile, cvText: raw } })).status()).toBe(200);
|
||||
const before = await (await page.request.get(`${apiUrl}/career/profile`)).json();
|
||||
const history = await (await page.request.get(`${apiUrl}/career/profile/versions`)).json();
|
||||
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
await enter(page, owner, sample);
|
||||
await navigateSection(page, 'en', 'import');
|
||||
const t = translations.en;
|
||||
await page.getByRole('button', { name: new RegExp(t.profileCvRawPanelTitle) }).click();
|
||||
await page.getByRole('button', { name: t.profileCopyCvText, exact: true }).click();
|
||||
const copied = await page.evaluate(() => navigator.clipboard.readText());
|
||||
await info.attach('native-clipboard-line-endings', { body: JSON.stringify({ requestedSource: raw, copied,
|
||||
requestedCodePoints: [...raw].map(value => value.codePointAt(0)), copiedCodePoints: [...copied].map(value => value.codePointAt(0)),
|
||||
qualification: 'Windows clipboard may convert LF to CRLF; preserve exact text after this explicit platform normalization, without trimming or other changes' }), contentType: 'application/json' });
|
||||
expect(copied.replace(/\r\n/g, '\n')).toBe(raw);
|
||||
await navigateSection(page, 'en', 'profile');
|
||||
const label = t.profileCvVersionHistory.replace('{count}', String(history.length));
|
||||
await page.getByRole('button', { name: label, exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: t.profileCvVersionRestore, exact: true }).first()).toBeVisible();
|
||||
expect(await (await page.request.get(`${apiUrl}/career/profile`)).json()).toEqual(before);
|
||||
expect(await (await page.request.get(`${apiUrl}/career/profile/versions`)).json()).toEqual(history);
|
||||
await info.attach('native-career-source-history-entry', { body: JSON.stringify({ clipboardContentPreservedWithExplicitWindowsLineEndingNormalization: true, historyEntryVisible: true,
|
||||
canonicalAndHistoryUnchanged: true, qualification: 'Browser clipboard permission granted; exact OS clipboard bytes/human acceptance not claimed; restore/save/import-review migration is P7-03' }), contentType: 'application/json' });
|
||||
});
|
||||
@@ -1,23 +1,182 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import CareerEvidenceBank from "./views/career/CareerEvidenceBank";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), delete: jest.fn() }, getApiErrorMessage: (_e: unknown, fallback: string) => fallback }));
|
||||
jest.mock("./confirm", () => ({ useConfirm: () => ({ confirm: jest.fn().mockResolvedValue(true) }) }));
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), post: jest.fn(), delete: jest.fn(), put: jest.fn(), patch: jest.fn() },
|
||||
getApiErrorMessage: jest.requireActual("./api").getApiErrorMessage,
|
||||
}));
|
||||
const mocked = api as jest.Mocked<typeof api>;
|
||||
const evidence = { id: 1, category: "achievement", title: "Reduced incidents", statement: "Added rollback checks", tags: ["reliability"], sourceReference: "retrospective" };
|
||||
|
||||
function open(language: "en" | "nb" = "en") {
|
||||
window.localStorage.setItem("uiLanguage", language);
|
||||
return render(<I18nProvider><ConfirmProvider><CareerEvidenceBank /></ConfirmProvider></I18nProvider>);
|
||||
}
|
||||
|
||||
function fillDraft() {
|
||||
fireEvent.change(screen.getByLabelText("Short title", { exact: false }), { target: { value: evidence.title } });
|
||||
fireEvent.change(screen.getByLabelText("Evidence statement", { exact: false }), { target: { value: evidence.statement } });
|
||||
fireEvent.change(screen.getByLabelText("Skills and keywords"), { target: { value: "reliability, C#" } });
|
||||
fireEvent.change(screen.getByLabelText("Source or provenance"), { target: { value: evidence.sourceReference } });
|
||||
}
|
||||
|
||||
function expectNoWrites() {
|
||||
for (const method of [mocked.post, mocked.delete, mocked.put, mocked.patch]) expect(method).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.localStorage.removeItem("uiLanguage");
|
||||
mocked.get.mockReset().mockResolvedValue({ data: [] } as any);
|
||||
for (const method of [mocked.post, mocked.delete, mocked.put, mocked.patch]) method.mockReset().mockRejectedValue(new Error("Unexpected write"));
|
||||
});
|
||||
|
||||
afterEach(() => window.localStorage.removeItem("uiLanguage"));
|
||||
|
||||
test("saves factual evidence with provenance and tags", async () => {
|
||||
mocked.get.mockResolvedValue({ data: [] } as any);
|
||||
mocked.post.mockResolvedValue({ data: { id: 1, category: "achievement", title: "Reduced incidents", statement: "Added rollback checks", tags: ["reliability"], sourceReference: "retrospective" } } as any);
|
||||
render(<I18nProvider><CareerEvidenceBank /></I18nProvider>);
|
||||
fireEvent.change(await screen.findByLabelText("Short title"), { target: { value: "Reduced incidents" } });
|
||||
fireEvent.change(screen.getByLabelText("Evidence statement"), { target: { value: "Added rollback checks" } });
|
||||
fireEvent.change(screen.getByLabelText("Skills and keywords"), { target: { value: "reliability" } });
|
||||
fireEvent.change(screen.getByLabelText("Source or provenance"), { target: { value: "retrospective" } });
|
||||
mocked.post.mockResolvedValue({ data: evidence } as any);
|
||||
open();
|
||||
await screen.findByText(translations.en.careerEvidenceEmpty);
|
||||
fillDraft();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Save evidence" }));
|
||||
await waitFor(() => expect(mocked.post).toHaveBeenCalledWith("/career/evidence", expect.objectContaining({ title: "Reduced incidents", isVerified: true, sourceType: "manual" })));
|
||||
expect(await screen.findByText("Added rollback checks")).toBeInTheDocument();
|
||||
await waitFor(() => expect(mocked.post).toHaveBeenCalledWith("/career/evidence", {
|
||||
category: "achievement", title: evidence.title, statement: evidence.statement,
|
||||
tags: ["reliability", " C#"], sourceType: "manual", sourceReference: evidence.sourceReference, isVerified: true,
|
||||
}));
|
||||
expect(await screen.findByText(evidence.statement)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("pending evidence reads announce loading without claiming an empty bank or allowing a raced save", async () => {
|
||||
let finish!: (value: any) => void;
|
||||
mocked.get.mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
open();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(translations.en.loading);
|
||||
expect(screen.queryByText(translations.en.careerEvidenceEmpty)).not.toBeInTheDocument();
|
||||
fillDraft();
|
||||
const save = screen.getByRole("button", { name: translations.en.careerEvidenceSave });
|
||||
expect(save).toBeDisabled();
|
||||
fireEvent.click(save);
|
||||
expectNoWrites();
|
||||
await act(async () => finish({ data: [] }));
|
||||
expect(await screen.findByText(translations.en.careerEvidenceEmpty)).toBeVisible();
|
||||
expect(save).toBeEnabled();
|
||||
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title);
|
||||
});
|
||||
|
||||
test("failed list reads remain distinct from empty state and retry only GET while preserving the draft", async () => {
|
||||
mocked.get.mockRejectedValueOnce({ response: { status: 503, data: "Evidence list unavailable" } });
|
||||
open();
|
||||
const alert = await screen.findByRole("alert");
|
||||
expect(alert).toHaveTextContent("Evidence list unavailable");
|
||||
expect(screen.queryByText(translations.en.careerEvidenceEmpty)).not.toBeInTheDocument();
|
||||
fillDraft();
|
||||
fireEvent.click(within(alert).getByRole("button", { name: translations.en.retry }));
|
||||
expect(await screen.findByText(translations.en.careerEvidenceEmpty)).toBeVisible();
|
||||
expect(screen.getByLabelText("Evidence statement", { exact: false })).toHaveValue(evidence.statement);
|
||||
expect(mocked.get.mock.calls.map(([url]) => url)).toEqual(["/career/evidence", "/career/evidence"]);
|
||||
expectNoWrites();
|
||||
});
|
||||
|
||||
test("failed saves retain factual inputs and retry the same explicit save without reloading the list", async () => {
|
||||
mocked.post.mockRejectedValueOnce({ response: { status: 503, data: "Evidence save unavailable" } }).mockResolvedValueOnce({ data: evidence } as any);
|
||||
open();
|
||||
await screen.findByText(translations.en.careerEvidenceEmpty);
|
||||
fillDraft();
|
||||
const form = screen.getByRole("region", { name: translations.en.careerEvidenceAdd });
|
||||
fireEvent.click(within(form).getByRole("button", { name: translations.en.careerEvidenceSave }));
|
||||
expect(await within(form).findByRole("alert")).toHaveTextContent("Evidence save unavailable");
|
||||
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title);
|
||||
expect(screen.getByLabelText("Evidence statement", { exact: false })).toHaveValue(evidence.statement);
|
||||
expect(screen.getByLabelText("Skills and keywords")).toHaveValue("reliability, C#");
|
||||
expect(screen.getByLabelText("Source or provenance")).toHaveValue(evidence.sourceReference);
|
||||
fireEvent.click(within(form).getByRole("button", { name: translations.en.careerEvidenceSave }));
|
||||
expect(await screen.findByRole("article", { name: evidence.title })).toHaveTextContent(evidence.statement);
|
||||
expect(mocked.post).toHaveBeenCalledTimes(2);
|
||||
expect(mocked.post.mock.calls[1]).toEqual(mocked.post.mock.calls[0]);
|
||||
expect(mocked.get).toHaveBeenCalledTimes(1);
|
||||
expect(mocked.delete).not.toHaveBeenCalled();
|
||||
expect(mocked.put).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue("");
|
||||
});
|
||||
|
||||
test("a pending save prevents duplicate writes and prevents edits being cleared by the response", async () => {
|
||||
let finish!: (value: any) => void;
|
||||
mocked.post.mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
open();
|
||||
await screen.findByText(translations.en.careerEvidenceEmpty);
|
||||
fillDraft();
|
||||
const save = screen.getByRole("button", { name: translations.en.careerEvidenceSave });
|
||||
fireEvent.click(save);
|
||||
expect(save).toBeDisabled();
|
||||
expect(screen.getByLabelText("Short title", { exact: false })).toBeDisabled();
|
||||
expect(screen.getByLabelText("Evidence statement", { exact: false })).toBeDisabled();
|
||||
fireEvent.click(save);
|
||||
expect(mocked.post).toHaveBeenCalledTimes(1);
|
||||
await act(async () => finish({ data: evidence }));
|
||||
expect(await screen.findByRole("article", { name: evidence.title })).toBeVisible();
|
||||
expect(save).toBeDisabled();
|
||||
expect(screen.getByLabelText("Short title", { exact: false })).toBeEnabled();
|
||||
});
|
||||
|
||||
test("contextual deletion is named and cancellation performs no writes or draft loss", async () => {
|
||||
mocked.get.mockResolvedValue({ data: [evidence] } as any);
|
||||
open();
|
||||
const item = await screen.findByRole("article", { name: evidence.title });
|
||||
fillDraft();
|
||||
const deleteButton = within(item).getByRole("button", { name: /Reduced incidents/ });
|
||||
deleteButton.focus();
|
||||
fireEvent.click(deleteButton);
|
||||
const dialog = await screen.findByRole("dialog", { name: translations.en.careerEvidenceDeleteTitle });
|
||||
expect(dialog).toHaveTextContent(evidence.title);
|
||||
expect(deleteButton).toBeEnabled();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.cancel }));
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
expect(deleteButton).toHaveFocus();
|
||||
expect(item).toBeVisible();
|
||||
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title);
|
||||
expectNoWrites();
|
||||
});
|
||||
|
||||
test("failed deletion keeps the row and draft, then reconfirms the same delete without list reload", async () => {
|
||||
mocked.get.mockResolvedValue({ data: [evidence] } as any);
|
||||
mocked.delete.mockRejectedValueOnce({ response: { status: 503, data: "Evidence deletion unavailable" } }).mockResolvedValueOnce({} as any);
|
||||
open();
|
||||
const item = await screen.findByRole("article", { name: evidence.title });
|
||||
fillDraft();
|
||||
fireEvent.click(within(item).getByRole("button", { name: /Reduced incidents/ }));
|
||||
let dialog = await screen.findByRole("dialog", { name: translations.en.careerEvidenceDeleteTitle });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.deleteAction }));
|
||||
expect(await within(item).findByRole("alert")).toHaveTextContent("Evidence deletion unavailable");
|
||||
expect(mocked.delete).toHaveBeenCalledWith("/career/evidence/1");
|
||||
expect(screen.getByLabelText("Evidence statement", { exact: false })).toHaveValue(evidence.statement);
|
||||
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
|
||||
fireEvent.click(within(item).getByRole("button", { name: translations.en.retry }));
|
||||
dialog = await screen.findByRole("dialog", { name: translations.en.careerEvidenceDeleteTitle });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: translations.en.deleteAction }));
|
||||
await waitFor(() => expect(screen.queryByRole("article", { name: evidence.title })).not.toBeInTheDocument());
|
||||
expect(mocked.delete.mock.calls.map(([url]) => url)).toEqual(["/career/evidence/1", "/career/evidence/1"]);
|
||||
expect(mocked.get).toHaveBeenCalledTimes(1);
|
||||
expect(mocked.post).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Short title", { exact: false })).toHaveValue(evidence.title);
|
||||
await waitFor(() => expect(screen.getByLabelText("Short title", { exact: false })).toHaveFocus());
|
||||
});
|
||||
|
||||
test("evidence sections and factual fields have semantic headings and required labels in Bokmål", async () => {
|
||||
mocked.get.mockResolvedValue({ data: [evidence] } as any);
|
||||
open("nb");
|
||||
expect(screen.getByRole("heading", { name: translations.nb.careerEvidenceTitle, level: 2 })).toBeInTheDocument();
|
||||
expect(screen.getByRole("heading", { name: translations.nb.careerEvidenceAdd, level: 3 })).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(translations.nb.careerEvidenceItemTitle, { exact: false })).toBeRequired();
|
||||
expect(screen.getByLabelText(translations.nb.careerEvidenceStatement, { exact: false })).toBeRequired();
|
||||
const item = await screen.findByRole("article", { name: evidence.title });
|
||||
expect(within(item).getByRole("heading", { name: evidence.title, level: 3 })).toBeInTheDocument();
|
||||
expect(item).toHaveTextContent(translations.nb.careerEvidenceCategory_achievement);
|
||||
expect(item).toHaveTextContent(evidence.sourceReference);
|
||||
expectNoWrites();
|
||||
});
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
|
||||
import { cvBuilderApi } from "./cvBuilder";
|
||||
import CareerWorkspaceOverview, { CareerWorkspaceImportRun } from "./views/career/CareerWorkspaceOverview";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import { translations } from "./i18n/translations";
|
||||
import { api } from "./api";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import CareerProfilePage from "./views/CareerProfilePage";
|
||||
|
||||
jest.mock("./cvBuilder", () => ({
|
||||
cvBuilderApi: { list: jest.fn() },
|
||||
}));
|
||||
|
||||
const mockedList = cvBuilderApi.list as jest.MockedFunction<typeof cvBuilderApi.list>;
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
const careerResponse = { profile: { version: "1", contact: { fullName: "Stored career name" }, skills: ["Stored C#"] }, completeness: { percent: 70, missing: ["Projects"], sections: [] }, cvText: "Stored raw text" };
|
||||
|
||||
function renderOverview({
|
||||
percent = 0,
|
||||
@@ -18,12 +25,22 @@ function renderOverview({
|
||||
runs = [],
|
||||
loading = false,
|
||||
loadError = null,
|
||||
runsLoading = false,
|
||||
runsLoadError = null,
|
||||
onRetryProfile,
|
||||
onRetryRuns,
|
||||
profileDirty = false,
|
||||
}: {
|
||||
percent?: number;
|
||||
missing?: string[];
|
||||
runs?: CareerWorkspaceImportRun[];
|
||||
loading?: boolean;
|
||||
loadError?: string | null;
|
||||
runsLoading?: boolean;
|
||||
runsLoadError?: string | null;
|
||||
onRetryProfile?: () => void;
|
||||
onRetryRuns?: () => void;
|
||||
profileDirty?: boolean;
|
||||
} = {}) {
|
||||
return render(
|
||||
<I18nProvider>
|
||||
@@ -32,14 +49,28 @@ function renderOverview({
|
||||
runs={runs}
|
||||
loading={loading}
|
||||
loadError={loadError}
|
||||
runsLoading={runsLoading}
|
||||
runsLoadError={runsLoadError}
|
||||
onRetryProfile={onRetryProfile}
|
||||
onRetryRuns={onRetryRuns}
|
||||
profileDirty={profileDirty}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
window.history.replaceState({}, "", "/career");
|
||||
window.localStorage.removeItem("uiLanguage");
|
||||
mockedList.mockResolvedValue([]);
|
||||
mockedList.mockReset().mockResolvedValue([]);
|
||||
mockedApi.get.mockReset().mockImplementation((url: string) => {
|
||||
if (url === "/career/profile") return Promise.resolve({ data: careerResponse } as any);
|
||||
if (url === "/auth/me") return Promise.resolve({ data: { id: "career-owner", provider: "local", displayName: "Account name" } } as any);
|
||||
if (["/profile-cv/runs", "/career/profile/versions", "/career/evidence"].includes(url)) return Promise.resolve({ data: [] } as any);
|
||||
return Promise.reject(new Error(`Unexpected read: ${url}`));
|
||||
});
|
||||
for (const method of [mockedApi.post, mockedApi.put, mockedApi.patch, mockedApi.delete]) method.mockReset().mockRejectedValue(new Error("Unexpected write"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -107,3 +138,118 @@ test("workspace represents loading and profile-load errors without inventing sta
|
||||
expect(screen.queryByText(/start by adding career information/i)).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(mockedList).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test("a settled failed profile read hides the completeness score and retries only through its existing owner", async () => {
|
||||
const retry = jest.fn();
|
||||
renderOverview({ percent: 70, loadError: "Profile unavailable", onRetryProfile: retry });
|
||||
const region = screen.getByRole("region", { name: translations.en.careerOverviewProfileStatus });
|
||||
expect(within(region).queryByRole("progressbar")).not.toBeInTheDocument();
|
||||
expect(within(region).queryByText("70%")).not.toBeInTheDocument();
|
||||
expect(within(region).getByRole("alert")).toHaveTextContent(translations.en.careerOverviewUnavailable);
|
||||
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
|
||||
expect(retry).toHaveBeenCalledTimes(1);
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
await screen.findByText(translations.en.careerOverviewNoCvs);
|
||||
});
|
||||
|
||||
test("an unknown import read cannot claim no imports, completion or first-run guidance", async () => {
|
||||
renderOverview({ runsLoading: true });
|
||||
const region = screen.getByRole("region", { name: translations.en.careerOverviewCvImport });
|
||||
expect(within(region).getByRole("status")).toHaveTextContent(translations.en.loading);
|
||||
expect(screen.queryByText(translations.en.careerOverviewNoImport)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(translations.en.careerOverviewImportComplete)).not.toBeInTheDocument();
|
||||
await screen.findByText(translations.en.careerOverviewNoCvs);
|
||||
expect(screen.queryByText(translations.en.careerOverviewFirstRun)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("a failed import read stays unknown and keeps its own retry separate from the profile read", async () => {
|
||||
const retryRuns = jest.fn();
|
||||
const retryProfile = jest.fn();
|
||||
renderOverview({ runsLoadError: "Run read unavailable", onRetryRuns: retryRuns, onRetryProfile: retryProfile });
|
||||
const region = screen.getByRole("region", { name: translations.en.careerOverviewCvImport });
|
||||
expect(within(region).getByRole("alert")).toHaveTextContent(translations.en.careerOverviewImportUnavailable);
|
||||
expect(screen.queryByText(translations.en.careerOverviewNoImport)).not.toBeInTheDocument();
|
||||
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
|
||||
expect(retryRuns).toHaveBeenCalledTimes(1);
|
||||
expect(retryProfile).not.toHaveBeenCalled();
|
||||
await screen.findByText(translations.en.careerOverviewNoCvs);
|
||||
expect(screen.queryByText(translations.en.careerOverviewFirstRun)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("failed recent-CV reads do not imply first-run and retry the same list without writes", async () => {
|
||||
mockedList.mockRejectedValueOnce(new Error("Recent list unavailable"));
|
||||
renderOverview();
|
||||
const region = screen.getByRole("region", { name: translations.en.careerOverviewRecentCvs });
|
||||
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.en.careerOverviewRecentFailed);
|
||||
expect(screen.queryByText(translations.en.careerOverviewFirstRun)).not.toBeInTheDocument();
|
||||
expect(within(region).queryByText(translations.en.careerOverviewNoCvs)).not.toBeInTheDocument();
|
||||
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
|
||||
expect(await within(region).findByText(translations.en.careerOverviewNoCvs)).toBeVisible();
|
||||
expect(mockedList).toHaveBeenCalledTimes(2);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
function renderCareer() {
|
||||
return render(<ToastProvider><I18nProvider><ConfirmProvider><CareerProfilePage /></ConfirmProvider></I18nProvider></ToastProvider>);
|
||||
}
|
||||
|
||||
test("the parent exposes failed import-read retry without canonical saves or history restores", async () => {
|
||||
let fail = true;
|
||||
const originalGet = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/profile-cv/runs" && fail
|
||||
? Promise.reject(new Error("Run read unavailable")) : originalGet(url));
|
||||
renderCareer();
|
||||
const region = screen.getByRole("region", { name: translations.en.careerOverviewCvImport });
|
||||
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.en.careerOverviewImportUnavailable);
|
||||
const careerReads = mockedApi.get.mock.calls.filter(([url]) => url === "/career/profile").length;
|
||||
fail = false;
|
||||
fireEvent.click(within(region).getByRole("button", { name: translations.en.retry }));
|
||||
expect(await within(region).findByText(translations.en.careerOverviewNoImport)).toBeVisible();
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/profile-cv/runs")).toHaveLength(2);
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/career/profile")).toHaveLength(careerReads);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("overview retry cannot overwrite a dirty canonical draft after a failed source refresh", async () => {
|
||||
renderCareer();
|
||||
await screen.findByText("70%");
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceProfileTab }));
|
||||
fireEvent.change(screen.getByLabelText("Full name"), { target: { value: "Unsaved canonical name" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceOverviewTab }));
|
||||
const originalGet = mockedApi.get.getMockImplementation()!;
|
||||
mockedApi.get.mockImplementation((url: string) => url === "/career/profile"
|
||||
? Promise.reject(new Error("Source refresh unavailable")) : originalGet(url));
|
||||
act(() => window.dispatchEvent(new StorageEvent("storage", { key: "uiLanguage", newValue: "nb" })));
|
||||
const region = await screen.findByRole("region", { name: translations.nb.careerOverviewProfileStatus });
|
||||
expect(await within(region).findByRole("alert")).toHaveTextContent(translations.nb.careerOverviewRetryDraftHint);
|
||||
const reads = mockedApi.get.mock.calls.length;
|
||||
const retry = within(region).getByRole("button", { name: translations.nb.retry });
|
||||
expect(retry).toBeDisabled();
|
||||
fireEvent.click(retry);
|
||||
expect(mockedApi.get).toHaveBeenCalledTimes(reads);
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.nb.careerWorkspaceProfileTab }));
|
||||
expect(screen.getByLabelText("Fullt navn")).toHaveValue("Unsaved canonical name");
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("visited evidence keeps its draft across sections without an unvisited or repeated list request", async () => {
|
||||
renderCareer();
|
||||
await screen.findByText("70%");
|
||||
expect(mockedApi.get).not.toHaveBeenCalledWith("/career/evidence");
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceEvidenceTab }));
|
||||
await screen.findByText(translations.en.careerEvidenceEmpty);
|
||||
fireEvent.change(screen.getByLabelText(translations.en.careerEvidenceItemTitle, { exact: false }), { target: { value: "Unsaved evidence title" } });
|
||||
fireEvent.change(screen.getByLabelText(translations.en.careerEvidenceStatement, { exact: false }), { target: { value: "Unsaved factual statement" } });
|
||||
fireEvent.change(screen.getByLabelText(translations.en.careerEvidenceTags), { target: { value: "C#, reliability" } });
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceProfileTab }));
|
||||
expect(screen.queryByRole("region", { name: translations.en.careerEvidenceTitle })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("tab", { name: translations.en.careerWorkspaceEvidenceTab }));
|
||||
expect(screen.getByLabelText(translations.en.careerEvidenceItemTitle, { exact: false })).toHaveValue("Unsaved evidence title");
|
||||
expect(screen.getByLabelText(translations.en.careerEvidenceStatement, { exact: false })).toHaveValue("Unsaved factual statement");
|
||||
expect(screen.getByLabelText(translations.en.careerEvidenceTags)).toHaveValue("C#, reliability");
|
||||
expect(mockedApi.get.mock.calls.filter(([url]) => url === "/career/evidence")).toHaveLength(1);
|
||||
expect(mockedApi.post).not.toHaveBeenCalled();
|
||||
expect(mockedApi.put).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -888,7 +888,9 @@ export const translations = {
|
||||
careerOverviewSubtitle: "Choose what you want to work on next.",
|
||||
careerOverviewOpenBuilder: "Open CV Builder",
|
||||
careerOverviewFirstRun: "Start by adding career information manually or importing a CV.",
|
||||
careerOverviewUnavailable: "Workspace status is unavailable. The profile editor below can retry the request.",
|
||||
careerOverviewUnavailable: "Workspace status is unavailable.",
|
||||
careerOverviewImportUnavailable: "CV import status could not be loaded.",
|
||||
careerOverviewRetryDraftHint: "Your Career profile has unsaved changes. Review them in the profile before retrying.",
|
||||
careerOverviewProfileStatus: "Profile status",
|
||||
careerOverviewCompleteness: "Profile completeness",
|
||||
careerOverviewCompletenessLabel: "Career profile completeness",
|
||||
@@ -927,7 +929,7 @@ export const translations = {
|
||||
careerOverviewChooseJob: "Choose a job",
|
||||
careerOverviewRecentCvs: "Recent CVs",
|
||||
careerOverviewViewAll: "View all CVs",
|
||||
careerOverviewRecentFailed: "Recent CVs could not be loaded. Open the CV Builder to try again.",
|
||||
careerOverviewRecentFailed: "Recent CVs could not be loaded.",
|
||||
careerOverviewNoCvs: "No CV documents yet.",
|
||||
careerOverviewUpdated: "Updated {date}",
|
||||
careerOverviewJobSpecific: "Job-specific",
|
||||
@@ -3476,7 +3478,9 @@ export const translations = {
|
||||
careerOverviewSubtitle: "Velg hva du vil arbeide med videre.",
|
||||
careerOverviewOpenBuilder: "Åpne CV-bygger",
|
||||
careerOverviewFirstRun: "Start med å legge til karriereinformasjon manuelt eller importere en CV.",
|
||||
careerOverviewUnavailable: "Status for karriereområdet er utilgjengelig. Profilredigeringen nedenfor kan prøve forespørselen på nytt.",
|
||||
careerOverviewUnavailable: "Status for karriereområdet er utilgjengelig.",
|
||||
careerOverviewImportUnavailable: "Kunne ikke laste status for CV-import.",
|
||||
careerOverviewRetryDraftHint: "Karriereprofilen har ulagrede endringer. Se gjennom dem i profilen før du prøver igjen.",
|
||||
careerOverviewProfileStatus: "Profilstatus",
|
||||
careerOverviewCompleteness: "Hvor komplett profilen er",
|
||||
careerOverviewCompletenessLabel: "Hvor komplett karriereprofilen er",
|
||||
@@ -3515,7 +3519,7 @@ export const translations = {
|
||||
careerOverviewChooseJob: "Velg en jobb",
|
||||
careerOverviewRecentCvs: "Nylige CV-er",
|
||||
careerOverviewViewAll: "Vis alle CV-er",
|
||||
careerOverviewRecentFailed: "Nylige CV-er kunne ikke lastes. Åpne CV-byggeren for å prøve igjen.",
|
||||
careerOverviewRecentFailed: "Kunne ikke laste nylige CV-er.",
|
||||
careerOverviewNoCvs: "Ingen CV-dokumenter ennå.",
|
||||
careerOverviewUpdated: "Oppdatert {date}",
|
||||
careerOverviewJobSpecific: "Jobbspesifikk",
|
||||
|
||||
@@ -150,7 +150,7 @@ export default function CareerProfilePage() {
|
||||
const cvInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const avatarInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [me, setMe] = useState<MeResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [uploadingCv, setUploadingCv] = useState(false);
|
||||
const [improvingCv, setImprovingCv] = useState(false);
|
||||
@@ -170,9 +170,11 @@ export default function CareerProfilePage() {
|
||||
// The raw import/section parser remains available as an advanced recovery tool.
|
||||
const [showAdvancedCvTools, setShowAdvancedCvTools] = useState(false);
|
||||
const [workspaceSection, setWorkspaceSection] = useState<CareerWorkspaceSection>(initialWorkspaceSection);
|
||||
const [evidenceVisited, setEvidenceVisited] = useState(() => initialWorkspaceSection() === "evidence");
|
||||
|
||||
const navigateWorkspace = useCallback((next: CareerWorkspaceSection) => {
|
||||
setWorkspaceSection(next);
|
||||
if (next === "evidence") setEvidenceVisited(true);
|
||||
if (typeof window === "undefined") return;
|
||||
const url = new URL(window.location.href);
|
||||
if (next === "overview") url.searchParams.delete("section");
|
||||
@@ -205,6 +207,9 @@ export default function CareerProfilePage() {
|
||||
}
|
||||
}, [loadVersions, t, toast]);
|
||||
const [extractionRuns, setExtractionRuns] = useState<ExtractionRun[]>([]);
|
||||
const [runsLoading, setRunsLoading] = useState(true);
|
||||
const [runsLoadError, setRunsLoadError] = useState<string | null>(null);
|
||||
const runsReadId = useRef(0);
|
||||
const [runDiffs, setRunDiffs] = useState<Record<number, CvImportDiff>>({});
|
||||
const [reviewingRunId, setReviewingRunId] = useState<number | null>(null);
|
||||
const [acceptedLowConfidenceIds, setAcceptedLowConfidenceIds] = useState<Record<number, string[]>>({});
|
||||
@@ -216,11 +221,19 @@ export default function CareerProfilePage() {
|
||||
}, []);
|
||||
|
||||
const loadExtractionRuns = useCallback(async () => {
|
||||
const readId = ++runsReadId.current;
|
||||
setRunsLoading(true);
|
||||
try {
|
||||
const response = await api.get<ExtractionRun[]>("/profile-cv/runs");
|
||||
setExtractionRuns(response.data ?? []);
|
||||
} catch {
|
||||
if (readId === runsReadId.current) {
|
||||
setExtractionRuns(response.data ?? []);
|
||||
setRunsLoadError(null);
|
||||
}
|
||||
} catch (error) {
|
||||
// Polling failure must not clear existing run state or touch unsaved profile edits.
|
||||
if (readId === runsReadId.current) setRunsLoadError(String(error));
|
||||
} finally {
|
||||
if (readId === runsReadId.current) setRunsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -367,11 +380,16 @@ export default function CareerProfilePage() {
|
||||
runs={extractionRuns}
|
||||
loading={loading}
|
||||
loadError={loadError}
|
||||
runsLoading={runsLoading}
|
||||
runsLoadError={runsLoadError}
|
||||
onRetryRuns={() => void loadExtractionRuns()}
|
||||
onRetryProfile={() => { if (!profileDirty) void loadProfile(); }}
|
||||
profileDirty={profileDirty}
|
||||
onNavigate={navigateWorkspace}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{workspaceSection === "evidence" ? <CareerEvidenceBank /> : null}
|
||||
{evidenceVisited ? <Box hidden={workspaceSection !== "evidence"}><CareerEvidenceBank /></Box> : null}
|
||||
|
||||
{workspaceSection === "profile" || workspaceSection === "import" ? <Paper id={workspaceSection === "profile" ? "career-profile-editor" : "career-cv-import"} sx={{ mt: 0, p: { xs: 1.5, sm: 2.5 }, borderRadius: 4, border: "none", boxShadow: "0px 1px 2px 0px rgba(15,23,42,0.04), 0px 8px 24px -12px rgba(15,23,42,0.12)", scrollMarginTop: 96 }}>
|
||||
<input
|
||||
|
||||
@@ -1,38 +1,104 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import { Alert, Box, Button, Chip, MenuItem, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import { api, getApiErrorMessage } from "../../api";
|
||||
import { useConfirm } from "../../confirm";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
import { PageSection } from "../../layout/PageLayout";
|
||||
|
||||
type Evidence = { id: number; category: string; title: string; statement: string; tags: string[]; sourceReference: string | null };
|
||||
|
||||
export default function CareerEvidenceBank() {
|
||||
const { t } = useI18n(); const { confirm } = useConfirm();
|
||||
const [items, setItems] = useState<Evidence[]>([]); const [title, setTitle] = useState(""); const [statement, setStatement] = useState("");
|
||||
const [category, setCategory] = useState("achievement"); const [tags, setTags] = useState(""); const [source, setSource] = useState("");
|
||||
const [error, setError] = useState<string | null>(null); const [busy, setBusy] = useState(false);
|
||||
const load = () => api.get<Evidence[]>("/career/evidence").then(r => { setItems(r.data); setError(null); }).catch(e => setError(getApiErrorMessage(e, t("careerEvidenceLoadFailed"))));
|
||||
useEffect(() => { void load(); }, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const add = async () => { setBusy(true); try { const r = await api.post<Evidence>("/career/evidence", { category, title, statement, tags: tags.split(","), sourceType: "manual", sourceReference: source, isVerified: true }); setItems(v => [r.data, ...v]); setTitle(""); setStatement(""); setTags(""); setSource(""); setError(null); } catch (e) { setError(getApiErrorMessage(e, t("careerEvidenceSaveFailed"))); } finally { setBusy(false); } };
|
||||
const remove = async (item: Evidence) => { if (!(await confirm({ title: t("careerEvidenceDeleteTitle"), message: t("careerEvidenceDeleteMessage", { title: item.title }), confirmLabel: t("deleteAction"), destructive: true }))) return; try { await api.delete(`/career/evidence/${item.id}`); setItems(v => v.filter(x => x.id !== item.id)); } catch (e) { setError(getApiErrorMessage(e, t("careerEvidenceDeleteFailed"))); } };
|
||||
const { t } = useI18n();
|
||||
const { confirm } = useConfirm();
|
||||
const [items, setItems] = useState<Evidence[]>([]);
|
||||
const [title, setTitle] = useState("");
|
||||
const [statement, setStatement] = useState("");
|
||||
const [category, setCategory] = useState("achievement");
|
||||
const [tags, setTags] = useState("");
|
||||
const [source, setSource] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [readAttempt, setReadAttempt] = useState(0);
|
||||
const [loadError, setLoadError] = useState<{ cause: unknown } | null>(null);
|
||||
const [saveError, setSaveError] = useState<{ cause: unknown } | null>(null);
|
||||
const [deleteErrors, setDeleteErrors] = useState<Record<number, { cause: unknown }>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const writePending = useRef(false);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const focusAfterDelete = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
api.get<Evidence[]>("/career/evidence")
|
||||
.then((response) => { if (!cancelled) { setItems(response.data); setLoadError(null); } })
|
||||
.catch((cause) => { if (!cancelled) setLoadError({ cause }); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [readAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusAfterDelete.current) {
|
||||
focusAfterDelete.current = false;
|
||||
titleInputRef.current?.focus();
|
||||
}
|
||||
}, [items]);
|
||||
|
||||
const add = async () => {
|
||||
if (writePending.current || loading || loadError || !title.trim() || !statement.trim()) return;
|
||||
writePending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
const response = await api.post<Evidence>("/career/evidence", { category, title, statement, tags: tags.split(","), sourceType: "manual", sourceReference: source, isVerified: true });
|
||||
setItems((previous) => [response.data, ...previous]);
|
||||
setTitle(""); setStatement(""); setTags(""); setSource("");
|
||||
setSaveError(null);
|
||||
} catch (cause) {
|
||||
setSaveError({ cause });
|
||||
} finally {
|
||||
writePending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (item: Evidence) => {
|
||||
if (writePending.current || loading || loadError) return;
|
||||
writePending.current = true;
|
||||
try {
|
||||
if (!(await confirm({ title: t("careerEvidenceDeleteTitle"), message: t("careerEvidenceDeleteMessage", { title: item.title }), confirmLabel: t("deleteAction"), destructive: true }))) return;
|
||||
setDeletingId(item.id);
|
||||
await api.delete(`/career/evidence/${item.id}`);
|
||||
focusAfterDelete.current = true;
|
||||
setItems((previous) => previous.filter((entry) => entry.id !== item.id));
|
||||
setDeleteErrors((previous) => { const next = { ...previous }; delete next[item.id]; return next; });
|
||||
} catch (cause) {
|
||||
setDeleteErrors((previous) => ({ ...previous, [item.id]: { cause } }));
|
||||
} finally {
|
||||
writePending.current = false;
|
||||
setDeletingId(null);
|
||||
}
|
||||
};
|
||||
const categories = ["achievement", "metric", "project", "technology", "responsibility", "example"];
|
||||
const categoryLabel = (value: string) => t(`careerEvidenceCategory_${value}` as "careerEvidenceCategory_achievement");
|
||||
return <Stack spacing={2}>
|
||||
<Box><Typography variant="h5" fontWeight={800}>{t("careerEvidenceTitle")}</Typography><Typography color="text.secondary">{t("careerEvidenceSubtitle")}</Typography></Box>
|
||||
{error && <Alert severity="error" action={<Button color="inherit" onClick={() => void load()}>{t("retry")}</Button>}>{error}</Alert>}
|
||||
<Paper variant="outlined" sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 3 }}><Stack spacing={1.5}>
|
||||
<Typography variant="subtitle1" fontWeight={700}>{t("careerEvidenceAdd")}</Typography>
|
||||
<TextField select size="small" label={t("careerEvidenceCategory")} value={category} onChange={e => setCategory(e.target.value)}>{categories.map(v => <MenuItem key={v} value={v}>{categoryLabel(v)}</MenuItem>)}</TextField>
|
||||
<TextField label={t("careerEvidenceItemTitle")} value={title} onChange={e => setTitle(e.target.value)} />
|
||||
<TextField label={t("careerEvidenceStatement")} value={statement} onChange={e => setStatement(e.target.value)} multiline minRows={3} helperText={t("careerEvidenceTruthHelp")} />
|
||||
<TextField label={t("careerEvidenceTags")} value={tags} onChange={e => setTags(e.target.value)} helperText={t("careerEvidenceTagsHelp")} />
|
||||
<TextField label={t("careerEvidenceSource")} value={source} onChange={e => setSource(e.target.value)} helperText={t("careerEvidenceSourceHelp")} />
|
||||
<Button variant="contained" disabled={busy || !title.trim() || !statement.trim()} onClick={() => void add()} sx={{ alignSelf: "flex-start" }}>{t("careerEvidenceSave")}</Button>
|
||||
</Stack></Paper>
|
||||
{items.length === 0 ? <Alert severity="info">{t("careerEvidenceEmpty")}</Alert> : items.map(item => <Paper key={item.id} variant="outlined" sx={{ p: 2, borderRadius: 3 }}><Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" gap={1}><Box><Typography fontWeight={700}>{item.title}</Typography><Typography variant="caption" color="text.secondary">{categoryLabel(item.category)} · {item.sourceReference || t("careerEvidenceManualSource")}</Typography></Box><Button color="error" size="small" startIcon={<DeleteOutlineIcon />} onClick={() => void remove(item)}>{t("deleteAction")}</Button></Stack>
|
||||
const categoryLabel = (value: string) => categories.includes(value) ? t(`careerEvidenceCategory_${value}` as "careerEvidenceCategory_achievement") : value;
|
||||
return <PageSection title={t("careerEvidenceTitle")} description={t("careerEvidenceSubtitle")}>
|
||||
<Stack spacing={2}>
|
||||
{loading ? <Typography role="status" color="text.secondary">{t("loading")}</Typography> : loadError ? <Alert severity="error" action={<Button color="inherit" disabled={busy || deletingId !== null} onClick={() => { titleInputRef.current?.focus(); setReadAttempt((attempt) => attempt + 1); }}>{t("retry")}</Button>}>{getApiErrorMessage(loadError.cause, t("careerEvidenceLoadFailed"))}</Alert> : null}
|
||||
<PageSection surface title={t("careerEvidenceAdd")} level="h3"><Stack spacing={1.5}>
|
||||
{saveError ? <Alert severity="error">{getApiErrorMessage(saveError.cause, t("careerEvidenceSaveFailed"))}</Alert> : null}
|
||||
<TextField select size="small" disabled={busy} label={t("careerEvidenceCategory")} value={category} onChange={e => setCategory(e.target.value)}>{categories.map(v => <MenuItem key={v} value={v}>{categoryLabel(v)}</MenuItem>)}</TextField>
|
||||
<TextField required disabled={busy} inputRef={titleInputRef} label={t("careerEvidenceItemTitle")} value={title} onChange={e => setTitle(e.target.value)} />
|
||||
<TextField required disabled={busy} label={t("careerEvidenceStatement")} value={statement} onChange={e => setStatement(e.target.value)} multiline minRows={3} helperText={t("careerEvidenceTruthHelp")} />
|
||||
<TextField disabled={busy} label={t("careerEvidenceTags")} value={tags} onChange={e => setTags(e.target.value)} helperText={t("careerEvidenceTagsHelp")} />
|
||||
<TextField disabled={busy} label={t("careerEvidenceSource")} value={source} onChange={e => setSource(e.target.value)} helperText={t("careerEvidenceSourceHelp")} />
|
||||
<Button variant="contained" disabled={busy || deletingId !== null || loading || Boolean(loadError) || !title.trim() || !statement.trim()} onClick={() => void add()} sx={{ alignSelf: "flex-start" }}>{t("careerEvidenceSave")}</Button>
|
||||
</Stack></PageSection>
|
||||
{!loading && !loadError && items.length === 0 ? <Alert severity="info">{t("careerEvidenceEmpty")}</Alert> : null}
|
||||
{items.map(item => <Paper key={item.id} component="article" aria-labelledby={`career-evidence-${item.id}-title`} variant="outlined" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, overflowWrap: "anywhere" }}><Stack spacing={1}>
|
||||
<Stack direction="row" justifyContent="space-between" flexWrap="wrap" gap={1}><Box sx={{ minWidth: 0 }}><Typography id={`career-evidence-${item.id}-title`} component="h3" variant="sectionTitle">{item.title}</Typography><Typography variant="caption" color="text.secondary">{categoryLabel(item.category)} · {item.sourceReference || t("careerEvidenceManualSource")}</Typography></Box><Button color="error" size="small" disabled={busy || deletingId !== null || loading || Boolean(loadError)} aria-label={t("careerEvidenceDeleteMessage", { title: item.title })} startIcon={<DeleteOutlineIcon />} onClick={() => void remove(item)}>{t("deleteAction")}</Button></Stack>
|
||||
{deleteErrors[item.id] ? <Alert severity="error" action={<Button color="inherit" disabled={busy || deletingId !== null || loading || Boolean(loadError)} onClick={() => void remove(item)}>{t("retry")}</Button>}>{getApiErrorMessage(deleteErrors[item.id].cause, t("careerEvidenceDeleteFailed"))}</Alert> : null}
|
||||
<Typography variant="body2">{item.statement}</Typography><Stack direction="row" flexWrap="wrap" gap={0.5}>{item.tags.map(tag => <Chip key={tag} size="small" label={tag} />)}</Stack>
|
||||
</Stack></Paper>)}
|
||||
</Stack>;
|
||||
</Stack>
|
||||
</PageSection>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { Alert, Box, Button, Chip, LinearProgress, Paper, Skeleton, Stack, Typography } from "@mui/material";
|
||||
import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
|
||||
@@ -9,6 +9,7 @@ import WorkOutlineIcon from "@mui/icons-material/WorkOutline";
|
||||
import { CvVariantSummary, cvBuilderApi } from "../../cvBuilder";
|
||||
import type { UserOperation } from "../../types";
|
||||
import { useI18n } from "../../i18n/I18nProvider";
|
||||
import { PageSection } from "../../layout/PageLayout";
|
||||
import { localizeCompletenessLabel } from "./careerCompletenessLabels";
|
||||
|
||||
export type CareerWorkspaceCompleteness = {
|
||||
@@ -40,10 +41,10 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
|
||||
onNavigate?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Paper variant="outlined" component="article" sx={{ p: 2, borderRadius: 3, display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Paper variant="outlined" component="article" sx={{ p: "var(--app-card-padding)", borderRadius: 1, minWidth: 0, display: "flex", flexDirection: "column", gap: 1.25 }}>
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
|
||||
<Box sx={{ color: "primary.main", display: "flex" }}>{icon}</Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{title}</Typography>
|
||||
<Typography component="h3" variant="sectionTitle" sx={{ overflowWrap: "anywhere" }}>{title}</Typography>
|
||||
</Box>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ flex: 1 }}>{body}</Typography>
|
||||
<Button
|
||||
@@ -58,20 +59,30 @@ function ActionCard({ title, body, href, label, icon, onNavigate }: {
|
||||
);
|
||||
}
|
||||
|
||||
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate }: {
|
||||
export default function CareerWorkspaceOverview({ completeness, runs, loading, loadError, onNavigate, runsLoading = false, runsLoadError = null, onRetryProfile, onRetryRuns, profileDirty = false }: {
|
||||
completeness: CareerWorkspaceCompleteness | null;
|
||||
runs: CareerWorkspaceImportRun[];
|
||||
loading: boolean;
|
||||
loadError: string | null;
|
||||
onNavigate?: (section: "profile" | "import") => void;
|
||||
runsLoading?: boolean;
|
||||
runsLoadError?: string | null;
|
||||
onRetryProfile?: () => void;
|
||||
onRetryRuns?: () => void;
|
||||
profileDirty?: boolean;
|
||||
}) {
|
||||
const { language, t } = useI18n();
|
||||
const [recentCvs, setRecentCvs] = useState<CvVariantSummary[]>([]);
|
||||
const [recentLoading, setRecentLoading] = useState(true);
|
||||
const [recentError, setRecentError] = useState(false);
|
||||
const [recentAttempt, setRecentAttempt] = useState(0);
|
||||
const profileLinkRef = useRef<HTMLAnchorElement>(null);
|
||||
const importLinkRef = useRef<HTMLAnchorElement>(null);
|
||||
const recentLinkRef = useRef<HTMLAnchorElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setRecentLoading(true);
|
||||
cvBuilderApi.list()
|
||||
.then((items) => {
|
||||
if (!cancelled) {
|
||||
@@ -88,7 +99,7 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
|
||||
if (!cancelled) setRecentLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
}, [recentAttempt]);
|
||||
|
||||
const importState = useMemo(() => {
|
||||
const review = runs.filter((run) => run.status === "pending_review");
|
||||
@@ -100,66 +111,60 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
|
||||
return { severity: "success" as const, label: t("careerOverviewImportCv"), message: t(runs.length > 0 ? "careerOverviewImportComplete" : "careerOverviewNoImport") };
|
||||
}, [runs, t]);
|
||||
|
||||
const isFirstRun = !loading && !recentLoading && !loadError && (completeness?.percent ?? 0) === 0 && runs.length === 0 && recentCvs.length === 0;
|
||||
const profileKnown = !loading && !loadError && completeness !== null;
|
||||
const importUnknown = runsLoading && runs.length === 0;
|
||||
const importLabel = importUnknown || runsLoadError ? t("careerWorkspaceImportTab") : importState.label;
|
||||
const isFirstRun = profileKnown && !runsLoading && !runsLoadError && !recentLoading && !recentError && completeness.percent === 0 && runs.length === 0 && recentCvs.length === 0;
|
||||
|
||||
return (
|
||||
<Box component="section" aria-labelledby="career-workspace-overview-title" sx={{ display: "grid", gap: 2 }}>
|
||||
<Paper sx={{ p: { xs: 2, sm: 2.5 }, borderRadius: 4 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography id="career-workspace-overview-title" component="h2" variant="h5" sx={{ fontWeight: 900 }}>{t("careerWorkspaceOverviewTab")}</Typography>
|
||||
<Typography color="text.secondary">{t("careerOverviewSubtitle")}</Typography>
|
||||
</Box>
|
||||
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} href="/career/builder">{t("careerOverviewOpenBuilder")}</Button>
|
||||
</Box>
|
||||
|
||||
{isFirstRun ? <Alert severity="info" sx={{ mt: 2 }}>{t("careerOverviewFirstRun")}</Alert> : null}
|
||||
{loadError ? <Alert severity="error" sx={{ mt: 2 }}>{t("careerOverviewUnavailable")}</Alert> : null}
|
||||
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 2 }}>
|
||||
<Box aria-label={t("careerOverviewProfileStatus")} sx={{ p: 2, borderRadius: 3, bgcolor: "background.default", border: "1px solid", borderColor: "divider" }}>
|
||||
<PageSection title={t("careerWorkspaceOverviewTab")} description={t("careerOverviewSubtitle")} actions={
|
||||
<Button variant="contained" startIcon={<DescriptionOutlinedIcon />} href="/career/builder">{t("careerOverviewOpenBuilder")}</Button>
|
||||
}>
|
||||
<Box sx={{ display: "grid", gap: "var(--app-section-gap)" }}>
|
||||
{isFirstRun ? <Alert severity="info">{t("careerOverviewFirstRun")}</Alert> : null}
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "minmax(0, 1fr) minmax(0, 1fr)" }, gap: 2 }}>
|
||||
<PageSection surface title={t("careerOverviewProfileStatus")} level="h3">
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center" }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerOverviewCompleteness")}</Typography>
|
||||
{loading ? <Skeleton width={42} /> : <Typography sx={{ fontWeight: 900 }}>{completeness?.percent ?? 0}%</Typography>}
|
||||
<Typography variant="body2" color="text.secondary">{t("careerOverviewCompleteness")}</Typography>
|
||||
{loading ? <Skeleton width={42} /> : profileKnown ? <Typography sx={{ fontWeight: 900 }}>{completeness.percent}%</Typography> : null}
|
||||
</Box>
|
||||
<LinearProgress aria-label={t("careerOverviewCompletenessLabel")} variant={loading ? "indeterminate" : "determinate"} value={completeness?.percent ?? 0} sx={{ mt: 1, height: 8, borderRadius: 999 }} />
|
||||
{!loading && completeness?.missing.length ? (
|
||||
{loading || profileKnown ? <LinearProgress aria-label={t("careerOverviewCompletenessLabel")} variant={loading ? "indeterminate" : "determinate"} value={completeness?.percent} sx={{ mt: 1, height: 8, borderRadius: 999 }} /> : null}
|
||||
{loadError ? <Alert severity="error" sx={{ mt: 1 }} action={onRetryProfile ? <Button color="inherit" disabled={loading || profileDirty} onClick={() => { profileLinkRef.current?.focus(); onRetryProfile(); }}>{t("retry")}</Button> : undefined}>
|
||||
{t("careerOverviewUnavailable")}
|
||||
{profileDirty ? <Typography variant="body2" sx={{ mt: 1 }}>{t("careerOverviewRetryDraftHint")}</Typography> : null}
|
||||
</Alert> : null}
|
||||
{profileKnown && completeness.missing.length ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewAddNext", { items: completeness.missing.slice(0, 3).map((label) => localizeCompletenessLabel(label, t)).join(", ") })}</Typography>
|
||||
) : null}
|
||||
<Button href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
|
||||
</Box>
|
||||
|
||||
<Alert severity={importState.severity} sx={{ borderRadius: 3, alignItems: "flex-start" }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>{t("careerOverviewCvImport")}</Typography>
|
||||
<Typography variant="body2">{importState.message}</Typography>
|
||||
<Button href="/career?section=import" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("import"); } : undefined} color="inherit" size="small" sx={{ mt: 0.75, px: 0.5 }}>{importState.label}</Button>
|
||||
</Alert>
|
||||
<Button ref={profileLinkRef} href="/career?section=profile" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("profile"); } : undefined} size="small" sx={{ mt: 1, px: 0.5 }}>{t("careerOverviewImproveProfile")}</Button>
|
||||
</PageSection>
|
||||
<PageSection surface title={t("careerOverviewCvImport")} level="h3">
|
||||
{importUnknown ? <Typography role="status" color="text.secondary">{t("loading")}</Typography>
|
||||
: runsLoadError ? <Alert severity="error" action={onRetryRuns ? <Button color="inherit" disabled={runsLoading} onClick={() => { importLinkRef.current?.focus(); onRetryRuns(); }}>{t("retry")}</Button> : undefined}>{t("careerOverviewImportUnavailable")}</Alert>
|
||||
: <Alert severity={importState.severity}>{importState.message}</Alert>}
|
||||
<Button ref={importLinkRef} href="/career?section=import" onClick={onNavigate ? (event) => { event.preventDefault(); onNavigate("import"); } : undefined} size="small" sx={{ mt: 0.75, px: 0.5 }}>{importLabel}</Button>
|
||||
</PageSection>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Box component="nav" aria-label={t("careerOverviewActions")} sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "repeat(2, minmax(0, 1fr))", xl: "repeat(4, minmax(0, 1fr))" }, gap: 1.5 }}>
|
||||
<ActionCard title={t("careerOverviewCareerProfile")} body={t("careerOverviewCareerProfileBody")} href="/career?section=profile" onNavigate={onNavigate ? () => onNavigate("profile") : undefined} label={t("careerOverviewEditProfile")} icon={<PersonOutlineIcon />} />
|
||||
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="/career?section=import" onNavigate={onNavigate ? () => onNavigate("import") : undefined} label={importState.label} icon={<UploadFileOutlinedIcon />} />
|
||||
<ActionCard title={t("careerOverviewImportCv")} body={t("careerOverviewImportBody")} href="/career?section=import" onNavigate={onNavigate ? () => onNavigate("import") : undefined} label={importLabel} icon={<UploadFileOutlinedIcon />} />
|
||||
<ActionCard title={t("careerOverviewGeneralCv")} body={t("careerOverviewGeneralCvBody")} href="/career/builder" label={t("careerOverviewCreateGeneralCv")} icon={<DescriptionOutlinedIcon />} />
|
||||
<ActionCard title={t("careerOverviewJobCv")} body={t("careerOverviewJobCvBody")} href="/jobs" label={t("careerOverviewChooseJob")} icon={<WorkOutlineIcon />} />
|
||||
</Box>
|
||||
|
||||
<Paper component="section" aria-labelledby="recent-cvs-title" sx={{ p: 2, borderRadius: 4 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Typography id="recent-cvs-title" variant="h6" sx={{ fontWeight: 800 }}>{t("careerOverviewRecentCvs")}</Typography>
|
||||
<Button href="/career/builder" size="small">{t("careerOverviewViewAll")}</Button>
|
||||
</Box>
|
||||
<PageSection surface title={t("careerOverviewRecentCvs")} level="h3" actions={<Button ref={recentLinkRef} href="/career/builder" size="small">{t("careerOverviewViewAll")}</Button>}>
|
||||
{recentLoading ? (
|
||||
<Stack spacing={1} sx={{ mt: 1.5 }}><Skeleton height={32} /><Skeleton height={32} /></Stack>
|
||||
<Stack spacing={1}><Typography role="status" color="text.secondary">{t("loading")}</Typography><Skeleton height={32} /><Skeleton height={32} /></Stack>
|
||||
) : recentError ? (
|
||||
<Alert severity="warning" sx={{ mt: 1.5 }}>{t("careerOverviewRecentFailed")}</Alert>
|
||||
<Alert severity="warning" action={<Button color="inherit" onClick={() => { recentLinkRef.current?.focus(); setRecentAttempt((attempt) => attempt + 1); }}>{t("retry")}</Button>}>{t("careerOverviewRecentFailed")}</Alert>
|
||||
) : recentCvs.length === 0 ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>{t("careerOverviewNoCvs")}</Typography>
|
||||
) : (
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
{recentCvs.map((cv) => (
|
||||
<Box key={cv.id} sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Box sx={{ minWidth: 0, overflowWrap: "anywhere" }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{cv.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{t("careerOverviewUpdated", { date: new Date(cv.updatedAtUtc).toLocaleDateString(language === "nb" ? "nb-NO" : "en-GB") })}</Typography>
|
||||
</Box>
|
||||
@@ -171,7 +176,8 @@ export default function CareerWorkspaceOverview({ completeness, runs, loading, l
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
</Box>
|
||||
</PageSection>
|
||||
</Box>
|
||||
</PageSection>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user