From 2e6f15bf8afb2b5738cbc552f9990f4cf63251ad Mon Sep 17 00:00:00 2001
From: Matthew Paver <30728931+MatthewPaver@users.noreply.github.com>
Date: Fri, 23 Oct 2026 11:12:18 +0100
Subject: [PATCH] Add guided review using the real ProjectLens workflow
---
Processing/tests/browser_walkthrough.py | 168 ++++++++++++++++++++
README.md | 10 +-
docs/change-assurance.html | 7 +
docs/change-assurance.js | 49 +++++-
docs/walkthrough.css | 94 ++++++++++++
docs/walkthrough.js | 195 ++++++++++++++++++++++++
scripts/run_browser_tests.py | 1 +
7 files changed, 516 insertions(+), 8 deletions(-)
create mode 100644 Processing/tests/browser_walkthrough.py
create mode 100644 docs/walkthrough.css
create mode 100644 docs/walkthrough.js
diff --git a/Processing/tests/browser_walkthrough.py b/Processing/tests/browser_walkthrough.py
new file mode 100644
index 0000000..e647891
--- /dev/null
+++ b/Processing/tests/browser_walkthrough.py
@@ -0,0 +1,168 @@
+"""Exercise the guide against the real review/record engine, with isolated examples."""
+from pathlib import Path
+import json
+import os
+from playwright.sync_api import sync_playwright, expect
+
+BASE = os.environ.get("PROJECTLENS_BROWSER_BASE_URL", "http://127.0.0.1:8765/change-assurance.html")
+CAPTURES = Path(os.environ.get("PROJECTLENS_SCREENSHOT_DIR", "/tmp/projectlens-browser"))
+CAPTURES.mkdir(parents=True, exist_ok=True)
+
+
+def next_step(page, label):
+ page.get_by_role("button", name=f"Next: {label}", exact=True).click()
+
+
+def snapshot(page):
+ return page.evaluate("window.ProjectLensWalkthrough.snapshot()")
+
+
+def ready(page):
+ page.goto(f"{BASE}?walkthrough=1")
+ expect(page.locator("#tourMovement")).to_contain_text("73 days")
+
+
+def run_journey(browser):
+ context = browser.new_context(viewport={"width": 1440, "height": 1000}, reduced_motion="reduce")
+ page = context.new_page()
+ page.goto(BASE)
+ # Existing work must survive the demonstration, its saves and its restart.
+ preserved = page.evaluate("""() => {
+ const record = {id:'existing-work',project:'Real work sentinel',owner:'Keep me',rationale:'Preserve this record',decision:'Return for revision',createdAt:'2026-10-01T00:00:00Z',evidenceVersion:'existing-source'};
+ const values = {'projectlens:change-decisions:v1':JSON.stringify([record]),'projectlens:change-conditions:v1':'[]','projectlens:evidence-requests:v1':'[]','projectlens:review-draft:v1':JSON.stringify({marker:'existing draft'})};
+ Object.entries(values).forEach(([key,value])=>localStorage.setItem(key,value)); return values;
+ }""")
+ requests, errors = [], []
+ page.on("request", lambda request: requests.append((request.method, request.url)))
+ page.on("pageerror", lambda error: errors.append(str(error)))
+ ready(page)
+ assert snapshot(page)["decisions"] == []
+ expect(page.locator("#tourPlay")).to_be_disabled()
+ assert page.evaluate("getComputedStyle(document.querySelector('.tour-bar.current')).animationName") == "none"
+ # The expected findings are recomputed by the publicly exposed product engine.
+ assert page.evaluate("""() => {
+ const {review,narrative}=ProjectLensWalkthrough.snapshot(), api=ProjectLensChangeAssurance;
+ const actual=api.analyseReview(api.parseXer(review.previous.sourceText,review.previous.fileName),api.parseXer(review.current.sourceText,review.current.fileName),narrative);
+ return JSON.stringify(actual.blockers)===JSON.stringify(review.blockers)&&actual.finishMovement===review.finishMovement;
+ }""")
+ page.screenshot(path=str(CAPTURES / "walkthrough-desktop.png"), full_page=True)
+ next_step(page, "inspect the conflict")
+ expect(page.locator("#tourTitle")).to_be_focused()
+ expect(page.locator("#blockerList .blocker")).to_have_count(3)
+ page.locator('[data-finding="1"]').click()
+ expect(page.locator("#findingEvidence")).to_contain_text("constraint")
+ next_step(page, "change the claim")
+ original = snapshot(page)
+ page.locator("#tourNarrative").fill("A new narrative awaiting checks.")
+ expect(page.locator("#tourNext")).to_be_disabled()
+ page.get_by_role("button", name="Try an acknowledged finish change", exact=True).click()
+ expect(page.locator("#tourCheckStatus")).to_contain_text("4 findings in the original pack; 3 now")
+ changed = snapshot(page)
+ assert changed["review"]["inputIdentity"] != original["review"]["inputIdentity"]
+ assert changed["review"]["previous"] == original["review"]["previous"]
+ assert changed["review"]["current"] == original["review"]["current"]
+ assert not any(finding["type"] == "Contradiction" for finding in changed["review"]["blockers"])
+ next_step(page, "make a decision")
+ expect(page.locator("#tourNext")).to_be_disabled()
+ assert snapshot(page)["decisions"] == []
+ page.get_by_label("Approve with conditions", exact=True).check()
+ page.locator("#decisionCondition").fill("Confirm the schedule basis")
+ page.get_by_role("button", name="Save decision record", exact=False).click()
+ expect(page.locator("#workspaceError")).to_contain_text("owner and a due date")
+ expect(page.locator("#tourPosition")).to_contain_text("Step 4")
+ page.locator("#conditionOwner").fill("Example planning lead")
+ page.locator("#conditionDue").fill("2027-12-01")
+ page.get_by_role("button", name="Save decision record", exact=False).click()
+ expect(page.locator("#tourPosition")).to_contain_text("Step 5")
+ with page.expect_download() as event:
+ page.get_by_role("button", name="Download walkthrough record", exact=True).click()
+ record = json.loads(Path(event.value.path()).read_text())
+ assert record["demonstration"] is True and record["storage"] == "memory-only"
+ assert record["decisions"][0]["findings"] == changed["review"]["blockers"]
+ assert record["decisions"][0]["narrative"] == changed["narrative"]
+ assert record["decisions"][0]["inputIdentity"] == changed["review"]["inputIdentity"]
+ assert len(record["decisions"][0]["precedentsNotReviewed"]) == 3
+ assert record["conditions"][0]["decisionId"] == record["decisions"][0]["id"]
+ next_step(page, "track the follow-up")
+ expect(page.locator("#tourNext")).to_have_text("Follow-up still open")
+ page.get_by_role("button", name="Mark closed", exact=True).click()
+ expect(page.locator("#tourComplete")).to_contain_text("Condition closed")
+ expect(page.locator("#tourNext")).to_have_text("Walkthrough complete")
+ with page.expect_download() as event:
+ page.get_by_role("button", name="Download walkthrough record", exact=True).click()
+ assert json.loads(Path(event.value.path()).read_text())["conditions"][0]["closed"] is True
+ assert page.evaluate("values => Object.entries(values).every(([key,value])=>localStorage.getItem(key)===value)", preserved)
+ page.get_by_role("button", name="Restart", exact=True).click()
+ expect(page.locator("#tourMovement")).to_contain_text("73 days")
+ assert snapshot(page)["decisions"] == []
+ assert snapshot(page)["conditions"] == []
+ assert page.evaluate("values => Object.entries(values).every(([key,value])=>localStorage.getItem(key)===value)", preserved)
+ assert all(method == "GET" for method, _ in requests), requests
+ assert not any(":8787" in url for _, url in requests)
+ assert not errors, errors
+ context.close()
+
+
+def run_phone(browser):
+ context = browser.new_context(viewport={"width":390,"height":844}, reduced_motion="reduce")
+ page = context.new_page()
+ ready(page)
+ assert page.evaluate("document.documentElement.scrollWidth<=innerWidth+1")
+ page.screenshot(path=str(CAPTURES / "walkthrough-mobile.png"), full_page=True)
+ for label in ["inspect the conflict", "change the claim", "make a decision"]:
+ next_step(page, label)
+ assert page.evaluate("document.documentElement.scrollWidth<=innerWidth+1")
+ page.get_by_label("Return for revision", exact=True).check()
+ page.get_by_role("button", name="Save decision record", exact=False).click()
+ next_step(page, "track the follow-up")
+ expect(page.locator("#tourComplete")).to_contain_text("without conditions")
+ assert snapshot(page)["conditions"] == []
+ context.close()
+
+
+def run_playback_and_recovery(browser):
+ context = browser.new_context(viewport={"width":1280,"height":900})
+ page = context.new_page()
+ page.clock.install()
+ ready(page)
+ page.get_by_role("button", name="Play walkthrough", exact=True).click()
+ page.clock.fast_forward(10000)
+ expect(page.locator("#tourPosition")).to_contain_text("Step 2")
+ page.get_by_role("button", name="Pause walkthrough", exact=True).click()
+ page.clock.fast_forward(20000)
+ expect(page.locator("#tourPosition")).to_contain_text("Step 2")
+ page.get_by_role("button", name="Play walkthrough", exact=True).click()
+ page.clock.fast_forward(10000)
+ expect(page.locator("#tourPosition")).to_contain_text("Step 3")
+ page.locator("#tourNarrative").focus()
+ page.clock.fast_forward(20000)
+ expect(page.locator("#tourPosition")).to_contain_text("Step 3")
+ page.get_by_role("button", name="Play walkthrough", exact=True).click()
+ page.clock.fast_forward(10000)
+ expect(page.locator("#tourPosition")).to_contain_text("Step 4")
+ expect(page.locator("#tourPlay")).to_be_disabled()
+ page.clock.fast_forward(30000)
+ assert snapshot(page)["decisions"] == []
+ context.close()
+ context = browser.new_context()
+ page = context.new_page()
+ page.route("**/demo/northstar-previous.xer", lambda route: route.abort())
+ page.goto(f"{BASE}?walkthrough=1")
+ expect(page.locator("#tourError")).to_be_visible()
+ expect(page.locator("#tourNext")).to_be_disabled()
+ page.unroute("**/demo/northstar-previous.xer")
+ page.get_by_role("button", name="Retry loading the example", exact=True).click()
+ expect(page.locator("#tourMovement")).to_contain_text("73 days")
+ expect(page.locator("#tourError")).to_be_hidden()
+ expect(page.locator("#workspaceError")).to_be_hidden()
+ context.close()
+
+
+if __name__ == "__main__":
+ with sync_playwright() as playwright:
+ browser = playwright.chromium.launch()
+ run_journey(browser)
+ run_phone(browser)
+ run_playback_and_recovery(browser)
+ browser.close()
+ print("Walkthrough passed: real checks, changed inputs, decision validation, export, closure, isolation, phone, playback and retry.")
diff --git a/README.md b/README.md
index 991a81f..4f75bb9 100644
--- a/README.md
+++ b/README.md
@@ -7,7 +7,7 @@ For the project controls reviewer who has to tell a board whether a change pack

-**Live demo (no install, no account):** [review a change pack](https://matthewpaver.github.io/ProjectLens/change-assurance.html) · [XER schedule review](https://matthewpaver.github.io/ProjectLens/schedule-review.html) · [board review](https://matthewpaver.github.io/ProjectLens/board-readiness.html) · [public GMPP evidence](https://matthewpaver.github.io/ProjectLens/) · [2-minute walkthrough (MP4)](docs/assets/projectlens-evidence-demo.mp4)
+**Live demo (no install, no account):** [interactive guided review](https://matthewpaver.github.io/ProjectLens/change-assurance.html?walkthrough=1) · [review a change pack](https://matthewpaver.github.io/ProjectLens/change-assurance.html) · [XER schedule review](https://matthewpaver.github.io/ProjectLens/schedule-review.html) · [board review](https://matthewpaver.github.io/ProjectLens/board-readiness.html) · [public GMPP evidence](https://matthewpaver.github.io/ProjectLens/) · [2-minute walkthrough (MP4)](docs/assets/projectlens-evidence-demo.mp4)
On the change-assurance page, select **Try the Northstar example**. It is a synthetic pack, and the result is one readiness verdict with three blockers and the questions to send the team before the meeting.
@@ -36,6 +36,14 @@ Expected result: the evidence desk shows three findings, including the 73-day fi
**Your own schedules.** Export two single-project XER files from Primavera P6 (**File → Export → Primavera XER**): the comparison point and the latest submission. Select **Use my own XER files**, choose both files and paste the narrative. Files are parsed in the browser and never uploaded. If you have no XER to hand, use the synthetic [Northstar](https://matthewpaver.github.io/ProjectLens/demo/northstar-previous.xer) ([current](https://matthewpaver.github.io/ProjectLens/demo/northstar-current.xer)) or [Riverside](https://matthewpaver.github.io/ProjectLens/demo/riverside-previous.xer) ([current](https://matthewpaver.github.io/ProjectLens/demo/riverside-current.xer), [narrative](https://matthewpaver.github.io/ProjectLens/demo/riverside-narrative.txt)) pairs.
+## Guided review
+
+[Take the Northstar walkthrough](https://matthewpaver.github.io/ProjectLens/change-assurance.html?walkthrough=1) to inspect the two bundled XER exports, edit the narrative and rerun the actual checks. Choose a human decision, download the source-linked record and close an owned condition. The guide uses the same parser, checks, form validation and registers as the full application.
+
+Playback starts only when selected and pauses for the human decision, editing or a hidden tab. Reduced-motion visitors use Next. Walkthrough records are held in memory only, separate from saved work; reload or Restart discards them. The downloaded JSON is labelled as a demonstration. The walkthrough makes no causation or approval claim.
+
+The walkthrough journey, isolation, playback, phone layout and failed-load recovery run with `make browser-test` (the focused suite is `Processing/tests/browser_walkthrough.py`).
+
## How it works
```mermaid
diff --git a/docs/change-assurance.html b/docs/change-assurance.html
index d9e74d5..835cf12 100644
--- a/docs/change-assurance.html
+++ b/docs/change-assurance.html
@@ -17,6 +17,7 @@
+
Skip to review workspace
@@ -38,6 +39,9 @@
+ Synthetic walkthrough · records exist only in this tab until reload. Exit to full ProjectLens
+
+
@@ -54,6 +58,7 @@
Check the change pack
before you decide.
+ Take the guided walkthrough · about one minute, at your pace
Your files stay in this browser. Fixed rules compare the dates, and a person makes the decision.