From 5b2fbfbb30b7f67b9ab8b87c883fce6e552c4029 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 22:02:56 +0000 Subject: [PATCH 01/33] docs(policy): move agent-fast / build gate from dev to unstable Introduce unstable as the qualification branch in the promotion chain (dev -> unstable -> stable) and move the required agent-fast / build status check there. dev remains the topic-integration branch but no longer carries branch-rule status requirements. --- .claude/policy-brief.md | 4 +- .cursor/agent-policy.md | 4 +- .github/workflows/ci.yml | 2 + .github/workflows/codeql.yml | 4 +- .github/workflows/documentation.yml | 2 + AGENTS.md | 4 +- agent-policy.json | 4 +- ...laude-agent-fast-build-promotion-9ljtaq.md | 6 ++ docs/BRANCH_POLICY.md | 29 +++++---- docs/CI.md | 31 +++++----- docs/REPO_MAP.md | 8 ++- docs/branch-policy.json | 8 ++- docs/generated/architecture-catalog.json | 17 ++++-- docs/schemas/agent-policy.schema.json | 8 ++- scripts/agent/check-change.py | 2 +- scripts/agent/generate-adapters.py | 27 +++++---- scripts/agent/test_check_change.py | 4 +- scripts/ci/check_branch_policy.py | 32 ++++++++-- scripts/ci/test_check_branch_policy.py | 59 ++++++++++++++----- scripts/generate-architecture-catalogs.py | 6 ++ scripts/hooks/pre-push.sh | 2 +- 21 files changed, 181 insertions(+), 82 deletions(-) create mode 100644 changes/claude-agent-fast-build-promotion-9ljtaq.md diff --git a/.claude/policy-brief.md b/.claude/policy-brief.md index e92d5b0d7..a33d79c99 100644 --- a/.claude/policy-brief.md +++ b/.claude/policy-brief.md @@ -5,8 +5,8 @@ Repository: `studio-berry/loupe`; version: `0.2.0-alpha`; language: `C++20`; min ## Branches and safety -- Integration: `dev`; release/default: `stable`; topic branches start from `dev`. -- Protected branches: `dev`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. +- Integration: `dev`; qualification: `unstable`; release/default: `stable`; topic branches start from `dev`. Promotion: `dev` → `unstable` → `stable`. +- Protected branches: `unstable`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. - Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped. ## Autonomous verification budget diff --git a/.cursor/agent-policy.md b/.cursor/agent-policy.md index dcbc8791c..bf03ca581 100644 --- a/.cursor/agent-policy.md +++ b/.cursor/agent-policy.md @@ -5,8 +5,8 @@ Repository: `studio-berry/loupe`; version: `0.2.0-alpha`; language: `C++20`; min ## Branches and safety -- Integration: `dev`; release/default: `stable`; topic branches start from `dev`. -- Protected branches: `dev`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. +- Integration: `dev`; qualification: `unstable`; release/default: `stable`; topic branches start from `dev`. Promotion: `dev` → `unstable` → `stable`. +- Protected branches: `unstable`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. - Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped. ## Autonomous verification budget diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 744d5ebb2..d8364912b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,10 +4,12 @@ on: push: branches: - dev + - unstable - stable pull_request: branches: - dev + - unstable workflow_dispatch: concurrency: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 017f98aea..9db8512d3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,9 +13,9 @@ name: "CodeQL Advanced" on: push: - branches: [ "dev", "stable" ] + branches: [ "dev", "unstable", "stable" ] pull_request: - branches: [ "dev", "stable" ] + branches: [ "dev", "unstable", "stable" ] schedule: - cron: '31 16 * * 4' diff --git a/.github/workflows/documentation.yml b/.github/workflows/documentation.yml index 2327647a0..42ab62328 100644 --- a/.github/workflows/documentation.yml +++ b/.github/workflows/documentation.yml @@ -5,10 +5,12 @@ on: branches: - stable - dev + - unstable pull_request: branches: - stable - dev + - unstable workflow_dispatch: permissions: diff --git a/AGENTS.md b/AGENTS.md index ca6d9476c..4afabac67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,8 @@ Repository: `studio-berry/loupe`; version: `0.2.0-alpha`; language: `C++20`; min ## Branches and safety -- Integration: `dev`; release/default: `stable`; topic branches start from `dev`. -- Protected branches: `dev`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. +- Integration: `dev`; qualification: `unstable`; release/default: `stable`; topic branches start from `dev`. Promotion: `dev` → `unstable` → `stable`. +- Protected branches: `unstable`, `stable`. Do not commit, push, merge, force-push, or rewrite history without approval. - Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped. ## Autonomous verification budget diff --git a/agent-policy.json b/agent-policy.json index 5465e6843..f881002b7 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -7,10 +7,12 @@ "branches": { "default": "stable", "integration": "dev", + "qualification": "unstable", "release": "stable", "topic_source": "dev", "topic_branch_patterns": ["gh-*", "feature/*", "fix/*", "chore/*", "docs/*"], - "protected": ["dev", "stable"] + "promotion_chain": ["dev", "unstable", "stable"], + "protected": ["unstable", "stable"] }, "autonomy": { "allowed": [ diff --git a/changes/claude-agent-fast-build-promotion-9ljtaq.md b/changes/claude-agent-fast-build-promotion-9ljtaq.md new file mode 100644 index 000000000..d05e6b37b --- /dev/null +++ b/changes/claude-agent-fast-build-promotion-9ljtaq.md @@ -0,0 +1,6 @@ +# Unstable qualification branch policy + +Category: internal +Audience: developers and release operators +Breaking-Change: no +Summary: Document the four-stage promotion chain (topic branch → dev → unstable → stable) and move the fast integration gate protections from dev to unstable. diff --git a/docs/BRANCH_POLICY.md b/docs/BRANCH_POLICY.md index 0c45d0df8..87613e09b 100644 --- a/docs/BRANCH_POLICY.md +++ b/docs/BRANCH_POLICY.md @@ -1,24 +1,26 @@ # Loupe branch policy `stable` is the release line and the repository default branch. `dev` is the -integration line. Short-lived topic branches are created from `dev` and merge -back into `dev`; releases promote reviewed commits from `dev` into `stable`. +first integration line. `unstable` is the qualification line. Short-lived topic +branches are created from `dev` and merge back into `dev`. Reviewed commits +promote along `dev` → `unstable` → `stable`. The full build and CodeQL workflows run for release qualification. `stable` is the protected release branch and requires the `release_ok` GitHub Actions status before merging. That check is produced by the dedicated Release Gate workflow, which always reports: failed, cancelled, skipped, and missing -dependencies reduce to an explicit terminal failure. `dev` is also protected -and requires the fast `agent-fast / build` status before merging. The fast gate +dependencies reduce to an explicit terminal failure. `unstable` is protected +and requires the fast `agent-fast / build` status before merging. That gate checks source integrity, contracts, affected-target compilation, focused tests, and the required PR changelog; expensive cross-platform/package qualification -remains on the release-candidate path. Direct pushes and force-pushes are -disabled by the corresponding GitHub branch rules. +remains on the release-candidate path. `dev` is an integration branch without +branch-rule status requirements; direct pushes and force-pushes are disabled on +the protected branches by the corresponding GitHub branch rules. The Release Gate workflow listens for `pull_request` targeting `stable` and for `merge_group` so an optional merge queue cannot wait on a check that never -runs. It has no path filters. Integration PRs targeting `dev` run `ci.yml` and -must pass `agent-fast / build`. +runs. It has no path filters. Integration PRs targeting `dev` or `unstable` run +`ci.yml`. Merges into `unstable` must pass `agent-fast / build`. The declarations below are intentionally machine-readable by `scripts/ci/check_branch_policy.py`. That check runs in CI, so a workflow @@ -26,8 +28,9 @@ trigger edited away from this policy fails before the build can be cited as release evidence. Pass `--live` to also compare these declarations with GitHub branch protection when a token can read it. -- CI branches: `dev`, `stable` -- Protected branches: `dev`, `stable` +- CI branches: `dev`, `unstable`, `stable` +- Protected branches: `unstable`, `stable` +- Promotion chain: `dev`, `unstable`, `stable` - Required check: `release_ok` - Required check app: GitHub Actions - Required integration check: `agent-fast / build` @@ -35,12 +38,12 @@ branch protection when a token can read it. - Release gate events: `pull_request`, `merge_group` - Release gate pull_request branches: `stable` - Integration workflow: `.github/workflows/ci.yml` -- Integration pull_request branches: `dev` +- Integration pull_request branches: `dev`, `unstable` `master` is not part of the Loupe branch policy. It is retained only in older historical documents or upstream references; new workflow triggers must not target it. -Ensure `stable` requires `release_ok` and `dev` requires `agent-fast / build`, +Ensure `stable` requires `release_ok` and `unstable` requires `agent-fast / build`, both bound to the GitHub Actions app (id 15368). The live policy check verifies -both protections. +both protections and rejects status-check requirements on `dev`. diff --git a/docs/CI.md b/docs/CI.md index 9888da5b8..d003455a4 100644 --- a/docs/CI.md +++ b/docs/CI.md @@ -1,24 +1,25 @@ # CI and diagnostic artifacts -Pull requests to `dev` run the Linux `agent-fast / build` workflow as the -required integration gate. It classifies the diff, runs source and contract -checks, compiles affected targets, and runs focused tests. Pull requests -require one structured changelog fragment under `changes/` named after the -head branch. Subsequent `dev` pushes skip that PR-only check so a merged -topic fragment is not rejected for not being `changes/dev.md`. Stacked topic -branches may carry their parent fragments, but every added fragment is -validated. Format and clang-tidy run on added, modified, renamed, or copied -C/C++ files only; deleted paths still classify modules. These are the fast -checks for -the shared integration baseline. The full Linux and Windows build-and-test -jobs run for release qualification. These are the two platforms Loupe V1 -supports; **macOS** CI is a **post-V1** track under +Pull requests to `dev` or `unstable` run the Linux `agent-fast / build` workflow. +Merges into `unstable` require that check. Topic PRs into `dev` still run the +workflow for signal, but `dev` no longer carries branch-rule status requirements. +The workflow classifies the diff, runs source and contract checks, compiles +affected targets, and runs focused tests. Pull requests require one structured +changelog fragment under `changes/` named after the head branch. Subsequent +integration-branch pushes skip that PR-only check so a merged topic fragment is +not rejected for not being `changes/dev.md`. Stacked topic branches may carry +their parent fragments, but every added fragment is validated. Format and +clang-tidy run on added, modified, renamed, or copied C/C++ files only; deleted +paths still classify modules. These are the fast checks for the shared +integration baseline. The full Linux and Windows build-and-test jobs run for +release qualification. These are the two platforms Loupe V1 supports; **macOS** +CI is a **post-V1** track under [MIC-336](https://linear.app/mbx2/issue/MIC-336) / [docs/PLATFORM_SUPPORT.md](PLATFORM_SUPPORT.md). Packaging artifacts are produced only for `stable` pushes and manual workflow runs. The standalone `Documentation truth` workflow runs for the policy branches -`dev` and `stable` pull requests and pushes. It checks every ADR's verification +`dev`, `unstable`, and `stable` pull requests and pushes. It checks every ADR's verification header and fails when [`docs/generated/architecture-catalog.json`](generated/architecture-catalog.json) is stale. Product versioning is SemVer 2.0 (`0.2.0-alpha`); CI also runs @@ -30,7 +31,7 @@ runs and fails when any required dependency failed, was cancelled, was skipped, or did not report. Requiring the platform-specific jobs directly would create multiple checks for the same gate. The release-gate workflow runs for every pull request targeting `stable` and for `merge_group` events; -it has no path filters. `dev` requires `agent-fast / build` for merging; +it has no path filters. `unstable` requires `agent-fast / build` for merging; `stable` requires `release_ok`. Hosted fuzzing (`.github/workflows/fuzz.yml`) validates the manifested diff --git a/docs/REPO_MAP.md b/docs/REPO_MAP.md index 09ea93956..5d3704d4f 100644 --- a/docs/REPO_MAP.md +++ b/docs/REPO_MAP.md @@ -7,7 +7,7 @@ tracking policy. | Role | Repository | Branch | |------|------------|--------| -| Loupe canonical repository | [studio-berry/loupe](https://github.com/studio-berry/loupe) | `stable` (default/release), `dev` (integration) | +| Loupe canonical repository | [studio-berry/loupe](https://github.com/studio-berry/loupe) | `stable` (default/release), `unstable` (qualification), `dev` (integration) | | Upstream PDF engine source | [JakubMelka/PDF4QT](https://github.com/JakubMelka/PDF4QT) | `master` (upstream only) | Loupe owns the product decisions, branding, release policy, and downstream @@ -16,10 +16,12 @@ tooling. Do not infer Loupe branch policy from upstream's `master` branch. ## Branch policy -- `dev` is the integration branch. +- `dev` is the first integration branch. +- `unstable` is the qualification branch; it carries the fast integration gate + formerly required on `dev`. - `stable` is the release branch and repository default. - Topic branches start from `dev`, stay focused, and merge back to `dev`. -- Releases promote a verified `dev` state to `stable`. +- Reviewed commits promote along `dev` → `unstable` → `stable`. - `master` is not an active Loupe branch. The reviewed machine-readable policy is diff --git a/docs/branch-policy.json b/docs/branch-policy.json index e3626be9b..a2cdc8565 100644 --- a/docs/branch-policy.json +++ b/docs/branch-policy.json @@ -3,6 +3,7 @@ "default_branch": "stable", "release_branch": "stable", "integration_branch": "dev", + "qualification_branch": "unstable", "topic_branch_source": "dev", "topic_branch_patterns": [ "gh-*", @@ -11,8 +12,13 @@ "chore/*", "docs/*" ], - "protected_branches": [ + "promotion_chain": [ "dev", + "unstable", + "stable" + ], + "protected_branches": [ + "unstable", "stable" ] } diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index cfe09a3da..96c52f23a 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -210,10 +210,16 @@ "branch_policy": { "default": "stable", "integration": "dev", - "protected": [ + "promotion_chain": [ "dev", + "unstable", "stable" ], + "protected": [ + "stable", + "unstable" + ], + "qualification": "unstable", "release": "stable", "topic_patterns": [ "chore/*", @@ -453,15 +459,18 @@ "workflow_branches": { ".github/workflows/ci.yml": [ "dev", - "stable" + "stable", + "unstable" ], ".github/workflows/codeql.yml": [ "dev", - "stable" + "stable", + "unstable" ], ".github/workflows/documentation.yml": [ "dev", - "stable" + "stable", + "unstable" ], ".github/workflows/release-gate.yml": [ "stable" diff --git a/docs/schemas/agent-policy.schema.json b/docs/schemas/agent-policy.schema.json index 56973a7c7..ef93723c2 100644 --- a/docs/schemas/agent-policy.schema.json +++ b/docs/schemas/agent-policy.schema.json @@ -9,13 +9,19 @@ "qt_minimum": {"type": "string"}, "branches": { "type": "object", - "required": ["default", "integration", "release", "topic_source", "protected"], + "required": ["default", "integration", "qualification", "release", "topic_source", "promotion_chain", "protected"], "properties": { "default": {"type": "string"}, "integration": {"type": "string"}, + "qualification": {"type": "string"}, "release": {"type": "string"}, "topic_source": {"type": "string"}, "topic_branch_patterns": {"type": "array", "items": {"type": "string"}}, + "promotion_chain": { + "type": "array", + "items": {"type": "string"}, + "minItems": 2 + }, "protected": {"type": "array", "items": {"type": "string"}, "minItems": 1} } }, diff --git a/scripts/agent/check-change.py b/scripts/agent/check-change.py index b70003e63..cfa8fe2cc 100644 --- a/scripts/agent/check-change.py +++ b/scripts/agent/check-change.py @@ -121,7 +121,7 @@ def current_branch(override: str | None) -> str: def policy_integration_branches(policy: dict) -> set[str]: branches = policy.get("branches", {}) names: set[str] = set(branches.get("protected") or []) - for key in ("integration", "release", "default"): + for key in ("integration", "qualification", "release", "default"): value = branches.get(key) if isinstance(value, str) and value: names.add(value) diff --git a/scripts/agent/generate-adapters.py b/scripts/agent/generate-adapters.py index e01dedad4..8e6201d8d 100644 --- a/scripts/agent/generate-adapters.py +++ b/scripts/agent/generate-adapters.py @@ -56,23 +56,24 @@ def load_policy() -> dict: def render(policy: dict, adapter: str) -> str: if adapter == "docs/branch-policy.json": branches = policy["branches"] - return json.dumps( - { - "generated_by": "scripts/agent/generate-adapters.py", - "default_branch": branches["default"], - "release_branch": branches["release"], - "integration_branch": branches["integration"], - "topic_branch_source": branches["topic_source"], - "topic_branch_patterns": branches["topic_branch_patterns"], - "protected_branches": branches["protected"] - }, - indent=2, - ) + "\n" + payload = { + "generated_by": "scripts/agent/generate-adapters.py", + "default_branch": branches["default"], + "release_branch": branches["release"], + "integration_branch": branches["integration"], + "qualification_branch": branches["qualification"], + "topic_branch_source": branches["topic_source"], + "topic_branch_patterns": branches["topic_branch_patterns"], + "promotion_chain": branches["promotion_chain"], + "protected_branches": branches["protected"], + } + return json.dumps(payload, indent=2) + "\n" branches = policy["branches"] autonomy = policy["autonomy"] changelog = policy["changelog"] version, prerelease = load_version_policy() display_version = format_product_version(version, prerelease) + promotion = " → ".join(f"`{branch}`" for branch in branches["promotion_chain"]) lines = [ "", "# Loupe agent policy adapter", @@ -81,7 +82,7 @@ def render(policy: dict, adapter: str) -> str: "", "## Branches and safety", "", - f"- Integration: `{branches['integration']}`; release/default: `{branches['release']}`; topic branches start from `{branches['topic_source']}`.", + f"- Integration: `{branches['integration']}`; qualification: `{branches['qualification']}`; release/default: `{branches['release']}`; topic branches start from `{branches['topic_source']}`. Promotion: {promotion}.", f"- Protected branches: {', '.join(f'`{branch}`' for branch in branches['protected'])}. Do not commit, push, merge, force-push, or rewrite history without approval.", "- Keep private data, credentials, logs, scratch plans, and build artifacts outside the repository. Do not edit vendored dependencies unless explicitly scoped.", "", diff --git a/scripts/agent/test_check_change.py b/scripts/agent/test_check_change.py index 98824a73c..bb995aa95 100644 --- a/scripts/agent/test_check_change.py +++ b/scripts/agent/test_check_change.py @@ -22,8 +22,9 @@ POLICY_BRANCHES = { "default": "stable", "integration": "dev", + "qualification": "unstable", "release": "stable", - "protected": ["dev", "stable"], + "protected": ["unstable", "stable"], } @@ -67,6 +68,7 @@ def test_skip_changelog_on_integration_branch(self) -> None: policy = {"branches": POLICY_BRANCHES} with patch.dict(os.environ, {"GITHUB_EVENT_NAME": ""}, clear=False): self.assertEqual(MODULE.skip_changelog_reason("dev", policy, False), "integration branch") + self.assertEqual(MODULE.skip_changelog_reason("unstable", policy, False), "integration branch") self.assertEqual(MODULE.skip_changelog_reason("stable", policy, False), "integration branch") self.assertIsNone(MODULE.skip_changelog_reason("cdx/foo", policy, False)) self.assertEqual(MODULE.skip_changelog_reason("cdx/foo", policy, True), "non-PR event") diff --git a/scripts/ci/check_branch_policy.py b/scripts/ci/check_branch_policy.py index 0e0be9dd9..2c22b44d1 100644 --- a/scripts/ci/check_branch_policy.py +++ b/scripts/ci/check_branch_policy.py @@ -22,6 +22,7 @@ DOCUMENTED_CI_BRANCHES = re.compile(r"^[-*]\s+CI branches:\s*(.+)$", re.MULTILINE) DOCUMENTED_PROTECTED_BRANCHES = re.compile(r"^[-*]\s+Protected branches:\s*(.+)$", re.MULTILINE) +DOCUMENTED_PROMOTION_CHAIN = re.compile(r"^[-*]\s+Promotion chain:\s*(.+)$", re.MULTILINE) DOCUMENTED_REQUIRED_CHECK = re.compile(r"^[-*]\s+Required check:\s*`([^`]+)`$", re.MULTILINE) DOCUMENTED_INTEGRATION_REQUIRED_CHECK = re.compile(r"^[-*]\s+Required integration check:\s*`([^`]+)`$", re.MULTILINE) DOCUMENTED_REQUIRED_CHECK_APP = re.compile(r"^[-*]\s+Required check app:\s*(.+)$", re.MULTILINE) @@ -46,6 +47,7 @@ class DocumentedPolicy: ci_branches: tuple[str, ...] protected_branches: tuple[str, ...] + promotion_chain: tuple[str, ...] required_check: str integration_required_check: str required_check_app: str @@ -90,6 +92,9 @@ def required(pattern: re.Pattern[str], label: str) -> str: protected = _branch_names(required(DOCUMENTED_PROTECTED_BRANCHES, "Protected branches:")) if not protected: raise ValueError("policy declares no protected branches") + promotion_chain = _branch_names(required(DOCUMENTED_PROMOTION_CHAIN, "Promotion chain:")) + if not promotion_chain: + raise ValueError("policy declares no promotion chain") required_check = required(DOCUMENTED_REQUIRED_CHECK, "Required check:") integration_required_check = required( DOCUMENTED_INTEGRATION_REQUIRED_CHECK, "Required integration check:" @@ -113,6 +118,7 @@ def required(pattern: re.Pattern[str], label: str) -> str: return DocumentedPolicy( ci_branches=ci_branches, protected_branches=protected, + promotion_chain=promotion_chain, required_check=required_check, integration_required_check=integration_required_check, required_check_app=required_app, @@ -376,6 +382,7 @@ def _validate_required_check( def validate_live_protection( *, stable_protection: dict[str, Any] | None, + unstable_protection: dict[str, Any] | None, dev_protection: dict[str, Any] | None, policy: DocumentedPolicy, ) -> list[str]: @@ -390,16 +397,23 @@ def validate_live_protection( required_check_app=policy.required_check_app, ) ) - if "dev" in policy.protected_branches: + if "unstable" in policy.protected_branches: violations.extend( _validate_required_check( - branch="dev", - protection=dev_protection, + branch="unstable", + protection=unstable_protection, expected=policy.integration_required_check, required_check_app=policy.required_check_app, ) ) - elif isinstance(dev_protection, dict): + elif isinstance(unstable_protection, dict): + contexts = [str(item.get("context")) for item in _required_check_entries(unstable_protection)] + if contexts: + violations.append( + "live protection: unstable must not require status checks, " + f"got {contexts}" + ) + if "dev" not in policy.protected_branches and isinstance(dev_protection, dict): contexts = [str(item.get("context")) for item in _required_check_entries(dev_protection)] if contexts: violations.append( @@ -496,8 +510,9 @@ def validate_repository( ) else: stable, stable_error = fetch_branch_protection(repo_name, "stable", auth) + unstable, unstable_error = fetch_branch_protection(repo_name, "unstable", auth) dev, dev_error = fetch_branch_protection(repo_name, "dev", auth) - if stable_error == "403" or dev_error == "403": + if stable_error == "403" or unstable_error == "403" or dev_error == "403": print( "WARNING: live branch protection is not readable with this token; " "file-based policy checks still ran.", @@ -508,6 +523,10 @@ def validate_repository( violations.append( f"live protection: failed to read stable rules ({stable_error})" ) + if unstable_error and unstable_error != "404": + violations.append( + f"live protection: failed to read unstable rules ({unstable_error})" + ) if dev_error and dev_error != "404": violations.append( f"live protection: failed to read dev rules ({dev_error})" @@ -516,6 +535,7 @@ def validate_repository( violations.extend( validate_live_protection( stable_protection=stable, + unstable_protection=unstable if unstable_error != "404" else {}, dev_protection=dev if dev_error != "404" else {}, policy=policy, ) @@ -538,7 +558,7 @@ def main() -> int: return 1 print( "Branch policy passed: workflow triggers match the documented " - "dev/stable contract." + "dev/unstable/stable contract." ) return 0 diff --git a/scripts/ci/test_check_branch_policy.py b/scripts/ci/test_check_branch_policy.py index 077efa759..e531b4bce 100644 --- a/scripts/ci/test_check_branch_policy.py +++ b/scripts/ci/test_check_branch_policy.py @@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[2] -EXPECTED_BRANCHES = ("dev", "stable") +EXPECTED_BRANCHES = ("dev", "unstable", "stable") class BranchPolicyTests(unittest.TestCase): @@ -33,13 +33,14 @@ def test_documented_policy_declares_ci_branches_and_required_check(self): self.assertEqual(required_check, "release_ok") self.assertEqual(policy.required_check, "release_ok") self.assertEqual(policy.required_check_app.lower(), "github actions") - self.assertEqual(policy.protected_branches, ("dev", "stable")) + self.assertEqual(policy.protected_branches, ("unstable", "stable")) + self.assertEqual(policy.promotion_chain, ("dev", "unstable", "stable")) self.assertEqual(policy.integration_required_check, "agent-fast / build") self.assertEqual(policy.release_gate_workflow, ".github/workflows/release-gate.yml") self.assertEqual(policy.release_gate_events, ("pull_request", "merge_group")) self.assertEqual(policy.release_gate_pull_request_branches, ("stable",)) self.assertEqual(policy.integration_workflow, ".github/workflows/ci.yml") - self.assertEqual(policy.integration_pull_request_branches, ("dev",)) + self.assertEqual(policy.integration_pull_request_branches, ("dev", "unstable")) def test_current_ci_workflow_matches_policy(self): policy = parse_documented_policy_full( @@ -47,7 +48,7 @@ def test_current_ci_workflow_matches_policy(self): ) workflow = (ROOT / ".github/workflows/ci.yml").read_text(encoding="utf-8") self.assertEqual(parse_workflow_branch_triggers(workflow)["push"], EXPECTED_BRANCHES) - self.assertEqual(parse_workflow_branch_triggers(workflow)["pull_request"], ("dev",)) + self.assertEqual(parse_workflow_branch_triggers(workflow)["pull_request"], ("dev", "unstable")) self.assertEqual(validate_integration_workflow(Path("ci.yml"), workflow, policy), []) def test_current_release_gate_matches_policy(self): @@ -76,6 +77,7 @@ def test_rejects_deliberately_stale_master_trigger(self): pull_request: branches: - dev + - unstable - stable """ violations = validate_workflow_branches(Path("stale.yml"), stale_workflow, EXPECTED_BRANCHES) @@ -144,9 +146,9 @@ def test_rejects_obsolete_ci_ok_aggregate(self): ) stale = """on: push: - branches: [dev, stable] + branches: [dev, unstable, stable] pull_request: - branches: [dev] + branches: [dev, unstable] jobs: ci_ok: @@ -161,9 +163,9 @@ def test_rejects_manual_dispatch_without_full_platform_jobs(self): ) stale = """on: push: - branches: [dev, stable] + branches: [dev, unstable, stable] pull_request: - branches: [dev] + branches: [dev, unstable] workflow_dispatch: jobs: @@ -193,11 +195,12 @@ def test_live_protection_rejects_ci_ok_and_unbound_app(self): } violations = validate_live_protection( stable_protection=stale_stable, - dev_protection={ + unstable_protection={ "required_status_checks": { "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], } }, + dev_protection={}, policy=policy, ) self.assertTrue(any("ci_ok" in item for item in violations)) @@ -213,7 +216,7 @@ def test_live_protection_accepts_github_actions_release_ok(self): "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], } } - dev = { + unstable = { "required_status_checks": { "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], } @@ -221,13 +224,14 @@ def test_live_protection_accepts_github_actions_release_ok(self): self.assertEqual( validate_live_protection( stable_protection=stable, - dev_protection=dev, + unstable_protection=unstable, + dev_protection={}, policy=policy, ), [], ) - def test_live_protection_rejects_mismatched_dev_checks(self): + def test_live_protection_rejects_mismatched_unstable_checks(self): policy = parse_documented_policy_full( (ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8") ) @@ -236,17 +240,44 @@ def test_live_protection_rejects_mismatched_dev_checks(self): "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], } } - dev = { + unstable = { + "required_status_checks": { + "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], + } + } + violations = validate_live_protection( + stable_protection=stable, + unstable_protection=unstable, + dev_protection={}, + policy=policy, + ) + self.assertTrue(any("unstable required checks" in item for item in violations)) + + def test_live_protection_rejects_dev_checks(self): + policy = parse_documented_policy_full( + (ROOT / "docs" / "BRANCH_POLICY.md").read_text(encoding="utf-8") + ) + stable = { "required_status_checks": { "checks": [{"context": "release_ok", "app_id": GITHUB_ACTIONS_APP_ID}], } } + dev = { + "required_status_checks": { + "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], + } + } violations = validate_live_protection( stable_protection=stable, + unstable_protection={ + "required_status_checks": { + "checks": [{"context": "agent-fast / build", "app_id": GITHUB_ACTIONS_APP_ID}], + } + }, dev_protection=dev, policy=policy, ) - self.assertTrue(any("dev required checks" in item for item in violations)) + self.assertTrue(any("dev must not require status checks" in item for item in violations)) if __name__ == "__main__": diff --git a/scripts/generate-architecture-catalogs.py b/scripts/generate-architecture-catalogs.py index 7b2c4878b..a35857ac4 100644 --- a/scripts/generate-architecture-catalogs.py +++ b/scripts/generate-architecture-catalogs.py @@ -46,8 +46,10 @@ def parse_branch_policy() -> dict[str, Any]: "default_branch", "release_branch", "integration_branch", + "qualification_branch", "topic_branch_source", "topic_branch_patterns", + "promotion_chain", "protected_branches", } missing = sorted(required - policy.keys()) @@ -57,8 +59,10 @@ def parse_branch_policy() -> dict[str, Any]: policy["default_branch"], policy["release_branch"], policy["integration_branch"], + policy["qualification_branch"], policy["topic_branch_source"], *policy["protected_branches"], + *policy["promotion_chain"], } if any(not isinstance(branch, str) or not branch for branch in branches): raise ValueError("branch policy contains an empty branch name") @@ -70,7 +74,9 @@ def parse_branch_policy() -> dict[str, Any]: "default": policy["default_branch"], "release": policy["release_branch"], "integration": policy["integration_branch"], + "qualification": policy["qualification_branch"], "topic_source": policy["topic_branch_source"], + "promotion_chain": policy["promotion_chain"], "protected": sorted(policy["protected_branches"]), "topic_patterns": sorted(policy["topic_branch_patterns"]), } diff --git a/scripts/hooks/pre-push.sh b/scripts/hooks/pre-push.sh index 660f0b58c..8770eb53c 100644 --- a/scripts/hooks/pre-push.sh +++ b/scripts/hooks/pre-push.sh @@ -2,7 +2,7 @@ # BSP-002 §3.3, §3.4, §3.2; BSP-006 §3.4, §5.1 — pre-push policy set -euo pipefail -protected="${IVORY_PROTECTED_BRANCHES:-^refs/heads/(main|master|stable|dev|release/.*)$}" +protected="${IVORY_PROTECTED_BRANCHES:-^refs/heads/(main|master|stable|unstable|dev|release/.*)$}" while read -r local_ref local_sha remote_ref remote_sha; do [[ -z "$remote_ref" ]] && continue From e2d0197dea2da7cae033df66075310d677839a72 Mon Sep 17 00:00:00 2001 From: mbx30 Date: Mon, 31 Aug 2026 16:53:19 -0700 Subject: [PATCH 02/33] fix: repair Session 07 package runtime workflows --- .github/workflows/LinuxInstall.yml | 2 +- .github/workflows/WindowsInstall.yml | 21 +++++++++++++++++++++ scripts/ci/test_workflow_contracts.py | 6 ++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index 6bccf2bea..c38f0100d 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -128,7 +128,7 @@ jobs: } ./vcpkg/bootstrap-vcpkg.sh - ./vcpkg integrate install + ./vcpkg/vcpkg integrate install - name: 'VCPKG: Cache vcpkg dependencies' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 diff --git a/.github/workflows/WindowsInstall.yml b/.github/workflows/WindowsInstall.yml index b64894456..768c96dc1 100644 --- a/.github/workflows/WindowsInstall.yml +++ b/.github/workflows/WindowsInstall.yml @@ -256,6 +256,27 @@ jobs: ctest --test-dir build -C Release --output-on-failure cmake --install build --config Release .\scripts\verify-loupe-surface.ps1 -InstallDir "${env:GITHUB_WORKSPACE}\loupe\build\install\usr\bin" -Profile loupe-release -BuildDir .\build -InstallManifestPath .\build\install_manifest.txt + + - name: Deploy Qt runtime closure to staged install tree + working-directory: loupe + shell: pwsh + run: | + $installBin = Join-Path $env:GITHUB_WORKSPACE "loupe\build\install\usr\bin" + $windeployqt = Join-Path $env:QT_ROOT_DIR "bin\windeployqt.exe" + if (-not (Test-Path -LiteralPath $windeployqt)) { + throw "Qt deployment tool was not found: $windeployqt" + } + + foreach ($name in @("LoupeEditor.exe", "PdfTool.exe")) { + $target = Join-Path $installBin $name + if (-not (Test-Path -LiteralPath $target)) { + throw "Expected staged executable was not found: $target" + } + & $windeployqt --release --no-compiler-runtime --no-translations --dir $installBin $target 2>&1 + if ($LASTEXITCODE -ne 0) { + throw "windeployqt failed for $name with exit code $LASTEXITCODE" + } + } env: VCToolsRedistDir: ${{ env.VCToolsRedistDir }} VSCMD_ARG_TGT_ARCH: ${{ env.VSCMD_ARG_TGT_ARCH }} diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index 561ca962a..f0f091fe3 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -62,6 +62,12 @@ def test_windows_release_gate_qualifies_without_widgets(self): def test_package_workflows_require_and_record_exact_source_sha(self): linux = (ROOT / ".github/workflows/LinuxInstall.yml").read_text(encoding="utf-8") windows = (ROOT / ".github/workflows/WindowsInstall.yml").read_text(encoding="utf-8") + self.assertIn("./vcpkg/vcpkg integrate install", linux) + self.assertNotIn("./vcpkg integrate install", linux) + self.assertIn("Deploy Qt runtime closure to staged install tree", windows) + self.assertIn("windeployqt.exe", windows) + self.assertIn("--no-compiler-runtime", windows) + self.assertIn("LoupeEditor.exe", windows) for workflow in (linux, windows): self.assertIn("source_sha:", workflow) self.assertRegex(workflow, r"source_sha:\n\s+description:.*\n\s+required:\s+true") From 3769e0bee5f3c1deb0cf6ce75c7da49495d87b96 Mon Sep 17 00:00:00 2001 From: mbx30 Date: Mon, 31 Aug 2026 17:10:46 -0700 Subject: [PATCH 03/33] fix: install Linux fontconfig headers for package build --- .github/workflows/LinuxInstall.yml | 2 +- scripts/ci/test_workflow_contracts.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index c38f0100d..dbd2863ca 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -26,7 +26,7 @@ jobs: - name: Install dependencies run: | sudo apt-get update - sudo apt-get install -y libxcb-cursor0 libspeechd2 gnupg2 wget appstream libcups2 libcups2-dev + sudo apt-get install -y libxcb-cursor0 libspeechd2 libfontconfig1-dev gnupg2 wget appstream libcups2 libcups2-dev - name: Checkout repository uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index f0f091fe3..ab01ab59a 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -64,6 +64,7 @@ def test_package_workflows_require_and_record_exact_source_sha(self): windows = (ROOT / ".github/workflows/WindowsInstall.yml").read_text(encoding="utf-8") self.assertIn("./vcpkg/vcpkg integrate install", linux) self.assertNotIn("./vcpkg integrate install", linux) + self.assertIn("libfontconfig1-dev", linux) self.assertIn("Deploy Qt runtime closure to staged install tree", windows) self.assertIn("windeployqt.exe", windows) self.assertIn("--no-compiler-runtime", windows) From be68d1e5212cbddca3ec2c0f8d2c228bc4e91a4c Mon Sep 17 00:00:00 2001 From: mbx30 Date: Mon, 31 Aug 2026 17:58:08 -0700 Subject: [PATCH 04/33] fix: harden package deployment evidence --- .github/workflows/LinuxInstall.yml | 8 +++++++- .github/workflows/WindowsInstall.yml | 12 +++++++++--- scripts/ci/test_workflow_contracts.py | 4 ++++ 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index dbd2863ca..a4519f6ac 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -196,6 +196,9 @@ jobs: - name: 'Linux Deploy Qt' working-directory: loupe/build run: | + evidence_dir="$RUNNER_TEMP/loupe-package-boundary-linux" + mkdir -p "$evidence_dir" + set -o pipefail cp install/usr/share/icons/hicolor/scalable/apps/io.github.mberrys.Loupe-pdf.svg install/io.github.mberrys.Loupe-pdf.svg bash "$GITHUB_WORKSPACE/loupe/scripts/ci/download_verified.sh" \ --gh-asset "probonopd/linuxdeployqt" \ @@ -203,7 +206,10 @@ jobs: deploy.AppImage \ "$LINUXDEPLOYQT_SHA256" chmod +x deploy.AppImage - ./deploy.AppImage install/usr/share/applications/io.github.mberrys.Loupe-pdf.desktop -executable-dir=install/usr/bin -extra-plugins=iconengines,imageformats,texttospeech + ./deploy.AppImage --appimage-extract-and-run \ + install/usr/share/applications/io.github.mberrys.Loupe-pdf.desktop \ + -executable-dir=install/usr/bin \ + -extra-plugins=iconengines,imageformats,texttospeech 2>&1 | tee "$evidence_dir/linuxdeployqt.txt" - name: Prepare GPG home if: vars.SIGN_APPIMAGE == 'true' diff --git a/.github/workflows/WindowsInstall.yml b/.github/workflows/WindowsInstall.yml index 768c96dc1..6f47e83a8 100644 --- a/.github/workflows/WindowsInstall.yml +++ b/.github/workflows/WindowsInstall.yml @@ -261,6 +261,8 @@ jobs: working-directory: loupe shell: pwsh run: | + $evidenceDir = Join-Path $env:RUNNER_TEMP "loupe-package-boundary-windows" + New-Item -ItemType Directory -Force -Path $evidenceDir | Out-Null $installBin = Join-Path $env:GITHUB_WORKSPACE "loupe\build\install\usr\bin" $windeployqt = Join-Path $env:QT_ROOT_DIR "bin\windeployqt.exe" if (-not (Test-Path -LiteralPath $windeployqt)) { @@ -272,9 +274,13 @@ jobs: if (-not (Test-Path -LiteralPath $target)) { throw "Expected staged executable was not found: $target" } - & $windeployqt --release --no-compiler-runtime --no-translations --dir $installBin $target 2>&1 - if ($LASTEXITCODE -ne 0) { - throw "windeployqt failed for $name with exit code $LASTEXITCODE" + $output = @(& $windeployqt --release --no-compiler-runtime --no-translations ` + --qmldir (Join-Path $env:GITHUB_WORKSPACE "loupe\LoupeEditor\qml") ` + --dir $installBin $target 2>&1) + $exitCode = $LASTEXITCODE + $output | Tee-Object -FilePath (Join-Path $evidenceDir "windeployqt-$name.txt") + if ($exitCode -ne 0) { + throw "windeployqt failed for $name with exit code $exitCode" } } env: diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index ab01ab59a..1d0268b42 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -68,7 +68,11 @@ def test_package_workflows_require_and_record_exact_source_sha(self): self.assertIn("Deploy Qt runtime closure to staged install tree", windows) self.assertIn("windeployqt.exe", windows) self.assertIn("--no-compiler-runtime", windows) + self.assertIn("--qmldir", windows) + self.assertIn("windeployqt-$name.txt", windows) self.assertIn("LoupeEditor.exe", windows) + self.assertIn("--appimage-extract-and-run", linux) + self.assertIn("linuxdeployqt.txt", linux) for workflow in (linux, windows): self.assertIn("source_sha:", workflow) self.assertRegex(workflow, r"source_sha:\n\s+description:.*\n\s+required:\s+true") From d099622a0abee38e98c9378cbfd9763f4233b8a8 Mon Sep 17 00:00:00 2001 From: mbx30 Date: Mon, 31 Aug 2026 19:18:35 -0700 Subject: [PATCH 05/33] fix: close Session 08 residue sweep --- .claude/policy-brief.md | 2 +- .cursor/agent-policy.md | 2 +- AGENTS.md | 2 +- changes/cdx-0.2.0-p5session7.md | 4 ++++ docs/ACCESSIBILITY_BASELINE.md | 6 +++--- docs/JOB_SCHEDULER.md | 6 +++--- docs/LOUPE_SHELL_CONTRACT.md | 8 ++++---- docs/LOUPE_WORKSPACES.md | 25 ++++++++++++------------- docs/PLATFORM_SUPPORT.md | 10 ++++++---- docs/REPO_MAP.md | 2 +- scripts/agent/generate-adapters.py | 2 +- scripts/ci/check_phase5_residue.py | 3 +++ scripts/ci/test_check_phase5_residue.py | 24 ++++++++++++++++++++++++ scripts/verify-command-catalog.py | 12 ++++++------ 14 files changed, 70 insertions(+), 38 deletions(-) create mode 100644 changes/cdx-0.2.0-p5session7.md diff --git a/.claude/policy-brief.md b/.claude/policy-brief.md index a33d79c99..59411cf08 100644 --- a/.claude/policy-brief.md +++ b/.claude/policy-brief.md @@ -39,7 +39,7 @@ Approval is required for: ## Module placement - Core PDF logic belongs in `LoupeLibCore`; it must not depend on Widgets. -- Interactive plugins belong in `LoupeEditorPlugins` hosted by the Editor; batch geometry belongs in PageMaster; unattended pipelines belong in PdfTool. +- Interactive capabilities belong in `LoupeEditor`/`LoupeLibQuick`; batch geometry belongs in `LoupeLibCore`/`PdfTool`; unattended pipelines belong in `PdfTool`. - Consult the generated architecture catalog and current code/tests for dynamic facts; narrative docs are not authoritative when they conflict. - Record parser/writer/renderer divergences from the upstream engine in `docs/UPSTREAM_DIVERGENCE.md`. Cosmetic Loupe-only code does not belong there. diff --git a/.cursor/agent-policy.md b/.cursor/agent-policy.md index bf03ca581..74e9dd69e 100644 --- a/.cursor/agent-policy.md +++ b/.cursor/agent-policy.md @@ -39,7 +39,7 @@ Approval is required for: ## Module placement - Core PDF logic belongs in `LoupeLibCore`; it must not depend on Widgets. -- Interactive plugins belong in `LoupeEditorPlugins` hosted by the Editor; batch geometry belongs in PageMaster; unattended pipelines belong in PdfTool. +- Interactive capabilities belong in `LoupeEditor`/`LoupeLibQuick`; batch geometry belongs in `LoupeLibCore`/`PdfTool`; unattended pipelines belong in `PdfTool`. - Consult the generated architecture catalog and current code/tests for dynamic facts; narrative docs are not authoritative when they conflict. - Record parser/writer/renderer divergences from the upstream engine in `docs/UPSTREAM_DIVERGENCE.md`. Cosmetic Loupe-only code does not belong there. diff --git a/AGENTS.md b/AGENTS.md index 4afabac67..1cce68494 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,7 +39,7 @@ Approval is required for: ## Module placement - Core PDF logic belongs in `LoupeLibCore`; it must not depend on Widgets. -- Interactive plugins belong in `LoupeEditorPlugins` hosted by the Editor; batch geometry belongs in PageMaster; unattended pipelines belong in PdfTool. +- Interactive capabilities belong in `LoupeEditor`/`LoupeLibQuick`; batch geometry belongs in `LoupeLibCore`/`PdfTool`; unattended pipelines belong in `PdfTool`. - Consult the generated architecture catalog and current code/tests for dynamic facts; narrative docs are not authoritative when they conflict. - Record parser/writer/renderer divergences from the upstream engine in `docs/UPSTREAM_DIVERGENCE.md`. Cosmetic Loupe-only code does not belong there. diff --git a/changes/cdx-0.2.0-p5session7.md b/changes/cdx-0.2.0-p5session7.md new file mode 100644 index 000000000..1876ef6a5 --- /dev/null +++ b/changes/cdx-0.2.0-p5session7.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Extend the Phase 5 residue sweep to generated policy adapters and normalize current Quick/Core architecture guidance. diff --git a/docs/ACCESSIBILITY_BASELINE.md b/docs/ACCESSIBILITY_BASELINE.md index 64fff64f0..771a18f38 100644 --- a/docs/ACCESSIBILITY_BASELINE.md +++ b/docs/ACCESSIBILITY_BASELINE.md @@ -39,9 +39,9 @@ remains an application-level follow-up under the GUI/E2E harness issue. ADR-007 adopts Qt Quick Controls as the 1.2 shell foundation. It extends this baseline; it does not create a second accessibility standard. Quick components -must expose the same meaningful name, description, role, state, visible focus, -keyboard reachability, contrast, status text, and DPI-aware sizing expected of -Widgets components. +must expose meaningful names, descriptions, roles, states, visible focus, +keyboard reachability, contrast, status text, and DPI-aware sizing under the +same application accessibility baseline. Every Quick `Dialog`, `Menu`, and `Popup` must have a keyboard/focus test that covers opening, traversal, typeahead where applicable, Escape dismissal, diff --git a/docs/JOB_SCHEDULER.md b/docs/JOB_SCHEDULER.md index da5b92dd4..e42f885a2 100644 --- a/docs/JOB_SCHEDULER.md +++ b/docs/JOB_SCHEDULER.md @@ -62,10 +62,10 @@ with document-revision binding. Inventory: | Page and overlay rendering | `LoupeLibQuick`, `LoupeLibCore` | `Rendering` | `VisiblePage` | **page compile and text layout migrated**; remaining overlay tiles stay on `PDFExecutionPolicy` | | Preflight and fixups | Editor / PdfTool | `Preflight` or `Export` | `Operator` | **PdfTool `preflight` and Editor preflight migrated** | | OCR and indexing | Editor plugins / Core | `OCR` | `Background` | remaining (out of S05 scope) | -| PageMaster export | `PdfTool`, `LoupeLibCore` | `Export` | `Operator` | **migrated** | +| Page production export | `PdfTool`, `LoupeLibCore` | `Export` | `Operator` | **migrated** | | Thumbnail generation | `LoupeLibQuick`, `LoupeLibCore` | `Thumbnail` | `NearViewport` | **migrated** | -| PageMaster preview | `PdfTool`, `LoupeLibCore` | `Rendering` | `NearViewport` | **migrated** (revision-fenced via `PDFJobScheduler`) | -| Batch analysis | PageMaster / PdfTool | `Batch` | `Background` | remaining (out of S05 scope) | +| Page production preview | `PdfTool`, `LoupeLibCore` | `Rendering` | `NearViewport` | **migrated** (revision-fenced via `PDFJobScheduler`) | +| Batch analysis | `PdfTool`, `LoupeLibCore` | `Batch` | `Background` | remaining (out of S05 scope) | | Agent context work | future agent surface | `Agent` | `Agent` | remaining | This migration boundary is deliberate: the scheduler provides the shared diff --git a/docs/LOUPE_SHELL_CONTRACT.md b/docs/LOUPE_SHELL_CONTRACT.md index 91e0c643f..9b3974f9d 100644 --- a/docs/LOUPE_SHELL_CONTRACT.md +++ b/docs/LOUPE_SHELL_CONTRACT.md @@ -3,7 +3,7 @@ This is the non-visual foundation for issue #193. The 0.1.1 release gate is complete, but product GUI work remains gated by the S21 canvas and S22 Quick admission contracts. This document therefore defines the state, routing, and -verification contract without changing the existing Widgets shell. The +verification contract without changing the existing shell. The repository may contain the qualification-only Quick smoke harness; it is not product UI or a shipped Qt Quick surface. @@ -16,7 +16,7 @@ Editor action inventory is recorded in [`loupe-shell-actions.json`](loupe-shell- `LoupeEditor` is the installed interactive Loupe shell on the P4-S7 navigable product root: a packaged `Loupe.Quick` `ApplicationWindow` that opens, closes, reopens, and navigates a PDF through the host-neutral Interaction/Canvas stack. -The former non-installed Widgets migration target has been retired after its +The former non-installed migration target has been retired after its parity assertions were moved into the Quick-native canvas contract suite. This is a navigable slice, not the Phase 4 operator loop or GUI exit gate. @@ -143,14 +143,14 @@ that is not in the contract is reported as a routing error rather than ignored. against `PDFActionManager::initActions` so the catalog cannot become a second command truth wearing the first one's ID set. -The Quick shell policy is self-authoritative; the retired Widgets form is not a +The Quick shell policy is self-authoritative; the retired migration form is not a runtime or documentation dependency. ## UI foundation gate Issue #178 selects Qt Quick Controls for the application shell. The installed `LoupeEditor` product root is now Qt Quick (`gui_status: quick-admitted` in -`loupe-shell.json`). The migration-only Widgets comparison target is retired; +`loupe-shell.json`). The migration-only comparison target is retired; the preserved parity evidence and Quick-native replacement checks are recorded in `docs/evidence/phase5-widgets-parity-evidence.json`. diff --git a/docs/LOUPE_WORKSPACES.md b/docs/LOUPE_WORKSPACES.md index 92f5fd36b..c814d3f28 100644 --- a/docs/LOUPE_WORKSPACES.md +++ b/docs/LOUPE_WORKSPACES.md @@ -10,15 +10,15 @@ is deferred to #193 and remains outside the pre-0.1.1 GUI scope. Loupe has two product surfaces: - **Loupe** — `LoupeEditor`, the interactive desktop shell. Opening a PDF is - the Document workspace and includes the inherited Viewer behavior. + the Document workspace and includes the standard document-viewing behavior. - **Loupe CLI** — `PdfTool`, the headless and automation surface. Its command names, JSON envelopes, and machine-readable capability discovery remain the automation contract. - `LoupeLibCore` and `LoupeLibQuick` are maintained implementation libraries, not additional user-facing products. -The former Viewer, PageMaster, Diff, LaunchPad, and editor-plugin artifacts are -deleted and absent from both supported profiles. Their dispositions remain in +Former standalone applications and editor-plugin artifacts are deleted and +absent from both supported profiles. Their dispositions remain in `docs/product-surface.json` so an accidental upstream reintroduction fails verification. @@ -29,10 +29,10 @@ receive a separate Loupe desktop entry, AppX application, or product identity. | Workspace | Owns | Drives | Explicitly does not own | | --- | --- | --- | --- | -| Document | Open, view, navigate, save/export, and ordinary PDF interaction | `LoupeEditor`, `LoupeLibQuick`, shared document/session contracts | A separate Viewer product or a second document model | +| Document | Open, view, navigate, save/export, and ordinary PDF interaction | `LoupeEditor`, `LoupeLibQuick`, shared document/session contracts | A second interactive document product or a second document model | | Preflight | Run/rerun/cancel inspection, findings, evidence, report export, and stale-result state | Core `PreflightEngine`, `PdfTool preflight`, Quick shell contract | A GUI-only interpretation of the CLI report | | Production Preview | Soft proofing, output preview, separations, and production rendering evidence | `LoupeLibCore`, `LoupeLibQuick`, shared render/color contracts | Final approval or an alternate PDF-writing pipeline | -| Pages / Production | Multi-document assembly, page geometry, crop, regrouping, bleed, optimization, and export | `PDFPageMasterExport`, ADR-003 stage order, ADR-004 batch manifest | A copied PageMaster engine or a reordered export pipeline | +| Pages / Production | Multi-document assembly, page geometry, crop, regrouping, bleed, optimization, and export | `PDFPageMasterExport`, ADR-003 stage order, ADR-004 batch manifest | A copied page-production engine or a reordered export pipeline | | Inspect | Contextual page, image, object, dimension, color, and evidence inspection | Core inspection APIs and the Quick shell contract | A standalone inspector application | | Fix | Deterministic, bounded corrective operations with preview, approval, output, and revalidation | Core repair operations and `PdfTool repair` | Silent mutation, GUI-only business logic, or implicit approval | | Compare | Proposed PDF comparison and production-proof evidence | Core `PDFDiff` contract if the product boundary is approved | An automatic replacement of the retired comparison product | @@ -41,9 +41,9 @@ The shell issue (#193) may model these as stateful workspaces, but switching workspace must preserve the open document and preflight revision. A workspace is not a new executable and must not own a duplicate Core semantic path. -## PageMaster disposition and capability crosswalk +## Page-production disposition and capability crosswalk -PageMaster is recorded as **CLI-ONLY** and its source is deleted. Its +Page production is recorded as **CLI-ONLY** and its former source is deleted. Its historical UI action inventory maps to the Pages / Production workspace later, while `PDFPageMasterExport` and its ADR-003/ADR-004 contracts remain the single source of truth. The retained capability inventory is assigned a destination or @@ -51,7 +51,7 @@ an explicit compatibility disposition. The following action map is retained as product intent; it does not imply that a standalone executable or UI file is still shipped. -| PageMaster action IDs | Disposition | Destination / contract | +| Page-production action IDs | Disposition | Destination / contract | | --- | --- | --- | | `actionOpenWorkspace`, `actionSaveWorkspace`, `actionAddDocuments`, `actionSaveCheckpoint`, `actionLoadCheckpoint`, `actionClear`, `actionClose`, `actionClearRecent`, `actionClearSearch` | ABSORB | Pages / Production workspace lifecycle, search/filter reset, and ADR-004 checkpoint/manifest behavior | | `actionCloneSelection`, `actionRemoveSelection`, `actionReplaceSelection`, `actionRestoreRemovedItems`, `actionCut`, `actionCopy`, `actionPaste` | ABSORB | Pages / Production document-item editing over the existing page-item model | @@ -66,7 +66,7 @@ still shipped. | `actionUndo`, `actionRedo` | ABSORB | Pages / Production history; must remain scoped to the workspace document model | | `actionGet_Source`, `actionBecomeASponsor`, `actionAbout`, `actionPrepare_Icon_Theme` | KEEP / ADVANCED | Loupe Help or developer/compatibility path; not a production capability | -No PageMaster semantic contract is silently retired, but the standalone +No page-production semantic contract is silently retired, but the standalone executable is absent from both profiles. Export order remains the ADR-003 contract: assembly, preflight, page geometry, bleed/content fixups, image optimization, then write, with ADR-004 manifest and @@ -75,8 +75,8 @@ rollback behavior unchanged. ## Compare disposition Compare is **OPEN**, not implicitly absorbed. The Core `PDFDiff` contract is -retained while the Diff executable is absent from both profiles because its -source was already deleted. The owner is `m.berry`; #193 is the follow-up for the shell +retained while the standalone comparison executable is absent from both profiles +because its source was already deleted. The owner is `m.berry`; #193 is the follow-up for the shell boundary and #197 is the release exit gate. No new UI replacement or product commitment is authorized by this document. @@ -92,8 +92,7 @@ must have: - one Linux desktop entry, `io.github.mberrys.Loupe-pdf.desktop`, launching `LoupeEditor` with `application/pdf` association; - one AppX application, `LoupeEditor`, with the same PDF association; -- no Viewer, PageMaster, Diff, LaunchPad, or other retired product desktop/AppX - entry; and +- no retired product desktop/AppX entry; and - only the manifest-declared `LoupeEditor`, `PdfTool`, `LoupeLibCore`, and `LoupeLibQuick` first-party artifacts; deleted compatibility/plugin artifacts are forbidden. diff --git a/docs/PLATFORM_SUPPORT.md b/docs/PLATFORM_SUPPORT.md index 895594e4a..d56cc0882 100644 --- a/docs/PLATFORM_SUPPORT.md +++ b/docs/PLATFORM_SUPPORT.md @@ -55,8 +55,9 @@ fixed absolute location. ## V1 slim distribution When `LOUPE_LOUPE_DISTRIBUTION=ON`, prefer Editor + PdfTool + core plugins -(LoupePreflight and required inspection plugins). PageMaster / Diff / Viewer / -LaunchPad may ship in full packages; still build them in CI on both supported OS. +(LoupePreflight and required inspection plugins). Retired standalone product +identities are absent from both supported package profiles and are not built by +the release graph. ## Cross-platform compatibility pass @@ -76,7 +77,7 @@ bundling** and **installer packaging** for modules that are already complete. | Page production export (MIC-307–312) | Yes | ☐ | ☐ | Atomic write + manifest; cancel; case-sensitive FS | | Retired secondary product identities | No | N/A | N/A | Replaced by LoupeEditor, PdfTool, and in-app workspaces | | loupe-preflight profiles + schemas | Yes | ☐ | ☐ | Installed at documented path; schema version contract | -| UnitTests (operator, corpus, PageMaster) | Yes | ☐ | ☐ | `ctest` green on both CI runners | +| UnitTests (operator, corpus, page production) | Yes | ☐ | ☐ | `ctest` green on both CI runners | | Windows MSI | Session 07 exact-SHA package boundary | ☐ | — | x64 WiX package, dependency evidence, clean VM operator/a11y loop; **V1 ships unsigned** (MIC-342 / MIC-345) | | Linux AppImage | Session 07 exact-SHA package boundary | — | ☐ | x86_64 package, dependency evidence, clean VM operator/a11y loop | | Flatpak / MSIX / portable ZIP | Out of Session 07 scope | — | — | Build or sandbox work may exist, but these formats are not release-gate evidence | @@ -157,7 +158,8 @@ candidate resolved and keep the layout table above synchronized with the VM evid macOS is explicitly **out of scope for V1**. The work below is retained as the entry criteria for adding it in a later release, not as a V1 checklist. -- Apps already set `MACOSX_BUNDLE ON` for Editor, Viewer, PageMaster, Diff, LaunchPad. +- The future application bundle must contain the Quick-based `LoupeEditor` only; + retired standalone product identities are not macOS bundle targets. - CMake today treats non-`LOUPE_LINUX` like Windows for `LOUPE_PLUGINS_DIR` (`pdfplugins`, `CMakeLists.txt:198-201`). That path must be confirmed inside a `.app` bundle or the install rules adjusted. - A `macos` job in `ci.yml` with Qt 6.11.1 + vcpkg, mirroring the Ubuntu/Windows `ctest` set, is the minimum bar before any macOS claim is restored. - Notarization and staple steps belong in a dedicated `macOSInstall.yml` before attaching artifacts to the release draft. This requires an **Apple Developer Program** enrollment, which is not currently held. diff --git a/docs/REPO_MAP.md b/docs/REPO_MAP.md index 5d3704d4f..5bcb84d11 100644 --- a/docs/REPO_MAP.md +++ b/docs/REPO_MAP.md @@ -88,7 +88,7 @@ targeted Core tests before merging an authorized sync. | Interactive editor | `LoupeEditor/`, `LoupeLibQuick/` | Primary Qt Quick shell and canvas host | | Headless CLI | `PdfTool/` | Automation, batch checks, rendering, and repair | | Page production | `PdfTool/`, `LoupeLibCore/` | Batch geometry, assembly, and production export | -| Editor plugins | `LoupeEditorPlugins/` | Editor-only capabilities | +| Editor capabilities | `LoupeEditor/`, `LoupeLibQuick/`, `LoupeLibCore/` | Contextual capabilities hosted by the Quick editor and shared libraries | | Tests | `UnitTests/` | Qt Test targets declared in `UnitTests/CMakeLists.txt` | | Preflight contract | `loupe-preflight/` | Profiles, schemas, examples, and report documentation | | Architecture records | `docs/adr/`, `docs/` | Decisions, policy, plans, and generated factual catalogs | diff --git a/scripts/agent/generate-adapters.py b/scripts/agent/generate-adapters.py index 8e6201d8d..ec1a52e8b 100644 --- a/scripts/agent/generate-adapters.py +++ b/scripts/agent/generate-adapters.py @@ -105,7 +105,7 @@ def render(policy: dict, adapter: str) -> str: "## Module placement", "", "- Core PDF logic belongs in `LoupeLibCore`; it must not depend on Widgets.", - "- Interactive plugins belong in `LoupeEditorPlugins` hosted by the Editor; batch geometry belongs in PageMaster; unattended pipelines belong in PdfTool.", + "- Interactive capabilities belong in `LoupeEditor`/`LoupeLibQuick`; batch geometry belongs in `LoupeLibCore`/`PdfTool`; unattended pipelines belong in `PdfTool`.", "- Consult the generated architecture catalog and current code/tests for dynamic facts; narrative docs are not authoritative when they conflict.", "- Record parser/writer/renderer divergences from the upstream engine in `docs/UPSTREAM_DIVERGENCE.md`. Cosmetic Loupe-only code does not belong there.", "", diff --git a/scripts/ci/check_phase5_residue.py b/scripts/ci/check_phase5_residue.py index d38fb06f4..ef9f51d04 100644 --- a/scripts/ci/check_phase5_residue.py +++ b/scripts/ci/check_phase5_residue.py @@ -16,6 +16,9 @@ "WixInstaller/", "Desktop/", "README.md", + "AGENTS.md", + ".claude/", + ".cursor/", "LoupeEditor/", "LoupeLibCore/", "LoupeLibInteraction/", diff --git a/scripts/ci/test_check_phase5_residue.py b/scripts/ci/test_check_phase5_residue.py index 96e8ae21d..fd7e82968 100644 --- a/scripts/ci/test_check_phase5_residue.py +++ b/scripts/ci/test_check_phase5_residue.py @@ -39,6 +39,30 @@ def test_current_docs_are_scanned(self) -> None: findings = check_phase5_residue.violations(root) self.assertEqual(len(findings), 1) + def test_generated_agent_adapters_are_scanned(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "AGENTS.md").write_text("LoupeLibGui is gone.\n", encoding="utf-8") + with mock.patch.object(check_phase5_residue, "tracked_paths", return_value=["AGENTS.md"]): + findings = check_phase5_residue.violations(root) + self.assertEqual(len(findings), 1) + + def test_validation_scripts_are_excluded(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + path = root / "scripts" + path.mkdir() + (path / "verify-installed-product-graph.py").write_text( + "FORBIDDEN = 'LoupeLibGui'\n", encoding="utf-8" + ) + with mock.patch.object( + check_phase5_residue, + "tracked_paths", + return_value=["scripts/verify-installed-product-graph.py"], + ): + findings = check_phase5_residue.violations(root) + self.assertEqual(findings, []) + if __name__ == "__main__": unittest.main() diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index c9d1f6c97..870c8dc3b 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -292,24 +292,24 @@ def check_shortcut_parity( for action_id, expected in sorted(expected_by_id.items()): command = commands.get(action_id) if command is None: - errors.append(f"{action_id}: has a Widgets shortcut but no catalog entry") + errors.append(f"{action_id}: has a legacy UI shortcut but no catalog entry") continue actual = command.get("shortcut") if actual is None: errors.append( - f"{action_id}: the Widgets shell binds {expected!r} but the catalog " + f"{action_id}: the legacy UI binds {expected!r} but the catalog " "declares no shortcut" ) elif actual != expected: errors.append( - f"{action_id}: catalog shortcut {actual!r} contradicts the Widgets " + f"{action_id}: catalog shortcut {actual!r} contradicts the legacy " f"shell's {expected!r}" ) for action_id, command in sorted(commands.items()): if "shortcut" in command and action_id not in expected_by_id: errors.append( - f"{action_id}: the catalog invents a shortcut the Widgets shell does " + f"{action_id}: the catalog invents a shortcut the legacy UI does " "not bind; add it to PDFActionManager::initActions first" ) @@ -372,7 +372,7 @@ def validate_catalog( pass else: errors.append( - "Widgets shortcut parity requires both pdfeditormainwindow.cpp and " + "Legacy UI shortcut parity requires both shell source files and " "pdfprogramcontroller.cpp, or neither after Issue 17" ) return errors @@ -391,7 +391,7 @@ def verify() -> str: f"{len(shortcuts)} shortcuts in parity with PDFActionManager::initActions." ) else: - shortcut_note = "Widgets shortcut parity skipped (Quick shell owns bindings after Issue 17)." + shortcut_note = "Legacy UI shortcut parity skipped (Quick shell owns bindings after Issue 17)." return ( f"Command catalog verified: {len(policy['actions'])} descriptors " f"({implemented} implemented, {len(policy['actions']) - implemented} declared); " From d7d7ae8c4df459c722fd59c554f097a9d67420a2 Mon Sep 17 00:00:00 2001 From: mbx30 Date: Mon, 31 Aug 2026 19:43:48 -0700 Subject: [PATCH 06/33] fix: repair package runtime deployment --- .github/workflows/LinuxInstall.yml | 4 +++- .github/workflows/WindowsInstall.yml | 12 ++++++++++++ changes/cdx-session-07-package-boundary-fix.md | 4 ++++ scripts/ci/test_workflow_contracts.py | 3 +++ 4 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 changes/cdx-session-07-package-boundary-fix.md diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index a4519f6ac..846d02f9d 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -15,7 +15,9 @@ permissions: jobs: build_ubuntu: - runs-on: ubuntu-24.04 + # linuxdeployqt is pinned to a glibc-2.35-compatible build. Keep the + # packaging runner aligned with that oldest-supported deployment tool. + runs-on: ubuntu-22.04 env: VCPKG_OVERLAY_PORTS: ${{ github.workspace }}/loupe/vcpkg/overlays/linux:${{ github.workspace }}/loupe/vcpkg/overlays/general VCPKG_INSTALLED_DIR: ${{ github.workspace }}/vcpkg_installed diff --git a/.github/workflows/WindowsInstall.yml b/.github/workflows/WindowsInstall.yml index 6f47e83a8..88bd9935d 100644 --- a/.github/workflows/WindowsInstall.yml +++ b/.github/workflows/WindowsInstall.yml @@ -283,6 +283,18 @@ jobs: throw "windeployqt failed for $name with exit code $exitCode" } } + + # The clean installed-artifact smoke intentionally removes developer + # Qt environment variables. Make the staged tree self-describing so + # Qt resolves its bundled QML imports and plugins without the runner's + # Qt installation. + @" + [Paths] + Prefix=. + Plugins=. + Qml2Imports=qml + "@ | Set-Content -LiteralPath (Join-Path $installBin "qt.conf") -Encoding ascii + Copy-Item -LiteralPath (Join-Path $installBin "qt.conf") -Destination (Join-Path $evidenceDir "qt.conf") env: VCToolsRedistDir: ${{ env.VCToolsRedistDir }} VSCMD_ARG_TGT_ARCH: ${{ env.VSCMD_ARG_TGT_ARCH }} diff --git a/changes/cdx-session-07-package-boundary-fix.md b/changes/cdx-session-07-package-boundary-fix.md new file mode 100644 index 000000000..fa7477baf --- /dev/null +++ b/changes/cdx-session-07-package-boundary-fix.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Align Linux package deployment with the pinned linuxdeployqt glibc floor and make the Windows staged Qt runtime self-describing for clean installed-artifact smoke. diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index 1d0268b42..b2d881753 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -65,12 +65,15 @@ def test_package_workflows_require_and_record_exact_source_sha(self): self.assertIn("./vcpkg/vcpkg integrate install", linux) self.assertNotIn("./vcpkg integrate install", linux) self.assertIn("libfontconfig1-dev", linux) + self.assertIn("runs-on: ubuntu-22.04", linux) self.assertIn("Deploy Qt runtime closure to staged install tree", windows) self.assertIn("windeployqt.exe", windows) self.assertIn("--no-compiler-runtime", windows) self.assertIn("--qmldir", windows) self.assertIn("windeployqt-$name.txt", windows) self.assertIn("LoupeEditor.exe", windows) + self.assertIn("Qml2Imports=qml", windows) + self.assertIn('Join-Path $installBin "qt.conf"', windows) self.assertIn("--appimage-extract-and-run", linux) self.assertIn("linuxdeployqt.txt", linux) for workflow in (linux, windows): From c46d040a437a0a594d0ee9fe4e99d3d0b80577ee Mon Sep 17 00:00:00 2001 From: mbx30 Date: Mon, 31 Aug 2026 19:59:57 -0700 Subject: [PATCH 07/33] ci: speed up package boundary workflows --- .github/workflows/LinuxInstall.yml | 5 ++++- .github/workflows/WindowsInstall.yml | 11 ++++------- scripts/ci/test_workflow_contracts.py | 12 ++++++++++++ 3 files changed, 20 insertions(+), 8 deletions(-) diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index 846d02f9d..5d7e99816 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -21,6 +21,7 @@ jobs: env: VCPKG_OVERLAY_PORTS: ${{ github.workspace }}/loupe/vcpkg/overlays/linux:${{ github.workspace }}/loupe/vcpkg/overlays/general VCPKG_INSTALLED_DIR: ${{ github.workspace }}/vcpkg_installed + VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/vcpkg-binary-cache GNUPGHOME: ${{ github.workspace }}/gnupg QT_QPA_PLATFORM: offscreen @@ -131,6 +132,7 @@ jobs: ./vcpkg/bootstrap-vcpkg.sh ./vcpkg/vcpkg integrate install + echo "VCPKG_BINARY_SOURCES=clear;files,$VCPKG_DEFAULT_BINARY_CACHE,readwrite" >> "$GITHUB_ENV" - name: 'VCPKG: Cache vcpkg dependencies' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 @@ -139,6 +141,7 @@ jobs: ./vcpkg/downloads ./vcpkg/packages ./vcpkg_installed + ./vcpkg-binary-cache key: ${{ runner.os }}-vcpkg-v2-${{ hashFiles('**/vcpkg.json', '**/vcpkg-configuration.json') }} restore-keys: | ${{ runner.os }}-vcpkg-v2- @@ -164,7 +167,7 @@ jobs: working-directory: loupe run: | cmake -B build -S . -DLOUPE_INSTALL_QT_DEPENDENCIES=0 -DCMAKE_TOOLCHAIN_FILE=../vcpkg/scripts/buildsystems/vcpkg.cmake -DCMAKE_BUILD_TYPE=Release -DCMAKE_VCPKG_BUILD_TYPE=Release -DLOUPE_INSTALL_TO_USR=ON -DLOUPE_LOUPE_DISTRIBUTION=ON -DLOUPE_PLUGIN_OCR=OFF -DLOUPE_BUILD_PRODUCT_QUICK_A11Y_SMOKE=ON - cmake --build build --target all release_translations -j6 + cmake --build build --target LoupeEditor PdfTool ProductQuickAccessibilitySmoke release_translations -j6 cmake --install build pwsh ./scripts/verify-loupe-surface.ps1 -InstallDir "$GITHUB_WORKSPACE/loupe/build/install" -Profile loupe-release -BuildDir build -InstallManifestPath build/install_manifest.txt diff --git a/.github/workflows/WindowsInstall.yml b/.github/workflows/WindowsInstall.yml index 88bd9935d..f5c0bb53e 100644 --- a/.github/workflows/WindowsInstall.yml +++ b/.github/workflows/WindowsInstall.yml @@ -184,8 +184,7 @@ jobs: cd vcpkg .\bootstrap-vcpkg.bat -disableMetrics .\vcpkg integrate install - set VCPKG_ROOT=${env:GITHUB_WORKSPACE}\vcpkg\ - set "VCPKG_BINARY_SOURCES=clear;files,${env:GITHUB_WORKSPACE}\vcpkg\archives,readwrite" + "VCPKG_BINARY_SOURCES=clear;files,$env:GITHUB_WORKSPACE\vcpkg-binary-cache,readwrite" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append - name: 'VCPKG: Cache vcpkg dependencies' uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 @@ -193,8 +192,8 @@ jobs: path: | ./vcpkg/downloads ./vcpkg/packages - ./vcpkg/installed - ./vcpkg/archives + ./vcpkg_installed + ./vcpkg-binary-cache key: ${{ runner.os }}-vcpkg-v2-${{ hashFiles('**/vcpkg.json', '**/vcpkg-configuration.json') }} restore-keys: | ${{ runner.os }}-vcpkg-v2- @@ -251,9 +250,7 @@ jobs: shell: pwsh run: | cmake -B build -S . -DCMAKE_BUILD_TYPE=Release -DCMAKE_VCPKG_BUILD_TYPE=Release -DLOUPE_INSTALL_QT_DEPENDENCIES=ON -DLOUPE_INSTALL_DEPENDENCIES=ON -DCMAKE_TOOLCHAIN_FILE="${env:GITHUB_WORKSPACE}\vcpkg\scripts\buildsystems\vcpkg.cmake" -DLOUPE_QT_ROOT="${env:QT_ROOT_DIR}" -DLOUPE_INSTALL_MSVC_REDISTRIBUTABLE=ON -DLOUPE_INSTALL_PREPARE_WIX_INSTALLER=ON -DLOUPE_INSTALL_TO_USR=ON -DLOUPE_LOUPE_DISTRIBUTION=ON -DLOUPE_PLUGIN_OCR=OFF -DLOUPE_BUNDLE_OCR_SERVICE=OFF -DLOUPE_BUILD_PRODUCT_QUICK_A11Y_SMOKE=ON - cmake --build build --target release_translations --config Release -j6 - cmake --build build --config Release -j6 - ctest --test-dir build -C Release --output-on-failure + cmake --build build --target LoupeEditor PdfTool ProductQuickAccessibilitySmoke release_translations --config Release -j6 cmake --install build --config Release .\scripts\verify-loupe-surface.ps1 -InstallDir "${env:GITHUB_WORKSPACE}\loupe\build\install\usr\bin" -Profile loupe-release -BuildDir .\build -InstallManifestPath .\build\install_manifest.txt diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index b2d881753..e2b1c6178 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -66,6 +66,12 @@ def test_package_workflows_require_and_record_exact_source_sha(self): self.assertNotIn("./vcpkg integrate install", linux) self.assertIn("libfontconfig1-dev", linux) self.assertIn("runs-on: ubuntu-22.04", linux) + self.assertIn("VCPKG_DEFAULT_BINARY_CACHE", linux) + self.assertIn("VCPKG_BINARY_SOURCES=clear;files", linux) + self.assertIn("./vcpkg-binary-cache", linux) + self.assertIn("cmake --build build --target LoupeEditor PdfTool ProductQuickAccessibilitySmoke release_translations -j6", linux) + self.assertNotIn("--target all", linux) + self.assertNotIn("ctest --test-dir build", linux) self.assertIn("Deploy Qt runtime closure to staged install tree", windows) self.assertIn("windeployqt.exe", windows) self.assertIn("--no-compiler-runtime", windows) @@ -74,6 +80,12 @@ def test_package_workflows_require_and_record_exact_source_sha(self): self.assertIn("LoupeEditor.exe", windows) self.assertIn("Qml2Imports=qml", windows) self.assertIn('Join-Path $installBin "qt.conf"', windows) + self.assertIn("VCPKG_BINARY_SOURCES=clear;files", windows) + self.assertIn("./vcpkg_installed", windows) + self.assertIn("./vcpkg-binary-cache", windows) + self.assertIn("cmake --build build --target LoupeEditor PdfTool ProductQuickAccessibilitySmoke release_translations --config Release -j6", windows) + self.assertNotIn("--target all", windows) + self.assertNotIn("ctest --test-dir build", windows) self.assertIn("--appimage-extract-and-run", linux) self.assertIn("linuxdeployqt.txt", linux) for workflow in (linux, windows): From 76ca91a37c983f3a449e0eee058e25940732729b Mon Sep 17 00:00:00 2001 From: mbx30 Date: Mon, 31 Aug 2026 21:12:13 -0700 Subject: [PATCH 08/33] Add Quick PDF4QT parity foundation --- LoupeEditor/CMakeLists.txt | 3 + LoupeEditor/editorhost.cpp | 113 ++++++++ LoupeEditor/editorhost.h | 19 ++ LoupeEditor/qml/DocumentPane.qml | 155 +++++++++++ LoupeEditor/qml/InspectorPane.qml | 28 ++ LoupeEditor/qml/Main.qml | 46 ++++ LoupeEditor/qml/Workspace.qml | 17 +- LoupeEditor/quickdocumentmodel.cpp | 323 +++++++++++++++++++++++ LoupeEditor/quickdocumentmodel.h | 203 ++++++++++++++ UnitTests/phase4-tests.cmake | 15 ++ UnitTests/tst_quickdocumentmodeltest.cpp | 48 ++++ changes/cdx-quick-pdf4qt-core-parity.md | 4 + docs/QUICK_PDF_PARITY.md | 25 ++ docs/generated/architecture-catalog.json | 1 + docs/loupe-shell-actions.json | 36 +-- scripts/verify-command-catalog.py | 11 +- 16 files changed, 1024 insertions(+), 23 deletions(-) create mode 100644 LoupeEditor/qml/DocumentPane.qml create mode 100644 LoupeEditor/quickdocumentmodel.cpp create mode 100644 LoupeEditor/quickdocumentmodel.h create mode 100644 UnitTests/tst_quickdocumentmodeltest.cpp create mode 100644 changes/cdx-quick-pdf4qt-core-parity.md create mode 100644 docs/QUICK_PDF_PARITY.md diff --git a/LoupeEditor/CMakeLists.txt b/LoupeEditor/CMakeLists.txt index ff0f2d0fd..1f22fa892 100644 --- a/LoupeEditor/CMakeLists.txt +++ b/LoupeEditor/CMakeLists.txt @@ -29,6 +29,8 @@ add_library(LoupeEditorQuick STATIC editorhost.h focusrestoration.cpp focusrestoration.h + quickdocumentmodel.cpp + quickdocumentmodel.h ) qt_add_qml_module(LoupeEditorQuick @@ -38,6 +40,7 @@ qt_add_qml_module(LoupeEditorQuick QML_FILES qml/Main.qml qml/Workspace.qml + qml/DocumentPane.qml qml/CanvasPane.qml qml/PreflightPane.qml qml/InspectorPane.qml diff --git a/LoupeEditor/editorhost.cpp b/LoupeEditor/editorhost.cpp index 8401dc23f..b9d3523e0 100644 --- a/LoupeEditor/editorhost.cpp +++ b/LoupeEditor/editorhost.cpp @@ -121,6 +121,7 @@ EditorHost::EditorHost(QObject* parent) : connectInteraction(); connectSurfaces(); registerShellHandlers(); + registerFeatureHandlers(); m_preflightOverlayBridge.setFindingsModel(m_preflight.findingsModel()); m_preflightOverlayBridge.setOverlayBuilder(m_session->overlays()); @@ -131,6 +132,12 @@ EditorHost::EditorHost(QObject* parent) : connect(&m_preflight, &pdfinteraction::PreflightController::navigationRequested, this, &EditorHost::onPreflightNavigation); connect(&m_inspector, &pdfinteraction::InspectorModel::selectionChanged, this, &EditorHost::bumpPresentation); connect(&m_preview, &pdfinteraction::PreviewStateModel::stateChanged, this, &EditorHost::bumpPresentation); + connect(&m_documentModel, &QuickDocumentModel::searchChanged, this, [this] + { + refreshFeatureAvailability(); + bumpPresentation(); + bumpCommandEpoch(); + }); } EditorHost::~EditorHost() @@ -208,6 +215,28 @@ QObject* EditorHost::preview() return &m_preview; } +void EditorHost::goToPage(int pageIndex) +{ + if (!hasDocument()) + { + return; + } + + m_session->commandBridge().goToPage(pageIndex); + bumpPresentation(); +} + +void EditorHost::acknowledgeWorkspaceRequest() +{ + if (m_workspaceRequest < 0) + { + return; + } + + m_workspaceRequest = -1; + Q_EMIT presentationChanged(); +} + QString EditorHost::preflightStateName() const { return preflightStateToString(m_preflight.state()); @@ -497,6 +526,18 @@ QString EditorHost::shortcutForCommand(const QString& commandId) const void EditorHost::connectFacade() { + connect(&m_session->context(), &pdf::PDFDocumentContext::revisionChanged, + this, + [this](const pdf::PDFRevisionIdentity&, const pdf::PDFRevisionIdentity&) + { + if (m_documentBound) + { + m_documentModel.setDocument(&m_session->context()); + m_searchRow = -1; + bumpPresentation(); + } + }); + connect(&m_session->facade(), &pdfinteraction::DocumentFacade::stateChanged, this, [this](pdfinteraction::DocumentState state) { if (state == pdfinteraction::DocumentState::Empty || state == pdfinteraction::DocumentState::Error) @@ -505,6 +546,7 @@ void EditorHost::connectFacade() } bumpPresentation(); + refreshFeatureAvailability(); bumpCommandEpoch(); }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::facetsChanged, this, [this](pdfinteraction::DocumentFacets) @@ -514,12 +556,14 @@ void EditorHost::connectFacade() { onDocumentGone(); onDocumentReady(); + refreshFeatureAvailability(); bumpPresentation(); bumpCommandEpoch(); }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::documentClosed, this, [this](quint64) { onDocumentGone(); + refreshFeatureAvailability(); bumpPresentation(); bumpCommandEpoch(); }); } @@ -562,6 +606,71 @@ void EditorHost::registerShellHandlers() m_session->catalog().setEnabled(QuitCommandId, true); } +void EditorHost::registerFeatureHandlers() +{ + auto bind = [this](const QString& id, std::function action) + { + pdfinteraction::CommandCatalog::Handler handler; + handler.invoke = [this, action = std::move(action)](pdfinteraction::CommandInvocationId invocation, + const QVariantMap&) + { + action(); + m_session->catalog().finishInvocation(invocation, pdfinteraction::CommandTerminalState::Completed); + bumpPresentation(); + }; + m_session->catalog().setHandler(id, std::move(handler)); + }; + + bind(QStringLiteral("actionPageLayoutContinuous"), [this] + { m_session->viewport().setPageLayout(pdfinteraction::PageLayout::OneColumn); }); + bind(QStringLiteral("actionPageLayoutSinglePage"), [this] + { m_session->viewport().setPageLayout(pdfinteraction::PageLayout::SinglePage); }); + bind(QStringLiteral("actionPageLayoutTwoColumns"), [this] + { m_session->viewport().setPageLayout(pdfinteraction::PageLayout::TwoColumnLeft); }); + bind(QStringLiteral("actionPageLayoutTwoPages"), [this] + { m_session->viewport().setPageLayout(pdfinteraction::PageLayout::TwoPagesLeft); }); + bind(QStringLiteral("actionFullscreenMode"), [this] + { m_fullscreenRequested = !m_fullscreenRequested; }); + bind(QStringLiteral("actionFind"), [this] + { m_searchPanelVisible = true; }); + bind(QStringLiteral("actionFindNext"), [this] + { moveSearch(1); }); + bind(QStringLiteral("actionFindPrevious"), [this] + { moveSearch(-1); }); + bind(QStringLiteral("actionProperties"), [this] + { m_workspaceRequest = 2; }); + refreshFeatureAvailability(); +} + +void EditorHost::refreshFeatureAvailability() +{ + const bool ready = hasDocument(); + QHash availability; + for (const QString& id : {QStringLiteral("actionPageLayoutContinuous"), QStringLiteral("actionPageLayoutSinglePage"), + QStringLiteral("actionPageLayoutTwoColumns"), QStringLiteral("actionPageLayoutTwoPages"), + QStringLiteral("actionFind"), QStringLiteral("actionProperties")}) + { + availability.insert(id, ready); + } + const bool hasSearchResults = ready && m_documentModel.searchResultCount() > 0; + availability.insert(QStringLiteral("actionFindNext"), hasSearchResults); + availability.insert(QStringLiteral("actionFindPrevious"), hasSearchResults); + availability.insert(QStringLiteral("actionFullscreenMode"), true); + m_session->catalog().setEnabledBatch(availability); +} + +void EditorHost::moveSearch(int direction) +{ + const int count = m_documentModel.searchResults()->rowCount(); + if (count == 0) + { + return; + } + + m_searchRow = (m_searchRow + direction + count) % count; + goToPage(m_documentModel.searchPageAt(m_searchRow)); +} + void EditorHost::refreshHitTestSources() { m_findingsHitTest.setTargets(m_preflight.findingsModel()->interactionTargets()); @@ -594,6 +703,8 @@ void EditorHost::onDocumentReady() m_session->prepareDocumentView(); syncRevisionModels(); + m_documentModel.setDocument(&m_session->context()); + m_searchRow = -1; refreshHitTestSources(); m_documentBound = true; bindCanvas(); @@ -607,6 +718,8 @@ void EditorHost::onDocumentGone() m_session->clearDocumentView(); m_preflight.findingsModel()->clear(); m_inspector.clearSelection(); + m_documentModel.clear(); + m_searchRow = -1; m_preview.clear(); m_session->hitTest()->clearSources(); m_documentBound = false; diff --git a/LoupeEditor/editorhost.h b/LoupeEditor/editorhost.h index 4f86b78d8..1375a4b1e 100644 --- a/LoupeEditor/editorhost.h +++ b/LoupeEditor/editorhost.h @@ -40,6 +40,7 @@ #include "focusrestoration.h" #include "documentviewsession.h" +#include "quickdocumentmodel.h" #include "pdfdocumentcontext.h" #include "pdfjobscheduler.h" @@ -87,6 +88,7 @@ class EditorHost final : public QObject Q_PROPERTY(QObject* preflight READ preflight CONSTANT) Q_PROPERTY(QObject* inspector READ inspector CONSTANT) Q_PROPERTY(QObject* preview READ preview CONSTANT) + Q_PROPERTY(QObject* documentModel READ documentModel CONSTANT) Q_PROPERTY(QObject* focusRestoration READ focusRestoration CONSTANT) Q_PROPERTY(QString preflightStateName READ preflightStateName NOTIFY presentationChanged) Q_PROPERTY(QString previewSummary READ previewSummary NOTIFY presentationChanged) @@ -96,6 +98,9 @@ class EditorHost final : public QObject Q_PROPERTY(bool pageFidelityIsExact READ pageFidelityIsExact NOTIFY presentationChanged) Q_PROPERTY(QString pageFidelityReason READ pageFidelityReason NOTIFY presentationChanged) Q_PROPERTY(bool pageFidelityIsAuthoritative READ pageFidelityIsAuthoritative NOTIFY presentationChanged) + Q_PROPERTY(bool searchPanelVisible READ searchPanelVisible NOTIFY presentationChanged) + Q_PROPERTY(bool fullscreenRequested READ fullscreenRequested NOTIFY presentationChanged) + Q_PROPERTY(int workspaceRequest READ workspaceRequest NOTIFY presentationChanged) public: explicit EditorHost(QObject* parent = nullptr); @@ -120,6 +125,7 @@ class EditorHost final : public QObject QObject* preflight(); QObject* inspector(); QObject* preview(); + QObject* documentModel() { return &m_documentModel; } FocusRestoration* focusRestoration() { return &m_focusRestoration; } QString preflightStateName() const; @@ -127,6 +133,9 @@ class EditorHost final : public QObject QString inspectorTitle() const; bool preferReducedMotion() const; bool highContrast() const; + bool searchPanelVisible() const noexcept { return m_searchPanelVisible; } + bool fullscreenRequested() const noexcept { return m_fullscreenRequested; } + int workspaceRequest() const noexcept { return m_workspaceRequest; } /// Overprint render fidelity for the currently displayed page (issue #49). /// True (and pageFidelityReason empty) when the page has no overprint @@ -149,6 +158,8 @@ class EditorHost final : public QObject /// authoritative overprint-accurate one. Re-renders only that page; /// the document stays open. Q_INVOKABLE void toggleCurrentPageFidelity(); + Q_INVOKABLE void goToPage(int pageIndex); + Q_INVOKABLE void acknowledgeWorkspaceRequest(); Q_INVOKABLE QVariantList commandDescriptors() const; Q_INVOKABLE bool isCommandEnabled(const QString& commandId) const; @@ -193,6 +204,9 @@ class EditorHost final : public QObject void connectInteraction(); void connectSurfaces(); void registerShellHandlers(); + void registerFeatureHandlers(); + void refreshFeatureAvailability(); + void moveSearch(int direction); void refreshHitTestSources(); void bumpPresentation(); void bumpCommandEpoch(); @@ -211,12 +225,17 @@ class EditorHost final : public QObject pdfinteraction::PreflightOverlayBridge m_preflightOverlayBridge; pdfinteraction::InspectorModel m_inspector; pdfinteraction::PreviewStateModel m_preview; + QuickDocumentModel m_documentModel; FocusRestoration m_focusRestoration; pdfinteraction::FindingListHitTestSource m_findingsHitTest; QPointer m_canvas; int m_commandEpoch = 0; bool m_documentBound = false; + bool m_searchPanelVisible = false; + bool m_fullscreenRequested = false; + int m_workspaceRequest = -1; + int m_searchRow = -1; }; #endif // EDITORHOST_H diff --git a/LoupeEditor/qml/DocumentPane.qml b/LoupeEditor/qml/DocumentPane.qml new file mode 100644 index 000000000..8376d428a --- /dev/null +++ b/LoupeEditor/qml/DocumentPane.qml @@ -0,0 +1,155 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import Loupe.Quick + +Item { + id: root + + property var host: editorHost + property var documentModel: host ? host.documentModel : null + + RowLayout { + anchors.fill: parent + spacing: 0 + + Pane { + Layout.preferredWidth: 230 + Layout.fillHeight: true + padding: 8 + + ColumnLayout { + anchors.fill: parent + spacing: 8 + + TabBar { + id: tabBar + Layout.fillWidth: true + + TabButton { + text: qsTr("Pages") + } + TabButton { + text: qsTr("Outline") + } + TabButton { + text: qsTr("Search") + } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: tabBar.currentIndex + + ListView { + id: pagesView + clip: true + focus: true + activeFocusOnTab: true + model: root.documentModel ? root.documentModel.pages : null + Accessible.name: qsTr("Page thumbnails") + + delegate: ItemDelegate { + width: pagesView.width + text: qsTr("Page %1 %2 × %3").arg(pageNumber).arg(Math.round(pageWidth)).arg(Math.round(pageHeight)) + highlighted: root.host && root.host.currentPage === index + Accessible.name: text + onClicked: if (root.host) + root.host.goToPage(index) + } + } + + ListView { + id: outlineView + clip: true + focus: true + activeFocusOnTab: true + model: root.documentModel ? root.documentModel.outline : null + Accessible.name: qsTr("Document outline") + + delegate: ItemDelegate { + width: outlineView.width + text: title + Accessible.name: title + } + + Label { + anchors.centerIn: parent + visible: outlineView.count === 0 + text: qsTr("No outline") + } + } + + ColumnLayout { + spacing: 8 + + RowLayout { + Layout.fillWidth: true + TextField { + id: searchField + Layout.fillWidth: true + placeholderText: qsTr("Find in document") + focus: true + Accessible.name: qsTr("Search text") + onAccepted: if (root.documentModel) + root.documentModel.search(text) + } + Button { + text: qsTr("Find") + enabled: searchField.text.length > 0 && !!root.documentModel + onClicked: root.documentModel.search(searchField.text) + } + } + + RowLayout { + Layout.fillWidth: true + Button { + text: qsTr("Previous") + enabled: root.host && root.host.isCommandEnabled("actionFindPrevious") + onClicked: if (root.host) + root.host.invokeCommand("actionFindPrevious") + } + Button { + text: qsTr("Next") + enabled: root.host && root.host.isCommandEnabled("actionFindNext") + onClicked: if (root.host) + root.host.invokeCommand("actionFindNext") + } + Label { + Layout.fillWidth: true + text: resultsView.count > 0 ? qsTr("%1 result(s)").arg(resultsView.count) : qsTr("No results") + } + } + + ListView { + id: resultsView + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + model: root.documentModel ? root.documentModel.searchResults : null + Accessible.name: qsTr("Search results") + + delegate: ItemDelegate { + width: resultsView.width + text: qsTr("Page %1: %2").arg(page + 1).arg(context) + Accessible.name: text + onClicked: if (root.host) + root.host.goToPage(page) + } + } + } + } + } + } + + CanvasPane { + id: canvasPane + Layout.fillWidth: true + Layout.fillHeight: true + host: root.host + Accessible.name: qsTr("Document canvas pane") + } + } +} diff --git a/LoupeEditor/qml/InspectorPane.qml b/LoupeEditor/qml/InspectorPane.qml index 40aff5478..594f0cc17 100644 --- a/LoupeEditor/qml/InspectorPane.qml +++ b/LoupeEditor/qml/InspectorPane.qml @@ -7,6 +7,7 @@ Pane { property var host: editorHost property var inspectorModel: host ? host.inspector : null + property var documentModel: host ? host.documentModel : null padding: 8 @@ -62,5 +63,32 @@ Pane { text: host ? qsTr("Preview: %1").arg(host.previewSummary) : "" Accessible.name: qsTr("Production preview status") } + + GroupBox { + Layout.fillWidth: true + title: qsTr("Document properties") + visible: host && host.hasDocument + + ColumnLayout { + anchors.fill: parent + spacing: 4 + + Label { + text: qsTr("Title: %1").arg(root.documentModel ? root.documentModel.title : "") + } + Label { + text: qsTr("Author: %1").arg(root.documentModel ? root.documentModel.author : "") + } + Label { + text: qsTr("PDF version: %1").arg(root.documentModel ? root.documentModel.version : "") + } + Label { + text: qsTr("Attachments: %1").arg(root.documentModel && root.documentModel.hasAttachments ? qsTr("present") : qsTr("none")) + } + Label { + text: qsTr("Optional content: %1").arg(root.documentModel && root.documentModel.hasOptionalContent ? qsTr("present") : qsTr("none")) + } + } + } } } diff --git a/LoupeEditor/qml/Main.qml b/LoupeEditor/qml/Main.qml index b9bca60ba..09d610d3e 100644 --- a/LoupeEditor/qml/Main.qml +++ b/LoupeEditor/qml/Main.qml @@ -66,6 +66,9 @@ ApplicationWindow { } else { window.title = qsTr("Loupe") } + if (host) { + window.visibility = host.fullscreenRequested ? Window.FullScreen : Window.Windowed + } } } @@ -147,6 +150,13 @@ ApplicationWindow { Menu { title: qsTr("&View") + Action { + text: qsTr("&Find…") + enabled: commandEnabled("actionFind") + shortcut: shortcutSequence(commandMap["actionFind"]) + onTriggered: invoke("actionFind") + } + MenuSeparator {} Action { text: qsTr("Zoom &In") enabled: commandEnabled("actionZoom_In") @@ -190,6 +200,42 @@ ApplicationWindow { shortcut: shortcutSequence(commandMap["actionRotateRight"]) onTriggered: invoke("actionRotateRight") } + MenuSeparator {} + Action { + text: qsTr("Continuous Layout") + enabled: commandEnabled("actionPageLayoutContinuous") + onTriggered: invoke("actionPageLayoutContinuous") + } + Action { + text: qsTr("Single Page Layout") + enabled: commandEnabled("actionPageLayoutSinglePage") + onTriggered: invoke("actionPageLayoutSinglePage") + } + Action { + text: qsTr("Two-Column Layout") + enabled: commandEnabled("actionPageLayoutTwoColumns") + onTriggered: invoke("actionPageLayoutTwoColumns") + } + Action { + text: qsTr("Two-Page Layout") + enabled: commandEnabled("actionPageLayoutTwoPages") + onTriggered: invoke("actionPageLayoutTwoPages") + } + Action { + text: qsTr("Fullscreen") + enabled: commandEnabled("actionFullscreenMode") + shortcut: shortcutSequence(commandMap["actionFullscreenMode"]) + onTriggered: invoke("actionFullscreenMode") + } + } + + Menu { + title: qsTr("&Document") + Action { + text: qsTr("&Properties") + enabled: commandEnabled("actionProperties") + onTriggered: invoke("actionProperties") + } } } diff --git a/LoupeEditor/qml/Workspace.qml b/LoupeEditor/qml/Workspace.qml index c11251166..8ec3da529 100644 --- a/LoupeEditor/qml/Workspace.qml +++ b/LoupeEditor/qml/Workspace.qml @@ -71,10 +71,9 @@ Item { Layout.fillHeight: true currentIndex: 0 - CanvasPane { - id: canvasPane + DocumentPane { + id: documentPane host: root.host - Accessible.name: qsTr("Document canvas pane") } PreflightPane { @@ -87,5 +86,15 @@ Item { } } - KeyNavigation.tab: canvasPane.canvasItem + KeyNavigation.tab: documentPane + + Connections { + target: root.host + function onPresentationChanged() { + if (root.host && root.host.workspaceRequest >= 0) { + workspaceStack.currentIndex = root.host.workspaceRequest + root.host.acknowledgeWorkspaceRequest() + } + } + } } diff --git a/LoupeEditor/quickdocumentmodel.cpp b/LoupeEditor/quickdocumentmodel.cpp new file mode 100644 index 000000000..bb0218033 --- /dev/null +++ b/LoupeEditor/quickdocumentmodel.cpp @@ -0,0 +1,323 @@ +// MIT License +#include "quickdocumentmodel.h" + +#include "pdfcatalog.h" +#include "pdfdocument.h" +#include "pdfdocumentcontext.h" +#include "pdfdocumentsession.h" +#include "pdfmeshqualitysettings.h" +#include "pdfoutline.h" +#include "pdfpage.h" +#include "pdftextlayout.h" +#include "pdftextlayoutgenerator.h" +#include "pdfutils.h" + +#include + +QuickPageModel::QuickPageModel(QObject* parent) : QAbstractListModel(parent) {} + +int QuickPageModel::rowCount(const QModelIndex& parent) const +{ + if (parent.isValid()) + return 0; + return m_pages.size(); +} + +QVariant QuickPageModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_pages.size()) + return {}; + + const auto& page = m_pages.at(index.row()); + switch (role) + { + case Qt::DisplayRole: + case PageNumberRole: + return index.row() + 1; + case WidthRole: + return page.width; + case HeightRole: + return page.height; + case RotationRole: + return page.rotation; + case LabelRole: + return QString::number(index.row() + 1); + } + return {}; +} + +QHash QuickPageModel::roleNames() const +{ + return {{PageNumberRole, "pageNumber"}, {WidthRole, "pageWidth"}, {HeightRole, "pageHeight"}, + {RotationRole, "pageRotation"}, {LabelRole, "label"}}; +} + +void QuickPageModel::replace(const pdf::PDFDocument* document) +{ + beginResetModel(); + m_pages.clear(); + if (document) + { + const pdf::PDFCatalog* catalog = document->getCatalog(); + m_pages.reserve(static_cast(catalog->getPageCount())); + for (size_t i = 0; i < catalog->getPageCount(); ++i) + { + const pdf::PDFPage* page = catalog->getPage(i); + m_pages.append(Page{page->getCropBox().width(), page->getCropBox().height(), + static_cast(page->getPageRotation()), static_cast(i)}); + } + } + endResetModel(); +} + +void QuickPageModel::clear() +{ + replace(nullptr); +} + +QuickOutlineModel::QuickOutlineModel(QObject* parent) : QAbstractItemModel(parent), m_root(std::make_unique()) {} +QuickOutlineModel::~QuickOutlineModel() = default; + +QuickOutlineModel::Node* QuickOutlineModel::nodeForIndex(const QModelIndex& index) const +{ + return index.isValid() ? static_cast(index.internalPointer()) : m_root.get(); +} + +QModelIndex QuickOutlineModel::indexForNode(Node* node) const +{ + if (!node || node == m_root.get() || !node->parent) + return {}; + for (int row = 0; row < static_cast(node->parent->children.size()); ++row) + { + if (node->parent->children.at(row).get() == node) + return createIndex(row, 0, node); + } + return {}; +} + +QModelIndex QuickOutlineModel::index(int row, int column, const QModelIndex& parent) const +{ + if (column != 0 || row < 0) + return {}; + Node* parentNode = nodeForIndex(parent); + if (!parentNode || row >= static_cast(parentNode->children.size())) + return {}; + return createIndex(row, column, parentNode->children.at(static_cast(row)).get()); +} + +QModelIndex QuickOutlineModel::parent(const QModelIndex& child) const +{ + if (!child.isValid()) + return {}; + return indexForNode(static_cast(child.internalPointer())->parent); +} + +int QuickOutlineModel::rowCount(const QModelIndex& parent) const +{ + return static_cast(nodeForIndex(parent)->children.size()); +} + +int QuickOutlineModel::columnCount(const QModelIndex&) const +{ + return 1; +} + +QVariant QuickOutlineModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid()) + return {}; + const Node* node = static_cast(index.internalPointer()); + if (!node->item) + return {}; + if (role == Qt::DisplayRole || role == TitleRole) + return node->item->getTitle(); + if (role == HasChildrenRole) + return !node->children.empty(); + return {}; +} + +QHash QuickOutlineModel::roleNames() const +{ + return {{TitleRole, "title"}, {HasChildrenRole, "hasChildren"}}; +} + +void QuickOutlineModel::build(Node* parent, const pdf::PDFOutlineItem* item) +{ + if (!item) + return; + for (size_t i = 0; i < item->getChildCount(); ++i) + { + auto child = std::make_unique(); + child->item = item->getChild(i); + child->parent = parent; + Node* childNode = child.get(); + parent->children.push_back(std::move(child)); + build(childNode, childNode->item); + } +} + +void QuickOutlineModel::replace(const pdf::PDFDocument* document) +{ + beginResetModel(); + m_root = std::make_unique(); + if (document) + build(m_root.get(), document->getCatalog()->getOutlineRootPtr().data()); + endResetModel(); +} + +void QuickOutlineModel::clear() +{ + replace(nullptr); +} + +QuickSearchResultModel::QuickSearchResultModel(QObject* parent) : QAbstractListModel(parent) {} + +int QuickSearchResultModel::rowCount(const QModelIndex& parent) const +{ + return parent.isValid() ? 0 : m_results.size(); +} + +QVariant QuickSearchResultModel::data(const QModelIndex& index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= m_results.size()) + return {}; + const Result& result = m_results.at(index.row()); + if (role == Qt::DisplayRole || role == MatchedRole) + return result.matched; + if (role == PageRole) + return result.page; + if (role == ContextRole) + return result.context; + return {}; +} + +QHash QuickSearchResultModel::roleNames() const +{ + return {{PageRole, "page"}, {MatchedRole, "matched"}, {ContextRole, "context"}}; +} + +void QuickSearchResultModel::replace(QList results, QString query, QString revision) +{ + beginResetModel(); + m_results = std::move(results); + m_query = std::move(query); + m_revision = std::move(revision); + endResetModel(); +} + +void QuickSearchResultModel::clear() +{ + replace({}, {}, {}); +} + +QuickDocumentModel::QuickDocumentModel(QObject* parent) : QObject(parent), m_pages(this), m_outline(this), m_searchResults(this) {} + +void QuickDocumentModel::setDocument(pdf::PDFDocumentContext* context) +{ + m_context = context; + m_session = context ? context->getSession() : nullptr; + const pdf::PDFDocument* document = context ? context->getDocument() : nullptr; + + if (!document || !m_session) + { + clear(); + return; + } + + m_pages.replace(document); + m_outline.replace(document); + const pdf::PDFDocumentInfo* info = document->getInfo(); + const pdf::PDFCatalog* catalog = document->getCatalog(); + m_title = info->title; + m_author = info->author; + m_subject = info->subject; + m_creator = info->creator; + m_producer = info->producer; + m_version = QString::fromLatin1(document->getVersion()); + m_revision = context->getRevision().toString(); + m_hasOutline = catalog->getOutlineRootPtr() && catalog->getOutlineRootPtr()->getChildCount() > 0; + m_hasAttachments = !catalog->getEmbeddedFiles().empty(); + m_hasOptionalContent = !catalog->getOptionalContentProperties()->getAllOptionalContentGroups().empty(); + m_searchResults.clear(); + Q_EMIT changed(); + Q_EMIT searchChanged(); +} + +void QuickDocumentModel::clear() +{ + m_pages.clear(); + m_outline.clear(); + m_searchResults.clear(); + m_context = nullptr; + m_session = nullptr; + m_title.clear(); + m_author.clear(); + m_subject.clear(); + m_creator.clear(); + m_producer.clear(); + m_version.clear(); + m_revision.clear(); + m_hasOutline = false; + m_hasAttachments = false; + m_hasOptionalContent = false; + Q_EMIT changed(); + Q_EMIT searchChanged(); +} + +bool QuickDocumentModel::search(const QString& query) +{ + if (!m_context || !m_session || query.trimmed().isEmpty()) + { + clearSearch(); + return false; + } + + const pdf::PDFRevisionIdentity revision = m_context->getRevision(); + const pdf::PDFDocument* document = m_context->getDocument(); + if (!document) + { + clearSearch(); + return false; + } + + QList results; + const pdf::PDFMeshQualitySettings meshQuality; + const pdf::PDFRenderer::Features features = pdf::PDFRenderer::IgnoreOptionalContent; + const pdf::PDFCatalog* catalog = document->getCatalog(); + for (size_t pageIndex = 0; pageIndex < catalog->getPageCount(); ++pageIndex) + { + const pdf::PDFPage* page = catalog->getPage(pageIndex); + pdf::PDFTextLayoutGenerator generator(features, page, document, + m_session->getFontCache(), m_session->getCMS(), + m_session->getOptionalContentActivity(), QTransform(), meshQuality, + m_session->getProcessingBudget()); + generator.processContents(); + const pdf::PDFTextLayout layout = generator.createTextLayout(); + const pdf::PDFTextFlows flows = pdf::PDFTextFlow::createTextFlows( + layout, pdf::PDFTextFlow::RemoveSoftHyphen | pdf::PDFTextFlow::AddLineBreaks, + static_cast(pageIndex)); + for (const pdf::PDFTextFlow& flow : flows) + { + for (const pdf::PDFFindResult& match : flow.find(query, Qt::CaseInsensitive)) + results.append({static_cast(pageIndex), match.matched, match.context}); + } + } + + if (!m_context->isCurrent(revision)) + return false; + m_searchResults.replace(std::move(results), query, revision.toString()); + Q_EMIT searchChanged(); + return true; +} + +void QuickDocumentModel::clearSearch() +{ + m_searchResults.clear(); + Q_EMIT searchChanged(); +} + +int QuickDocumentModel::searchPageAt(int row) const +{ + const QModelIndex index = m_searchResults.index(row, 0); + return index.isValid() ? m_searchResults.data(index, QuickSearchResultModel::PageRole).toInt() : -1; +} diff --git a/LoupeEditor/quickdocumentmodel.h b/LoupeEditor/quickdocumentmodel.h new file mode 100644 index 000000000..17aec88fd --- /dev/null +++ b/LoupeEditor/quickdocumentmodel.h @@ -0,0 +1,203 @@ +// MIT License +#ifndef QUICKDOCUMENTMODEL_H +#define QUICKDOCUMENTMODEL_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace pdf +{ +class PDFDocumentContext; +class PDFDocumentSession; +class PDFOutlineItem; +class PDFDocument; +} + +class QuickPageModel final : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Role + { + PageNumberRole = Qt::UserRole + 1, + WidthRole, + HeightRole, + RotationRole, + LabelRole, + }; + + explicit QuickPageModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replace(const pdf::PDFDocument* document); + void clear(); + +private: + struct Page + { + qreal width = 0.0; + qreal height = 0.0; + int rotation = 0; + int index = -1; + }; + + QList m_pages; +}; + +class QuickOutlineModel final : public QAbstractItemModel +{ + Q_OBJECT + +public: + enum Role + { + TitleRole = Qt::UserRole + 1, + HasChildrenRole, + }; + + explicit QuickOutlineModel(QObject* parent = nullptr); + ~QuickOutlineModel() override; + + QModelIndex index(int row, int column, const QModelIndex& parent = {}) const override; + QModelIndex parent(const QModelIndex& child) const override; + int rowCount(const QModelIndex& parent = {}) const override; + int columnCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replace(const pdf::PDFDocument* document); + void clear(); + +private: + struct Node + { + const pdf::PDFOutlineItem* item = nullptr; + Node* parent = nullptr; + std::vector> children; + }; + + void build(Node* parent, const pdf::PDFOutlineItem* item); + Node* nodeForIndex(const QModelIndex& index) const; + QModelIndex indexForNode(Node* node) const; + + std::unique_ptr m_root; +}; + +class QuickSearchResultModel final : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Role + { + PageRole = Qt::UserRole + 1, + MatchedRole, + ContextRole, + }; + + struct Result + { + int page = -1; + QString matched; + QString context; + }; + + explicit QuickSearchResultModel(QObject* parent = nullptr); + + int rowCount(const QModelIndex& parent = {}) const override; + QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + + void replace(QList results, QString query, QString revision); + void clear(); + QString query() const noexcept { return m_query; } + QString revision() const noexcept { return m_revision; } + +private: + QList m_results; + QString m_query; + QString m_revision; +}; + +class QuickDocumentModel final : public QObject +{ + Q_OBJECT + Q_PROPERTY(QAbstractItemModel* pages READ pages CONSTANT) + Q_PROPERTY(QAbstractItemModel* outline READ outline CONSTANT) + Q_PROPERTY(QAbstractItemModel* searchResults READ searchResults CONSTANT) + Q_PROPERTY(QString title READ title NOTIFY changed) + Q_PROPERTY(QString author READ author NOTIFY changed) + Q_PROPERTY(QString subject READ subject NOTIFY changed) + Q_PROPERTY(QString creator READ creator NOTIFY changed) + Q_PROPERTY(QString producer READ producer NOTIFY changed) + Q_PROPERTY(QString version READ version NOTIFY changed) + Q_PROPERTY(QString revision READ revision NOTIFY changed) + Q_PROPERTY(bool hasOutline READ hasOutline NOTIFY changed) + Q_PROPERTY(bool hasAttachments READ hasAttachments NOTIFY changed) + Q_PROPERTY(bool hasOptionalContent READ hasOptionalContent NOTIFY changed) + Q_PROPERTY(int searchResultCount READ searchResultCount NOTIFY searchChanged) + +public: + explicit QuickDocumentModel(QObject* parent = nullptr); + + QAbstractItemModel* pages() noexcept { return &m_pages; } + QAbstractItemModel* outline() noexcept { return &m_outline; } + QAbstractItemModel* searchResults() noexcept { return &m_searchResults; } + + QString title() const { return m_title; } + QString author() const { return m_author; } + QString subject() const { return m_subject; } + QString creator() const { return m_creator; } + QString producer() const { return m_producer; } + QString version() const { return m_version; } + QString revision() const { return m_revision; } + bool hasOutline() const noexcept { return m_hasOutline; } + bool hasAttachments() const noexcept { return m_hasAttachments; } + bool hasOptionalContent() const noexcept { return m_hasOptionalContent; } + int searchResultCount() const noexcept { return m_searchResults.rowCount(); } + + void setDocument(pdf::PDFDocumentContext* context); + void clear(); + + /// Performs a Core text search against a captured document revision. The + /// result is admitted only if the context still owns that revision. + Q_INVOKABLE bool search(const QString& query); + Q_INVOKABLE void clearSearch(); + Q_INVOKABLE int searchPageAt(int row) const; + +signals: + void changed(); + void searchChanged(); + +private: + QuickPageModel m_pages; + QuickOutlineModel m_outline; + QuickSearchResultModel m_searchResults; + pdf::PDFDocumentContext* m_context = nullptr; + pdf::PDFDocumentSession* m_session = nullptr; + QString m_title; + QString m_author; + QString m_subject; + QString m_creator; + QString m_producer; + QString m_version; + QString m_revision; + bool m_hasOutline = false; + bool m_hasAttachments = false; + bool m_hasOptionalContent = false; +}; + +#endif diff --git a/UnitTests/phase4-tests.cmake b/UnitTests/phase4-tests.cmake index 294048b87..13cacde41 100644 --- a/UnitTests/phase4-tests.cmake +++ b/UnitTests/phase4-tests.cmake @@ -214,6 +214,21 @@ if(NOT LOUPE_BUILD_ONLY_CORE_LIBRARY) add_test(UnitTestsP4S9Interaction "${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_BIN_DIR}/UnitTestsP4S9Interaction") if(LOUPE_BUILD_QUICK_CANVAS) + add_executable(UnitTestsQuickDocumentModel + tst_quickdocumentmodeltest.cpp + ) + + target_link_libraries(UnitTestsQuickDocumentModel PRIVATE LoupeEditorQuick LoupeLibCore Qt6::Core Qt6::Gui Qt6::Qml Qt6::Quick Qt6::Test) + + set_target_properties(UnitTestsQuickDocumentModel PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_BIN_DIR} + ) + add_test(UnitTestsQuickDocumentModel "${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_BIN_DIR}/UnitTestsQuickDocumentModel") + set_tests_properties(UnitTestsQuickDocumentModel PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + add_executable(UnitTestsQuickAccessibility tst_quickaccessibilitytest.cpp ) diff --git a/UnitTests/tst_quickdocumentmodeltest.cpp b/UnitTests/tst_quickdocumentmodeltest.cpp new file mode 100644 index 000000000..cd8d276b5 --- /dev/null +++ b/UnitTests/tst_quickdocumentmodeltest.cpp @@ -0,0 +1,48 @@ +// MIT License +#include "quickdocumentmodel.h" + +#include +#include + +class QuickDocumentModelTest final : public QObject +{ + Q_OBJECT + +private slots: + void emptyModelIsSafe(); + void searchResultsExposeOnlyValueRoles(); +}; + +void QuickDocumentModelTest::emptyModelIsSafe() +{ + QuickDocumentModel model; + + QCOMPARE(model.pages()->rowCount(), 0); + QCOMPARE(model.outline()->rowCount(), 0); + QCOMPARE(model.searchResults()->rowCount(), 0); + QVERIFY(!model.hasOutline()); + QVERIFY(!model.hasAttachments()); + QVERIFY(!model.hasOptionalContent()); + QVERIFY(!model.search(QStringLiteral("text"))); +} + +void QuickDocumentModelTest::searchResultsExposeOnlyValueRoles() +{ + QuickSearchResultModel model; + QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); + + model.replace({{3, QStringLiteral("match"), QStringLiteral("before match after")}}, + QStringLiteral("match"), QStringLiteral("revision")); + + QCOMPARE(resetSpy.count(), 1); + QCOMPARE(model.rowCount(), 1); + const QModelIndex index = model.index(0, 0); + QCOMPARE(model.data(index, QuickSearchResultModel::PageRole).toInt(), 3); + QCOMPARE(model.data(index, QuickSearchResultModel::MatchedRole).toString(), QStringLiteral("match")); + QCOMPARE(model.data(index, QuickSearchResultModel::ContextRole).toString(), QStringLiteral("before match after")); + QCOMPARE(model.query(), QStringLiteral("match")); + QCOMPARE(model.revision(), QStringLiteral("revision")); +} + +QTEST_GUILESS_MAIN(QuickDocumentModelTest) +#include "tst_quickdocumentmodeltest.moc" diff --git a/changes/cdx-quick-pdf4qt-core-parity.md b/changes/cdx-quick-pdf4qt-core-parity.md new file mode 100644 index 000000000..38d08a60a --- /dev/null +++ b/changes/cdx-quick-pdf4qt-core-parity.md @@ -0,0 +1,4 @@ +Category: added +Audience: developers, users +Breaking-Change: no +Summary: Add the first Quick PDF4QT parity slice: revision-bound page, outline, search, and property models; Document workspace navigation and search; layout, fullscreen, and properties catalog handlers; and focused contract tests. Remaining annotation, forms, security, print, and export workflows stay declared or policy-excluded pending their typed bridges. diff --git a/docs/QUICK_PDF_PARITY.md b/docs/QUICK_PDF_PARITY.md new file mode 100644 index 000000000..1e1181793 --- /dev/null +++ b/docs/QUICK_PDF_PARITY.md @@ -0,0 +1,25 @@ +# Quick PDF parity + +The Quick shell owns the interactive PDF surface; `LoupeLibCore` remains the +owner of PDF objects, document revisions, and persistence. The first parity +slice is intentionally model-driven: + +- `QuickDocumentModel` exposes immutable page, outline, properties, attachment + presence, optional-content presence, and revision values to QML. +- `QuickSearchResultModel` admits Core text-search results only when the + captured `PDFRevisionIdentity` is still current. +- `DocumentPane.qml` provides pages, outline, search, next/previous result + navigation, and the existing canvas in one Document workspace. +- Layout, fullscreen, find, and properties use the existing + `CommandCatalog`; there is no QML action registry. + +The following remain deliberately declared or policy-excluded until their +typed bridge and revision-fenced tests land: annotation/form overlays and +editing, attachments and metadata editing, print/export, undo/redo, password +and encryption workflows, sanitization, optimization, signature verification, +OCR, PageMaster, Compare, Redaction, signature creation, and deep inspection. + +Search currently runs through the Core model on the host thread. It is a +functional read-only bridge, but its next hardening step is to submit the same +snapshot computation through `PDFJobScheduler` and admit the value on the +owner thread, matching the renderer and preflight paths. diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index 96c52f23a..6c4af6044 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -434,6 +434,7 @@ "UnitTestsProfileIdentity", "UnitTestsQuickAccessibility", "UnitTestsQuickCanvas", + "UnitTestsQuickDocumentModel", "UnitTestsRedactVerifier", "UnitTestsRepairDiff", "UnitTestsRepairOperation", diff --git a/docs/loupe-shell-actions.json b/docs/loupe-shell-actions.json index 07ec9e40b..0186d2886 100644 --- a/docs/loupe-shell-actions.json +++ b/docs/loupe-shell-actions.json @@ -536,9 +536,9 @@ "standard_key": "Find" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -551,9 +551,9 @@ "standard_key": "FindNext" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -566,9 +566,9 @@ "standard_key": "FindPrevious" }, "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -629,9 +629,9 @@ "sequence": "Ctrl+L" }, "parameters": [], - "capability": "unclassified", + "capability": "application", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -881,9 +881,9 @@ "command": { "label_key": "command.actionPageLayoutContinuous.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -893,9 +893,9 @@ "command": { "label_key": "command.actionPageLayoutSinglePage.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -905,9 +905,9 @@ "command": { "label_key": "command.actionPageLayoutTwoColumns.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -917,9 +917,9 @@ "command": { "label_key": "command.actionPageLayoutTwoPages.label", "parameters": [], - "capability": "unclassified", + "capability": "none", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { @@ -944,9 +944,9 @@ "command": { "label_key": "command.actionProperties.label", "parameters": [], - "capability": "unclassified", + "capability": "document.read", "cancellable": false, - "availability": "declared" + "availability": "implemented" } }, { diff --git a/scripts/verify-command-catalog.py b/scripts/verify-command-catalog.py index c9d1f6c97..2edd849ec 100644 --- a/scripts/verify-command-catalog.py +++ b/scripts/verify-command-catalog.py @@ -59,6 +59,15 @@ SHELL_IMPLEMENTED_COMMANDS = frozenset( { "actionQuit", + "actionFind", + "actionFindNext", + "actionFindPrevious", + "actionFullscreenMode", + "actionPageLayoutContinuous", + "actionPageLayoutSinglePage", + "actionPageLayoutTwoColumns", + "actionPageLayoutTwoPages", + "actionProperties", } ) @@ -384,7 +393,7 @@ def verify() -> str: raise ContractError("\n".join(f" - {error}" for error in errors)) policy = load_policy(POLICY_PATH) - implemented = len(IMPLEMENTED_COMMANDS) + implemented = len(IMPLEMENTED_COMMANDS | SHELL_IMPLEMENTED_COMMANDS) if MAIN_WINDOW_PATH.is_file() and CONTROLLER_PATH.is_file(): shortcuts = widget_shortcuts(read_source(CONTROLLER_PATH), CONTROLLER_PATH) shortcut_note = ( From e97eff9c67fa27d7e3e2a4aa823bc3b7a3ef888a Mon Sep 17 00:00:00 2001 From: mbx30 Date: Mon, 31 Aug 2026 23:34:46 -0700 Subject: [PATCH 09/33] Expose competitor document capability state --- LoupeEditor/editorhost.cpp | 30 +++++++++++- LoupeEditor/editorhost.h | 1 + LoupeEditor/qml/InspectorPane.qml | 12 +++++ LoupeEditor/quickdocumentmodel.cpp | 57 +++++++++++++++++++++++ LoupeEditor/quickdocumentmodel.h | 59 ++++++++++++++++++++++++ UnitTests/tst_quickdocumentmodeltest.cpp | 56 ++++++++++++++++++++++ changes/cdx-quick-pdf4qt-core-parity.md | 2 +- docs/QUICK_PDF_PARITY.md | 7 +-- 8 files changed, 219 insertions(+), 5 deletions(-) diff --git a/LoupeEditor/editorhost.cpp b/LoupeEditor/editorhost.cpp index b9d3523e0..d856f3c3f 100644 --- a/LoupeEditor/editorhost.cpp +++ b/LoupeEditor/editorhost.cpp @@ -540,6 +540,7 @@ void EditorHost::connectFacade() connect(&m_session->facade(), &pdfinteraction::DocumentFacade::stateChanged, this, [this](pdfinteraction::DocumentState state) { + syncDocumentLifecycle(); if (state == pdfinteraction::DocumentState::Empty || state == pdfinteraction::DocumentState::Error) { onDocumentGone(); @@ -550,7 +551,10 @@ void EditorHost::connectFacade() bumpCommandEpoch(); }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::facetsChanged, this, [this](pdfinteraction::DocumentFacets) - { bumpPresentation(); }); + { + syncDocumentLifecycle(); + bumpPresentation(); + }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::documentReplaced, this, [this](quint64) { @@ -704,6 +708,7 @@ void EditorHost::onDocumentReady() syncRevisionModels(); m_documentModel.setDocument(&m_session->context()); + syncDocumentLifecycle(); m_searchRow = -1; refreshHitTestSources(); m_documentBound = true; @@ -712,6 +717,29 @@ void EditorHost::onDocumentReady() announceDocumentState(tr("Document ready.")); } +void EditorHost::syncDocumentLifecycle() +{ + const auto& facade = m_session->facade(); + QString outputState; + switch (facade.outputState()) + { + case pdfinteraction::DocumentOutputState::None: + outputState = QStringLiteral("none"); + break; + case pdfinteraction::DocumentOutputState::Pending: + outputState = QStringLiteral("pending"); + break; + case pdfinteraction::DocumentOutputState::Saved: + outputState = QStringLiteral("saved"); + break; + } + + m_documentModel.setLifecycleState(QString::fromLatin1(pdfinteraction::getDocumentStateName(facade.state())), + facade.facets().testFlag(pdfinteraction::DocumentFacet::Dirty), + facade.facets().testFlag(pdfinteraction::DocumentFacet::Stale), + std::move(outputState), facade.typedError()); +} + void EditorHost::onDocumentGone() { unbindCanvas(); diff --git a/LoupeEditor/editorhost.h b/LoupeEditor/editorhost.h index 1375a4b1e..0695e8d6c 100644 --- a/LoupeEditor/editorhost.h +++ b/LoupeEditor/editorhost.h @@ -213,6 +213,7 @@ class EditorHost final : public QObject void onDocumentReady(); void onDocumentGone(); + void syncDocumentLifecycle(); void bindCanvas(); void unbindCanvas(); void syncRevisionModels(); diff --git a/LoupeEditor/qml/InspectorPane.qml b/LoupeEditor/qml/InspectorPane.qml index 594f0cc17..356cd19c0 100644 --- a/LoupeEditor/qml/InspectorPane.qml +++ b/LoupeEditor/qml/InspectorPane.qml @@ -88,6 +88,18 @@ Pane { Label { text: qsTr("Optional content: %1").arg(root.documentModel && root.documentModel.hasOptionalContent ? qsTr("present") : qsTr("none")) } + Label { + text: qsTr("Lifecycle: %1").arg(root.documentModel ? root.documentModel.lifecycleState : "") + } + Label { + text: qsTr("Document state: %1").arg(root.documentModel && root.documentModel.modified ? qsTr("modified") : qsTr("unchanged")) + } + Label { + text: qsTr("Security: %1").arg(root.documentModel && root.documentModel.encrypted ? qsTr("encrypted") : qsTr("not encrypted")) + } + Label { + text: qsTr("Permissions: %1").arg(root.documentModel && root.documentModel.canPrint ? qsTr("printing allowed") : qsTr("printing restricted")) + } } } } diff --git a/LoupeEditor/quickdocumentmodel.cpp b/LoupeEditor/quickdocumentmodel.cpp index bb0218033..4ffbaf698 100644 --- a/LoupeEditor/quickdocumentmodel.cpp +++ b/LoupeEditor/quickdocumentmodel.cpp @@ -238,11 +238,50 @@ void QuickDocumentModel::setDocument(pdf::PDFDocumentContext* context) m_hasOutline = catalog->getOutlineRootPtr() && catalog->getOutlineRootPtr()->getChildCount() > 0; m_hasAttachments = !catalog->getEmbeddedFiles().empty(); m_hasOptionalContent = !catalog->getOptionalContentProperties()->getAllOptionalContentGroups().empty(); + m_hasForm = catalog->getFormObject().isValid(); + m_hasLogicalStructure = catalog->isLogicalStructureMarked(); + + const pdf::PDFSecurityHandler* security = document->getStorage().getSecurityHandler(); + m_encrypted = security && security->getMode() != pdf::EncryptionMode::None; + m_canPrint = security && (security->isAllowed(pdf::PDFSecurityHandler::Permission::PrintLowResolution) || + security->isAllowed(pdf::PDFSecurityHandler::Permission::PrintHighResolution)); + m_canHighResolutionPrint = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::PrintHighResolution); + m_canCopy = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::CopyContent); + m_canModify = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::Modify); + m_canComment = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::ModifyInteractiveItems); + m_canFillForms = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::ModifyFormFields); + m_canAssemble = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::Assemble); + m_canAccessibility = security && security->isAllowed(pdf::PDFSecurityHandler::Permission::Accessibility); m_searchResults.clear(); Q_EMIT changed(); Q_EMIT searchChanged(); } +void QuickDocumentModel::setLifecycleState(QString state, + bool modified, + bool stale, + QString outputState, + QString typedError) +{ + const bool stateChanged = m_lifecycleState != state || m_modified != modified || m_stale != stale || + m_outputState != outputState || m_typedError != typedError || + m_outputPending != (outputState == QStringLiteral("pending")) || + m_outputSaved != (outputState == QStringLiteral("saved")); + if (!stateChanged) + { + return; + } + + m_lifecycleState = std::move(state); + m_modified = modified; + m_stale = stale; + m_outputState = std::move(outputState); + m_typedError = std::move(typedError); + m_outputPending = m_outputState == QStringLiteral("pending"); + m_outputSaved = m_outputState == QStringLiteral("saved"); + Q_EMIT changed(); +} + void QuickDocumentModel::clear() { m_pages.clear(); @@ -260,6 +299,24 @@ void QuickDocumentModel::clear() m_hasOutline = false; m_hasAttachments = false; m_hasOptionalContent = false; + m_hasForm = false; + m_hasLogicalStructure = false; + m_encrypted = false; + m_canPrint = false; + m_canHighResolutionPrint = false; + m_canCopy = false; + m_canModify = false; + m_canComment = false; + m_canFillForms = false; + m_canAssemble = false; + m_canAccessibility = false; + m_modified = false; + m_stale = false; + m_outputPending = false; + m_outputSaved = false; + m_lifecycleState.clear(); + m_outputState.clear(); + m_typedError.clear(); Q_EMIT changed(); Q_EMIT searchChanged(); } diff --git a/LoupeEditor/quickdocumentmodel.h b/LoupeEditor/quickdocumentmodel.h index 17aec88fd..b19e99624 100644 --- a/LoupeEditor/quickdocumentmodel.h +++ b/LoupeEditor/quickdocumentmodel.h @@ -148,6 +148,24 @@ class QuickDocumentModel final : public QObject Q_PROPERTY(bool hasOutline READ hasOutline NOTIFY changed) Q_PROPERTY(bool hasAttachments READ hasAttachments NOTIFY changed) Q_PROPERTY(bool hasOptionalContent READ hasOptionalContent NOTIFY changed) + Q_PROPERTY(bool hasForm READ hasForm NOTIFY changed) + Q_PROPERTY(bool hasLogicalStructure READ hasLogicalStructure NOTIFY changed) + Q_PROPERTY(bool encrypted READ encrypted NOTIFY changed) + Q_PROPERTY(bool canPrint READ canPrint NOTIFY changed) + Q_PROPERTY(bool canHighResolutionPrint READ canHighResolutionPrint NOTIFY changed) + Q_PROPERTY(bool canCopy READ canCopy NOTIFY changed) + Q_PROPERTY(bool canModify READ canModify NOTIFY changed) + Q_PROPERTY(bool canComment READ canComment NOTIFY changed) + Q_PROPERTY(bool canFillForms READ canFillForms NOTIFY changed) + Q_PROPERTY(bool canAssemble READ canAssemble NOTIFY changed) + Q_PROPERTY(bool canAccessibility READ canAccessibility NOTIFY changed) + Q_PROPERTY(bool modified READ modified NOTIFY changed) + Q_PROPERTY(bool stale READ stale NOTIFY changed) + Q_PROPERTY(bool outputPending READ outputPending NOTIFY changed) + Q_PROPERTY(bool outputSaved READ outputSaved NOTIFY changed) + Q_PROPERTY(QString lifecycleState READ lifecycleState NOTIFY changed) + Q_PROPERTY(QString outputState READ outputState NOTIFY changed) + Q_PROPERTY(QString typedError READ typedError NOTIFY changed) Q_PROPERTY(int searchResultCount READ searchResultCount NOTIFY searchChanged) public: @@ -167,9 +185,32 @@ class QuickDocumentModel final : public QObject bool hasOutline() const noexcept { return m_hasOutline; } bool hasAttachments() const noexcept { return m_hasAttachments; } bool hasOptionalContent() const noexcept { return m_hasOptionalContent; } + bool hasForm() const noexcept { return m_hasForm; } + bool hasLogicalStructure() const noexcept { return m_hasLogicalStructure; } + bool encrypted() const noexcept { return m_encrypted; } + bool canPrint() const noexcept { return m_canPrint; } + bool canHighResolutionPrint() const noexcept { return m_canHighResolutionPrint; } + bool canCopy() const noexcept { return m_canCopy; } + bool canModify() const noexcept { return m_canModify; } + bool canComment() const noexcept { return m_canComment; } + bool canFillForms() const noexcept { return m_canFillForms; } + bool canAssemble() const noexcept { return m_canAssemble; } + bool canAccessibility() const noexcept { return m_canAccessibility; } + bool modified() const noexcept { return m_modified; } + bool stale() const noexcept { return m_stale; } + bool outputPending() const noexcept { return m_outputPending; } + bool outputSaved() const noexcept { return m_outputSaved; } + QString lifecycleState() const { return m_lifecycleState; } + QString outputState() const { return m_outputState; } + QString typedError() const { return m_typedError; } int searchResultCount() const noexcept { return m_searchResults.rowCount(); } void setDocument(pdf::PDFDocumentContext* context); + void setLifecycleState(QString state, + bool modified, + bool stale, + QString outputState, + QString typedError); void clear(); /// Performs a Core text search against a captured document revision. The @@ -198,6 +239,24 @@ class QuickDocumentModel final : public QObject bool m_hasOutline = false; bool m_hasAttachments = false; bool m_hasOptionalContent = false; + bool m_hasForm = false; + bool m_hasLogicalStructure = false; + bool m_encrypted = false; + bool m_canPrint = false; + bool m_canHighResolutionPrint = false; + bool m_canCopy = false; + bool m_canModify = false; + bool m_canComment = false; + bool m_canFillForms = false; + bool m_canAssemble = false; + bool m_canAccessibility = false; + bool m_modified = false; + bool m_stale = false; + bool m_outputPending = false; + bool m_outputSaved = false; + QString m_lifecycleState; + QString m_outputState; + QString m_typedError; }; #endif diff --git a/UnitTests/tst_quickdocumentmodeltest.cpp b/UnitTests/tst_quickdocumentmodeltest.cpp index cd8d276b5..db386e332 100644 --- a/UnitTests/tst_quickdocumentmodeltest.cpp +++ b/UnitTests/tst_quickdocumentmodeltest.cpp @@ -1,6 +1,9 @@ // MIT License #include "quickdocumentmodel.h" +#include "pdfdocumentbuilder.h" +#include "pdfdocumentcontext.h" + #include #include @@ -11,6 +14,8 @@ class QuickDocumentModelTest final : public QObject private slots: void emptyModelIsSafe(); void searchResultsExposeOnlyValueRoles(); + void documentCapabilitiesAreValueState(); + void lifecycleStateTracksOutputAndErrors(); }; void QuickDocumentModelTest::emptyModelIsSafe() @@ -44,5 +49,56 @@ void QuickDocumentModelTest::searchResultsExposeOnlyValueRoles() QCOMPARE(model.revision(), QStringLiteral("revision")); } +void QuickDocumentModelTest::documentCapabilitiesAreValueState() +{ + pdf::PDFDocumentBuilder builder; + builder.appendPage(QRectF(0, 0, 100, 100)); + pdf::PDFDocumentContext context(pdf::PDFDocumentPointer(new pdf::PDFDocument(builder.build()))); + QuickDocumentModel model; + + QSignalSpy changedSpy(&model, &QuickDocumentModel::changed); + model.setDocument(&context); + + QVERIFY(changedSpy.count() > 0); + QCOMPARE(model.pages()->rowCount(), 1); + QVERIFY(!model.encrypted()); + QVERIFY(model.canPrint()); + QVERIFY(model.canHighResolutionPrint()); + QVERIFY(model.canCopy()); + QVERIFY(model.canModify()); + QVERIFY(model.canComment()); + QVERIFY(model.canFillForms()); + QVERIFY(model.canAssemble()); + QVERIFY(model.canAccessibility()); + QVERIFY(!model.hasForm()); + QVERIFY(!model.modified()); + QCOMPARE(model.revision(), context.getRevision().toString()); +} + +void QuickDocumentModelTest::lifecycleStateTracksOutputAndErrors() +{ + QuickDocumentModel model; + QSignalSpy changedSpy(&model, &QuickDocumentModel::changed); + + model.setLifecycleState(QStringLiteral("ready"), true, false, QStringLiteral("pending"), {}); + QVERIFY(model.modified()); + QVERIFY(!model.stale()); + QVERIFY(model.outputPending()); + QVERIFY(!model.outputSaved()); + QCOMPARE(model.lifecycleState(), QStringLiteral("ready")); + QCOMPARE(model.outputState(), QStringLiteral("pending")); + + model.setLifecycleState(QStringLiteral("ready"), false, false, QStringLiteral("saved"), {}); + QVERIFY(!model.modified()); + QVERIFY(!model.outputPending()); + QVERIFY(model.outputSaved()); + + model.setLifecycleState(QStringLiteral("error"), false, false, QStringLiteral("none"), + QStringLiteral("document/load-failed")); + QCOMPARE(model.lifecycleState(), QStringLiteral("error")); + QCOMPARE(model.typedError(), QStringLiteral("document/load-failed")); + QVERIFY(changedSpy.count() >= 3); +} + QTEST_GUILESS_MAIN(QuickDocumentModelTest) #include "tst_quickdocumentmodeltest.moc" diff --git a/changes/cdx-quick-pdf4qt-core-parity.md b/changes/cdx-quick-pdf4qt-core-parity.md index 38d08a60a..bb55f95be 100644 --- a/changes/cdx-quick-pdf4qt-core-parity.md +++ b/changes/cdx-quick-pdf4qt-core-parity.md @@ -1,4 +1,4 @@ Category: added Audience: developers, users Breaking-Change: no -Summary: Add the first Quick PDF4QT parity slice: revision-bound page, outline, search, and property models; Document workspace navigation and search; layout, fullscreen, and properties catalog handlers; and focused contract tests. Remaining annotation, forms, security, print, and export workflows stay declared or policy-excluded pending their typed bridges. +Summary: Add the first Quick competitor-parity slice: revision-bound page, outline, search, property, capability, and lifecycle models; Document workspace navigation and search; layout, fullscreen, and properties catalog handlers; and focused contract tests. Remaining annotation, forms, security, print, and export workflows stay declared or policy-excluded pending their typed bridges. diff --git a/docs/QUICK_PDF_PARITY.md b/docs/QUICK_PDF_PARITY.md index 1e1181793..acf9c4a1b 100644 --- a/docs/QUICK_PDF_PARITY.md +++ b/docs/QUICK_PDF_PARITY.md @@ -1,11 +1,12 @@ -# Quick PDF parity +# Quick competitor parity The Quick shell owns the interactive PDF surface; `LoupeLibCore` remains the owner of PDF objects, document revisions, and persistence. The first parity slice is intentionally model-driven: -- `QuickDocumentModel` exposes immutable page, outline, properties, attachment - presence, optional-content presence, and revision values to QML. +- `QuickDocumentModel` exposes immutable page, outline, properties, capability, + lifecycle, attachment presence, optional-content presence, and revision values + to QML. - `QuickSearchResultModel` admits Core text-search results only when the captured `PDFRevisionIdentity` is still current. - `DocumentPane.qml` provides pages, outline, search, next/previous result From 7326e10d0ee84ab9b3e5c216dc4d469804e06a8b Mon Sep 17 00:00:00 2001 From: michael berry Date: Mon, 31 Aug 2026 23:57:39 -0700 Subject: [PATCH 10/33] Format Quick search changes --- LoupeEditor/editorhost.cpp | 16 ++--- LoupeEditor/qml/DocumentPane.qml | 13 ++++ LoupeEditor/quickdocumentmodel.cpp | 76 +++++++++------------- LoupeLibCore/CMakeLists.txt | 2 + LoupeLibCore/sources/pdfdocumentsearch.cpp | 55 ++++++++++++++++ LoupeLibCore/sources/pdfdocumentsearch.h | 38 +++++++++++ changes/work.md | 4 ++ 7 files changed, 152 insertions(+), 52 deletions(-) create mode 100644 LoupeLibCore/sources/pdfdocumentsearch.cpp create mode 100644 LoupeLibCore/sources/pdfdocumentsearch.h create mode 100644 changes/work.md diff --git a/LoupeEditor/editorhost.cpp b/LoupeEditor/editorhost.cpp index d856f3c3f..cea36c200 100644 --- a/LoupeEditor/editorhost.cpp +++ b/LoupeEditor/editorhost.cpp @@ -136,8 +136,7 @@ EditorHost::EditorHost(QObject* parent) : { refreshFeatureAvailability(); bumpPresentation(); - bumpCommandEpoch(); - }); + bumpCommandEpoch(); }); } EditorHost::~EditorHost() @@ -553,8 +552,7 @@ void EditorHost::connectFacade() connect(&m_session->facade(), &pdfinteraction::DocumentFacade::facetsChanged, this, [this](pdfinteraction::DocumentFacets) { syncDocumentLifecycle(); - bumpPresentation(); - }); + bumpPresentation(); }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::documentReplaced, this, [this](quint64) { @@ -636,7 +634,9 @@ void EditorHost::registerFeatureHandlers() bind(QStringLiteral("actionFullscreenMode"), [this] { m_fullscreenRequested = !m_fullscreenRequested; }); bind(QStringLiteral("actionFind"), [this] - { m_searchPanelVisible = true; }); + { + m_searchPanelVisible = true; + m_workspaceRequest = 0; }); bind(QStringLiteral("actionFindNext"), [this] { moveSearch(1); }); bind(QStringLiteral("actionFindPrevious"), [this] @@ -650,9 +650,9 @@ void EditorHost::refreshFeatureAvailability() { const bool ready = hasDocument(); QHash availability; - for (const QString& id : {QStringLiteral("actionPageLayoutContinuous"), QStringLiteral("actionPageLayoutSinglePage"), - QStringLiteral("actionPageLayoutTwoColumns"), QStringLiteral("actionPageLayoutTwoPages"), - QStringLiteral("actionFind"), QStringLiteral("actionProperties")}) + for (const QString& id : { QStringLiteral("actionPageLayoutContinuous"), QStringLiteral("actionPageLayoutSinglePage"), + QStringLiteral("actionPageLayoutTwoColumns"), QStringLiteral("actionPageLayoutTwoPages"), + QStringLiteral("actionFind"), QStringLiteral("actionProperties") }) { availability.insert(id, ready); } diff --git a/LoupeEditor/qml/DocumentPane.qml b/LoupeEditor/qml/DocumentPane.qml index 8376d428a..9f277484b 100644 --- a/LoupeEditor/qml/DocumentPane.qml +++ b/LoupeEditor/qml/DocumentPane.qml @@ -10,6 +10,11 @@ Item { property var host: editorHost property var documentModel: host ? host.documentModel : null + function revealSearch() { + tabBar.currentIndex = 2 + searchField.forceActiveFocus() + } + RowLayout { anchors.fill: parent spacing: 0 @@ -152,4 +157,12 @@ Item { Accessible.name: qsTr("Document canvas pane") } } + + Connections { + target: root.host + function onPresentationChanged() { + if (root.host && root.host.searchPanelVisible) + root.revealSearch() + } + } } diff --git a/LoupeEditor/quickdocumentmodel.cpp b/LoupeEditor/quickdocumentmodel.cpp index 4ffbaf698..aa10700bf 100644 --- a/LoupeEditor/quickdocumentmodel.cpp +++ b/LoupeEditor/quickdocumentmodel.cpp @@ -4,17 +4,18 @@ #include "pdfcatalog.h" #include "pdfdocument.h" #include "pdfdocumentcontext.h" +#include "pdfdocumentsearch.h" #include "pdfdocumentsession.h" -#include "pdfmeshqualitysettings.h" #include "pdfoutline.h" #include "pdfpage.h" -#include "pdftextlayout.h" -#include "pdftextlayoutgenerator.h" #include "pdfutils.h" #include -QuickPageModel::QuickPageModel(QObject* parent) : QAbstractListModel(parent) {} +QuickPageModel::QuickPageModel(QObject* parent) : + QAbstractListModel(parent) +{ +} int QuickPageModel::rowCount(const QModelIndex& parent) const { @@ -48,8 +49,7 @@ QVariant QuickPageModel::data(const QModelIndex& index, int role) const QHash QuickPageModel::roleNames() const { - return {{PageNumberRole, "pageNumber"}, {WidthRole, "pageWidth"}, {HeightRole, "pageHeight"}, - {RotationRole, "pageRotation"}, {LabelRole, "label"}}; + return { { PageNumberRole, "pageNumber" }, { WidthRole, "pageWidth" }, { HeightRole, "pageHeight" }, { RotationRole, "pageRotation" }, { LabelRole, "label" } }; } void QuickPageModel::replace(const pdf::PDFDocument* document) @@ -63,8 +63,8 @@ void QuickPageModel::replace(const pdf::PDFDocument* document) for (size_t i = 0; i < catalog->getPageCount(); ++i) { const pdf::PDFPage* page = catalog->getPage(i); - m_pages.append(Page{page->getCropBox().width(), page->getCropBox().height(), - static_cast(page->getPageRotation()), static_cast(i)}); + m_pages.append(Page{ page->getCropBox().width(), page->getCropBox().height(), + static_cast(page->getPageRotation()), static_cast(i) }); } } endResetModel(); @@ -75,7 +75,11 @@ void QuickPageModel::clear() replace(nullptr); } -QuickOutlineModel::QuickOutlineModel(QObject* parent) : QAbstractItemModel(parent), m_root(std::make_unique()) {} +QuickOutlineModel::QuickOutlineModel(QObject* parent) : + QAbstractItemModel(parent), + m_root(std::make_unique()) +{ +} QuickOutlineModel::~QuickOutlineModel() = default; QuickOutlineModel::Node* QuickOutlineModel::nodeForIndex(const QModelIndex& index) const @@ -138,7 +142,7 @@ QVariant QuickOutlineModel::data(const QModelIndex& index, int role) const QHash QuickOutlineModel::roleNames() const { - return {{TitleRole, "title"}, {HasChildrenRole, "hasChildren"}}; + return { { TitleRole, "title" }, { HasChildrenRole, "hasChildren" } }; } void QuickOutlineModel::build(Node* parent, const pdf::PDFOutlineItem* item) @@ -170,7 +174,10 @@ void QuickOutlineModel::clear() replace(nullptr); } -QuickSearchResultModel::QuickSearchResultModel(QObject* parent) : QAbstractListModel(parent) {} +QuickSearchResultModel::QuickSearchResultModel(QObject* parent) : + QAbstractListModel(parent) +{ +} int QuickSearchResultModel::rowCount(const QModelIndex& parent) const { @@ -193,7 +200,7 @@ QVariant QuickSearchResultModel::data(const QModelIndex& index, int role) const QHash QuickSearchResultModel::roleNames() const { - return {{PageRole, "page"}, {MatchedRole, "matched"}, {ContextRole, "context"}}; + return { { PageRole, "page" }, { MatchedRole, "matched" }, { ContextRole, "context" } }; } void QuickSearchResultModel::replace(QList results, QString query, QString revision) @@ -210,7 +217,13 @@ void QuickSearchResultModel::clear() replace({}, {}, {}); } -QuickDocumentModel::QuickDocumentModel(QObject* parent) : QObject(parent), m_pages(this), m_outline(this), m_searchResults(this) {} +QuickDocumentModel::QuickDocumentModel(QObject* parent) : + QObject(parent), + m_pages(this), + m_outline(this), + m_searchResults(this) +{ +} void QuickDocumentModel::setDocument(pdf::PDFDocumentContext* context) { @@ -329,40 +342,15 @@ bool QuickDocumentModel::search(const QString& query) return false; } - const pdf::PDFRevisionIdentity revision = m_context->getRevision(); - const pdf::PDFDocument* document = m_context->getDocument(); - if (!document) - { - clearSearch(); + const pdf::PDFDocumentSearchResult searchResult = pdf::searchDocumentText(m_context, query); + if (!searchResult.admitted) return false; - } QList results; - const pdf::PDFMeshQualitySettings meshQuality; - const pdf::PDFRenderer::Features features = pdf::PDFRenderer::IgnoreOptionalContent; - const pdf::PDFCatalog* catalog = document->getCatalog(); - for (size_t pageIndex = 0; pageIndex < catalog->getPageCount(); ++pageIndex) - { - const pdf::PDFPage* page = catalog->getPage(pageIndex); - pdf::PDFTextLayoutGenerator generator(features, page, document, - m_session->getFontCache(), m_session->getCMS(), - m_session->getOptionalContentActivity(), QTransform(), meshQuality, - m_session->getProcessingBudget()); - generator.processContents(); - const pdf::PDFTextLayout layout = generator.createTextLayout(); - const pdf::PDFTextFlows flows = pdf::PDFTextFlow::createTextFlows( - layout, pdf::PDFTextFlow::RemoveSoftHyphen | pdf::PDFTextFlow::AddLineBreaks, - static_cast(pageIndex)); - for (const pdf::PDFTextFlow& flow : flows) - { - for (const pdf::PDFFindResult& match : flow.find(query, Qt::CaseInsensitive)) - results.append({static_cast(pageIndex), match.matched, match.context}); - } - } - - if (!m_context->isCurrent(revision)) - return false; - m_searchResults.replace(std::move(results), query, revision.toString()); + results.reserve(searchResult.matches.size()); + for (const pdf::PDFDocumentSearchMatch& match : searchResult.matches) + results.append({ static_cast(match.pageIndex), match.matched, match.context }); + m_searchResults.replace(std::move(results), query, searchResult.revision.toString()); Q_EMIT searchChanged(); return true; } diff --git a/LoupeLibCore/CMakeLists.txt b/LoupeLibCore/CMakeLists.txt index 4ec79ac27..3ccb3bbc8 100644 --- a/LoupeLibCore/CMakeLists.txt +++ b/LoupeLibCore/CMakeLists.txt @@ -89,6 +89,8 @@ add_library(LoupeLibCore SHARED sources/pdfworkloadenvelope.h sources/pdfdocumentcontext.cpp sources/pdfdocumentcontext.h + sources/pdfdocumentsearch.cpp + sources/pdfdocumentsearch.h sources/pdfprocessingbudget.cpp sources/pdfprocessingbudget.h sources/pdfjobscheduler.cpp diff --git a/LoupeLibCore/sources/pdfdocumentsearch.cpp b/LoupeLibCore/sources/pdfdocumentsearch.cpp new file mode 100644 index 000000000..df09378b9 --- /dev/null +++ b/LoupeLibCore/sources/pdfdocumentsearch.cpp @@ -0,0 +1,55 @@ +// MIT License +#include "pdfdocumentsearch.h" + +#include "pdfcatalog.h" +#include "pdfdocumentsession.h" +#include "pdfmeshqualitysettings.h" +#include "pdfpage.h" +#include "pdftextlayout.h" +#include "pdftextlayoutgenerator.h" + +namespace pdf +{ + +PDFDocumentSearchResult searchDocumentText(PDFDocumentContext* context, + const QString& query, + Qt::CaseSensitivity sensitivity) +{ + PDFDocumentSearchResult result; + if (!context || query.trimmed().isEmpty()) + return result; + + PDFDocumentSession* session = context->getSession(); + const PDFDocument* document = context->getDocument(); + if (!session || !document) + return result; + + result.revision = context->getRevision(); + const PDFMeshQualitySettings meshQuality; + const PDFRenderer::Features features = PDFRenderer::IgnoreOptionalContent; + const PDFCatalog* catalog = document->getCatalog(); + for (size_t pageIndex = 0; pageIndex < catalog->getPageCount(); ++pageIndex) + { + const PDFPage* page = catalog->getPage(pageIndex); + PDFTextLayoutGenerator generator(features, page, document, + session->getFontCache(), session->getCMS(), + session->getOptionalContentActivity(), QTransform(), meshQuality, + session->getProcessingBudget()); + generator.processContents(); + const PDFTextFlows flows = PDFTextFlow::createTextFlows( + generator.createTextLayout(), PDFTextFlow::RemoveSoftHyphen | PDFTextFlow::AddLineBreaks, + static_cast(pageIndex)); + for (const PDFTextFlow& flow : flows) + { + for (const PDFFindResult& match : flow.find(query, sensitivity)) + result.matches.push_back({ static_cast(pageIndex), match.matched, match.context }); + } + } + + result.admitted = context->isCurrent(result.revision); + if (!result.admitted) + result.matches.clear(); + return result; +} + +} // namespace pdf diff --git a/LoupeLibCore/sources/pdfdocumentsearch.h b/LoupeLibCore/sources/pdfdocumentsearch.h new file mode 100644 index 000000000..3a7cb8e79 --- /dev/null +++ b/LoupeLibCore/sources/pdfdocumentsearch.h @@ -0,0 +1,38 @@ +// MIT License +#ifndef PDFDOCUMENTSEARCH_H +#define PDFDOCUMENTSEARCH_H + +#include "pdfdocumentcontext.h" +#include "pdfglobal.h" + +#include +#include + +namespace pdf +{ + +struct LOUPELIBCORESHARED_EXPORT PDFDocumentSearchMatch +{ + PDFInteger pageIndex = -1; + QString matched; + QString context; +}; + +struct LOUPELIBCORESHARED_EXPORT PDFDocumentSearchResult +{ + QVector matches; + PDFRevisionIdentity revision; + bool admitted = false; +}; + +/// Extracts and searches the text flows for every page in the context's +/// current document. Results are admitted only while the captured revision is +/// still current, so presentation layers do not need to implement parsing or +/// revision-fencing policy themselves. +LOUPELIBCORESHARED_EXPORT PDFDocumentSearchResult searchDocumentText(PDFDocumentContext* context, + const QString& query, + Qt::CaseSensitivity sensitivity = Qt::CaseInsensitive); + +} // namespace pdf + +#endif // PDFDOCUMENTSEARCH_H diff --git a/changes/work.md b/changes/work.md new file mode 100644 index 000000000..a3177a083 --- /dev/null +++ b/changes/work.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: users +Breaking-Change: no +Summary: Route Quick document search through LoupeLibCore and make Find reveal and focus the Search tab. From 62a225ebe37a9cac6dc3d13b4a2b7432ac71de91 Mon Sep 17 00:00:00 2001 From: mberrys Date: Tue, 1 Sep 2026 00:43:05 -0700 Subject: [PATCH 11/33] fix: correct PDFTextLayoutGenerator arity and FlowFlags construction for fuzz --- LoupeLibCore/sources/pdfdocumentsearch.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LoupeLibCore/sources/pdfdocumentsearch.cpp b/LoupeLibCore/sources/pdfdocumentsearch.cpp index df09378b9..88a1813d7 100644 --- a/LoupeLibCore/sources/pdfdocumentsearch.cpp +++ b/LoupeLibCore/sources/pdfdocumentsearch.cpp @@ -33,11 +33,11 @@ PDFDocumentSearchResult searchDocumentText(PDFDocumentContext* context, const PDFPage* page = catalog->getPage(pageIndex); PDFTextLayoutGenerator generator(features, page, document, session->getFontCache(), session->getCMS(), - session->getOptionalContentActivity(), QTransform(), meshQuality, - session->getProcessingBudget()); + session->getOptionalContentActivity(), QTransform(), meshQuality); generator.processContents(); const PDFTextFlows flows = PDFTextFlow::createTextFlows( - generator.createTextLayout(), PDFTextFlow::RemoveSoftHyphen | PDFTextFlow::AddLineBreaks, + generator.createTextLayout(), + PDFTextFlow::FlowFlags(PDFTextFlow::RemoveSoftHyphen) | PDFTextFlow::AddLineBreaks, static_cast(pageIndex)); for (const PDFTextFlow& flow : flows) { From dd9e04fd33d05010e82c97c64146adbd06ea55d4 Mon Sep 17 00:00:00 2001 From: mberrys Date: Tue, 1 Sep 2026 00:45:22 -0700 Subject: [PATCH 12/33] fix: rename changelog fragment to match branch Moves changes/work.md to changes/codex-fix-issues-from-codex-review.md so check-change finds the expected fragment. --- changes/{work.md => codex-fix-issues-from-codex-review.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changes/{work.md => codex-fix-issues-from-codex-review.md} (100%) diff --git a/changes/work.md b/changes/codex-fix-issues-from-codex-review.md similarity index 100% rename from changes/work.md rename to changes/codex-fix-issues-from-codex-review.md From 8d7e86b7bdc3ace6d28a7451b1433f1e1fae27dc Mon Sep 17 00:00:00 2001 From: mberrys Date: Tue, 1 Sep 2026 00:47:15 -0700 Subject: [PATCH 13/33] fix: move Quick document search into LoupeLibCore and wire Find to Search tab - add LoupeLibCore/pdfdocumentsearch for text extraction and search, keep revision fencing in Core - make QuickDocumentModel use the Core search API instead of local parsing - make Find select the Document Search tab and focus its field - addresses Codex P1 comments on PR 491 --- LoupeEditor/editorhost.cpp | 16 ++--- LoupeEditor/qml/DocumentPane.qml | 13 ++++ LoupeEditor/quickdocumentmodel.cpp | 76 +++++++++------------- LoupeLibCore/CMakeLists.txt | 2 + LoupeLibCore/sources/pdfdocumentsearch.cpp | 55 ++++++++++++++++ LoupeLibCore/sources/pdfdocumentsearch.h | 38 +++++++++++ 6 files changed, 148 insertions(+), 52 deletions(-) create mode 100644 LoupeLibCore/sources/pdfdocumentsearch.cpp create mode 100644 LoupeLibCore/sources/pdfdocumentsearch.h diff --git a/LoupeEditor/editorhost.cpp b/LoupeEditor/editorhost.cpp index d856f3c3f..cea36c200 100644 --- a/LoupeEditor/editorhost.cpp +++ b/LoupeEditor/editorhost.cpp @@ -136,8 +136,7 @@ EditorHost::EditorHost(QObject* parent) : { refreshFeatureAvailability(); bumpPresentation(); - bumpCommandEpoch(); - }); + bumpCommandEpoch(); }); } EditorHost::~EditorHost() @@ -553,8 +552,7 @@ void EditorHost::connectFacade() connect(&m_session->facade(), &pdfinteraction::DocumentFacade::facetsChanged, this, [this](pdfinteraction::DocumentFacets) { syncDocumentLifecycle(); - bumpPresentation(); - }); + bumpPresentation(); }); connect(&m_session->facade(), &pdfinteraction::DocumentFacade::documentReplaced, this, [this](quint64) { @@ -636,7 +634,9 @@ void EditorHost::registerFeatureHandlers() bind(QStringLiteral("actionFullscreenMode"), [this] { m_fullscreenRequested = !m_fullscreenRequested; }); bind(QStringLiteral("actionFind"), [this] - { m_searchPanelVisible = true; }); + { + m_searchPanelVisible = true; + m_workspaceRequest = 0; }); bind(QStringLiteral("actionFindNext"), [this] { moveSearch(1); }); bind(QStringLiteral("actionFindPrevious"), [this] @@ -650,9 +650,9 @@ void EditorHost::refreshFeatureAvailability() { const bool ready = hasDocument(); QHash availability; - for (const QString& id : {QStringLiteral("actionPageLayoutContinuous"), QStringLiteral("actionPageLayoutSinglePage"), - QStringLiteral("actionPageLayoutTwoColumns"), QStringLiteral("actionPageLayoutTwoPages"), - QStringLiteral("actionFind"), QStringLiteral("actionProperties")}) + for (const QString& id : { QStringLiteral("actionPageLayoutContinuous"), QStringLiteral("actionPageLayoutSinglePage"), + QStringLiteral("actionPageLayoutTwoColumns"), QStringLiteral("actionPageLayoutTwoPages"), + QStringLiteral("actionFind"), QStringLiteral("actionProperties") }) { availability.insert(id, ready); } diff --git a/LoupeEditor/qml/DocumentPane.qml b/LoupeEditor/qml/DocumentPane.qml index 8376d428a..9f277484b 100644 --- a/LoupeEditor/qml/DocumentPane.qml +++ b/LoupeEditor/qml/DocumentPane.qml @@ -10,6 +10,11 @@ Item { property var host: editorHost property var documentModel: host ? host.documentModel : null + function revealSearch() { + tabBar.currentIndex = 2 + searchField.forceActiveFocus() + } + RowLayout { anchors.fill: parent spacing: 0 @@ -152,4 +157,12 @@ Item { Accessible.name: qsTr("Document canvas pane") } } + + Connections { + target: root.host + function onPresentationChanged() { + if (root.host && root.host.searchPanelVisible) + root.revealSearch() + } + } } diff --git a/LoupeEditor/quickdocumentmodel.cpp b/LoupeEditor/quickdocumentmodel.cpp index 4ffbaf698..aa10700bf 100644 --- a/LoupeEditor/quickdocumentmodel.cpp +++ b/LoupeEditor/quickdocumentmodel.cpp @@ -4,17 +4,18 @@ #include "pdfcatalog.h" #include "pdfdocument.h" #include "pdfdocumentcontext.h" +#include "pdfdocumentsearch.h" #include "pdfdocumentsession.h" -#include "pdfmeshqualitysettings.h" #include "pdfoutline.h" #include "pdfpage.h" -#include "pdftextlayout.h" -#include "pdftextlayoutgenerator.h" #include "pdfutils.h" #include -QuickPageModel::QuickPageModel(QObject* parent) : QAbstractListModel(parent) {} +QuickPageModel::QuickPageModel(QObject* parent) : + QAbstractListModel(parent) +{ +} int QuickPageModel::rowCount(const QModelIndex& parent) const { @@ -48,8 +49,7 @@ QVariant QuickPageModel::data(const QModelIndex& index, int role) const QHash QuickPageModel::roleNames() const { - return {{PageNumberRole, "pageNumber"}, {WidthRole, "pageWidth"}, {HeightRole, "pageHeight"}, - {RotationRole, "pageRotation"}, {LabelRole, "label"}}; + return { { PageNumberRole, "pageNumber" }, { WidthRole, "pageWidth" }, { HeightRole, "pageHeight" }, { RotationRole, "pageRotation" }, { LabelRole, "label" } }; } void QuickPageModel::replace(const pdf::PDFDocument* document) @@ -63,8 +63,8 @@ void QuickPageModel::replace(const pdf::PDFDocument* document) for (size_t i = 0; i < catalog->getPageCount(); ++i) { const pdf::PDFPage* page = catalog->getPage(i); - m_pages.append(Page{page->getCropBox().width(), page->getCropBox().height(), - static_cast(page->getPageRotation()), static_cast(i)}); + m_pages.append(Page{ page->getCropBox().width(), page->getCropBox().height(), + static_cast(page->getPageRotation()), static_cast(i) }); } } endResetModel(); @@ -75,7 +75,11 @@ void QuickPageModel::clear() replace(nullptr); } -QuickOutlineModel::QuickOutlineModel(QObject* parent) : QAbstractItemModel(parent), m_root(std::make_unique()) {} +QuickOutlineModel::QuickOutlineModel(QObject* parent) : + QAbstractItemModel(parent), + m_root(std::make_unique()) +{ +} QuickOutlineModel::~QuickOutlineModel() = default; QuickOutlineModel::Node* QuickOutlineModel::nodeForIndex(const QModelIndex& index) const @@ -138,7 +142,7 @@ QVariant QuickOutlineModel::data(const QModelIndex& index, int role) const QHash QuickOutlineModel::roleNames() const { - return {{TitleRole, "title"}, {HasChildrenRole, "hasChildren"}}; + return { { TitleRole, "title" }, { HasChildrenRole, "hasChildren" } }; } void QuickOutlineModel::build(Node* parent, const pdf::PDFOutlineItem* item) @@ -170,7 +174,10 @@ void QuickOutlineModel::clear() replace(nullptr); } -QuickSearchResultModel::QuickSearchResultModel(QObject* parent) : QAbstractListModel(parent) {} +QuickSearchResultModel::QuickSearchResultModel(QObject* parent) : + QAbstractListModel(parent) +{ +} int QuickSearchResultModel::rowCount(const QModelIndex& parent) const { @@ -193,7 +200,7 @@ QVariant QuickSearchResultModel::data(const QModelIndex& index, int role) const QHash QuickSearchResultModel::roleNames() const { - return {{PageRole, "page"}, {MatchedRole, "matched"}, {ContextRole, "context"}}; + return { { PageRole, "page" }, { MatchedRole, "matched" }, { ContextRole, "context" } }; } void QuickSearchResultModel::replace(QList results, QString query, QString revision) @@ -210,7 +217,13 @@ void QuickSearchResultModel::clear() replace({}, {}, {}); } -QuickDocumentModel::QuickDocumentModel(QObject* parent) : QObject(parent), m_pages(this), m_outline(this), m_searchResults(this) {} +QuickDocumentModel::QuickDocumentModel(QObject* parent) : + QObject(parent), + m_pages(this), + m_outline(this), + m_searchResults(this) +{ +} void QuickDocumentModel::setDocument(pdf::PDFDocumentContext* context) { @@ -329,40 +342,15 @@ bool QuickDocumentModel::search(const QString& query) return false; } - const pdf::PDFRevisionIdentity revision = m_context->getRevision(); - const pdf::PDFDocument* document = m_context->getDocument(); - if (!document) - { - clearSearch(); + const pdf::PDFDocumentSearchResult searchResult = pdf::searchDocumentText(m_context, query); + if (!searchResult.admitted) return false; - } QList results; - const pdf::PDFMeshQualitySettings meshQuality; - const pdf::PDFRenderer::Features features = pdf::PDFRenderer::IgnoreOptionalContent; - const pdf::PDFCatalog* catalog = document->getCatalog(); - for (size_t pageIndex = 0; pageIndex < catalog->getPageCount(); ++pageIndex) - { - const pdf::PDFPage* page = catalog->getPage(pageIndex); - pdf::PDFTextLayoutGenerator generator(features, page, document, - m_session->getFontCache(), m_session->getCMS(), - m_session->getOptionalContentActivity(), QTransform(), meshQuality, - m_session->getProcessingBudget()); - generator.processContents(); - const pdf::PDFTextLayout layout = generator.createTextLayout(); - const pdf::PDFTextFlows flows = pdf::PDFTextFlow::createTextFlows( - layout, pdf::PDFTextFlow::RemoveSoftHyphen | pdf::PDFTextFlow::AddLineBreaks, - static_cast(pageIndex)); - for (const pdf::PDFTextFlow& flow : flows) - { - for (const pdf::PDFFindResult& match : flow.find(query, Qt::CaseInsensitive)) - results.append({static_cast(pageIndex), match.matched, match.context}); - } - } - - if (!m_context->isCurrent(revision)) - return false; - m_searchResults.replace(std::move(results), query, revision.toString()); + results.reserve(searchResult.matches.size()); + for (const pdf::PDFDocumentSearchMatch& match : searchResult.matches) + results.append({ static_cast(match.pageIndex), match.matched, match.context }); + m_searchResults.replace(std::move(results), query, searchResult.revision.toString()); Q_EMIT searchChanged(); return true; } diff --git a/LoupeLibCore/CMakeLists.txt b/LoupeLibCore/CMakeLists.txt index 4ec79ac27..3ccb3bbc8 100644 --- a/LoupeLibCore/CMakeLists.txt +++ b/LoupeLibCore/CMakeLists.txt @@ -89,6 +89,8 @@ add_library(LoupeLibCore SHARED sources/pdfworkloadenvelope.h sources/pdfdocumentcontext.cpp sources/pdfdocumentcontext.h + sources/pdfdocumentsearch.cpp + sources/pdfdocumentsearch.h sources/pdfprocessingbudget.cpp sources/pdfprocessingbudget.h sources/pdfjobscheduler.cpp diff --git a/LoupeLibCore/sources/pdfdocumentsearch.cpp b/LoupeLibCore/sources/pdfdocumentsearch.cpp new file mode 100644 index 000000000..df09378b9 --- /dev/null +++ b/LoupeLibCore/sources/pdfdocumentsearch.cpp @@ -0,0 +1,55 @@ +// MIT License +#include "pdfdocumentsearch.h" + +#include "pdfcatalog.h" +#include "pdfdocumentsession.h" +#include "pdfmeshqualitysettings.h" +#include "pdfpage.h" +#include "pdftextlayout.h" +#include "pdftextlayoutgenerator.h" + +namespace pdf +{ + +PDFDocumentSearchResult searchDocumentText(PDFDocumentContext* context, + const QString& query, + Qt::CaseSensitivity sensitivity) +{ + PDFDocumentSearchResult result; + if (!context || query.trimmed().isEmpty()) + return result; + + PDFDocumentSession* session = context->getSession(); + const PDFDocument* document = context->getDocument(); + if (!session || !document) + return result; + + result.revision = context->getRevision(); + const PDFMeshQualitySettings meshQuality; + const PDFRenderer::Features features = PDFRenderer::IgnoreOptionalContent; + const PDFCatalog* catalog = document->getCatalog(); + for (size_t pageIndex = 0; pageIndex < catalog->getPageCount(); ++pageIndex) + { + const PDFPage* page = catalog->getPage(pageIndex); + PDFTextLayoutGenerator generator(features, page, document, + session->getFontCache(), session->getCMS(), + session->getOptionalContentActivity(), QTransform(), meshQuality, + session->getProcessingBudget()); + generator.processContents(); + const PDFTextFlows flows = PDFTextFlow::createTextFlows( + generator.createTextLayout(), PDFTextFlow::RemoveSoftHyphen | PDFTextFlow::AddLineBreaks, + static_cast(pageIndex)); + for (const PDFTextFlow& flow : flows) + { + for (const PDFFindResult& match : flow.find(query, sensitivity)) + result.matches.push_back({ static_cast(pageIndex), match.matched, match.context }); + } + } + + result.admitted = context->isCurrent(result.revision); + if (!result.admitted) + result.matches.clear(); + return result; +} + +} // namespace pdf diff --git a/LoupeLibCore/sources/pdfdocumentsearch.h b/LoupeLibCore/sources/pdfdocumentsearch.h new file mode 100644 index 000000000..3a7cb8e79 --- /dev/null +++ b/LoupeLibCore/sources/pdfdocumentsearch.h @@ -0,0 +1,38 @@ +// MIT License +#ifndef PDFDOCUMENTSEARCH_H +#define PDFDOCUMENTSEARCH_H + +#include "pdfdocumentcontext.h" +#include "pdfglobal.h" + +#include +#include + +namespace pdf +{ + +struct LOUPELIBCORESHARED_EXPORT PDFDocumentSearchMatch +{ + PDFInteger pageIndex = -1; + QString matched; + QString context; +}; + +struct LOUPELIBCORESHARED_EXPORT PDFDocumentSearchResult +{ + QVector matches; + PDFRevisionIdentity revision; + bool admitted = false; +}; + +/// Extracts and searches the text flows for every page in the context's +/// current document. Results are admitted only while the captured revision is +/// still current, so presentation layers do not need to implement parsing or +/// revision-fencing policy themselves. +LOUPELIBCORESHARED_EXPORT PDFDocumentSearchResult searchDocumentText(PDFDocumentContext* context, + const QString& query, + Qt::CaseSensitivity sensitivity = Qt::CaseInsensitive); + +} // namespace pdf + +#endif // PDFDOCUMENTSEARCH_H From 94b877623a0fe07d535b7b1880a3c479687e7ddd Mon Sep 17 00:00:00 2001 From: mberrys Date: Tue, 1 Sep 2026 00:43:05 -0700 Subject: [PATCH 14/33] fix: correct PDFTextLayoutGenerator arity and FlowFlags construction for fuzz --- LoupeLibCore/sources/pdfdocumentsearch.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/LoupeLibCore/sources/pdfdocumentsearch.cpp b/LoupeLibCore/sources/pdfdocumentsearch.cpp index df09378b9..88a1813d7 100644 --- a/LoupeLibCore/sources/pdfdocumentsearch.cpp +++ b/LoupeLibCore/sources/pdfdocumentsearch.cpp @@ -33,11 +33,11 @@ PDFDocumentSearchResult searchDocumentText(PDFDocumentContext* context, const PDFPage* page = catalog->getPage(pageIndex); PDFTextLayoutGenerator generator(features, page, document, session->getFontCache(), session->getCMS(), - session->getOptionalContentActivity(), QTransform(), meshQuality, - session->getProcessingBudget()); + session->getOptionalContentActivity(), QTransform(), meshQuality); generator.processContents(); const PDFTextFlows flows = PDFTextFlow::createTextFlows( - generator.createTextLayout(), PDFTextFlow::RemoveSoftHyphen | PDFTextFlow::AddLineBreaks, + generator.createTextLayout(), + PDFTextFlow::FlowFlags(PDFTextFlow::RemoveSoftHyphen) | PDFTextFlow::AddLineBreaks, static_cast(pageIndex)); for (const PDFTextFlow& flow : flows) { From f7fa814b19b4ccd98519099de424bd7916363d69 Mon Sep 17 00:00:00 2001 From: mberrys Date: Tue, 1 Sep 2026 00:50:21 -0700 Subject: [PATCH 15/33] fix: address remaining Quick parity review comments - Main.qml: keep maximized state when presentation changes, only leave FullScreen if we are in it - DocumentPane.qml: make Previous/Next observe commandEpoch so they update when search results change - DocumentPane.qml: use TreeView for outline so nested bookmarks are reachable - editorhost.cpp: handle unset cursor for backward search - rewrite changelog summary in plain language Fixes Codex P2 comments on PR 491. --- LoupeEditor/editorhost.cpp | 6 +++++- LoupeEditor/qml/DocumentPane.qml | 11 ++++++----- LoupeEditor/qml/Main.qml | 6 +++++- changes/cdx-quick-pdf4qt-core-parity.md | 2 +- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/LoupeEditor/editorhost.cpp b/LoupeEditor/editorhost.cpp index cea36c200..b1b6472d6 100644 --- a/LoupeEditor/editorhost.cpp +++ b/LoupeEditor/editorhost.cpp @@ -671,7 +671,11 @@ void EditorHost::moveSearch(int direction) return; } - m_searchRow = (m_searchRow + direction + count) % count; + if (m_searchRow < 0) { + m_searchRow = direction > 0 ? 0 : count - 1; + } else { + m_searchRow = (m_searchRow + direction + count) % count; + } goToPage(m_documentModel.searchPageAt(m_searchRow)); } diff --git a/LoupeEditor/qml/DocumentPane.qml b/LoupeEditor/qml/DocumentPane.qml index 9f277484b..a84144f66 100644 --- a/LoupeEditor/qml/DocumentPane.qml +++ b/LoupeEditor/qml/DocumentPane.qml @@ -66,7 +66,7 @@ Item { } } - ListView { + TreeView { id: outlineView clip: true focus: true @@ -76,8 +76,9 @@ Item { delegate: ItemDelegate { width: outlineView.width - text: title - Accessible.name: title + text: model.display !== undefined ? model.display : title + Accessible.name: text + onClicked: if (root.host && model.index !== undefined) root.host.goToOutlineIndex(model.index) } Label { @@ -112,13 +113,13 @@ Item { Layout.fillWidth: true Button { text: qsTr("Previous") - enabled: root.host && root.host.isCommandEnabled("actionFindPrevious") + enabled: root.host && root.host.commandEpoch >= 0 && root.host.isCommandEnabled("actionFindPrevious") onClicked: if (root.host) root.host.invokeCommand("actionFindPrevious") } Button { text: qsTr("Next") - enabled: root.host && root.host.isCommandEnabled("actionFindNext") + enabled: root.host && root.host.commandEpoch >= 0 && root.host.isCommandEnabled("actionFindNext") onClicked: if (root.host) root.host.invokeCommand("actionFindNext") } diff --git a/LoupeEditor/qml/Main.qml b/LoupeEditor/qml/Main.qml index 09d610d3e..5021553cf 100644 --- a/LoupeEditor/qml/Main.qml +++ b/LoupeEditor/qml/Main.qml @@ -67,7 +67,11 @@ ApplicationWindow { window.title = qsTr("Loupe") } if (host) { - window.visibility = host.fullscreenRequested ? Window.FullScreen : Window.Windowed + if (host.fullscreenRequested) { + window.visibility = Window.FullScreen + } else if (window.visibility === Window.FullScreen) { + window.visibility = Window.Windowed + } } } } diff --git a/changes/cdx-quick-pdf4qt-core-parity.md b/changes/cdx-quick-pdf4qt-core-parity.md index bb55f95be..a8a840463 100644 --- a/changes/cdx-quick-pdf4qt-core-parity.md +++ b/changes/cdx-quick-pdf4qt-core-parity.md @@ -1,4 +1,4 @@ Category: added Audience: developers, users Breaking-Change: no -Summary: Add the first Quick competitor-parity slice: revision-bound page, outline, search, property, capability, and lifecycle models; Document workspace navigation and search; layout, fullscreen, and properties catalog handlers; and focused contract tests. Remaining annotation, forms, security, print, and export workflows stay declared or policy-excluded pending their typed bridges. +Summary: Add the first Quick parity slice: page and outline models, search, properties and capability handling, plus workspace navigation and layout controls. Includes focused tests. Other workflows remain declared but not yet implemented. From daa123a27a9e962fcfb6d28584b90ab716d84457 Mon Sep 17 00:00:00 2001 From: michael berry Date: Tue, 1 Sep 2026 00:57:20 -0700 Subject: [PATCH 16/33] test: add resource envelope fixture matrix runner (#493) * test: add resource envelope fixture matrix runner * test: strengthen resource envelope qualification * fix: validate envelope evidence identity and resolve paths - check both commit and fixture digest against candidate SHA and input digest before accepting a result - resolve pdf_tool and fixture paths to absolute before stat and before launching child with cwd=ROOT - pin rasterizers to fixed default and document why - rewrite changelog summary in plain language Fixes Codex P1/P2 on PR 493. --- LoupeLibCore/sources/pdfworkloadenvelope.cpp | 13 + LoupeLibCore/sources/pdfworkloadenvelope.h | 4 + PdfTool/pdftoolrender.cpp | 1 + UnitTests/tst_workloadenvelopetest.cpp | 1 + changes/cdx-issue-242-qualification-matrix.md | 4 + docs/RESOURCE_ENVELOPE.md | 67 +++ docs/RESOURCE_ENVELOPE_QUALIFICATION.md | 16 +- .../resource-envelope-fixtures.schema.json | 31 ++ docs/schemas/workload-envelope.schema.json | 1 + .../create_fixture_manifest.py | 56 ++ scripts/resource_envelope/run_matrix.py | 499 ++++++++++++++++++ scripts/resource_envelope/test_run_matrix.py | 157 ++++++ 12 files changed, 846 insertions(+), 4 deletions(-) create mode 100644 changes/cdx-issue-242-qualification-matrix.md create mode 100644 docs/schemas/resource-envelope-fixtures.schema.json create mode 100644 scripts/resource_envelope/create_fixture_manifest.py create mode 100644 scripts/resource_envelope/run_matrix.py create mode 100644 scripts/resource_envelope/test_run_matrix.py diff --git a/LoupeLibCore/sources/pdfworkloadenvelope.cpp b/LoupeLibCore/sources/pdfworkloadenvelope.cpp index 13d6add0b..e970d265b 100644 --- a/LoupeLibCore/sources/pdfworkloadenvelope.cpp +++ b/LoupeLibCore/sources/pdfworkloadenvelope.cpp @@ -201,6 +201,18 @@ qint64 PDFWorkloadEnvelope::currentRssHighWaterBytes() return -1; } +qint64 PDFWorkloadEnvelope::currentProcessCommitHighWaterBytes() +{ +#ifdef Q_OS_WIN + PROCESS_MEMORY_COUNTERS counters{}; + if (GetProcessMemoryInfo(GetCurrentProcess(), &counters, sizeof(counters))) + { + return static_cast(counters.PeakPagefileUsage); + } +#endif + return -1; +} + void PDFWorkloadEnvelope::recordResources(const PDFResourceBudget& budget) { resources = budget.toJson(); @@ -225,6 +237,7 @@ QJsonObject PDFWorkloadEnvelope::toJson() const object.insert(QStringLiteral("page_count"), pageCount); object.insert(QStringLiteral("open_to_first_view_ms"), openToFirstViewMs); object.insert(QStringLiteral("rss_high_water_bytes"), rssHighWaterBytes); + object.insert(QStringLiteral("process_commit_high_water_bytes"), processCommitHighWaterBytes); object.insert(QStringLiteral("cache_high_water_bytes"), cacheHighWaterBytes); object.insert(QStringLiteral("preflight_high_water_bytes"), preflightHighWaterBytes); object.insert(QStringLiteral("pages_materialized"), pagesMaterialized); diff --git a/LoupeLibCore/sources/pdfworkloadenvelope.h b/LoupeLibCore/sources/pdfworkloadenvelope.h index 9fab45994..11b172f3e 100644 --- a/LoupeLibCore/sources/pdfworkloadenvelope.h +++ b/LoupeLibCore/sources/pdfworkloadenvelope.h @@ -66,6 +66,9 @@ struct LOUPELIBCORESHARED_EXPORT PDFWorkloadEnvelope // -1 means that the platform could not provide this measurement. It is // intentionally distinct from zero so unavailable evidence cannot pass. qint64 rssHighWaterBytes = -1; + // Windows exposes peak commit charge separately from peak working set. + // Linux leaves this unavailable rather than substituting virtual size. + qint64 processCommitHighWaterBytes = -1; qint64 cacheHighWaterBytes = -1; qint64 preflightHighWaterBytes = -1; qint64 pagesMaterialized = -1; @@ -78,6 +81,7 @@ struct LOUPELIBCORESHARED_EXPORT PDFWorkloadEnvelope QJsonObject resources; static qint64 currentRssHighWaterBytes(); + static qint64 currentProcessCommitHighWaterBytes(); void recordResources(const PDFResourceBudget& budget); QJsonObject toJson() const; }; diff --git a/PdfTool/pdftoolrender.cpp b/PdfTool/pdftoolrender.cpp index d6be47f20..9c7e8e1db 100644 --- a/PdfTool/pdftoolrender.cpp +++ b/PdfTool/pdftoolrender.cpp @@ -214,6 +214,7 @@ void PDFToolBenchmark::finish(const PDFToolOptions& options) : QStringLiteral("incomplete"); envelope.pageCount = static_cast(m_pageInfo.size()); envelope.rssHighWaterBytes = pdf::PDFWorkloadEnvelope::currentRssHighWaterBytes(); + envelope.processCommitHighWaterBytes = pdf::PDFWorkloadEnvelope::currentProcessCommitHighWaterBytes(); envelope.elapsedMs = m_wallTime; envelope.cancellationLatencyMs = cancelled ? cancellationLatencyMs() : -1; envelope.incompleteReason = cancelled diff --git a/UnitTests/tst_workloadenvelopetest.cpp b/UnitTests/tst_workloadenvelopetest.cpp index 796c2a35f..721a0649f 100644 --- a/UnitTests/tst_workloadenvelopetest.cpp +++ b/UnitTests/tst_workloadenvelopetest.cpp @@ -135,6 +135,7 @@ void WorkloadEnvelopeTest::pageHeavyEnvelopeRecordsIdentity() QVERIFY(json.value(QStringLiteral("identity")).toObject().contains(QStringLiteral("os"))); QVERIFY(json.value(QStringLiteral("identity")).toObject().contains(QStringLiteral("qt"))); QVERIFY(!json.value(QStringLiteral("identity")).toObject().value(QStringLiteral("fixture_digest")).toString().isEmpty()); + QVERIFY(json.contains(QStringLiteral("process_commit_high_water_bytes"))); QVERIFY(json.value(QStringLiteral("prefetch_shed")).toBool()); QVERIFY(json.value(QStringLiteral("interaction_slot_held")).toBool()); QVERIFY(json.value(QStringLiteral("resources")).toObject().contains(QStringLiteral("pools"))); diff --git a/changes/cdx-issue-242-qualification-matrix.md b/changes/cdx-issue-242-qualification-matrix.md new file mode 100644 index 000000000..ded4e935f --- /dev/null +++ b/changes/cdx-issue-242-qualification-matrix.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Add a fixture matrix that runs each PDF in a fresh process, validates the digest and size from the manifest, compares RSS and elapsed time to a baseline, and checks cancel behavior. Rasterizers are pinned to 8 so results stay comparable. diff --git a/docs/RESOURCE_ENVELOPE.md b/docs/RESOURCE_ENVELOPE.md index d0fd30802..ac22c7ef6 100644 --- a/docs/RESOURCE_ENVELOPE.md +++ b/docs/RESOURCE_ENVELOPE.md @@ -102,3 +102,70 @@ python scripts/resource_envelope/pathological_workload.py ` Use `scripts/resource_envelope/validate_envelope.py` to validate a record against the checked-in limits before attaching it to a qualification dossier. + +For strict qualification, create a manifest containing the exact `sha256`, +`size_bytes`, and provenance for each external PDF, then pass it with +`--manifest`. Relative paths are resolved relative to the manifest file. The +runner records the Windows peak commit value as +`process_commit_high_water_bytes`; Linux keeps that field at `-1` because +virtual-size high water is not equivalent to process commit. + +## Run the fixture matrix + +Write `C:\temp\resource-envelope-fixtures.json` using the checked-in + +The helper records exact digests and sizes: + +```powershell +python scripts/resource_envelope/create_fixture_manifest.py ` + --fixture office-2mb=C:\fixtures\office-2mb.pdf ` + --fixture image-heavy-500mb=C:\fixtures\image-heavy-500mb.pdf ` + --fixture ten-thousand-page=C:\temp\loupe-div2k-10000-pages.pdf ` + --fixture pathological-vector=C:\temp\loupe-pathological-vector.pdf ` + --fixture transparency-spots=C:\temp\loupe-transparency-spots.pdf ` + --provenance "release fixture bundle 2026-08" ` + --output C:\temp\resource-envelope-fixtures.json +``` + +The issue #242 matrix is run against externally stored PDFs so large fixtures +do not enter the repository. The multi-GB fixture is optional when the +platform or available disk cannot support it. The manifest has this shape: + +```json +{ + "schema_kind": "loupe-resource-envelope-fixtures", + "schema_version": 1, + "fixtures": [ + { + "fixture_id": "pathological-vector", + "path": "C:\\temp\\loupe-pathological-vector.pdf", + "sha256": "<64 lowercase hex characters>", + "size_bytes": 123456, + "provenance": "pathological_workload.py --family pathological-vector" + } + ] +} +``` + +Then run the strict qualification profile. Use `--repetitions 3` for the +recommended cold-process timing/RSS sample: + +```powershell +python scripts/resource_envelope/run_matrix.py ` + --pdf-tool C:\path\to\PdfTool.exe ` + --manifest C:\temp\resource-envelope-fixtures.json ` + --repetitions 3 --rasterizers 8 --strict ` + --output C:\temp\resource-envelope-matrix.json +``` + +Each fixture attempt records its input digest, exact PdfTool command, process +exit code, individual envelopes, conservative peak-RSS statistics, validation +errors, and optional baseline regressions. Missing, timed-out, or incomplete +measurements remain flagged in the JSON; they are never converted to zero or +reported as a passing complete run. Add +`--baseline C:\previous\resource-envelope-matrix.json` to compare matching +fixture digests and platform/toolchain identities. The default regression +margin is `2.0`; use a narrower margin only after collecting stable platform +baselines. Add `--cancel-fixture pathological-vector +--cancel-after-seconds 1` to send an interrupt to one controlled probe and +record the application's cancellation latency. diff --git a/docs/RESOURCE_ENVELOPE_QUALIFICATION.md b/docs/RESOURCE_ENVELOPE_QUALIFICATION.md index 6c25f37d5..f29358e59 100644 --- a/docs/RESOURCE_ENVELOPE_QUALIFICATION.md +++ b/docs/RESOURCE_ENVELOPE_QUALIFICATION.md @@ -19,10 +19,18 @@ Quick product path is implemented in Phase 4. 1. Validate the external DIV2K corpus and generate one canonical manifest with `--hash-all`. 2. Build the deterministic 10,000-page image-heavy PDF and record its digest. -3. Run PdfTool benchmark profiles on Linux and Windows with the same manifest. -4. Run the integrated session/scheduler harness with the same workload identity. -5. Replay the bounded lifecycle trace corpus on both platforms. -6. Attach JSON results, digests, platform identities, and dispositions to the +3. Create an external fixture manifest using the schema at + `docs/schemas/resource-envelope-fixtures.schema.json`, then run + `scripts/resource_envelope/run_matrix.py --manifest ... --strict` with the 2 MB office, + image-heavy, 10,000-page, pathological-vector, and transparency/spot + fixtures. Supply the multi-GB fixture when platform addressability permits. + The strict job is expected to remain non-passing until the native benchmark + also supplies preflight and recovery measurements; unavailable fields must + not be promoted to zero. +4. Run PdfTool benchmark profiles on Linux and Windows with the same manifest. +5. Run the integrated session/scheduler harness with the same workload identity. +6. Replay the bounded lifecycle trace corpus on both platforms. +7. Attach JSON results, digests, platform identities, and dispositions to the candidate-SHA evidence dossier. No unavailable measurement may be converted to zero or treated as a pass. diff --git a/docs/schemas/resource-envelope-fixtures.schema.json b/docs/schemas/resource-envelope-fixtures.schema.json new file mode 100644 index 000000000..c51e8f19f --- /dev/null +++ b/docs/schemas/resource-envelope-fixtures.schema.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/studio-berry/loop/schema/resource-envelope-fixtures-v1.json", + "title": "Loop resource-envelope external fixture manifest", + "type": "object", + "required": ["schema_kind", "schema_version", "fixtures"], + "properties": { + "schema_kind": { "const": "loupe-resource-envelope-fixtures" }, + "schema_version": { "const": 1 }, + "fixtures": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["fixture_id", "path", "sha256", "size_bytes", "provenance"], + "properties": { + "fixture_id": { + "enum": ["office-2mb", "image-heavy-500mb", "multi-gb", "ten-thousand-page", "pathological-vector", "transparency-spots"] + }, + "path": { "type": "string", "minLength": 1 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "size_bytes": { "type": "integer", "minimum": 1 }, + "page_count": { "type": "integer", "minimum": 1 }, + "provenance": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/docs/schemas/workload-envelope.schema.json b/docs/schemas/workload-envelope.schema.json index 5d6bc41de..921952743 100644 --- a/docs/schemas/workload-envelope.schema.json +++ b/docs/schemas/workload-envelope.schema.json @@ -26,6 +26,7 @@ "page_count": { "type": "integer", "minimum": 0 }, "open_to_first_view_ms": { "type": "integer", "minimum": -1 }, "rss_high_water_bytes": { "type": "integer", "minimum": -1 }, + "process_commit_high_water_bytes": { "type": "integer", "minimum": -1 }, "cache_high_water_bytes": { "type": "integer", "minimum": -1 }, "preflight_high_water_bytes": { "type": "integer", "minimum": -1 }, "pages_materialized": { "type": "integer", "minimum": -1 }, diff --git a/scripts/resource_envelope/create_fixture_manifest.py b/scripts/resource_envelope/create_fixture_manifest.py new file mode 100644 index 000000000..0d9647845 --- /dev/null +++ b/scripts/resource_envelope/create_fixture_manifest.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Create a provenance manifest for external resource-envelope fixtures.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Sequence + +from scripts.resource_envelope.run_matrix import FIXTURE_SPECS, _fixture_args, _sha256 + + +def create_manifest(fixtures: dict[str, Path], provenance: str) -> dict[str, object]: + records: list[dict[str, object]] = [] + for fixture_id, path in fixtures.items(): + if not path.is_file(): + raise ValueError(f"fixture not found: {fixture_id}: {path}") + record: dict[str, object] = { + "fixture_id": fixture_id, + "path": str(path.resolve()), + "sha256": _sha256(path), + "size_bytes": path.stat().st_size, + "provenance": provenance, + } + expected_page_count = FIXTURE_SPECS[fixture_id]["expected_page_count"] + if expected_page_count is not None: + record["page_count"] = expected_page_count + records.append(record) + return { + "schema_kind": "loupe-resource-envelope-fixtures", + "schema_version": 1, + "fixtures": records, + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--fixture", action="append", default=[], metavar="NAME=PATH") + parser.add_argument("--provenance", required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args(argv) + try: + manifest = create_manifest(_fixture_args(args.fixture), args.provenance) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") + except (OSError, ValueError) as exc: + print(f"resource-envelope manifest error: {exc}", file=sys.stderr) + return 2 + print(json.dumps({"output": str(args.output.resolve()), "fixtures": len(manifest["fixtures"])}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resource_envelope/run_matrix.py b/scripts/resource_envelope/run_matrix.py new file mode 100644 index 000000000..9520188ef --- /dev/null +++ b/scripts/resource_envelope/run_matrix.py @@ -0,0 +1,499 @@ +#!/usr/bin/env python3 +"""Run and validate the resource-envelope fixture matrix. + +Large PDFs stay outside the repository. A qualification run should use a +manifest with exact fixture digests and sizes; the legacy ``--fixture`` form is +kept for exploratory runs and is intentionally not sufficient for ``--strict``. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import signal +import statistics +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Mapping, Sequence + +from scripts.resource_envelope.validate_envelope import validate_envelope + + +ROOT = Path(__file__).resolve().parents[2] +DEFAULT_BUDGETS = ROOT / "docs" / "RESOURCE_ENVELOPE_BUDGETS.json" +MATRIX_KIND = "loupe-resource-envelope-matrix" +DEFAULT_RASTERIZERS = 8 + +# These names mirror issue #242. multi-gb is optional because platform +# addressability and available disk are environment-dependent. +FIXTURE_SPECS: dict[str, dict[str, Any]] = { + "office-2mb": {"required": True, "expected_page_count": None, "workload": None, "min_bytes": 1_500_000, "max_bytes": 2_500_000}, + "image-heavy-500mb": {"required": True, "expected_page_count": None, "workload": None, "min_bytes": 450_000_000, "max_bytes": 550_000_000}, + "multi-gb": {"required": False, "expected_page_count": None, "workload": None, "min_bytes": 1_000_000_000, "max_bytes": None}, + "ten-thousand-page": {"required": True, "expected_page_count": 10000, "workload": "div2k-image-heavy", "min_bytes": None, "max_bytes": None}, + "pathological-vector": {"required": True, "expected_page_count": 256, "workload": "pathological-vector", "min_bytes": None, "max_bytes": None}, + "transparency-spots": {"required": True, "expected_page_count": 256, "workload": None, "min_bytes": None, "max_bytes": None}, +} + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for block in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(block) + return digest.hexdigest() + + +def _extract_json(stdout: str) -> dict[str, Any] | None: + """Extract PdfTool's JSON object, tolerating diagnostic text on stdout.""" + decoder = json.JSONDecoder() + for index, character in enumerate(stdout): + if character != "{": + continue + try: + value, _ = decoder.raw_decode(stdout[index:]) + except json.JSONDecodeError: + continue + if isinstance(value, dict): + return value + return None + + +def _envelope_from_output(payload: Mapping[str, Any]) -> dict[str, Any] | None: + data = payload.get("data") + if isinstance(data, Mapping) and isinstance(data.get("workload_envelope"), Mapping): + return dict(data["workload_envelope"]) + if isinstance(payload.get("workload_envelope"), Mapping): + return dict(payload["workload_envelope"]) + return None + + +def _git_head() -> str: + try: + return subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=ROOT, + check=True, + capture_output=True, + text=True, + ).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return "" + + +def _candidate_identity() -> dict[str, Any]: + local_sha = _git_head() + environment_sha = next((os.environ.get(key, "").strip() for key in ("GITHUB_SHA", "GIT_COMMIT") if os.environ.get(key, "").strip()), "") + return { + "candidate_sha": local_sha or environment_sha, + "source": "git-head" if local_sha else "environment-fallback", + "environment_sha": environment_sha, + "verified": bool(local_sha) and (not environment_sha or environment_sha == local_sha), + } + + +def _run_benchmark_process(command: list[str], timeout_seconds: float, cancel_after_seconds: float | None) -> subprocess.CompletedProcess[str]: + creationflags = 0 + popen_kwargs: dict[str, Any] = {} + if os.name == "nt": + creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0) + else: + popen_kwargs["start_new_session"] = True + process = subprocess.Popen( + command, + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + creationflags=creationflags, + **popen_kwargs, + ) + started = time.monotonic() + if cancel_after_seconds is not None: + while process.poll() is None and time.monotonic() - started < cancel_after_seconds: + time.sleep(min(0.05, cancel_after_seconds - (time.monotonic() - started))) + if process.poll() is None: + if os.name == "nt": + process.send_signal(getattr(signal, "CTRL_BREAK_EVENT", signal.SIGTERM)) + else: + process.send_signal(signal.SIGINT) + try: + stdout, stderr = process.communicate(timeout=max(0.1, timeout_seconds - (time.monotonic() - started))) + except subprocess.TimeoutExpired: + process.kill() + stdout, stderr = process.communicate() + return subprocess.CompletedProcess(command, process.returncode, stdout, stderr) + + +def _empty_result(fixture_id: str, reason: str) -> dict[str, Any]: + return { + "fixture_id": fixture_id, + "status": "unavailable", + "reason": reason, + "result": None, + "runs": [], + "validation_errors": [], + "regressions": [], + } + + +def _baseline_records(path: Path | None) -> dict[str, Mapping[str, Any]]: + if path is None: + return {} + payload = json.loads(path.read_text(encoding="utf-8")) + records = payload.get("fixtures") + if not isinstance(records, list): + raise ValueError("baseline must contain a fixtures array") + return { + str(record["fixture_id"]): record + for record in records + if isinstance(record, Mapping) and "fixture_id" in record + } + + +def _regressions(current: Mapping[str, Any], baseline: Mapping[str, Any] | None, margin: float) -> list[str]: + if baseline is None: + return [] + baseline_result = baseline.get("result") + if not isinstance(baseline_result, Mapping): + return [] + if current.get("fixture_sha256") != baseline.get("fixture_sha256"): + return ["baseline fixture digest does not match current fixture"] + current_identity = current.get("identity") + baseline_identity = baseline.get("identity") + if isinstance(current_identity, Mapping) and isinstance(baseline_identity, Mapping): + for key in ("os", "qt", "compiler", "renderer"): + if current_identity.get(key) != baseline_identity.get(key): + return [f"baseline identity mismatch: {key}"] + errors: list[str] = [] + for field in ("rss_high_water_bytes", "elapsed_ms"): + value = current.get(field) + old_value = baseline_result.get(field) + if not isinstance(value, int) or value < 0 or not isinstance(old_value, int) or old_value <= 0: + continue + if value > old_value * margin: + errors.append(f"{field} {value} exceeds baseline {old_value} by margin {margin:g}") + return errors + + +def _fixture_metadata(fixture_id: str, fixture_path: Path, metadata: Mapping[str, Any] | None, require_provenance: bool) -> tuple[dict[str, Any], list[str]]: + spec = FIXTURE_SPECS[fixture_id] + size = fixture_path.stat().st_size + digest = _sha256(fixture_path) + details: dict[str, Any] = {"fixture_sha256": digest, "input_bytes": size} + errors: list[str] = [] + if metadata is None: + if require_provenance: + errors.append("fixture provenance manifest not supplied") + else: + expected_digest = metadata.get("sha256") + expected_size = metadata.get("size_bytes") + if expected_digest != digest: + errors.append("fixture SHA-256 does not match manifest") + if expected_size != size: + errors.append(f"fixture size {size} does not match manifest {expected_size}") + details["provenance"] = metadata.get("provenance", "") + details["manifest_sha256"] = expected_digest + minimum = spec.get("min_bytes") + maximum = spec.get("max_bytes") + if minimum is not None and size < minimum: + errors.append(f"fixture is smaller than {minimum} bytes for {fixture_id}") + if maximum is not None and size > maximum: + errors.append(f"fixture is larger than {maximum} bytes for {fixture_id}") + return details, errors + + +def _aggregate_envelopes(envelopes: list[Mapping[str, Any]]) -> tuple[dict[str, Any], dict[str, Any]]: + # Use the highest-RSS run as the safety representative and the median + # elapsed time. This keeps peak-memory validation conservative while + # reducing scheduler noise in timing comparisons. + representative = dict(max(envelopes, key=lambda item: item.get("rss_high_water_bytes", -1))) + elapsed = [item["elapsed_ms"] for item in envelopes if isinstance(item.get("elapsed_ms"), int) and item["elapsed_ms"] >= 0] + rss = [item["rss_high_water_bytes"] for item in envelopes if isinstance(item.get("rss_high_water_bytes"), int) and item["rss_high_water_bytes"] >= 0] + stats = { + "repetitions": len(envelopes), + "elapsed_ms": {"median": statistics.median(elapsed) if elapsed else -1, "min": min(elapsed) if elapsed else -1, "max": max(elapsed) if elapsed else -1}, + "rss_high_water_bytes": {"median": statistics.median(rss) if rss else -1, "min": min(rss) if rss else -1, "max": max(rss) if rss else -1}, + "unstable": bool(rss and statistics.median(rss) > 0 and max(rss) > statistics.median(rss) * 1.2), + } + if elapsed: + representative["elapsed_ms"] = int(statistics.median(elapsed)) + if rss: + representative["rss_high_water_bytes"] = max(rss) + return representative, stats + + +def run_fixture( + pdf_tool: Path, + fixture_id: str, + fixture_path: Path, + budgets: Mapping[str, Any], + timeout_seconds: float, + baseline: Mapping[str, Any] | None = None, + margin: float = 2.0, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + metadata: Mapping[str, Any] | None = None, + repetitions: int = 1, + rasterizers: int = DEFAULT_RASTERIZERS, + require_provenance: bool = False, + cancel_after_seconds: float | None = None, +) -> dict[str, Any]: + if repetitions < 1 or rasterizers < 1: + raise ValueError("repetitions and rasterizers must be positive") + # Resolve relative paths before the child is launched with cwd=ROOT. + # Otherwise a relative fixture or tool path checked against the caller + # directory would be looked up again relative to ROOT in the child. + pdf_tool = Path(pdf_tool).resolve() + fixture_path = Path(fixture_path).resolve() + spec = FIXTURE_SPECS[fixture_id] + fixture_details, provenance_errors = _fixture_metadata(fixture_id, fixture_path, metadata, require_provenance) + # Pin rasterizers to a fixed value (8) so the same code and fixtures + # produce comparable RSS and elapsed time across hosts with different + # CPU counts. The value is recorded in the result profile. + command = [str(pdf_tool), "benchmark", str(fixture_path), "--render-hw-accel", "0", "--render-rasterizers", str(rasterizers), "--console-format", "json"] + record: dict[str, Any] = { + "fixture_id": fixture_id, + "path": str(fixture_path.resolve()), + "expected_page_count": metadata.get("page_count", spec["expected_page_count"]) if metadata else spec["expected_page_count"], + "workload": spec["workload"], + "profile": {"render_hw_accel": False, "render_rasterizers": rasterizers}, + "command": command, + **fixture_details, + } + if provenance_errors: + record.update({"status": "failed", "result": None, "runs": [], "validation_errors": provenance_errors, "regressions": []}) + return record + + runs: list[dict[str, Any]] = [] + envelopes: list[Mapping[str, Any]] = [] + for index in range(repetitions): + try: + if runner is subprocess.run and cancel_after_seconds is not None: + completed = _run_benchmark_process(command, timeout_seconds, cancel_after_seconds) + else: + completed = runner(command, cwd=ROOT, check=False, capture_output=True, text=True, timeout=timeout_seconds) + except subprocess.TimeoutExpired: + runs.append({"run": index + 1, "status": "unavailable", "reason": "benchmark-timeout", "process_exit_code": None}) + continue + except OSError as exc: + runs.append({"run": index + 1, "status": "unavailable", "reason": f"benchmark-launch-failed:{exc}", "process_exit_code": None}) + continue + payload = _extract_json(completed.stdout) + envelope = _envelope_from_output(payload) if payload else None + if envelope is None: + runs.append({"run": index + 1, "status": "unavailable", "reason": "benchmark-envelope-missing", "process_exit_code": completed.returncode, "stderr": completed.stderr[-2000:]}) + continue + envelopes.append(envelope) + runs.append({"run": index + 1, "status": "recorded", "process_exit_code": completed.returncode, "result": envelope}) + + if not envelopes: + record.update({"status": "unavailable", "result": None, "runs": runs, "validation_errors": [], "regressions": []}) + return record + + representative, stats = _aggregate_envelopes(envelopes) + validation_errors: list[str] = [] + for index, envelope in enumerate(envelopes, start=1): + for error in validate_envelope(envelope, budgets, spec["workload"]): + validation_errors.append(f"run {index}: {error}") + expected_page_count = record["expected_page_count"] + if expected_page_count is not None and envelope.get("page_count") != expected_page_count: + validation_errors.append(f"run {index}: page_count {envelope.get('page_count')} does not match expected {expected_page_count}") + rss = envelope.get("rss_high_water_bytes") + resident_limit = budgets.get("resource_budget", {}).get("resident_limit_bytes") + if isinstance(rss, int) and rss >= 0 and isinstance(resident_limit, int) and rss > resident_limit: + validation_errors.append(f"run {index}: RSS {rss} exceeds resident policy {resident_limit}") + record["identity"] = representative.get("identity", {}) + record["result"] = representative + record["statistics"] = stats + record["runs"] = runs + unavailable_runs = [run for run in runs if run["status"] != "recorded"] + validation_errors.extend( + f"run {run['run']}: {run['reason']}" for run in unavailable_runs + ) + record["validation_errors"] = sorted(set(validation_errors)) + comparison = dict(representative) + comparison["fixture_sha256"] = record["fixture_sha256"] + comparison["identity"] = record["identity"] + record["regressions"] = _regressions(comparison, baseline, margin) + if cancel_after_seconds is not None: + if representative.get("status") != "cancelled": + validation_errors.append("cancellation probe did not produce a cancelled envelope") + if not isinstance(representative.get("cancellation_latency_ms"), int) or representative["cancellation_latency_ms"] < 0: + validation_errors.append("cancellation probe did not report cancellation latency") + record["cancellation_probe"] = {"requested_after_seconds": cancel_after_seconds} + record["validation_errors"] = sorted(set(validation_errors)) + hard_error_markers = ("does not match", "exceeds", "identity", "fixture SHA", "manifest") + hard_errors = [error for error in record["validation_errors"] if any(marker in error for marker in hard_error_markers)] + if record["regressions"] or hard_errors: + record["status"] = "failed" + elif record["validation_errors"] or representative.get("status") != "complete": + record["status"] = "flagged" + else: + record["status"] = "measured" + return record + + +def _load_fixture_manifest(path: Path) -> dict[str, dict[str, Any]]: + payload = json.loads(path.read_text(encoding="utf-8")) + if payload.get("schema_kind") != "loupe-resource-envelope-fixtures" or payload.get("schema_version") != 1: + raise ValueError("fixture manifest schema_kind/schema_version is invalid") + records = payload.get("fixtures") + if not isinstance(records, list): + raise ValueError("fixture manifest must contain a fixtures array") + result: dict[str, dict[str, Any]] = {} + for record in records: + if not isinstance(record, dict) or record.get("fixture_id") not in FIXTURE_SPECS: + raise ValueError("fixture manifest contains an unknown fixture_id") + fixture_id = str(record["fixture_id"]) + if fixture_id in result: + raise ValueError(f"fixture manifest contains duplicate fixture_id: {fixture_id}") + if not isinstance(record.get("path"), str) or not record["path"]: + raise ValueError(f"fixture manifest path missing: {fixture_id}") + digest = record.get("sha256") + if not isinstance(digest, str) or len(digest) != 64 or any(character not in "0123456789abcdef" for character in digest): + raise ValueError(f"fixture manifest sha256 missing: {fixture_id}") + if not isinstance(record.get("size_bytes"), int) or record["size_bytes"] < 1: + raise ValueError(f"fixture manifest size_bytes missing: {fixture_id}") + if not isinstance(record.get("provenance"), str) or not record["provenance"].strip(): + raise ValueError(f"fixture manifest provenance missing: {fixture_id}") + if "page_count" in record and (not isinstance(record["page_count"], int) or record["page_count"] < 1): + raise ValueError(f"fixture manifest page_count is invalid: {fixture_id}") + normalized = dict(record) + fixture_path = Path(str(record["path"])) + if not fixture_path.is_absolute(): + normalized["path"] = str((path.parent / fixture_path).resolve()) + result[fixture_id] = normalized + return result + + +def _fixture_args(values: Sequence[str]) -> dict[str, Path]: + fixtures: dict[str, Path] = {} + for value in values: + name, separator, path = value.partition("=") + if not separator or name not in FIXTURE_SPECS or not path: + raise ValueError(f"fixture must be NAME=PATH for one of: {', '.join(FIXTURE_SPECS)}") + if name in fixtures: + raise ValueError(f"fixture supplied more than once: {name}") + fixtures[name] = Path(path) + return fixtures + + +def run_matrix( + pdf_tool: Path, + fixtures: Mapping[str, Path | Mapping[str, Any]], + budgets: Mapping[str, Any], + timeout_seconds: float, + baseline: Mapping[str, Any] | Path | None = None, + margin: float = 2.0, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, + repetitions: int = 1, + rasterizers: int = DEFAULT_RASTERIZERS, + cancel_fixture: str | None = None, + cancel_after_seconds: float | None = None, +) -> dict[str, Any]: + pdf_tool = Path(pdf_tool).resolve() + baseline_by_fixture = _baseline_records(baseline) if isinstance(baseline, Path) else (baseline or {}) + identity = _candidate_identity() + candidate_sha = identity["candidate_sha"] + records: list[dict[str, Any]] = [] + for fixture_id, spec in FIXTURE_SPECS.items(): + supplied = fixtures.get(fixture_id) + if supplied is None: + record = _empty_result(fixture_id, "fixture-not-supplied" if spec["required"] else "fixture-not-supplied-optional") + record["required"] = spec["required"] + records.append(record) + continue + metadata = dict(supplied) if isinstance(supplied, Mapping) else None + fixture_path = Path(metadata["path"]) if metadata else Path(supplied) + fixture_path = fixture_path.resolve() + if not fixture_path.is_file(): + record = _empty_result(fixture_id, "fixture-not-found") + record["required"] = spec["required"] + records.append(record) + continue + record = run_fixture(pdf_tool, fixture_id, fixture_path, budgets, timeout_seconds, baseline_by_fixture.get(fixture_id), margin, runner, metadata, repetitions, rasterizers, bool(metadata), cancel_after_seconds if fixture_id == cancel_fixture else None) + record["required"] = spec["required"] + result = record.get("result") + if isinstance(result, Mapping): + result_identity = result.get("identity") + if isinstance(result_identity, Mapping): + commit = result_identity.get("commit") + if commit != candidate_sha: + record["validation_errors"] = sorted(set(record.get("validation_errors", []) + ["PdfTool identity commit does not match checkout HEAD"])) + record["status"] = "failed" + expected_digest = record.get("fixture_sha256") + fixture_digest = result_identity.get("fixture_digest") + if expected_digest and fixture_digest != expected_digest: + record["validation_errors"] = sorted(set(record.get("validation_errors", []) + ["PdfTool identity fixture digest does not match input SHA-256"])) + record["status"] = "failed" + records.append(record) + + failed = sum(record["status"] == "failed" for record in records) + flagged = sum(record["required"] and record["status"] in {"flagged", "unavailable"} for record in records) + skipped = sum(not record["required"] and record["status"] == "unavailable" for record in records) + return { + "schema_kind": MATRIX_KIND, + "schema_version": 2, + "candidate_sha": identity["candidate_sha"], + "candidate_identity": identity, + "generated_at_utc": datetime.now(timezone.utc).isoformat(), + "fixtures": records, + "summary": { + "total": len(records), + "measured": sum(record["status"] == "measured" for record in records), + "flagged": flagged, + "skipped": skipped, + "failed": failed, + "candidate_sha_verified": identity["verified"], + }, + } + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--pdf-tool", type=Path, required=True) + source = parser.add_mutually_exclusive_group() + source.add_argument("--manifest", type=Path) + source.add_argument("--fixture", action="append", default=[], metavar="NAME=PATH") + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--budgets", type=Path, default=DEFAULT_BUDGETS) + parser.add_argument("--baseline", type=Path) + parser.add_argument("--margin", type=float, default=2.0) + parser.add_argument("--repetitions", type=int, default=1) + parser.add_argument("--rasterizers", type=int, default=DEFAULT_RASTERIZERS) + parser.add_argument("--timeout-seconds", type=float, default=120.0) + parser.add_argument("--cancel-fixture", choices=tuple(FIXTURE_SPECS)) + parser.add_argument("--cancel-after-seconds", type=float) + parser.add_argument("--strict", action="store_true", help="fail when required fixtures, provenance, or measurements are unavailable") + args = parser.parse_args(argv) + try: + if args.margin <= 0 or args.timeout_seconds <= 0 or args.repetitions < 1 or args.rasterizers < 1: + raise ValueError("margin, timeout-seconds, repetitions, and rasterizers must be positive") + if args.strict and args.manifest is None: + raise ValueError("--strict requires a fixture --manifest with exact digests and sizes") + if (args.cancel_fixture is None) != (args.cancel_after_seconds is None): + raise ValueError("--cancel-fixture and --cancel-after-seconds must be supplied together") + if args.cancel_after_seconds is not None and args.cancel_after_seconds <= 0: + raise ValueError("cancel-after-seconds must be positive") + if args.cancel_fixture is not None and args.repetitions != 1: + raise ValueError("cancellation probes require --repetitions 1") + fixtures: Mapping[str, Path | Mapping[str, Any]] = _load_fixture_manifest(args.manifest) if args.manifest else _fixture_args(args.fixture) + budgets = json.loads(args.budgets.read_text(encoding="utf-8")) + matrix = run_matrix(args.pdf_tool, fixtures, budgets, args.timeout_seconds, args.baseline, args.margin, repetitions=args.repetitions, rasterizers=args.rasterizers, cancel_fixture=args.cancel_fixture, cancel_after_seconds=args.cancel_after_seconds) + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(matrix, indent=2) + "\n", encoding="utf-8") + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"resource-envelope matrix error: {exc}", file=sys.stderr) + return 2 + + print(json.dumps(matrix["summary"], indent=2)) + return 1 if matrix["summary"]["failed"] or args.strict and (matrix["summary"]["flagged"] or not matrix["summary"]["candidate_sha_verified"]) else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resource_envelope/test_run_matrix.py b/scripts/resource_envelope/test_run_matrix.py new file mode 100644 index 000000000..10218d7d3 --- /dev/null +++ b/scripts/resource_envelope/test_run_matrix.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import json +import subprocess +import tempfile +import unittest +from pathlib import Path + +from scripts.resource_envelope.run_matrix import ( + FIXTURE_SPECS, + _fixture_args, + _load_fixture_manifest, + run_fixture, + run_matrix, +) +from scripts.resource_envelope.create_fixture_manifest import create_manifest +from scripts.resource_envelope.validate_envelope import POOL_NAMES + + +def _policy() -> dict: + return { + "resource_budget": { + "resident_limit_bytes": 200, + "pool_limits_bytes": {pool: 100 for pool in POOL_NAMES}, + }, + "workloads": { + "pathological-vector": {"page_count": 256, "wall_time_ms": 100, "rss_high_water_bytes": 200}, + }, + } + + +def _envelope(page_count: int = 256, rss: int = 10, elapsed: int = 10) -> dict: + return { + "identity": {}, + "family": "test", + "status": "incomplete", + "page_count": page_count, + "rss_high_water_bytes": rss, + "preflight_high_water_bytes": -1, + "pages_materialized": page_count, + "elapsed_ms": elapsed, + "prefetch_shed": False, + "interaction_slot_held": True, + "resources": { + "config": {"resident_limit_bytes": 200, "pool_limits_bytes": {pool: 100 for pool in POOL_NAMES}}, + "resident_bytes": 0, + "resident_high_water_bytes": 0, + "pressure": "normal", + "pools": { + pool: {"limit_bytes": 100, "current_bytes": 0, "high_water_bytes": 0, "evictions": 0, "shed": 0} + for pool in POOL_NAMES + }, + }, + } + + +def _metadata(fixture: Path) -> dict: + import hashlib + + return { + "path": str(fixture), + "sha256": hashlib.sha256(fixture.read_bytes()).hexdigest(), + "size_bytes": fixture.stat().st_size, + "provenance": "unit-test fixture", + "page_count": 256, + } + + +class RunMatrixTest(unittest.TestCase): + def test_fixture_argument_rejects_unknown_name(self) -> None: + with self.assertRaises(ValueError): + _fixture_args(["unknown=file.pdf"]) + + def test_run_fixture_extracts_nested_envelope_and_flags_incomplete(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + payload = json.dumps({"data": {"workload_envelope": _envelope()}}) + + def runner(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, payload, "") + + record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, runner=runner, metadata=_metadata(fixture)) + self.assertEqual(record["status"], "flagged") + self.assertEqual(record["result"]["page_count"], 256) + + def test_baseline_regression_fails_record(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + payload = json.dumps({"workload_envelope": _envelope(rss=50, elapsed=50)}) + + def runner(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, payload, "") + + baseline = {"result": _envelope(rss=10, elapsed=10)} + metadata = _metadata(fixture) + current = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, baseline=None, runner=runner, metadata=metadata) + baseline["fixture_sha256"] = current["fixture_sha256"] + baseline["identity"] = current["identity"] + record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, baseline=baseline, margin=2, runner=runner, metadata=metadata) + self.assertEqual(record["status"], "failed") + self.assertEqual(len(record["regressions"]), 2) + + def test_repetitions_record_conservative_memory_and_median_time(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + envelopes = [_envelope(rss=value, elapsed=value) for value in (10, 20, 30)] + + def runner(*args, **kwargs): + return subprocess.CompletedProcess(args[0], 0, json.dumps({"workload_envelope": envelopes.pop(0)}), "") + + record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, runner=runner, metadata=_metadata(fixture), repetitions=3) + self.assertEqual(record["statistics"]["elapsed_ms"]["median"], 20) + self.assertEqual(record["result"]["rss_high_water_bytes"], 30) + + def test_manifest_resolves_relative_paths(self) -> None: + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + fixture = root / "fixture.pdf" + fixture.write_bytes(b"fixture") + manifest = root / "manifest.json" + manifest.write_text(json.dumps({ + "schema_kind": "loupe-resource-envelope-fixtures", + "schema_version": 1, + "fixtures": [{ + "fixture_id": "pathological-vector", + "path": "fixture.pdf", + "sha256": "0" * 64, + "size_bytes": 7, + "provenance": "unit-test", + }], + }), encoding="utf-8") + loaded = _load_fixture_manifest(manifest) + self.assertEqual(Path(loaded["pathological-vector"]["path"]), fixture.resolve()) + + def test_create_manifest_records_digest_size_and_expected_pages(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + manifest = create_manifest({"pathological-vector": fixture}, "unit-test generator") + record = manifest["fixtures"][0] + self.assertEqual(record["size_bytes"], 7) + self.assertEqual(record["page_count"], 256) + self.assertEqual(record["provenance"], "unit-test generator") + + def test_matrix_records_missing_required_and_optional_fixtures(self) -> None: + with tempfile.TemporaryDirectory() as directory: + matrix = run_matrix(Path("PdfTool.exe"), {}, _policy(), 1) + self.assertEqual(matrix["summary"]["total"], len(FIXTURE_SPECS)) + self.assertEqual(matrix["summary"]["flagged"], sum(spec["required"] for spec in FIXTURE_SPECS.values())) + self.assertEqual(matrix["summary"]["failed"], 0) + + +if __name__ == "__main__": + unittest.main() From cf5c0d9fb644e9204188702c280d04277deb946c Mon Sep 17 00:00:00 2001 From: michael berry Date: Tue, 1 Sep 2026 01:05:53 -0700 Subject: [PATCH 17/33] fix: validate resource-envelope evidence identity per run (#497) --- changes/cdx-identity-fix-followup.md | 4 ++ scripts/resource_envelope/run_matrix.py | 10 ++- scripts/resource_envelope/test_run_matrix.py | 67 +++++++++++++++++--- 3 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 changes/cdx-identity-fix-followup.md diff --git a/changes/cdx-identity-fix-followup.md b/changes/cdx-identity-fix-followup.md new file mode 100644 index 000000000..a64c86d08 --- /dev/null +++ b/changes/cdx-identity-fix-followup.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: users +Breaking-Change: no +Summary: Validate resource-envelope evidence identity per run, ensuring PdfTool commit and fixture digest match the current candidate and input. \ No newline at end of file diff --git a/scripts/resource_envelope/run_matrix.py b/scripts/resource_envelope/run_matrix.py index 9520188ef..fd0a8fed3 100644 --- a/scripts/resource_envelope/run_matrix.py +++ b/scripts/resource_envelope/run_matrix.py @@ -242,9 +242,12 @@ def run_fixture( rasterizers: int = DEFAULT_RASTERIZERS, require_provenance: bool = False, cancel_after_seconds: float | None = None, + candidate_sha: str | None = None, ) -> dict[str, Any]: if repetitions < 1 or rasterizers < 1: raise ValueError("repetitions and rasterizers must be positive") + if candidate_sha is None: + candidate_sha = _candidate_identity()["candidate_sha"] # Resolve relative paths before the child is launched with cwd=ROOT. # Otherwise a relative fixture or tool path checked against the caller # directory would be looked up again relative to ROOT in the child. @@ -307,6 +310,11 @@ def run_fixture( resident_limit = budgets.get("resource_budget", {}).get("resident_limit_bytes") if isinstance(rss, int) and rss >= 0 and isinstance(resident_limit, int) and rss > resident_limit: validation_errors.append(f"run {index}: RSS {rss} exceeds resident policy {resident_limit}") + identity = envelope.get("identity") if isinstance(envelope.get("identity"), Mapping) else {} + if identity.get("commit") != candidate_sha: + validation_errors.append(f"run {index}: identity.commit {identity.get('commit')!r} does not match candidate {candidate_sha!r}") + if identity.get("fixture_digest") != record.get("fixture_sha256"): + validation_errors.append(f"run {index}: identity.fixture_digest {identity.get('fixture_digest')!r} does not match input {record.get('fixture_sha256')!r}") record["identity"] = representative.get("identity", {}) record["result"] = representative record["statistics"] = stats @@ -416,7 +424,7 @@ def run_matrix( record["required"] = spec["required"] records.append(record) continue - record = run_fixture(pdf_tool, fixture_id, fixture_path, budgets, timeout_seconds, baseline_by_fixture.get(fixture_id), margin, runner, metadata, repetitions, rasterizers, bool(metadata), cancel_after_seconds if fixture_id == cancel_fixture else None) + record = run_fixture(pdf_tool, fixture_id, fixture_path, budgets, timeout_seconds, baseline_by_fixture.get(fixture_id), margin, runner, metadata, repetitions, rasterizers, bool(metadata), cancel_after_seconds if fixture_id == cancel_fixture else None, candidate_sha) record["required"] = spec["required"] result = record.get("result") if isinstance(result, Mapping): diff --git a/scripts/resource_envelope/test_run_matrix.py b/scripts/resource_envelope/test_run_matrix.py index 10218d7d3..dbe77aa50 100644 --- a/scripts/resource_envelope/test_run_matrix.py +++ b/scripts/resource_envelope/test_run_matrix.py @@ -1,5 +1,6 @@ from __future__ import annotations +import hashlib import json import subprocess import tempfile @@ -29,9 +30,12 @@ def _policy() -> dict: } -def _envelope(page_count: int = 256, rss: int = 10, elapsed: int = 10) -> dict: +def _envelope(page_count: int = 256, rss: int = 10, elapsed: int = 10, commit: str | None = None, fixture_digest: str | None = None) -> dict: + identity = {} + if commit is not None or fixture_digest is not None: + identity = {"commit": commit, "fixture_digest": fixture_digest} return { - "identity": {}, + "identity": identity, "family": "test", "status": "incomplete", "page_count": page_count, @@ -75,12 +79,14 @@ def test_run_fixture_extracts_nested_envelope_and_flags_incomplete(self) -> None with tempfile.TemporaryDirectory() as directory: fixture = Path(directory) / "fixture.pdf" fixture.write_bytes(b"fixture") - payload = json.dumps({"data": {"workload_envelope": _envelope()}}) + import hashlib + digest = hashlib.sha256(b"fixture").hexdigest() + payload = json.dumps({"data": {"workload_envelope": _envelope(commit="candidate-sha", fixture_digest=digest)}}) def runner(*args, **kwargs): return subprocess.CompletedProcess(args[0], 0, payload, "") - record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, runner=runner, metadata=_metadata(fixture)) + record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, runner=runner, metadata=_metadata(fixture), candidate_sha="candidate-sha") self.assertEqual(record["status"], "flagged") self.assertEqual(record["result"]["page_count"], 256) @@ -88,30 +94,71 @@ def test_baseline_regression_fails_record(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = Path(directory) / "fixture.pdf" fixture.write_bytes(b"fixture") - payload = json.dumps({"workload_envelope": _envelope(rss=50, elapsed=50)}) + import hashlib + digest = hashlib.sha256(b"fixture").hexdigest() + payload = json.dumps({"workload_envelope": _envelope(rss=50, elapsed=50, commit="candidate-sha", fixture_digest=digest)}) def runner(*args, **kwargs): return subprocess.CompletedProcess(args[0], 0, payload, "") - baseline = {"result": _envelope(rss=10, elapsed=10)} + baseline = {"result": _envelope(rss=10, elapsed=10, commit="candidate-sha", fixture_digest=digest)} metadata = _metadata(fixture) - current = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, baseline=None, runner=runner, metadata=metadata) + current = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, baseline=None, runner=runner, metadata=metadata, candidate_sha="candidate-sha") baseline["fixture_sha256"] = current["fixture_sha256"] baseline["identity"] = current["identity"] - record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, baseline=baseline, margin=2, runner=runner, metadata=metadata) + record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, baseline=baseline, margin=2, runner=runner, metadata=metadata, candidate_sha="candidate-sha") self.assertEqual(record["status"], "failed") self.assertEqual(len(record["regressions"]), 2) + def test_identity_must_match_candidate_and_fixture(self) -> None: + with tempfile.TemporaryDirectory() as directory: + fixture = Path(directory) / "fixture.pdf" + fixture.write_bytes(b"fixture") + import hashlib + payload = json.dumps( + { + "workload_envelope": _envelope( + commit="stale-candidate", + fixture_digest=hashlib.sha256(b"other fixture").hexdigest(), + ), + } + ) + + def runner(*args, **kwargs): + return __import__("subprocess").CompletedProcess(args[0], 0, payload, "") + + metadata = _metadata(fixture) + record = run_fixture( + Path("PdfTool.exe"), + "pathological-vector", + fixture, + _policy(), + 1, + runner=runner, + metadata=metadata, + candidate_sha="candidate-sha", + ) + + self.assertEqual(record["status"], "failed") + self.assertTrue( + any("identity.commit" in error for error in record["validation_errors"]) + ) + self.assertTrue( + any("identity.fixture_digest" in error for error in record["validation_errors"]) + ) + def test_repetitions_record_conservative_memory_and_median_time(self) -> None: with tempfile.TemporaryDirectory() as directory: fixture = Path(directory) / "fixture.pdf" fixture.write_bytes(b"fixture") - envelopes = [_envelope(rss=value, elapsed=value) for value in (10, 20, 30)] + import hashlib + digest = hashlib.sha256(b"fixture").hexdigest() + envelopes = [_envelope(rss=value, elapsed=value, commit="candidate-sha", fixture_digest=digest) for value in (10, 20, 30)] def runner(*args, **kwargs): return subprocess.CompletedProcess(args[0], 0, json.dumps({"workload_envelope": envelopes.pop(0)}), "") - record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, runner=runner, metadata=_metadata(fixture), repetitions=3) + record = run_fixture(Path("PdfTool.exe"), "pathological-vector", fixture, _policy(), 1, runner=runner, metadata=_metadata(fixture), repetitions=3, candidate_sha="candidate-sha") self.assertEqual(record["statistics"]["elapsed_ms"]["median"], 20) self.assertEqual(record["result"]["rss_high_water_bytes"], 30) From 98a487b58d358216858e3388ec33c12a25b9573f Mon Sep 17 00:00:00 2001 From: michael berry Date: Tue, 1 Sep 2026 01:05:58 -0700 Subject: [PATCH 18/33] Fix Linux cache and Windows MSI Qt packaging (#494) * Fix Linux cache and Windows MSI Qt packaging * fix: unslop changelog summary Rewrite in plain language without the redundant phrasing. * fix: trim trailing blank line from changelog fragment --- .github/workflows/LinuxInstall.yml | 1 + WixInstaller/Product.wxs.in | 3 +++ changes/cdx-ci-packaging-repair.md | 4 ++++ 3 files changed, 8 insertions(+) create mode 100644 changes/cdx-ci-packaging-repair.md diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index 5d7e99816..e05254029 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -109,6 +109,7 @@ jobs: - name: 'VCPKG: Set up VCPKG' run: | + mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE" VCPKG_COMMIT="$(python3 - <<'PY' import json with open("loupe/vcpkg-configuration.json", encoding="utf-8") as f: diff --git a/WixInstaller/Product.wxs.in b/WixInstaller/Product.wxs.in index ded0b92f7..945907039 100644 --- a/WixInstaller/Product.wxs.in +++ b/WixInstaller/Product.wxs.in @@ -254,6 +254,9 @@ ${LOUPE_WIX_QT_STYLES_COMPONENT} + + + diff --git a/changes/cdx-ci-packaging-repair.md b/changes/cdx-ci-packaging-repair.md new file mode 100644 index 000000000..65397b7c3 --- /dev/null +++ b/changes/cdx-ci-packaging-repair.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: maintainers +Breaking-Change: no +Summary: Create the vcpkg cache directory on Linux and include the staged Qt config in the Windows MSI. From 70af4b623a8a55cdc30e6c658d35d2532519ef78 Mon Sep 17 00:00:00 2001 From: michael berry Date: Tue, 1 Sep 2026 11:10:07 -0700 Subject: [PATCH 19/33] fix: resolve Quick parity CI failures (#498) --- LoupeEditor/editorhost.cpp | 7 +++++-- UnitTests/tst_quickdocumentmodeltest.cpp | 2 +- changes/codex-fix-issues-from-codex-review.md | 4 ---- 3 files changed, 6 insertions(+), 7 deletions(-) delete mode 100644 changes/codex-fix-issues-from-codex-review.md diff --git a/LoupeEditor/editorhost.cpp b/LoupeEditor/editorhost.cpp index b1b6472d6..a02330925 100644 --- a/LoupeEditor/editorhost.cpp +++ b/LoupeEditor/editorhost.cpp @@ -671,9 +671,12 @@ void EditorHost::moveSearch(int direction) return; } - if (m_searchRow < 0) { + if (m_searchRow < 0) + { m_searchRow = direction > 0 ? 0 : count - 1; - } else { + } + else + { m_searchRow = (m_searchRow + direction + count) % count; } goToPage(m_documentModel.searchPageAt(m_searchRow)); diff --git a/UnitTests/tst_quickdocumentmodeltest.cpp b/UnitTests/tst_quickdocumentmodeltest.cpp index db386e332..3bf04c93f 100644 --- a/UnitTests/tst_quickdocumentmodeltest.cpp +++ b/UnitTests/tst_quickdocumentmodeltest.cpp @@ -36,7 +36,7 @@ void QuickDocumentModelTest::searchResultsExposeOnlyValueRoles() QuickSearchResultModel model; QSignalSpy resetSpy(&model, &QAbstractItemModel::modelReset); - model.replace({{3, QStringLiteral("match"), QStringLiteral("before match after")}}, + model.replace({ { 3, QStringLiteral("match"), QStringLiteral("before match after") } }, QStringLiteral("match"), QStringLiteral("revision")); QCOMPARE(resetSpy.count(), 1); diff --git a/changes/codex-fix-issues-from-codex-review.md b/changes/codex-fix-issues-from-codex-review.md deleted file mode 100644 index a3177a083..000000000 --- a/changes/codex-fix-issues-from-codex-review.md +++ /dev/null @@ -1,4 +0,0 @@ -Category: fixed -Audience: users -Breaking-Change: no -Summary: Route Quick document search through LoupeLibCore and make Find reveal and focus the Search tab. From c39ddd5ae9039105a77a24d76415ecec67b232bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 19:18:49 +0000 Subject: [PATCH 20/33] test: add the gh-146 interaction trace corpus and CI checker Issue #146 needs traces that replay identically on every runner. This adds the data half of that, ahead of the C++ harness, so a malformed scenario fails in seconds rather than after a compile. A scenario is a manifest that embeds an InteractionTrace rather than an extension of it. InteractionTrace is a shipping type whose privacy contract forbids geometry and target identity, and a scenario needs exactly those to declare its fixture and expected selection. Embedding reuses the tested round-trip with no change to interactiontrace.cpp, and lets a recorded field trace drop straight in as the 'trace' member. The report schema pins the rule that missing telemetry is reported as available:false with null percentiles, never as zero, and that a failing run names both the contract it broke and the phase responsible. The checker enforces both, plus that every corpus scenario produced a run -- a scenario that silently stops running is otherwise invisible. Corpus digests are pinned to LF in .gitattributes and normalized in the checker. The repository checks text out as CRLF, so without this the gate would pass on the machine that wrote the manifest and fail on every fresh checkout. Refs #146, #139. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PWGFQCBSNwStAPYUCAZzpN --- .gitattributes | 1 + .github/workflows/ci.yml | 4 + .../click-select-deselect.json | 231 +++++++ .../interaction-traces/hover-sparse.json | 96 +++ .../testdata/interaction-traces/manifest.json | 33 + .../testdata/interaction-traces/pan.json | 111 ++++ changes/cc-gh146-interaction-trace-corpus.md | 4 + docs/schemas/interaction-scenario.schema.json | 299 +++++++++ .../interaction-trace-report.schema.json | 247 +++++++ scripts/ci/check_interaction_traces.py | 623 ++++++++++++++++++ scripts/ci/test_check_interaction_traces.py | 364 ++++++++++ 11 files changed, 2013 insertions(+) create mode 100644 UnitTests/testdata/interaction-traces/click-select-deselect.json create mode 100644 UnitTests/testdata/interaction-traces/hover-sparse.json create mode 100644 UnitTests/testdata/interaction-traces/manifest.json create mode 100644 UnitTests/testdata/interaction-traces/pan.json create mode 100644 changes/cc-gh146-interaction-trace-corpus.md create mode 100644 docs/schemas/interaction-scenario.schema.json create mode 100644 docs/schemas/interaction-trace-report.schema.json create mode 100755 scripts/ci/check_interaction_traces.py create mode 100644 scripts/ci/test_check_interaction_traces.py diff --git a/.gitattributes b/.gitattributes index 89b9f55db..a2ef05320 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ *.sh text eol=lf docs/generated/phase5-widgets-inventory.json text eol=lf docs/generated/phase5-widgets-disposition.json text eol=lf +UnitTests/testdata/interaction-traces/** text eol=lf *.pdf binary *.icc binary Fuzz/corpus/regression/** binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8364912b..383d5e3dd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,6 +39,10 @@ jobs: python3 scripts/ci/check_phase5_residue.py - name: Verify unmanaged async launch allowlist run: python3 scripts/ci/check_unmanaged_async.py + - name: Verify interaction trace corpus + run: | + python3 -m unittest scripts.ci.test_check_interaction_traces -q + python3 scripts/ci/check_interaction_traces.py --corpus-only - name: Verify semantic-trust source boundaries run: python3 scripts/ci/test_check_trust_contract_sources.py && python3 scripts/ci/check_trust_contract_sources.py - name: Verify generated dependency paths are untracked diff --git a/UnitTests/testdata/interaction-traces/click-select-deselect.json b/UnitTests/testdata/interaction-traces/click-select-deselect.json new file mode 100644 index 000000000..321a62d11 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/click-select-deselect.json @@ -0,0 +1,231 @@ +{ + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "click-select-deselect", + "description": "Click selects the finding under the pointer; a second click on empty page space clears it. Both presses stay under the drag threshold, so neither may emit a document operation.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "", + "hover_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "click-select-deselect", + "inputs": [ + { + "pointer": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "action": "move", + "position_px": { + "x": 300, + "y": 200 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "action": "move", + "position_px": { + "x": 200, + "y": 120 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "action": "move", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "action": "press", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 1, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "action": "release", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 1, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "action": "move", + "position_px": { + "x": 300, + "y": 400 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "action": "move", + "position_px": { + "x": 500, + "y": 520 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 133333336, + "sequence": 8 + }, + "action": "press", + "position_px": { + "x": 500, + "y": 520 + }, + "button": 1, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 150000003, + "sequence": 9 + }, + "action": "release", + "position_px": { + "x": 500, + "y": 520 + }, + "button": 1, + "buttons": 0, + "modifiers": 0 + } + } + ] + } +} diff --git a/UnitTests/testdata/interaction-traces/hover-sparse.json b/UnitTests/testdata/interaction-traces/hover-sparse.json new file mode 100644 index 000000000..301814803 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/hover-sparse.json @@ -0,0 +1,96 @@ +{ + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "hover-sparse", + "description": "Pointer sweeps left to right across a page holding three findings. Establishes the sparse-page candidate-count and latency baseline that hover-dense is compared against.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "hover_id": "", + "selected_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "input_script": [ + { + "kind": "hover-sweep", + "at_px": { + "x": 40, + "y": 60 + }, + "to_px": { + "x": 700, + "y": 60 + }, + "steps": 120, + "interval_ns": 16666667 + } + ] +} diff --git a/UnitTests/testdata/interaction-traces/manifest.json b/UnitTests/testdata/interaction-traces/manifest.json new file mode 100644 index 000000000..f5e3d3901 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/manifest.json @@ -0,0 +1,33 @@ +{ + "schema_kind": "loupe-interaction-corpus", + "schema_version": 1, + "scenarios": [ + { + "id": "click-select-deselect", + "path": "UnitTests/testdata/interaction-traces/click-select-deselect.json", + "issue": 146, + "sha256": "718f433ac5669a93190bce6f02b0a48f8d5859f9ce98346f96d570650c77eda5", + "lanes": [ + "deterministic" + ] + }, + { + "id": "hover-sparse", + "path": "UnitTests/testdata/interaction-traces/hover-sparse.json", + "issue": 146, + "sha256": "79dc49c89dd6ef1805539ad41ec47920b363c02620b73d0cdea6b5f91efa328a", + "lanes": [ + "deterministic" + ] + }, + { + "id": "pan", + "path": "UnitTests/testdata/interaction-traces/pan.json", + "issue": 146, + "sha256": "007117ede01e0af716967ef7339376936de628d5c8c59dec18b8eecbcfb91c9c", + "lanes": [ + "deterministic" + ] + } + ] +} diff --git a/UnitTests/testdata/interaction-traces/pan.json b/UnitTests/testdata/interaction-traces/pan.json new file mode 100644 index 000000000..01795d6d5 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/pan.json @@ -0,0 +1,111 @@ +{ + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "pan", + "description": "Middle-button drag pans the viewport. Asserts the viewport request generation does not advance, which is issue #142's rule that a pan must not cancel in-flight page renders.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "input_script": [ + { + "kind": "pointer-press", + "at_px": { + "x": 600, + "y": 400 + }, + "button": "middle" + }, + { + "kind": "hover-sweep", + "at_px": { + "x": 600, + "y": 400 + }, + "to_px": { + "x": 300, + "y": 250 + }, + "steps": 60, + "interval_ns": 16666667 + }, + { + "kind": "pointer-release", + "at_px": { + "x": 300, + "y": 250 + }, + "button": "middle" + } + ] +} diff --git a/changes/cc-gh146-interaction-trace-corpus.md b/changes/cc-gh146-interaction-trace-corpus.md new file mode 100644 index 000000000..cde60e324 --- /dev/null +++ b/changes/cc-gh146-interaction-trace-corpus.md @@ -0,0 +1,4 @@ +Category: internal +Audience: developers +Breaking-Change: no +Summary: Add the interaction-performance trace corpus for issue #146: a scenario schema, a report schema, three seed scenarios with a digest manifest, and a CI checker that validates the corpus with no build and enforces that a failing run names the contract it broke and the phase responsible. Missing telemetry must be reported as unavailable rather than as zero. diff --git a/docs/schemas/interaction-scenario.schema.json b/docs/schemas/interaction-scenario.schema.json new file mode 100644 index 000000000..8ea465fc4 --- /dev/null +++ b/docs/schemas/interaction-scenario.schema.json @@ -0,0 +1,299 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/studio-berry/loupe/schema/interaction-scenario-v1.json", + "title": "Loupe interaction regression scenario", + "description": "One replayable direct-manipulation scenario for issue #146. The recorded input lives under 'trace' in exactly the shape InteractionTrace::toJson() emits, or is generated from 'input_script'; everything else declares the fixture, the synthetic cost model, the budgets, and the expected end state. Scenario payload is deliberately kept out of InteractionTrace itself, whose privacy contract forbids geometry and target identity.", + "type": "object", + "required": [ + "schema_kind", + "schema_version", + "scenario_id", + "description", + "fixture", + "cost_model", + "budgets", + "expected" + ], + "properties": { + "schema_kind": { "const": "loupe-interaction-scenario" }, + "schema_version": { "const": 1 }, + "scenario_id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "description": { "type": "string", "minLength": 1 }, + + "fixture": { + "type": "object", + "required": [ + "page_count", + "page_size_mm", + "pixel_per_mm", + "device_pixel_ratio", + "initial_zoom", + "viewport_size_px", + "page_layout" + ], + "properties": { + "page_count": { "type": "integer", "minimum": 1 }, + "page_size_mm": { "$ref": "#/$defs/size" }, + "pixel_per_mm": { "type": "number", "exclusiveMinimum": 0 }, + "device_pixel_ratio": { "type": "number", "exclusiveMinimum": 0 }, + "initial_zoom": { "type": "number", "exclusiveMinimum": 0 }, + "viewport_size_px": { "$ref": "#/$defs/sizeInt" }, + "page_layout": { + "enum": [ + "single-page", + "one-column", + "two-pages-left", + "two-pages-right", + "two-column-left", + "two-column-right" + ] + }, + "hit_targets": { "type": "array", "items": { "$ref": "#/$defs/hitTarget" } }, + "generated_targets": { + "description": "A dense page is declared as a grid rather than listed. Four thousand literal targets is a diff nobody reviews.", + "type": "object", + "required": ["kind", "count", "page_index", "grid"], + "properties": { + "kind": { "$ref": "#/$defs/targetKind" }, + "count": { "type": "integer", "minimum": 1, "maximum": 100000 }, + "page_index": { "type": "integer", "minimum": 0 }, + "id_prefix": { "type": "string", "minLength": 1 }, + "grid": { + "type": "object", + "required": ["columns", "rows", "origin", "stride", "size"], + "properties": { + "columns": { "type": "integer", "minimum": 1 }, + "rows": { "type": "integer", "minimum": 1 }, + "origin": { "$ref": "#/$defs/point" }, + "stride": { "$ref": "#/$defs/size" }, + "size": { "$ref": "#/$defs/size" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "guides": { "type": "array", "items": { "$ref": "#/$defs/guide" } }, + "snapping": { + "type": "object", + "required": ["enabled"], + "properties": { + "enabled": { "type": "boolean" }, + "screen_threshold_px": { "type": "number", "minimum": 0 } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + + "async": { + "description": "Job actions applied at a given index in the input stream, so 'drag during preflight' happens at the same input on every runner.", + "type": "object", + "required": ["jobs"], + "properties": { + "jobs": { + "type": "array", + "items": { + "type": "object", + "required": ["at_input_index", "action"], + "properties": { + "at_input_index": { "type": "integer", "minimum": 0 }, + "action": { + "enum": ["submit", "pump", "cancel", "fail", "stale-result", "bump-revision"] + }, + "kind": { + "enum": ["rendering", "preflight", "ocr", "export", "thumbnail", "batch", "agent", "other"] + }, + "count": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + + "cost_model": { + "description": "Synthetic per-stage costs in nanoseconds, multiplied by real run products (index candidates, overlay primitives, cache misses). The deterministic lane reads no real clock, so these are what make latency assertable and byte-identical across machines.", + "type": "object", + "required": ["base_frame_ns"], + "properties": { + "base_frame_ns": { "type": "integer", "minimum": 0 }, + "hit_test_ns_per_candidate": { "type": "integer", "minimum": 0 }, + "overlay_ns_per_primitive": { "type": "integer", "minimum": 0 }, + "page_surface_admit_ns": { "type": "integer", "minimum": 0 }, + "cache_miss_ns": { "type": "integer", "minimum": 0 }, + "external_present_ns": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + + "budgets": { + "type": "object", + "required": ["refresh_rate_hz", "frame_p95_ms", "input_to_frame_p95_ms"], + "properties": { + "refresh_rate_hz": { "type": "number", "minimum": 0 }, + "frame_p95_ms": { "type": "number", "exclusiveMinimum": 0 }, + "input_to_frame_p95_ms": { "type": "number", "exclusiveMinimum": 0 }, + "max_slow_frames": { "type": "integer", "minimum": 0 }, + "max_dropped_frames": { "type": "integer", "minimum": 0 }, + "variance_band_multiplier": { + "description": "Applied by the desktop/GPU lane only. The deterministic lane is strict and ignores it.", + "type": "number", + "minimum": 1 + } + }, + "additionalProperties": false + }, + + "expected": { + "description": "Final interaction and document state (AC2). An empty string asserts 'nothing selected' or 'nothing hovered', which is distinct from the key being absent.", + "type": "object", + "properties": { + "selected_id": { "type": "string" }, + "hover_id": { "type": "string" }, + "zoom": { "type": "number", "exclusiveMinimum": 0 }, + "current_page": { "type": "integer", "minimum": 0 }, + "scroll_offset_px": { "$ref": "#/$defs/pointInt" }, + "drag_completed": { "type": "integer", "minimum": 0 }, + "snapped_to": { "type": "string" }, + "request_generation_changed": { "type": "boolean" }, + "cancellations": { + "type": "array", + "items": { "$ref": "#/$defs/cancelReason" } + }, + "counters": { + "type": "object", + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "unbalanced_frames": { "type": "integer", "minimum": 0 }, + "pending_inputs": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + + "trace": { + "description": "Verbatim InteractionTrace::toJson() output. Present when the scenario is a recorded session; mutually exclusive with input_script.", + "type": "object" + }, + + "input_script": { + "description": "Generated input, for scenarios where a literal trace would be hundreds of near-identical records. Mutually exclusive with trace.", + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { + "enum": ["pointer-move", "pointer-press", "pointer-release", "wheel", "key", "notification", "hover-sweep"] + }, + "at_px": { "$ref": "#/$defs/pointInt" }, + "to_px": { "$ref": "#/$defs/pointInt" }, + "steps": { "type": "integer", "minimum": 1 }, + "interval_ns": { "type": "integer", "minimum": 1 }, + "button": { "enum": ["left", "right", "middle", "none"] }, + "modifiers": { + "type": "array", + "items": { "enum": ["shift", "control", "alt", "meta"] } + }, + "angle_delta": { "type": "integer" }, + "key": { "type": "integer" }, + "notification": { "$ref": "#/$defs/notification" } + }, + "additionalProperties": false + } + } + }, + + "oneOf": [ + { "required": ["trace"] }, + { "required": ["input_script"] } + ], + + "additionalProperties": false, + + "$defs": { + "point": { + "type": "object", + "required": ["x", "y"], + "properties": { "x": { "type": "number" }, "y": { "type": "number" } }, + "additionalProperties": false + }, + "pointInt": { + "type": "object", + "required": ["x", "y"], + "properties": { "x": { "type": "integer" }, "y": { "type": "integer" } }, + "additionalProperties": false + }, + "size": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { "type": "number", "exclusiveMinimum": 0 }, + "height": { "type": "number", "exclusiveMinimum": 0 } + }, + "additionalProperties": false + }, + "sizeInt": { + "type": "object", + "required": ["width", "height"], + "properties": { + "width": { "type": "integer", "minimum": 1 }, + "height": { "type": "integer", "minimum": 1 } + }, + "additionalProperties": false + }, + "rect": { + "type": "object", + "required": ["x", "y", "width", "height"], + "properties": { + "x": { "type": "number" }, + "y": { "type": "number" }, + "width": { "type": "number", "minimum": 0 }, + "height": { "type": "number", "minimum": 0 } + }, + "additionalProperties": false + }, + "targetKind": { "enum": ["finding", "guide", "page-box", "page", "handle"] }, + "hitTarget": { + "type": "object", + "required": ["kind", "page_index", "id", "page_bounds"], + "properties": { + "kind": { "$ref": "#/$defs/targetKind" }, + "page_index": { "type": "integer", "minimum": 0 }, + "id": { "type": "string", "minLength": 1 }, + "page_bounds": { "$ref": "#/$defs/rect" } + }, + "additionalProperties": false + }, + "guide": { + "type": "object", + "required": ["id", "page_index", "orientation", "position"], + "properties": { + "id": { "type": "string", "minLength": 1 }, + "page_index": { "type": "integer", "minimum": 0 }, + "orientation": { "enum": ["horizontal", "vertical"] }, + "position": { "type": "number" } + }, + "additionalProperties": false + }, + "cancelReason": { + "enum": [ + "explicit", + "escape", + "pointer-cancelled", + "focus-lost", + "capture-lost", + "tool-changed", + "selection-changed", + "revision-changed", + "document-closed" + ] + }, + "notification": { + "enum": ["focus-lost", "capture-lost", "document-closed", "tool-changed"] + } + } +} diff --git a/docs/schemas/interaction-trace-report.schema.json b/docs/schemas/interaction-trace-report.schema.json new file mode 100644 index 000000000..62a9cf1b7 --- /dev/null +++ b/docs/schemas/interaction-trace-report.schema.json @@ -0,0 +1,247 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/studio-berry/loupe/schema/interaction-trace-report-v1.json", + "title": "Loupe interaction trace regression report", + "description": "The CI evidence artifact for issue #146, emitted by the test binary rather than parsed out of ctest output. Missing telemetry is reported as available:false with null percentiles and never as zero, per docs/RESOURCE_BUDGETS.md.", + "type": "object", + "required": ["schema_kind", "schema_version", "lane", "identity", "runs"], + "properties": { + "schema_kind": { "const": "loupe-interaction-trace-report" }, + "schema_version": { "const": 1 }, + "lane": { + "description": "deterministic is the gating lane with a synthetic clock; present is the desktop/GPU lane with real timing and variance bands.", + "enum": ["deterministic", "present"] + }, + "identity": { + "type": "object", + "required": [ + "commit", + "compiler", + "os", + "qt", + "cpu", + "renderer", + "fixture_digest", + "profile_or_operation_version", + "corpus_digest" + ], + "properties": { + "commit": { "type": "string", "minLength": 1 }, + "compiler": { "type": "string", "minLength": 1 }, + "os": { "type": "string", "minLength": 1 }, + "qt": { "type": "string", "minLength": 1 }, + "cpu": { "type": "string", "minLength": 1 }, + "renderer": { "type": "string", "minLength": 1 }, + "fixture_digest": { "type": "string", "minLength": 1 }, + "profile_or_operation_version": { "type": "string", "minLength": 1 }, + "corpus_digest": { "type": "string", "pattern": "^[0-9a-f]{64}$" } + }, + "additionalProperties": false + }, + "runs": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/run" } + } + }, + "additionalProperties": false, + + "$defs": { + "run": { + "type": "object", + "required": [ + "scenario_id", + "status", + "trace_id", + "summary_schema_version", + "budgets", + "samples", + "input_to_frame_ms", + "frame_time_ms", + "stage_ms", + "slow_frame_causes", + "hit_test", + "async_overlap", + "page_surface_cache", + "present_timing", + "passed", + "first_violated_contract", + "responsible_phase", + "failure_excerpt" + ], + "properties": { + "scenario_id": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" }, + "status": { + "description": "verified means the measurement actually happened; static-only means the contracts passed but present/GPU timing was unavailable; infrastructure-blocked means the lane could not run. A static-only run must never be reported as a verified measurement.", + "enum": ["verified", "static-only", "infrastructure-blocked"] + }, + "trace_id": { "type": "string" }, + "summary_schema_version": { "type": "integer", "minimum": 1 }, + "budgets": { + "type": "object", + "required": ["status", "reference_60_hz_ms", "reference_120_hz_ms", "applied"], + "properties": { + "status": { "enum": ["known", "unavailable"] }, + "refresh_rate_hz": { "type": ["number", "null"] }, + "frame_budget_ms": { "type": ["number", "null"] }, + "reference_60_hz_ms": { "type": "number" }, + "reference_120_hz_ms": { "type": "number" }, + "applied": { + "type": "object", + "required": ["mode", "frame_p95_ms", "input_to_frame_p95_ms"], + "properties": { + "mode": { "enum": ["strict", "variance-band"] }, + "frame_p95_ms": { "type": "number" }, + "input_to_frame_p95_ms": { "type": "number" }, + "variance_band_multiplier": { "type": ["number", "null"] } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "samples": { + "type": "object", + "required": ["inputs", "frames", "pending_inputs", "dropped_frames", "unbalanced_frames"], + "properties": { + "inputs": { "type": "integer", "minimum": 0 }, + "frames": { "type": "integer", "minimum": 0 }, + "pending_inputs": { "type": "integer", "minimum": 0 }, + "dropped_frames": { "type": "integer", "minimum": 0 }, + "unbalanced_frames": { "type": "integer", "minimum": 0 }, + "dropped_input_records": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "input_to_frame_ms": { "$ref": "#/$defs/durationPercentiles" }, + "frame_time_ms": { "$ref": "#/$defs/durationPercentiles" }, + "stage_ms": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/stageName" }, + "additionalProperties": { "$ref": "#/$defs/durationPercentiles" } + }, + "slow_frame_causes": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/stageName" }, + "additionalProperties": { "type": "integer", "minimum": 0 } + }, + "hit_test": { + "type": "object", + "required": ["index_candidates", "precise_hits", "duration_ms"], + "properties": { + "index_candidates": { "$ref": "#/$defs/countPercentiles" }, + "precise_hits": { "$ref": "#/$defs/countPercentiles" }, + "duration_ms": { "$ref": "#/$defs/durationPercentiles" } + }, + "additionalProperties": false + }, + "async_overlap": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/jobKind" }, + "additionalProperties": { + "type": "object", + "required": ["frames_overlapped", "slow_frames_overlapped", "active"], + "properties": { + "frames_overlapped": { "type": "integer", "minimum": 0 }, + "slow_frames_overlapped": { "type": "integer", "minimum": 0 }, + "active": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } + }, + "page_surface_cache": { + "type": "object", + "required": ["hits", "misses"], + "properties": { + "hits": { "type": "integer", "minimum": 0 }, + "misses": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "present_timing": { + "description": "A present record on a lane that cannot measure presentation reports available:false with a reason, never a zero p95.", + "oneOf": [ + { + "type": "object", + "required": ["available", "reason"], + "properties": { + "available": { "const": false }, + "reason": { "type": "string", "minLength": 1 } + }, + "additionalProperties": false + }, + { + "allOf": [{ "$ref": "#/$defs/durationPercentiles" }], + "properties": { "available": { "const": true } } + } + ] + }, + "passed": { "type": "boolean" }, + "first_violated_contract": { + "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/contract" }] + }, + "responsible_phase": { + "oneOf": [{ "type": "null" }, { "$ref": "#/$defs/phase" }] + }, + "failure_excerpt": { "type": "array", "items": { "type": "string", "minLength": 1 } } + }, + "additionalProperties": false + }, + + "durationPercentiles": { + "type": "object", + "required": ["available", "sample_count", "p50_ms", "p95_ms", "p99_ms"], + "properties": { + "available": { "type": "boolean" }, + "sample_count": { "type": "integer", "minimum": 0 }, + "p50_ms": { "type": ["number", "null"] }, + "p95_ms": { "type": ["number", "null"] }, + "p99_ms": { "type": ["number", "null"] } + }, + "additionalProperties": false + }, + "countPercentiles": { + "type": "object", + "required": ["available", "sample_count", "p50", "p95", "p99"], + "properties": { + "available": { "type": "boolean" }, + "sample_count": { "type": "integer", "minimum": 0 }, + "p50": { "type": ["number", "null"] }, + "p95": { "type": ["number", "null"] }, + "p99": { "type": ["number", "null"] } + }, + "additionalProperties": false + }, + "stageName": { + "enum": ["interaction", "hit-test", "overlay", "page-surface", "external", "unknown"] + }, + "jobKind": { + "enum": ["rendering", "preflight", "ocr", "export", "thumbnail", "batch", "agent", "other"] + }, + "contract": { + "description": "Evaluated in this order, so 'first violated' is a documented constant rather than JSON iteration order (AC7).", + "enum": [ + "input-acknowledged", + "frame-balance", + "telemetry-available", + "p95-input-to-frame", + "p95-frame-time", + "slow-frame-budget", + "dropped-frames", + "stale-result-safety", + "final-state" + ] + }, + "phase": { + "enum": [ + "input", + "hit-test", + "page-cache", + "overlay", + "composition", + "async-overlap", + "unknown" + ] + } + } +} diff --git a/scripts/ci/check_interaction_traces.py b/scripts/ci/check_interaction_traces.py new file mode 100755 index 000000000..be924d4cb --- /dev/null +++ b/scripts/ci/check_interaction_traces.py @@ -0,0 +1,623 @@ +#!/usr/bin/env python3 +"""Validate the interaction trace corpus and the CI trace report (issue #146). + +Two jobs, deliberately in one script because they share the corpus: + + --corpus-only validate every scenario against the scenario schema and the + manifest digests. Needs no build, so a malformed scenario + fails in seconds rather than after a compile. + (default) validate a report emitted by the trace test binary: identity, + scenario coverage, and the rule that missing telemetry is + reported as unavailable rather than as zero. + +The schema check is hand-rolled against the tracked JSON Schema documents +because the repository pins no jsonschema dependency; check_fuzz_corpus.py and +scripts/resource_envelope/validate_envelope.py take the same approach. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +CORPUS_DIR = ROOT / "UnitTests" / "testdata" / "interaction-traces" +MANIFEST_PATH = CORPUS_DIR / "manifest.json" +SCENARIO_SCHEMA = ROOT / "docs" / "schemas" / "interaction-scenario.schema.json" +REPORT_SCHEMA = ROOT / "docs" / "schemas" / "interaction-trace-report.schema.json" + +KEBAB_CASE = re.compile(r"^[a-z][a-z0-9-]*$") +SHA256 = re.compile(r"^[0-9a-f]{64}$") + +# Mirrors docs/schemas/interaction-trace-report.schema.json. Kept as literals +# rather than parsed out of the schema so that a schema edit that drops a value +# fails a test instead of silently widening the checker. +CONTRACTS = ( + "input-acknowledged", + "frame-balance", + "telemetry-available", + "p95-input-to-frame", + "p95-frame-time", + "slow-frame-budget", + "dropped-frames", + "stale-result-safety", + "final-state", +) +PHASES = ( + "input", + "hit-test", + "page-cache", + "overlay", + "composition", + "async-overlap", + "unknown", +) +STATUSES = ("verified", "static-only", "infrastructure-blocked") +LANES = ("deterministic", "present") +IDENTITY_FIELDS = ( + "commit", + "compiler", + "os", + "qt", + "cpu", + "renderer", + "fixture_digest", + "profile_or_operation_version", + "corpus_digest", +) + +REQUIRED_SCENARIO_FIELDS = frozenset( + { + "schema_kind", + "schema_version", + "scenario_id", + "description", + "fixture", + "cost_model", + "budgets", + "expected", + } +) +REQUIRED_FIXTURE_FIELDS = frozenset( + { + "page_count", + "page_size_mm", + "pixel_per_mm", + "device_pixel_ratio", + "initial_zoom", + "viewport_size_px", + "page_layout", + } +) +REQUIRED_BUDGET_FIELDS = frozenset( + {"refresh_rate_hz", "frame_p95_ms", "input_to_frame_p95_ms"} +) + +Violation = tuple[str, str] + + +def sha256_file(path: Path) -> str: + """SHA-256 of a scenario file, over line-ending-normalized bytes. + + .gitattributes checks this repository's text out as CRLF by default, and + pins the corpus back to LF so the digests stay stable. Normalizing here as + well means a checkout that lost that pin -- a zip export, a contributor with + a global setting -- reports a real corpus edit rather than a line-ending + difference nobody made. These files are JSON, so their bytes carry no + meaning a newline conversion can destroy. + """ + digest = hashlib.sha256() + with path.open("rb") as handle: + payload = handle.read() + digest.update(payload.replace(b"\r\n", b"\n")) + return digest.hexdigest() + + +def corpus_digest(manifest: dict) -> str: + """A digest over the manifest's scenario digests, in id order. + + This is the value a report's identity.corpus_digest must carry, so a report + produced against a different corpus cannot be compared to this one. + """ + joined = "\n".join( + f"{entry.get('id')}:{entry.get('sha256')}" + for entry in sorted(manifest.get("scenarios", []), key=lambda e: str(e.get("id"))) + ) + return hashlib.sha256(joined.encode("utf-8")).hexdigest() + + +def load_manifest(corpus_dir: Path = CORPUS_DIR) -> dict: + """Load and return the corpus manifest.""" + with (corpus_dir / "manifest.json").open(encoding="utf-8") as handle: + return json.load(handle) + + +def validate_scenario(scenario: dict, label: str) -> list[Violation]: + """Return (subject, reason) for every scenario-document violation.""" + violations: list[Violation] = [] + + if scenario.get("schema_kind") != "loupe-interaction-scenario": + violations.append((label, "schema_kind must be loupe-interaction-scenario")) + if scenario.get("schema_version") != 1: + violations.append((label, "schema_version must be 1")) + + missing = REQUIRED_SCENARIO_FIELDS - scenario.keys() + if missing: + violations.append((label, f"missing required fields: {sorted(missing)}")) + return violations + + scenario_id = scenario["scenario_id"] + if not isinstance(scenario_id, str) or not KEBAB_CASE.match(scenario_id): + violations.append((label, f"scenario_id must be kebab-case, got {scenario_id!r}")) + + if not str(scenario.get("description", "")).strip(): + violations.append((label, "description must not be empty")) + + has_trace = "trace" in scenario + has_script = "input_script" in scenario + if has_trace == has_script: + violations.append( + (label, "exactly one of 'trace' or 'input_script' is required") + ) + + if has_trace: + trace = scenario["trace"] + if not isinstance(trace, dict): + violations.append((label, "trace must be an object")) + else: + if trace.get("schema_version") != 1: + violations.append((label, "trace.schema_version must be 1")) + if not isinstance(trace.get("inputs"), list) or not trace["inputs"]: + violations.append((label, "trace.inputs must be a non-empty array")) + + if has_script: + script = scenario["input_script"] + if not isinstance(script, list) or not script: + violations.append((label, "input_script must be a non-empty array")) + + fixture = scenario["fixture"] + if not isinstance(fixture, dict): + violations.append((label, "fixture must be an object")) + else: + fixture_missing = REQUIRED_FIXTURE_FIELDS - fixture.keys() + if fixture_missing: + violations.append( + (label, f"fixture missing required fields: {sorted(fixture_missing)}") + ) + if not isinstance(fixture.get("page_count"), int) or fixture.get("page_count", 0) < 1: + violations.append((label, "fixture.page_count must be a positive integer")) + for key in ("pixel_per_mm", "device_pixel_ratio", "initial_zoom"): + value = fixture.get(key) + if not isinstance(value, (int, float)) or value <= 0: + violations.append((label, f"fixture.{key} must be greater than zero")) + + seen_target_ids: set[str] = set() + for index, target in enumerate(fixture.get("hit_targets", []) or []): + if not isinstance(target, dict): + violations.append((label, f"hit_targets[{index}] must be an object")) + continue + target_id = target.get("id") + if not isinstance(target_id, str) or not target_id: + violations.append((label, f"hit_targets[{index}] needs a non-empty id")) + elif target_id in seen_target_ids: + violations.append((label, f"duplicate hit target id {target_id!r}")) + else: + seen_target_ids.add(target_id) + + cost_model = scenario["cost_model"] + if not isinstance(cost_model, dict) or "base_frame_ns" not in cost_model: + violations.append((label, "cost_model must be an object with base_frame_ns")) + else: + for key, value in cost_model.items(): + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + violations.append( + (label, f"cost_model.{key} must be a non-negative integer of nanoseconds") + ) + + budgets = scenario["budgets"] + if not isinstance(budgets, dict): + violations.append((label, "budgets must be an object")) + else: + budget_missing = REQUIRED_BUDGET_FIELDS - budgets.keys() + if budget_missing: + violations.append( + (label, f"budgets missing required fields: {sorted(budget_missing)}") + ) + for key in ("frame_p95_ms", "input_to_frame_p95_ms"): + value = budgets.get(key) + if not isinstance(value, (int, float)) or value <= 0: + violations.append((label, f"budgets.{key} must be greater than zero")) + band = budgets.get("variance_band_multiplier") + if band is not None and (not isinstance(band, (int, float)) or band < 1): + violations.append((label, "budgets.variance_band_multiplier must be at least 1")) + + if not isinstance(scenario["expected"], dict): + violations.append((label, "expected must be an object")) + + return violations + + +def validate_corpus(corpus_dir: Path = CORPUS_DIR, root: Path = ROOT) -> list[Violation]: + """Return (subject, reason) for every corpus violation.""" + try: + manifest = load_manifest(corpus_dir) + except (OSError, json.JSONDecodeError) as exc: + return [("manifest.json", f"unable to load manifest: {exc}")] + + violations: list[Violation] = [] + + if manifest.get("schema_kind") != "loupe-interaction-corpus": + violations.append(("manifest.json", "schema_kind must be loupe-interaction-corpus")) + if manifest.get("schema_version") != 1: + violations.append(("manifest.json", "schema_version must be 1")) + return violations + + entries = manifest.get("scenarios") + if not isinstance(entries, list) or not entries: + violations.append(("manifest.json", "scenarios must be a non-empty array")) + return violations + + seen_ids: set[str] = set() + manifest_paths: set[str] = set() + + for index, entry in enumerate(entries): + label = f"scenarios[{index}]" + if not isinstance(entry, dict): + violations.append((label, "scenario entry must be an object")) + continue + + missing = {"id", "path", "issue", "sha256"} - entry.keys() + if missing: + violations.append((label, f"missing required fields: {sorted(missing)}")) + continue + + entry_id = entry["id"] + if not isinstance(entry_id, str) or not KEBAB_CASE.match(entry_id): + violations.append((label, f"id must be kebab-case, got {entry_id!r}")) + elif entry_id in seen_ids: + violations.append((label, f"duplicate id {entry_id!r}")) + else: + seen_ids.add(entry_id) + + rel_path = str(entry["path"]).replace("\\", "/") + manifest_paths.add(rel_path) + + if not rel_path.startswith("UnitTests/testdata/interaction-traces/"): + violations.append((rel_path, "path must live under the interaction-traces corpus")) + continue + + absolute = root / rel_path + if not absolute.is_file(): + violations.append((rel_path, "manifest path does not exist")) + continue + + digest = entry["sha256"] + if not isinstance(digest, str) or not SHA256.match(digest): + violations.append((rel_path, "sha256 must be a 64-character lowercase hex digest")) + else: + actual = sha256_file(absolute) + if actual != digest: + violations.append( + (rel_path, f"sha256 mismatch (manifest {digest}, actual {actual})") + ) + + try: + scenario = json.loads(absolute.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + violations.append((rel_path, f"unable to load scenario: {exc}")) + continue + + if scenario.get("scenario_id") != entry_id: + violations.append( + (rel_path, f"scenario_id {scenario.get('scenario_id')!r} != manifest id {entry_id!r}") + ) + + violations.extend(validate_scenario(scenario, rel_path)) + + for path in sorted(corpus_dir.glob("*.json")): + if path.name == "manifest.json": + continue + rel_path = str(path.relative_to(root)).replace("\\", "/") + if rel_path not in manifest_paths: + violations.append((rel_path, "tracked scenario is missing from manifest.json")) + + return violations + + +def _percentile_keys(block: dict) -> list[str]: + return [key for key in block if key.startswith("p") and key[1:].split("_")[0].isdigit()] + + +def validate_percentiles(block: object, label: str) -> list[Violation]: + """Enforce the no-zero-for-missing rule (#140 AC2, docs/RESOURCE_BUDGETS.md). + + An unavailable measurement must carry null percentiles; an available one + must carry real numbers. A zero standing in for a measurement that never + happened is the failure this exists to prevent. + """ + if not isinstance(block, dict): + return [(label, "percentile block must be an object")] + + violations: list[Violation] = [] + + if "available" not in block: + return [(label, "percentile block must carry 'available'")] + + available = block["available"] + if not isinstance(available, bool): + return [(label, "'available' must be a boolean")] + + keys = _percentile_keys(block) + if not keys: + return [(label, "percentile block must carry p50/p95/p99 values")] + + for key in keys: + value = block[key] + if available: + if not isinstance(value, (int, float)) or isinstance(value, bool): + violations.append((label, f"{key} must be a number when available is true")) + elif value is not None: + violations.append( + (label, f"{key} must be null when available is false, got {value!r}") + ) + + if not available and block.get("sample_count", 0) not in (0, None): + violations.append((label, "sample_count must be 0 when available is false")) + + return violations + + +def validate_report( + report: dict, + corpus_ids: set[str] | None = None, + expected_corpus_digest: str | None = None, +) -> list[Violation]: + """Return (subject, reason) for every report violation.""" + violations: list[Violation] = [] + + if report.get("schema_kind") != "loupe-interaction-trace-report": + violations.append(("report", "schema_kind must be loupe-interaction-trace-report")) + if report.get("schema_version") != 1: + violations.append(("report", "schema_version must be 1")) + return violations + + lane = report.get("lane") + if lane not in LANES: + violations.append(("report", f"lane must be one of {list(LANES)}, got {lane!r}")) + + identity = report.get("identity") + if not isinstance(identity, dict): + violations.append(("report.identity", "identity must be an object")) + else: + for field in IDENTITY_FIELDS: + value = identity.get(field) + if not isinstance(value, str) or not value.strip(): + violations.append(("report.identity", f"{field} must be a non-empty string")) + digest = identity.get("corpus_digest") + if ( + expected_corpus_digest + and isinstance(digest, str) + and digest != expected_corpus_digest + ): + violations.append( + ( + "report.identity", + f"corpus_digest {digest} does not match the tracked corpus {expected_corpus_digest}", + ) + ) + + runs = report.get("runs") + if not isinstance(runs, list) or not runs: + violations.append(("report.runs", "runs must be a non-empty array")) + return violations + + seen: set[str] = set() + + for index, run in enumerate(runs): + label = f"runs[{index}]" + if not isinstance(run, dict): + violations.append((label, "run must be an object")) + continue + + scenario_id = run.get("scenario_id") + if not isinstance(scenario_id, str) or not KEBAB_CASE.match(scenario_id or ""): + violations.append((label, f"scenario_id must be kebab-case, got {scenario_id!r}")) + elif scenario_id in seen: + violations.append((label, f"duplicate scenario_id {scenario_id!r}")) + else: + seen.add(scenario_id) + label = f"runs[{scenario_id}]" + + status = run.get("status") + if status not in STATUSES: + violations.append((label, f"status must be one of {list(STATUSES)}, got {status!r}")) + + passed = run.get("passed") + if not isinstance(passed, bool): + violations.append((label, "passed must be a boolean")) + passed = None + + contract = run.get("first_violated_contract") + phase = run.get("responsible_phase") + excerpt = run.get("failure_excerpt") + + if passed is False: + # AC7: a failure must name what broke and who is responsible. A + # red run with no attribution is the outcome this check exists for. + if contract not in CONTRACTS: + violations.append( + (label, f"failed run needs first_violated_contract in {list(CONTRACTS)}, got {contract!r}") + ) + if phase not in PHASES: + violations.append( + (label, f"failed run needs responsible_phase in {list(PHASES)}, got {phase!r}") + ) + if not isinstance(excerpt, list) or not excerpt: + violations.append((label, "failed run needs a non-empty failure_excerpt")) + elif passed is True: + if contract is not None: + violations.append((label, "passing run must not name a violated contract")) + if phase is not None: + violations.append((label, "passing run must not name a responsible phase")) + + for key in ("input_to_frame_ms", "frame_time_ms"): + if key in run: + violations.extend(validate_percentiles(run[key], f"{label}.{key}")) + + for key, block in (run.get("stage_ms") or {}).items(): + violations.extend(validate_percentiles(block, f"{label}.stage_ms.{key}")) + + hit_test = run.get("hit_test") or {} + for key, block in hit_test.items(): + violations.extend(validate_percentiles(block, f"{label}.hit_test.{key}")) + + present = run.get("present_timing") + if isinstance(present, dict): + if present.get("available") is False and not str(present.get("reason", "")).strip(): + violations.append( + (label, "present_timing must carry a reason when unavailable") + ) + elif present.get("available") is True: + violations.extend(validate_percentiles(present, f"{label}.present_timing")) + + # A verified status is a claim that the measurement happened. It may + # not be paired with telemetry that says it did not. + if status == "verified": + latency = run.get("input_to_frame_ms") + if isinstance(latency, dict) and latency.get("available") is False: + violations.append( + (label, "status is verified but input_to_frame_ms is unavailable") + ) + + if corpus_ids is not None: + for missing_id in sorted(corpus_ids - seen): + violations.append(("report.runs", f"corpus scenario {missing_id!r} has no run")) + for extra_id in sorted(seen - corpus_ids): + violations.append(("report.runs", f"run {extra_id!r} is not in the corpus")) + + return violations + + +def trend_rows(report: dict, baseline: dict | None) -> list[str]: + """Per-scenario p50/p95/p99 lines, with deltas when a baseline is given.""" + base_runs = {} + if isinstance(baseline, dict): + base_runs = { + run.get("scenario_id"): run + for run in baseline.get("runs", []) + if isinstance(run, dict) + } + + rows = [] + for run in report.get("runs", []): + if not isinstance(run, dict): + continue + scenario_id = run.get("scenario_id", "?") + latency = run.get("input_to_frame_ms") or {} + if not latency.get("available"): + rows.append(f"{scenario_id}: input-to-frame unavailable ({run.get('status')})") + continue + + line = ( + f"{scenario_id}: p50={latency.get('p50_ms')}ms " + f"p95={latency.get('p95_ms')}ms p99={latency.get('p99_ms')}ms" + ) + base_latency = (base_runs.get(scenario_id) or {}).get("input_to_frame_ms") or {} + if base_latency.get("available"): + try: + delta = float(latency["p95_ms"]) - float(base_latency["p95_ms"]) + line += f" (p95 delta {delta:+.3f}ms)" + except (TypeError, ValueError, KeyError): + pass + rows.append(line) + + return rows + + +def load_json(path: Path) -> dict: + with path.open(encoding="utf-8") as handle: + return json.load(handle) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("report", nargs="?", help="trace report emitted by the test binary") + parser.add_argument( + "--corpus-only", + action="store_true", + help="validate the scenario corpus and exit; requires no build", + ) + parser.add_argument("--baseline", help="baseline report for --trend deltas") + parser.add_argument( + "--trend", + action="store_true", + help="print per-scenario percentiles and deltas; never fails the run", + ) + args = parser.parse_args(argv) + + corpus_violations = validate_corpus() + if corpus_violations: + print("ERROR: interaction trace corpus failed validation:", file=sys.stderr) + for subject, reason in corpus_violations: + print(f" {subject}: {reason}", file=sys.stderr) + return 1 + + if args.corpus_only: + print(f"Interaction trace corpus policy passed ({len(load_manifest()['scenarios'])} scenarios).") + return 0 + + if not args.report: + parser.error("a report path is required unless --corpus-only is given") + + report_path = Path(args.report) + if not report_path.is_absolute(): + report_path = Path.cwd() / report_path + + try: + report = load_json(report_path) + except (OSError, json.JSONDecodeError) as exc: + print(f"ERROR: unable to load {report_path}: {exc}", file=sys.stderr) + return 1 + + manifest = load_manifest() + corpus_ids = {str(entry["id"]) for entry in manifest["scenarios"]} + violations = validate_report(report, corpus_ids, corpus_digest(manifest)) + + if violations: + print("ERROR: interaction trace report failed validation:", file=sys.stderr) + for subject, reason in violations: + print(f" {subject}: {reason}", file=sys.stderr) + return 1 + + failed = [run for run in report["runs"] if run.get("passed") is False] + for run in failed: + print( + f"FAIL {run['scenario_id']}: {run['first_violated_contract']} " + f"(phase {run['responsible_phase']})", + file=sys.stderr, + ) + for line in run.get("failure_excerpt", []): + print(f" {line}", file=sys.stderr) + + if args.trend: + baseline = None + if args.baseline and Path(args.baseline).is_file(): + baseline = load_json(Path(args.baseline)) + print("Interaction trace trend:") + for row in trend_rows(report, baseline): + print(f" {row}") + + if failed: + return 1 + + print(f"Interaction trace report passed ({len(report['runs'])} scenarios, lane {report['lane']}).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci/test_check_interaction_traces.py b/scripts/ci/test_check_interaction_traces.py new file mode 100644 index 000000000..dd14aec53 --- /dev/null +++ b/scripts/ci/test_check_interaction_traces.py @@ -0,0 +1,364 @@ +#!/usr/bin/env python3 +"""Tests for the interaction trace corpus and report checker.""" + +from __future__ import annotations + +import copy +import json +import pathlib +import sys +import tempfile +import unittest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2])) + +from scripts.ci.check_interaction_traces import ( # noqa: E402 + corpus_digest, + load_manifest, + trend_rows, + validate_corpus, + validate_percentiles, + validate_report, + validate_scenario, +) + + +def minimal_scenario(**over) -> dict: + scenario = { + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "example", + "description": "An example scenario.", + "fixture": { + "page_count": 1, + "page_size_mm": {"width": 210.0, "height": 297.0}, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": {"width": 800, "height": 600}, + "page_layout": "single-page", + }, + "cost_model": {"base_frame_ns": 500000}, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + }, + "expected": {"selected_id": ""}, + "input_script": [{"kind": "pointer-move", "at_px": {"x": 1, "y": 1}}], + } + scenario.update(over) + return scenario + + +def duration(available=True, p50=1.0, p95=2.0, p99=3.0, sample_count=10) -> dict: + if not available: + return {"available": False, "sample_count": 0, "p50_ms": None, "p95_ms": None, "p99_ms": None} + return { + "available": True, + "sample_count": sample_count, + "p50_ms": p50, + "p95_ms": p95, + "p99_ms": p99, + } + + +def minimal_report(**over) -> dict: + report = { + "schema_kind": "loupe-interaction-trace-report", + "schema_version": 1, + "lane": "deterministic", + "identity": { + "commit": "abc123", + "compiler": "GNU 13.2", + "os": "Linux", + "qt": "6.11.1", + "cpu": "x86_64", + "renderer": "software", + "fixture_digest": "deadbeef", + "profile_or_operation_version": "interaction-trace-summary/2", + "corpus_digest": "0" * 64, + }, + "runs": [ + { + "scenario_id": "example", + "status": "verified", + "trace_id": "example", + "summary_schema_version": 2, + "budgets": {}, + "samples": {}, + "input_to_frame_ms": duration(), + "frame_time_ms": duration(), + "stage_ms": {}, + "slow_frame_causes": {}, + "hit_test": {}, + "async_overlap": {}, + "page_surface_cache": {"hits": 1, "misses": 0}, + "present_timing": { + "available": False, + "reason": "interaction-trace/present-timing-unavailable", + }, + "passed": True, + "first_violated_contract": None, + "responsible_phase": None, + "failure_excerpt": [], + } + ], + } + report.update(over) + return report + + +class CorpusTests(unittest.TestCase): + def test_repository_corpus_passes(self): + self.assertEqual(validate_corpus(), []) + + def test_every_manifest_scenario_exists_and_matches(self): + manifest = load_manifest() + self.assertTrue(manifest["scenarios"]) + for entry in manifest["scenarios"]: + self.assertEqual(entry["issue"], 146) + + def test_corpus_digest_is_order_independent(self): + manifest = load_manifest() + reversed_manifest = { + **manifest, + "scenarios": list(reversed(manifest["scenarios"])), + } + self.assertEqual(corpus_digest(manifest), corpus_digest(reversed_manifest)) + + def test_detects_digest_mismatch(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + corpus = root / "UnitTests" / "testdata" / "interaction-traces" + corpus.mkdir(parents=True) + (corpus / "example.json").write_text( + json.dumps(minimal_scenario()), encoding="utf-8" + ) + (corpus / "manifest.json").write_text( + json.dumps( + { + "schema_kind": "loupe-interaction-corpus", + "schema_version": 1, + "scenarios": [ + { + "id": "example", + "path": "UnitTests/testdata/interaction-traces/example.json", + "issue": 146, + "sha256": "f" * 64, + } + ], + } + ), + encoding="utf-8", + ) + violations = validate_corpus(corpus, root) + self.assertTrue(any("sha256 mismatch" in reason for _, reason in violations)) + + def test_digest_survives_a_crlf_checkout(self): + """.gitattributes checks text out as CRLF; the digests must not care. + + Without this the corpus gate passes on the machine that wrote the + manifest and fails on every fresh CI checkout. + """ + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + corpus = root / "UnitTests" / "testdata" / "interaction-traces" + corpus.mkdir(parents=True) + + source = pathlib.Path(__file__).resolve().parents[2] / "UnitTests" / "testdata" / "interaction-traces" + for path in source.glob("*.json"): + (corpus / path.name).write_bytes( + path.read_bytes().replace(b"\r\n", b"\n").replace(b"\n", b"\r\n") + ) + + self.assertEqual(validate_corpus(corpus, root), []) + + def test_untracked_scenario_is_reported(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + corpus = root / "UnitTests" / "testdata" / "interaction-traces" + corpus.mkdir(parents=True) + (corpus / "stray.json").write_text(json.dumps(minimal_scenario()), encoding="utf-8") + (corpus / "manifest.json").write_text( + json.dumps( + { + "schema_kind": "loupe-interaction-corpus", + "schema_version": 1, + "scenarios": [ + { + "id": "example", + "path": "UnitTests/testdata/interaction-traces/example.json", + "issue": 146, + "sha256": "f" * 64, + } + ], + } + ), + encoding="utf-8", + ) + violations = validate_corpus(corpus, root) + self.assertTrue(any("missing from manifest" in reason for _, reason in violations)) + + +class ScenarioTests(unittest.TestCase): + def test_minimal_scenario_passes(self): + self.assertEqual(validate_scenario(minimal_scenario(), "example"), []) + + def test_rejects_both_trace_and_script(self): + scenario = minimal_scenario(trace={"schema_version": 1, "inputs": [{}]}) + violations = validate_scenario(scenario, "example") + self.assertTrue(any("exactly one of" in reason for _, reason in violations)) + + def test_rejects_neither_trace_nor_script(self): + scenario = minimal_scenario() + del scenario["input_script"] + violations = validate_scenario(scenario, "example") + self.assertTrue(any("exactly one of" in reason for _, reason in violations)) + + def test_rejects_duplicate_hit_target_ids(self): + scenario = minimal_scenario() + bounds = {"x": 0.0, "y": 0.0, "width": 1.0, "height": 1.0} + scenario["fixture"]["hit_targets"] = [ + {"kind": "finding", "page_index": 0, "id": "f-1", "page_bounds": bounds}, + {"kind": "finding", "page_index": 0, "id": "f-1", "page_bounds": bounds}, + ] + violations = validate_scenario(scenario, "example") + self.assertTrue(any("duplicate hit target" in reason for _, reason in violations)) + + def test_rejects_non_kebab_scenario_id(self): + violations = validate_scenario(minimal_scenario(scenario_id="Example_One"), "example") + self.assertTrue(any("kebab-case" in reason for _, reason in violations)) + + def test_rejects_fractional_cost(self): + scenario = minimal_scenario() + scenario["cost_model"]["hit_test_ns_per_candidate"] = 1.5 + violations = validate_scenario(scenario, "example") + self.assertTrue(any("nanoseconds" in reason for _, reason in violations)) + + +class PercentileTests(unittest.TestCase): + """The no-zero-for-missing rule from docs/RESOURCE_BUDGETS.md.""" + + def test_available_block_passes(self): + self.assertEqual(validate_percentiles(duration(), "latency"), []) + + def test_unavailable_block_passes_with_nulls(self): + self.assertEqual(validate_percentiles(duration(available=False), "latency"), []) + + def test_zero_standing_in_for_missing_is_rejected(self): + block = {"available": False, "sample_count": 0, "p50_ms": 0.0, "p95_ms": 0.0, "p99_ms": 0.0} + violations = validate_percentiles(block, "latency") + self.assertTrue(any("must be null" in reason for _, reason in violations)) + + def test_available_block_with_nulls_is_rejected(self): + block = {"available": True, "sample_count": 4, "p50_ms": None, "p95_ms": None, "p99_ms": None} + violations = validate_percentiles(block, "latency") + self.assertTrue(any("must be a number" in reason for _, reason in violations)) + + def test_unavailable_block_may_not_claim_samples(self): + block = {"available": False, "sample_count": 7, "p50_ms": None, "p95_ms": None, "p99_ms": None} + violations = validate_percentiles(block, "latency") + self.assertTrue(any("sample_count must be 0" in reason for _, reason in violations)) + + +class ReportTests(unittest.TestCase): + def test_minimal_report_passes(self): + self.assertEqual(validate_report(minimal_report()), []) + + def test_failed_run_must_name_contract_and_phase(self): + report = minimal_report() + report["runs"][0].update(passed=False, failure_excerpt=[]) + violations = validate_report(report) + self.assertTrue(any("first_violated_contract" in reason for _, reason in violations)) + self.assertTrue(any("responsible_phase" in reason for _, reason in violations)) + self.assertTrue(any("failure_excerpt" in reason for _, reason in violations)) + + def test_failed_run_with_attribution_passes(self): + report = minimal_report() + report["runs"][0].update( + passed=False, + first_violated_contract="p95-input-to-frame", + responsible_phase="overlay", + failure_excerpt=["p95 input-to-frame 24.10 ms exceeds 16.67 ms"], + ) + self.assertEqual(validate_report(report), []) + + def test_rejects_unknown_contract(self): + report = minimal_report() + report["runs"][0].update( + passed=False, + first_violated_contract="vibes", + responsible_phase="overlay", + failure_excerpt=["something"], + ) + violations = validate_report(report) + self.assertTrue(any("first_violated_contract" in reason for _, reason in violations)) + + def test_passing_run_may_not_name_a_violation(self): + report = minimal_report() + report["runs"][0]["first_violated_contract"] = "final-state" + violations = validate_report(report) + self.assertTrue(any("must not name a violated contract" in reason for _, reason in violations)) + + def test_verified_status_requires_available_latency(self): + report = minimal_report() + report["runs"][0]["input_to_frame_ms"] = duration(available=False) + violations = validate_report(report) + self.assertTrue(any("verified but" in reason for _, reason in violations)) + + def test_static_only_run_may_report_unavailable_latency(self): + report = minimal_report() + report["runs"][0]["status"] = "static-only" + report["runs"][0]["input_to_frame_ms"] = duration(available=False) + self.assertEqual(validate_report(report), []) + + def test_present_timing_unavailable_needs_a_reason(self): + report = minimal_report() + report["runs"][0]["present_timing"] = {"available": False, "reason": " "} + violations = validate_report(report) + self.assertTrue(any("must carry a reason" in reason for _, reason in violations)) + + def test_missing_corpus_scenario_is_reported(self): + violations = validate_report(minimal_report(), corpus_ids={"example", "pan"}) + self.assertTrue(any("has no run" in reason for _, reason in violations)) + + def test_unknown_run_is_reported(self): + violations = validate_report(minimal_report(), corpus_ids=set()) + self.assertTrue(any("is not in the corpus" in reason for _, reason in violations)) + + def test_duplicate_run_is_reported(self): + report = minimal_report() + report["runs"].append(copy.deepcopy(report["runs"][0])) + violations = validate_report(report) + self.assertTrue(any("duplicate scenario_id" in reason for _, reason in violations)) + + def test_corpus_digest_mismatch_is_reported(self): + violations = validate_report(minimal_report(), expected_corpus_digest="a" * 64) + self.assertTrue(any("does not match the tracked corpus" in reason for _, reason in violations)) + + def test_empty_identity_field_is_reported(self): + report = minimal_report() + report["identity"]["commit"] = " " + violations = validate_report(report) + self.assertTrue(any("commit must be a non-empty string" in reason for _, reason in violations)) + + +class TrendTests(unittest.TestCase): + def test_trend_reports_delta_against_baseline(self): + report = minimal_report() + baseline = minimal_report() + baseline["runs"][0]["input_to_frame_ms"] = duration(p95=1.5) + rows = trend_rows(report, baseline) + self.assertTrue(any("p95 delta +0.500ms" in row for row in rows)) + + def test_trend_says_unavailable_rather_than_zero(self): + report = minimal_report() + report["runs"][0]["status"] = "static-only" + report["runs"][0]["input_to_frame_ms"] = duration(available=False) + rows = trend_rows(report, None) + self.assertTrue(any("unavailable" in row for row in rows)) + + +if __name__ == "__main__": + unittest.main() From 12f30815331ca180cabb450cd068e9fe44b02ed1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 19:26:01 +0000 Subject: [PATCH 21/33] test: complete the gh-146 lane-1 scenario corpus Six more scenarios covering the direct-manipulation half of issue #146's list: hover on a dense page, drag with and without snapping, cursor-anchored zoom, rapid zoom reversal with page switching, and a page whose overlay exceeds its bounds. hover-dense asserts the index candidate count rather than latency. A spatial index that stopped narrowing and a page that simply got heavier look the same in a frame-time percentile; only the candidate count separates them, which is the regression issue #145 can actually suffer. zoom-reversal is the corpus port of InteractionControllerTest::rapidZoomReversalAndPageSwitchSettleWithinTraceBudget. The C++ case stays where it is as the unit-level guard. A manifest entry may now carry blocked_on with a blocked_reason. drag-snap needs DragSnapper, which is still in PR #488, so nothing can run it yet. Marking it explicitly is what keeps the report coverage check strict for the other eight: without it the check would have to be relaxed for the whole corpus, and a scenario that silently stopped running would become invisible. Also documents the two lanes, the contract evaluation order, and the trace stage to phase mapping in INTERACTION_CONTRACT.md, and records Q-05 in the 0.2.0 closeout matrix as partial -- no verified latency measurement exists for this candidate, and the matrix must not imply one does. Refs #146, #139. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01PWGFQCBSNwStAPYUCAZzpN --- .../interaction-traces/drag-no-snap.json | 265 ++++++++++ .../interaction-traces/drag-snap.json | 290 +++++++++++ .../interaction-traces/hover-dense.json | 82 ++++ .../testdata/interaction-traces/manifest.json | 56 +++ .../interaction-traces/overlay-dense.json | 324 +++++++++++++ .../interaction-traces/zoom-anchored.json | 228 +++++++++ .../interaction-traces/zoom-reversal.json | 458 ++++++++++++++++++ changes/cc-gh146-interaction-trace-corpus.md | 2 +- docs/0.2.0-closeout-matrix.md | 1 + docs/INTERACTION_CONTRACT.md | 81 ++++ scripts/ci/check_interaction_traces.py | 53 +- scripts/ci/test_check_interaction_traces.py | 61 +++ 12 files changed, 1896 insertions(+), 5 deletions(-) create mode 100644 UnitTests/testdata/interaction-traces/drag-no-snap.json create mode 100644 UnitTests/testdata/interaction-traces/drag-snap.json create mode 100644 UnitTests/testdata/interaction-traces/hover-dense.json create mode 100644 UnitTests/testdata/interaction-traces/overlay-dense.json create mode 100644 UnitTests/testdata/interaction-traces/zoom-anchored.json create mode 100644 UnitTests/testdata/interaction-traces/zoom-reversal.json diff --git a/UnitTests/testdata/interaction-traces/drag-no-snap.json b/UnitTests/testdata/interaction-traces/drag-no-snap.json new file mode 100644 index 000000000..8106adf3a --- /dev/null +++ b/UnitTests/testdata/interaction-traces/drag-no-snap.json @@ -0,0 +1,265 @@ +{ + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "drag-no-snap", + "description": "Drag a finding with snapping off. Asserts the grab offset taken at the press survives the whole gesture, so the object does not jump its corner to the cursor on the first move, and that exactly one operation is emitted on release.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ], + "snapping": { + "enabled": false + } + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "f-0001", + "drag_completed": 1, + "snapped_to": "", + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "drag-no-snap", + "inputs": [ + { + "pointer": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "action": "move", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "action": "press", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 1, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "action": "move", + "position_px": { + "x": 130, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "action": "move", + "position_px": { + "x": 150, + "y": 75 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "action": "move", + "position_px": { + "x": 170, + "y": 80 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "action": "move", + "position_px": { + "x": 190, + "y": 85 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "action": "move", + "position_px": { + "x": 210, + "y": 90 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 133333336, + "sequence": 8 + }, + "action": "move", + "position_px": { + "x": 230, + "y": 95 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 150000003, + "sequence": 9 + }, + "action": "move", + "position_px": { + "x": 250, + "y": 100 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 166666670, + "sequence": 10 + }, + "action": "move", + "position_px": { + "x": 270, + "y": 105 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 183333337, + "sequence": 11 + }, + "action": "release", + "position_px": { + "x": 270, + "y": 105 + }, + "button": 1, + "buttons": 0, + "modifiers": 0 + } + } + ] + } +} diff --git a/UnitTests/testdata/interaction-traces/drag-snap.json b/UnitTests/testdata/interaction-traces/drag-snap.json new file mode 100644 index 000000000..f575a316b --- /dev/null +++ b/UnitTests/testdata/interaction-traces/drag-snap.json @@ -0,0 +1,290 @@ +{ + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "drag-snap", + "description": "Drag a finding past a guide with snapping on, then hold Alt. Asserts the preview latches to the guide while unmodified and releases it under Alt, and that the snap the user watched is the one recorded on the session.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ], + "guides": [ + { + "id": "guide-3", + "page_index": 0, + "orientation": "vertical", + "position": 100.0 + } + ], + "snapping": { + "enabled": true, + "screen_threshold_px": 8.0 + } + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "f-0001", + "drag_completed": 1, + "snapped_to": "", + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "drag-snap", + "inputs": [ + { + "pointer": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "action": "move", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "action": "press", + "position_px": { + "x": 110, + "y": 70 + }, + "button": 1, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "action": "move", + "position_px": { + "x": 130, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "action": "move", + "position_px": { + "x": 150, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "action": "move", + "position_px": { + "x": 170, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "action": "move", + "position_px": { + "x": 190, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "action": "move", + "position_px": { + "x": 210, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 133333336, + "sequence": 8 + }, + "action": "move", + "position_px": { + "x": 230, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 0 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 150000003, + "sequence": 9 + }, + "action": "move", + "position_px": { + "x": 250, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 134217728 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 166666670, + "sequence": 10 + }, + "action": "move", + "position_px": { + "x": 270, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 134217728 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 183333337, + "sequence": 11 + }, + "action": "move", + "position_px": { + "x": 290, + "y": 70 + }, + "button": 0, + "buttons": 1, + "modifiers": 134217728 + } + }, + { + "pointer": { + "stamp": { + "monotonic_ns": 200000004, + "sequence": 12 + }, + "action": "release", + "position_px": { + "x": 290, + "y": 70 + }, + "button": 1, + "buttons": 0, + "modifiers": 134217728 + } + } + ] + } +} diff --git a/UnitTests/testdata/interaction-traces/hover-dense.json b/UnitTests/testdata/interaction-traces/hover-dense.json new file mode 100644 index 000000000..17eaed906 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/hover-dense.json @@ -0,0 +1,82 @@ +{ + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "hover-dense", + "description": "Pointer sweeps across a page holding 4000 findings. The assertion that matters is the index candidate count, not latency: a spatial index that stopped narrowing looks identical to a heavier page in a frame-time percentile.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "generated_targets": { + "kind": "finding", + "count": 4000, + "page_index": 0, + "id_prefix": "d-", + "grid": { + "columns": 80, + "rows": 50, + "origin": { + "x": 5.0, + "y": 5.0 + }, + "stride": { + "width": 2.5, + "height": 5.8 + }, + "size": { + "width": 2.0, + "height": 4.0 + } + } + } + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "input_script": [ + { + "kind": "hover-sweep", + "at_px": { + "x": 30, + "y": 200 + }, + "to_px": { + "x": 780, + "y": 200 + }, + "steps": 200, + "interval_ns": 16666667 + } + ] +} diff --git a/UnitTests/testdata/interaction-traces/manifest.json b/UnitTests/testdata/interaction-traces/manifest.json index f5e3d3901..6a17b89d4 100644 --- a/UnitTests/testdata/interaction-traces/manifest.json +++ b/UnitTests/testdata/interaction-traces/manifest.json @@ -11,6 +11,35 @@ "deterministic" ] }, + { + "id": "drag-no-snap", + "path": "UnitTests/testdata/interaction-traces/drag-no-snap.json", + "issue": 146, + "sha256": "7148cc4b13ea5a04dc2a7ccb4eb7f0adc9f04f7b6e8a0fdca7a4c71424dfd1c7", + "lanes": [ + "deterministic" + ] + }, + { + "id": "drag-snap", + "path": "UnitTests/testdata/interaction-traces/drag-snap.json", + "issue": 146, + "sha256": "c352f12024bd91d51bec7d1613c16c6887c6e8406668411459c83d75f93ad46d", + "lanes": [ + "deterministic" + ], + "blocked_on": "gh-488", + "blocked_reason": "DragSnapper and the Alt-suppression rule land in PR #488" + }, + { + "id": "hover-dense", + "path": "UnitTests/testdata/interaction-traces/hover-dense.json", + "issue": 146, + "sha256": "0dd807c3ae2f831dfd4db17b04809654edaf0ffd3c910848248078a5c1977ee4", + "lanes": [ + "deterministic" + ] + }, { "id": "hover-sparse", "path": "UnitTests/testdata/interaction-traces/hover-sparse.json", @@ -20,6 +49,15 @@ "deterministic" ] }, + { + "id": "overlay-dense", + "path": "UnitTests/testdata/interaction-traces/overlay-dense.json", + "issue": 146, + "sha256": "21937d56f2a6b64a314596039061087765b45a604d748f2814e7dd7aec1ed5bf", + "lanes": [ + "deterministic" + ] + }, { "id": "pan", "path": "UnitTests/testdata/interaction-traces/pan.json", @@ -28,6 +66,24 @@ "lanes": [ "deterministic" ] + }, + { + "id": "zoom-anchored", + "path": "UnitTests/testdata/interaction-traces/zoom-anchored.json", + "issue": 146, + "sha256": "cf31f186276f957a69c3c62614f68659cc805f7fef1ba517d1ce5f141fa05f28", + "lanes": [ + "deterministic" + ] + }, + { + "id": "zoom-reversal", + "path": "UnitTests/testdata/interaction-traces/zoom-reversal.json", + "issue": 146, + "sha256": "5bde62d81c39852cfbaed28fe45f50fa1c566da78342f38d102229a94d89f459", + "lanes": [ + "deterministic" + ] } ] } diff --git a/UnitTests/testdata/interaction-traces/overlay-dense.json b/UnitTests/testdata/interaction-traces/overlay-dense.json new file mode 100644 index 000000000..536b747ae --- /dev/null +++ b/UnitTests/testdata/interaction-traces/overlay-dense.json @@ -0,0 +1,324 @@ +{ + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "overlay-dense", + "description": "Hover over a page carrying far more findings and guides than the overlay bounds admit. Asserts the frame stays bounded at maxPrimitives and reports what it dropped, rather than growing without limit or aborting the pass.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "generated_targets": { + "kind": "finding", + "count": 6000, + "page_index": 0, + "id_prefix": "o-", + "grid": { + "columns": 100, + "rows": 60, + "origin": { + "x": 2.0, + "y": 2.0 + }, + "stride": { + "width": 2.0, + "height": 4.8 + }, + "size": { + "width": 1.5, + "height": 3.0 + } + } + }, + "guides": [ + { + "id": "g-000", + "page_index": 0, + "orientation": "horizontal", + "position": 5.0 + }, + { + "id": "g-001", + "page_index": 0, + "orientation": "vertical", + "position": 7.0 + }, + { + "id": "g-002", + "page_index": 0, + "orientation": "horizontal", + "position": 9.0 + }, + { + "id": "g-003", + "page_index": 0, + "orientation": "vertical", + "position": 11.0 + }, + { + "id": "g-004", + "page_index": 0, + "orientation": "horizontal", + "position": 13.0 + }, + { + "id": "g-005", + "page_index": 0, + "orientation": "vertical", + "position": 15.0 + }, + { + "id": "g-006", + "page_index": 0, + "orientation": "horizontal", + "position": 17.0 + }, + { + "id": "g-007", + "page_index": 0, + "orientation": "vertical", + "position": 19.0 + }, + { + "id": "g-008", + "page_index": 0, + "orientation": "horizontal", + "position": 21.0 + }, + { + "id": "g-009", + "page_index": 0, + "orientation": "vertical", + "position": 23.0 + }, + { + "id": "g-010", + "page_index": 0, + "orientation": "horizontal", + "position": 25.0 + }, + { + "id": "g-011", + "page_index": 0, + "orientation": "vertical", + "position": 27.0 + }, + { + "id": "g-012", + "page_index": 0, + "orientation": "horizontal", + "position": 29.0 + }, + { + "id": "g-013", + "page_index": 0, + "orientation": "vertical", + "position": 31.0 + }, + { + "id": "g-014", + "page_index": 0, + "orientation": "horizontal", + "position": 33.0 + }, + { + "id": "g-015", + "page_index": 0, + "orientation": "vertical", + "position": 35.0 + }, + { + "id": "g-016", + "page_index": 0, + "orientation": "horizontal", + "position": 37.0 + }, + { + "id": "g-017", + "page_index": 0, + "orientation": "vertical", + "position": 39.0 + }, + { + "id": "g-018", + "page_index": 0, + "orientation": "horizontal", + "position": 41.0 + }, + { + "id": "g-019", + "page_index": 0, + "orientation": "vertical", + "position": 43.0 + }, + { + "id": "g-020", + "page_index": 0, + "orientation": "horizontal", + "position": 45.0 + }, + { + "id": "g-021", + "page_index": 0, + "orientation": "vertical", + "position": 47.0 + }, + { + "id": "g-022", + "page_index": 0, + "orientation": "horizontal", + "position": 49.0 + }, + { + "id": "g-023", + "page_index": 0, + "orientation": "vertical", + "position": 51.0 + }, + { + "id": "g-024", + "page_index": 0, + "orientation": "horizontal", + "position": 53.0 + }, + { + "id": "g-025", + "page_index": 0, + "orientation": "vertical", + "position": 55.0 + }, + { + "id": "g-026", + "page_index": 0, + "orientation": "horizontal", + "position": 57.0 + }, + { + "id": "g-027", + "page_index": 0, + "orientation": "vertical", + "position": 59.0 + }, + { + "id": "g-028", + "page_index": 0, + "orientation": "horizontal", + "position": 61.0 + }, + { + "id": "g-029", + "page_index": 0, + "orientation": "vertical", + "position": 63.0 + }, + { + "id": "g-030", + "page_index": 0, + "orientation": "horizontal", + "position": 65.0 + }, + { + "id": "g-031", + "page_index": 0, + "orientation": "vertical", + "position": 67.0 + }, + { + "id": "g-032", + "page_index": 0, + "orientation": "horizontal", + "position": 69.0 + }, + { + "id": "g-033", + "page_index": 0, + "orientation": "vertical", + "position": 71.0 + }, + { + "id": "g-034", + "page_index": 0, + "orientation": "horizontal", + "position": 73.0 + }, + { + "id": "g-035", + "page_index": 0, + "orientation": "vertical", + "position": 75.0 + }, + { + "id": "g-036", + "page_index": 0, + "orientation": "horizontal", + "position": 77.0 + }, + { + "id": "g-037", + "page_index": 0, + "orientation": "vertical", + "position": 79.0 + }, + { + "id": "g-038", + "page_index": 0, + "orientation": "horizontal", + "position": 81.0 + }, + { + "id": "g-039", + "page_index": 0, + "orientation": "vertical", + "position": 83.0 + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "selected_id": "", + "drag_completed": 0, + "request_generation_changed": false, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "input_script": [ + { + "kind": "hover-sweep", + "at_px": { + "x": 40, + "y": 150 + }, + "to_px": { + "x": 760, + "y": 450 + }, + "steps": 150, + "interval_ns": 16666667 + } + ] +} diff --git a/UnitTests/testdata/interaction-traces/zoom-anchored.json b/UnitTests/testdata/interaction-traces/zoom-anchored.json new file mode 100644 index 000000000..097247e76 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/zoom-anchored.json @@ -0,0 +1,228 @@ +{ + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "zoom-anchored", + "description": "Six wheel-zoom steps anchored at one cursor position. Asserts the page point under the cursor is unchanged at the end, and that a zoom does advance the viewport request generation -- the half of issue #142's rule that pan must not.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "zoom": 2.985984, + "request_generation_changed": true, + "drag_completed": 0, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "zoom-anchored", + "inputs": [ + { + "pointer": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "action": "move", + "position_px": { + "x": 500, + "y": 350 + }, + "button": 0, + "buttons": 0, + "modifiers": 0 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "position_px": { + "x": 500, + "y": 350 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + } + ] + } +} diff --git a/UnitTests/testdata/interaction-traces/zoom-reversal.json b/UnitTests/testdata/interaction-traces/zoom-reversal.json new file mode 100644 index 000000000..fe118f515 --- /dev/null +++ b/UnitTests/testdata/interaction-traces/zoom-reversal.json @@ -0,0 +1,458 @@ +{ + "schema_kind": "loupe-interaction-scenario", + "schema_version": 1, + "scenario_id": "zoom-reversal", + "description": "Eight zoom steps in, eight back out, then page switches. The corpus port of InteractionControllerTest::rapidZoomReversalAndPageSwitchSettleWithinTraceBudget; the C++ case stays in place as the unit-level guard.", + "fixture": { + "page_count": 4, + "page_size_mm": { + "width": 210.0, + "height": 297.0 + }, + "pixel_per_mm": 2.0, + "device_pixel_ratio": 1.0, + "initial_zoom": 1.0, + "viewport_size_px": { + "width": 1200, + "height": 800 + }, + "page_layout": "single-page", + "hit_targets": [ + { + "kind": "finding", + "page_index": 0, + "id": "f-0001", + "page_bounds": { + "x": 20.0, + "y": 20.0, + "width": 40.0, + "height": 20.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0002", + "page_bounds": { + "x": 90.0, + "y": 20.0, + "width": 30.0, + "height": 30.0 + } + }, + { + "kind": "finding", + "page_index": 0, + "id": "f-0003", + "page_bounds": { + "x": 20.0, + "y": 80.0, + "width": 60.0, + "height": 15.0 + } + } + ] + }, + "cost_model": { + "base_frame_ns": 500000, + "hit_test_ns_per_candidate": 20000, + "overlay_ns_per_primitive": 3000, + "page_surface_admit_ns": 400000, + "cache_miss_ns": 2000000, + "external_present_ns": 1500000 + }, + "budgets": { + "refresh_rate_hz": 60.0, + "frame_p95_ms": 16.667, + "input_to_frame_p95_ms": 16.667, + "max_slow_frames": 0, + "max_dropped_frames": 0, + "variance_band_multiplier": 2.5 + }, + "expected": { + "zoom": 1.0, + "request_generation_changed": true, + "drag_completed": 0, + "cancellations": [], + "unbalanced_frames": 0, + "pending_inputs": 0 + }, + "trace": { + "schema_version": 1, + "trace_id": "zoom-reversal", + "inputs": [ + { + "wheel": { + "stamp": { + "monotonic_ns": 16666667, + "sequence": 1 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 33333334, + "sequence": 2 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 50000001, + "sequence": 3 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 66666668, + "sequence": 4 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 83333335, + "sequence": 5 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 100000002, + "sequence": 6 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 116666669, + "sequence": 7 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 133333336, + "sequence": 8 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": 120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 150000003, + "sequence": 9 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 166666670, + "sequence": 10 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 183333337, + "sequence": 11 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 200000004, + "sequence": 12 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 216666671, + "sequence": 13 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 233333338, + "sequence": 14 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 250000005, + "sequence": 15 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "wheel": { + "stamp": { + "monotonic_ns": 266666672, + "sequence": 16 + }, + "position_px": { + "x": 600, + "y": 400 + }, + "angle_delta": { + "x": 0, + "y": -120 + }, + "pixel_delta": { + "x": 0, + "y": 0 + }, + "modifiers": 67108864 + } + }, + { + "key": { + "stamp": { + "monotonic_ns": 283333339, + "sequence": 17 + }, + "action": "press", + "key": 16777238, + "modifiers": 0, + "auto_repeat": false + } + }, + { + "key": { + "stamp": { + "monotonic_ns": 300000006, + "sequence": 18 + }, + "action": "press", + "key": 16777239, + "modifiers": 0, + "auto_repeat": false + } + }, + { + "key": { + "stamp": { + "monotonic_ns": 316666673, + "sequence": 19 + }, + "action": "press", + "key": 16777238, + "modifiers": 0, + "auto_repeat": false + } + } + ] + } +} diff --git a/changes/cc-gh146-interaction-trace-corpus.md b/changes/cc-gh146-interaction-trace-corpus.md index cde60e324..4e93c5f33 100644 --- a/changes/cc-gh146-interaction-trace-corpus.md +++ b/changes/cc-gh146-interaction-trace-corpus.md @@ -1,4 +1,4 @@ Category: internal Audience: developers Breaking-Change: no -Summary: Add the interaction-performance trace corpus for issue #146: a scenario schema, a report schema, three seed scenarios with a digest manifest, and a CI checker that validates the corpus with no build and enforces that a failing run names the contract it broke and the phase responsible. Missing telemetry must be reported as unavailable rather than as zero. +Summary: Add the interaction-performance trace corpus for issue #146: a scenario schema, a report schema, nine seed scenarios with a digest manifest, and a CI checker that validates the corpus with no build. A failing run must name the contract it broke and the phase responsible, missing telemetry must be reported as unavailable rather than as zero, and a scenario whose harness support has not landed is marked blocked so the coverage check stays strict for the rest. Document the two lanes and the phase vocabulary in the interaction contract. diff --git a/docs/0.2.0-closeout-matrix.md b/docs/0.2.0-closeout-matrix.md index 3bb3a4769..e43e46f9a 100644 --- a/docs/0.2.0-closeout-matrix.md +++ b/docs/0.2.0-closeout-matrix.md @@ -35,6 +35,7 @@ local implementation evidence alone. | Q-02 | Direct canvas | Direct `QQuickItem`, scene-graph lifecycle, fidelity/color, backends | Implemented P4-S5–S6; CI on branch | | Q-03 | Quick product workflow | Open → detect → pinpoint → inspect → understand-state | `UnitTestsProductOperatorLoop` + focused suite on branch | | Q-04 | Surface disposition | All maintained Widgets targets/forms have observed graph rows and one explicit Phase 5 disposition | **Session 01–03 Issue 10:** generated inventory/disposition; Viewer/LaunchPad `DELETE`; PageMaster/Diff `HEADLESS-REPLACE` onto existing Core/CLI owners; Compare workspace remains OPEN. **Session 04 Issue 13:** AudioBook/Ocr removed from install graph; ABSORB/ADVANCED/BLOCKED plugin rows verified by `scripts/verify-plugin-surface-policies.py`; RedactPlugin remains the sole BLOCKED row | +| Q-05 | Interaction regression traces | Replayable scenario corpus, two lanes, and a report that names the first violated contract and the phase responsible | **Partial** — issue #146. The corpus, both schemas, and `scripts/ci/check_interaction_traces.py` are in place and gated in CI (`--corpus-only`, no build). Nine scenarios are tracked; one is marked `blocked_on: gh-488`. The C++ replay harness (`UnitTestsInteractionTraces`), the report writer, and the desktop/GPU present lane are still open, so no verified latency measurement is recorded for this candidate | | W-01 | No Widgets on installed editor | Installed `LoupeEditor` must not link or ship Widgets | **Closed (static + configure)** — `verify-installed-product-graph.py`, `verify-widgets-free-release-profile.py` (static + configure probe) in CI, package smoke scans; E-01 hosted proof still open | | P-01 | Cross-platform/package | Linux/Windows native/software smoke, clean-machine package, QML deployment | **Partial** — smoke scripts enforce Qt6Widgets absence; hosted package proof pending merge SHA | | P-02 | Supply chain/licensing | SBOM, notices, LGPL relink evidence | Open — `docs/quick-runtime-manifest.json` release_gates | diff --git a/docs/INTERACTION_CONTRACT.md b/docs/INTERACTION_CONTRACT.md index cca10192c..e299c34bd 100644 --- a/docs/INTERACTION_CONTRACT.md +++ b/docs/INTERACTION_CONTRACT.md @@ -190,6 +190,87 @@ feeds it back in order, leaving the controller in the state the original session the same viewport, hit-test sources and document state. Recording is suppressed during replay, so replaying into a recording controller does not append the trace to itself. +## Regression traces + +Issue #146. The corpus lives in `UnitTests/testdata/interaction-traces/`, one +JSON scenario per file plus a `manifest.json` of ids and digests. Schemas: +[interaction-scenario.schema.json](schemas/interaction-scenario.schema.json) and +[interaction-trace-report.schema.json](schemas/interaction-trace-report.schema.json). +`scripts/ci/check_interaction_traces.py --corpus-only` validates the corpus +without a build, so a malformed scenario fails in seconds rather than after a +compile. + +A scenario **embeds** an `InteractionTrace` under `trace`; it does not extend +one. `InteractionTrace` may not carry geometry or target identity — that is the +privacy rule above, and a test enforces it — while a scenario must declare both +to state its fixture and its expected selection. Embedding keeps the shipping +type unchanged and lets a recorded field trace drop in as the `trace` member. A +scenario that would otherwise be hundreds of near-identical records uses +`input_script` instead, and a dense page declares `generated_targets` as a grid: +a corpus nobody can read in review is a corpus nobody checks. + +### Two lanes + +| | Deterministic | Present | +| --- | --- | --- | +| Target | `UnitTestsInteractionTraces` | `UnitTestsInteractionTracesPresent` | +| Clock | `ManualClock`, set from each `InputStamp` | `SteadyMonotonicClock` | +| Budgets | strict, from the scenario | scenario budget × `variance_band_multiplier` | +| Gating | yes | no | + +The deterministic lane reads no real clock. Stage time comes from the +scenario's `cost_model` multiplied by real run products — index candidates, +overlay primitives, cache misses, admitted surfaces — so the same scenario +produces byte-identical output on every machine. + +The cost this buys is worth stating: `StageTimer` measures zero under a manual +clock, so a regression that is purely slower code is invisible in this lane *as +elapsed time*. What catches it is the counts the cost model multiplies, since a +regression that costs time almost always costs one of those. Real elapsed time +lives in the present lane, where a shared CI runner's variance is absorbed by a +band rather than pretended away. + +A present run reports `verified`, `static-only`, or `infrastructure-blocked`. +Only `verified` participates in the band assertion. A lane that cannot measure +presentation reports `available: false` with +`interaction-trace/present-timing-unavailable` and never a zero percentile — +the same rule [RESOURCE_BUDGETS.md](RESOURCE_BUDGETS.md) applies to every other +budget, and the reason a headless run may not be recorded as a desktop result. + +### What a failure says + +A failed run names one contract and one phase (issue #146 AC7). Contracts are +evaluated in a fixed order, so "first violated" is a documented constant rather +than whichever key the JSON happened to yield first: + +`input-acknowledged` → `frame-balance` → `telemetry-available` → +`p95-input-to-frame` → `p95-frame-time` → `slow-frame-budget` → +`dropped-frames` → `stale-result-safety` → `final-state`. + +The phase is derived from the slow-frame attribution, translating trace stages +into the vocabulary the issue asks a reader to act on: + +| `TraceStage` | Phase | +| --- | --- | +| `Interaction` | `input` | +| `HitTest` | `hit-test` | +| `PageSurface` | `page-cache` | +| `Overlay` | `overlay` | +| `External` | `composition` | +| `Unknown` | `async-overlap` when a job overlapped a slow frame, else `unknown` | + +`Unknown` is the interesting row. A frame slowed by something no stage measured +must not have a cause invented for it, but it is not nothing either: if an +expensive job was in flight across it, the overlap is the finding. + +### Scenarios ahead of the harness + +A manifest entry may carry `blocked_on` with a `blocked_reason`. Such a +scenario is validated as data but is not required to produce a run, which is +what lets the coverage check stay strict for everything else — a scenario that +silently stops running is otherwise indistinguishable from one that was never +wired up. + ## Not in this session - The developer-facing trace overlay and GPU/present timing from issue #140. Neither can exist diff --git a/scripts/ci/check_interaction_traces.py b/scripts/ci/check_interaction_traces.py index be924d4cb..dfefe79e5 100755 --- a/scripts/ci/check_interaction_traces.py +++ b/scripts/ci/check_interaction_traces.py @@ -276,6 +276,17 @@ def validate_corpus(corpus_dir: Path = CORPUS_DIR, root: Path = ROOT) -> list[Vi violations.append((label, f"missing required fields: {sorted(missing)}")) continue + blocked_on = entry.get("blocked_on") + if blocked_on is not None: + # A scenario may be reviewed as data before the harness can run it. + # Saying so explicitly is what keeps the coverage check strict for + # everything else: without this the check would have to be relaxed + # for the whole corpus. + if not isinstance(blocked_on, str) or not blocked_on.strip(): + violations.append((label, "blocked_on must be a non-empty string when present")) + if not str(entry.get("blocked_reason", "")).strip(): + violations.append((label, "a blocked scenario must carry a blocked_reason")) + entry_id = entry["id"] if not isinstance(entry_id, str) or not KEBAB_CASE.match(entry_id): violations.append((label, f"id must be kebab-case, got {entry_id!r}")) @@ -329,6 +340,29 @@ def validate_corpus(corpus_dir: Path = CORPUS_DIR, root: Path = ROOT) -> list[Vi return violations +def runnable_ids(manifest: dict) -> set[str]: + """Corpus ids whose harness support has landed. + + A blocked scenario is tracked and validated as data, but nothing can run it + yet, so demanding a run for it would report the corpus as broken rather + than as ahead of the harness. + """ + return { + str(entry["id"]) + for entry in manifest.get("scenarios", []) + if not entry.get("blocked_on") + } + + +def blocked_ids(manifest: dict) -> dict[str, str]: + """Blocked corpus ids mapped to what they are waiting on.""" + return { + str(entry["id"]): str(entry.get("blocked_on")) + for entry in manifest.get("scenarios", []) + if entry.get("blocked_on") + } + + def _percentile_keys(block: dict) -> list[str]: return [key for key in block if key.startswith("p") and key[1:].split("_")[0].isdigit()] @@ -376,6 +410,7 @@ def validate_report( report: dict, corpus_ids: set[str] | None = None, expected_corpus_digest: str | None = None, + known_ids: set[str] | None = None, ) -> list[Violation]: """Return (subject, reason) for every report violation.""" violations: list[Violation] = [] @@ -495,9 +530,11 @@ def validate_report( ) if corpus_ids is not None: + # corpus_ids are the scenarios that must run; known_ids additionally + # covers blocked scenarios, which may run early but need not. for missing_id in sorted(corpus_ids - seen): violations.append(("report.runs", f"corpus scenario {missing_id!r} has no run")) - for extra_id in sorted(seen - corpus_ids): + for extra_id in sorted(seen - (known_ids if known_ids is not None else corpus_ids)): violations.append(("report.runs", f"run {extra_id!r} is not in the corpus")) return violations @@ -568,7 +605,14 @@ def main(argv: list[str] | None = None) -> int: return 1 if args.corpus_only: - print(f"Interaction trace corpus policy passed ({len(load_manifest()['scenarios'])} scenarios).") + manifest = load_manifest() + blocked = blocked_ids(manifest) + print( + f"Interaction trace corpus policy passed " + f"({len(manifest['scenarios'])} scenarios, {len(blocked)} awaiting harness support)." + ) + for scenario_id, waiting_on in sorted(blocked.items()): + print(f" blocked: {scenario_id} (waiting on {waiting_on})") return 0 if not args.report: @@ -585,8 +629,9 @@ def main(argv: list[str] | None = None) -> int: return 1 manifest = load_manifest() - corpus_ids = {str(entry["id"]) for entry in manifest["scenarios"]} - violations = validate_report(report, corpus_ids, corpus_digest(manifest)) + required = runnable_ids(manifest) + known = required | set(blocked_ids(manifest)) + violations = validate_report(report, required, corpus_digest(manifest), known) if violations: print("ERROR: interaction trace report failed validation:", file=sys.stderr) diff --git a/scripts/ci/test_check_interaction_traces.py b/scripts/ci/test_check_interaction_traces.py index dd14aec53..fa2088c23 100644 --- a/scripts/ci/test_check_interaction_traces.py +++ b/scripts/ci/test_check_interaction_traces.py @@ -13,8 +13,10 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2])) from scripts.ci.check_interaction_traces import ( # noqa: E402 + blocked_ids, corpus_digest, load_manifest, + runnable_ids, trend_rows, validate_corpus, validate_percentiles, @@ -201,6 +203,65 @@ def test_untracked_scenario_is_reported(self): self.assertTrue(any("missing from manifest" in reason for _, reason in violations)) +class BlockedScenarioTests(unittest.TestCase): + """A scenario may be reviewed as data before the harness can run it.""" + + def test_runnable_and_blocked_partition_the_corpus(self): + manifest = load_manifest() + every_id = {str(entry["id"]) for entry in manifest["scenarios"]} + self.assertEqual(runnable_ids(manifest) | set(blocked_ids(manifest)), every_id) + self.assertEqual(runnable_ids(manifest) & set(blocked_ids(manifest)), set()) + + def test_blocked_scenario_needs_a_reason(self): + with tempfile.TemporaryDirectory() as tmp: + root = pathlib.Path(tmp) + corpus = root / "UnitTests" / "testdata" / "interaction-traces" + corpus.mkdir(parents=True) + scenario = minimal_scenario() + body = json.dumps(scenario) + (corpus / "example.json").write_text(body, encoding="utf-8") + import hashlib + + digest = hashlib.sha256(body.encode("utf-8")).hexdigest() + (corpus / "manifest.json").write_text( + json.dumps( + { + "schema_kind": "loupe-interaction-corpus", + "schema_version": 1, + "scenarios": [ + { + "id": "example", + "path": "UnitTests/testdata/interaction-traces/example.json", + "issue": 146, + "sha256": digest, + "blocked_on": "gh-488", + } + ], + } + ), + encoding="utf-8", + ) + violations = validate_corpus(corpus, root) + self.assertTrue(any("blocked_reason" in reason for _, reason in violations)) + + def test_blocked_scenario_does_not_need_a_run(self): + # The coverage check stays strict for everything else. + violations = validate_report( + minimal_report(), + corpus_ids={"example"}, + known_ids={"example", "drag-snap"}, + ) + self.assertEqual(violations, []) + + def test_blocked_scenario_may_still_report_a_run(self): + report = minimal_report() + report["runs"][0]["scenario_id"] = "drag-snap" + violations = validate_report( + report, corpus_ids=set(), known_ids={"example", "drag-snap"} + ) + self.assertEqual(violations, []) + + class ScenarioTests(unittest.TestCase): def test_minimal_scenario_passes(self): self.assertEqual(validate_scenario(minimal_scenario(), "example"), []) From de844ac9c73274dd1ddb1a63df56df1d846a6f78 Mon Sep 17 00:00:00 2001 From: michael berry Date: Tue, 1 Sep 2026 13:39:42 -0700 Subject: [PATCH 22/33] fix: stage Loupe.Quick QML and drop unbundlable SQL drivers for package boundary (#500) Repair the remaining Session 07 hosted packaging failures after #494: - copy the built Loupe.Quick module into the staged install tree on Windows so scrubbed --quick-smoke can resolve the packaged shell - stage Loupe.Quick under usr/lib/qml on Linux and remove optional Qt SQL drivers before linuxdeployqt probes missing vendor libraries Co-authored-by: Cursor Agent Co-authored-by: michael berry --- .github/workflows/LinuxInstall.yml | 11 +++++++++++ .github/workflows/WindowsInstall.yml | 8 ++++++++ changes/cursor-session-07-closeout-06ea.md | 4 ++++ scripts/ci/test_workflow_contracts.py | 3 +++ 4 files changed, 26 insertions(+) create mode 100644 changes/cursor-session-07-closeout-06ea.md diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index e05254029..cb4cb7899 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -205,6 +205,17 @@ jobs: evidence_dir="$RUNNER_TEMP/loupe-package-boundary-linux" mkdir -p "$evidence_dir" set -o pipefail + loupe_quick_src="$GITHUB_WORKSPACE/loupe/build/Loupe/Quick" + qml_dest="install/usr/lib/qml/Loupe/Quick" + if [ ! -d "$loupe_quick_src" ]; then + echo "::error::Built Loupe.Quick QML module was not found: $loupe_quick_src" + exit 1 + fi + mkdir -p "$(dirname "$qml_dest")" + cp -a "$loupe_quick_src" "$qml_dest" + # Qt 6.11 ships optional SQL drivers that linuxdeployqt probes but we do + # not bundle. Remove them so deploy does not fail on missing vendor libs. + rm -rf "${QT_ROOT_DIR}/plugins/sqldrivers" cp install/usr/share/icons/hicolor/scalable/apps/io.github.mberrys.Loupe-pdf.svg install/io.github.mberrys.Loupe-pdf.svg bash "$GITHUB_WORKSPACE/loupe/scripts/ci/download_verified.sh" \ --gh-asset "probonopd/linuxdeployqt" \ diff --git a/.github/workflows/WindowsInstall.yml b/.github/workflows/WindowsInstall.yml index f5c0bb53e..3c1405e1b 100644 --- a/.github/workflows/WindowsInstall.yml +++ b/.github/workflows/WindowsInstall.yml @@ -281,6 +281,14 @@ jobs: } } + $builtLoupeQuick = Join-Path $env:GITHUB_WORKSPACE "loupe\build\Loupe\Quick" + $qmlLoupeQuick = Join-Path $installBin "qml\Loupe\Quick" + if (-not (Test-Path -LiteralPath $builtLoupeQuick)) { + throw "Built Loupe.Quick QML module was not found: $builtLoupeQuick" + } + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $qmlLoupeQuick) | Out-Null + Copy-Item -Recurse -Force -LiteralPath $builtLoupeQuick -Destination $qmlLoupeQuick + # The clean installed-artifact smoke intentionally removes developer # Qt environment variables. Make the staged tree self-describing so # Qt resolves its bundled QML imports and plugins without the runner's diff --git a/changes/cursor-session-07-closeout-06ea.md b/changes/cursor-session-07-closeout-06ea.md new file mode 100644 index 000000000..9e1cec745 --- /dev/null +++ b/changes/cursor-session-07-closeout-06ea.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: maintainers +Breaking-Change: no +Summary: Stage the built Loupe.Quick QML module in Linux and Windows package trees and drop unbundlable Qt SQL drivers before linuxdeployqt so Session 07 package-boundary smoke can pass with scrubbed Qt paths. diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index e2b1c6178..e04665d44 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -80,6 +80,9 @@ def test_package_workflows_require_and_record_exact_source_sha(self): self.assertIn("LoupeEditor.exe", windows) self.assertIn("Qml2Imports=qml", windows) self.assertIn('Join-Path $installBin "qt.conf"', windows) + self.assertIn("build\\Loupe\\Quick", windows) + self.assertIn("plugins/sqldrivers", linux) + self.assertIn("build/Loupe/Quick", linux) self.assertIn("VCPKG_BINARY_SOURCES=clear;files", windows) self.assertIn("./vcpkg_installed", windows) self.assertIn("./vcpkg-binary-cache", windows) From 13b48fd6318085e13bc0bb0ed9c809dd77574e7a Mon Sep 17 00:00:00 2001 From: mbx30 Date: Tue, 1 Sep 2026 14:16:37 -0700 Subject: [PATCH 23/33] fix: use PDFObject null check in Quick document model --- LoupeEditor/quickdocumentmodel.cpp | 2 +- changes/cdx-package-boundary-linux-compile.md | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 changes/cdx-package-boundary-linux-compile.md diff --git a/LoupeEditor/quickdocumentmodel.cpp b/LoupeEditor/quickdocumentmodel.cpp index aa10700bf..a08f3370e 100644 --- a/LoupeEditor/quickdocumentmodel.cpp +++ b/LoupeEditor/quickdocumentmodel.cpp @@ -251,7 +251,7 @@ void QuickDocumentModel::setDocument(pdf::PDFDocumentContext* context) m_hasOutline = catalog->getOutlineRootPtr() && catalog->getOutlineRootPtr()->getChildCount() > 0; m_hasAttachments = !catalog->getEmbeddedFiles().empty(); m_hasOptionalContent = !catalog->getOptionalContentProperties()->getAllOptionalContentGroups().empty(); - m_hasForm = catalog->getFormObject().isValid(); + m_hasForm = !catalog->getFormObject().isNull(); m_hasLogicalStructure = catalog->isLogicalStructureMarked(); const pdf::PDFSecurityHandler* security = document->getStorage().getSecurityHandler(); diff --git a/changes/cdx-package-boundary-linux-compile.md b/changes/cdx-package-boundary-linux-compile.md new file mode 100644 index 000000000..7c97cf1f9 --- /dev/null +++ b/changes/cdx-package-boundary-linux-compile.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: developers +Breaking-Change: no +Summary: Fix the Quick document model's form-presence check for the current PDFObject API so Linux package qualification builds successfully. From 3c5e1a60a71e9cea2c1b1eaa44ac60b9fc0fb7ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:15:13 +0000 Subject: [PATCH 24/33] Add gh-243 resource-exhaustion corpus for PDFProcessingBudget Seven small, deterministic, synthetic adversarial PDF fixtures (generated by scripts/resource_envelope/budget_exhaustion_corpus.py), one per budget dimension named in the issue. New UnitTestsBudgetCorpus reads each fixture through PDFDocumentReader/PreflightEngine like a real upload and asserts the run terminates within a bounded time and fails closed with the exact exceeded budget attributed, never a hang, an OOM kill, or a silent clean result. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014oPh587Ls4UZTLwpxxQtpt --- UnitTests/CMakeLists.txt | 20 + .../cumulative_decoded_bytes.pdf | Bin 0 -> 15037 bytes .../budget_exhaustion/decompression_bomb.pdf | Bin 0 -> 694 bytes .../deep_nested_content_streams.pdf | Bin 0 -> 2729 bytes .../deep_recursive_object_graph.pdf | Bin 0 -> 593 bytes .../long_running_render_work.pdf | Bin 0 -> 975 bytes .../testdata/budget_exhaustion/manifest.json | 152 +++++++ .../pathological_object_count.pdf | Bin 0 -> 5421 bytes .../raster_probe_pixel_budget.pdf | Bin 0 -> 484 bytes UnitTests/tst_budgetcorpustest.cpp | 354 +++++++++++++++++ changes/cc-nice-noether-u5ie9s.md | 19 + docs/RESOURCE_BUDGETS.md | 39 ++ docs/generated/architecture-catalog.json | 1 + .../budget_exhaustion_corpus.py | 371 ++++++++++++++++++ .../test_budget_exhaustion_corpus.py | 136 +++++++ 15 files changed, 1092 insertions(+) create mode 100644 UnitTests/testdata/budget_exhaustion/cumulative_decoded_bytes.pdf create mode 100644 UnitTests/testdata/budget_exhaustion/decompression_bomb.pdf create mode 100644 UnitTests/testdata/budget_exhaustion/deep_nested_content_streams.pdf create mode 100644 UnitTests/testdata/budget_exhaustion/deep_recursive_object_graph.pdf create mode 100644 UnitTests/testdata/budget_exhaustion/long_running_render_work.pdf create mode 100644 UnitTests/testdata/budget_exhaustion/manifest.json create mode 100644 UnitTests/testdata/budget_exhaustion/pathological_object_count.pdf create mode 100644 UnitTests/testdata/budget_exhaustion/raster_probe_pixel_budget.pdf create mode 100644 UnitTests/tst_budgetcorpustest.cpp create mode 100644 changes/cc-nice-noether-u5ie9s.md create mode 100644 scripts/resource_envelope/budget_exhaustion_corpus.py create mode 100644 scripts/resource_envelope/test_budget_exhaustion_corpus.py diff --git a/UnitTests/CMakeLists.txt b/UnitTests/CMakeLists.txt index b24a47922..284277037 100644 --- a/UnitTests/CMakeLists.txt +++ b/UnitTests/CMakeLists.txt @@ -810,6 +810,26 @@ set_target_properties(UnitTestsBudgetExhaustion PROPERTIES ) add_test(UnitTestsBudgetExhaustion "${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_BIN_DIR}/UnitTestsBudgetExhaustion") +# gh-243: synthetic adversarial PDF corpus, one fixture per PDFProcessingBudget +# dimension, read through PDFDocumentReader/PreflightEngine like a real upload. +# Fixtures and manifest.json are generated (not hand-written) by +# scripts/resource_envelope/budget_exhaustion_corpus.py; see +# UnitTests/testdata/budget_exhaustion/manifest.json and tst_budgetcorpustest.cpp. +add_executable(UnitTestsBudgetCorpus + tst_budgetcorpustest.cpp +) +target_link_libraries(UnitTestsBudgetCorpus PRIVATE LoupeLibCore Qt6::Core Qt6::Gui Qt6::Test) +target_compile_definitions(UnitTestsBudgetCorpus PRIVATE + BUDGET_CORPUS_DIR="${CMAKE_SOURCE_DIR}/UnitTests/testdata/budget_exhaustion" +) +set_target_properties(UnitTestsBudgetCorpus PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_BIN_DIR} +) +add_test(UnitTestsBudgetCorpus "${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_BIN_DIR}/UnitTestsBudgetCorpus") + add_executable(UnitTestsResourceBudget tst_resourcebudgettest.cpp ) diff --git a/UnitTests/testdata/budget_exhaustion/cumulative_decoded_bytes.pdf b/UnitTests/testdata/budget_exhaustion/cumulative_decoded_bytes.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d7fd0984ddb7ddf2d665c603f7d3d527dcc301bf GIT binary patch literal 15037 zcmeI3&2HN;41n)`3SDyCEK2?biXQgU%Qh@XcME#(8YRon#e?kv-ClBsy}|Y(qvWcw zC1Xf{#6VIefB`eKNGbFuinb=xH&-vOEiJ+2h$wl0Ss)M8z{Dj(RKqke)dq`ANbXR0pmt6S`w z?)0=OZ?9Fu<{=Ku4I0rFHx0H^v&i_o?Ylm=cHgVbs`p{zjWAX{KlidW$2&`oy6cGyJ zgEA<}pu82HDQ8(w92HS+{SC`aTR0M;2vLNnT@#Icl9E%?^^+)5Y99qrZkZgYPEH|7 zPd(`A4^B;;{CNOTV-?E5t&|&qg(GEAltpy|_0;Bm^7l~jJAkOMYUSV-%Z-2#)t^Hs z)DIQvG?J$|k^_hut6VPJa=8%@q6ksDKkOmwoF;bm`QLm*nO8e4D2`b!7jC)S2&`pV z=$(J??Gvb^`&{E;08wMTR4&|dxe*YeD2t*jiZ)AZUpE;*)L7+m;g-vdfDlE9B193Q z{)ecCN-YDlm?4@=@$#%#%+WP1Cb{;G<4K#rjTFX4C2Q k`lrHp!EKpkiQ7C8vF3%`x+$v0L=!|f2Ek7&)#~SosnDfNv-x3j-T$ zFd}{d;cG()@CDp<&+qQN^PSi2gzI9X$-I}>hesyB2Yq_RS}owmmlp_pS6P+nG4QS$ zV-A5M^q}2ln8ie-{AaO;FoG2(BrIMb`^FmXZz)Qc2;4>e!h(7UvqmK;n#IBOlPd)2ma8@0O0l=x QDq|O(WKA&7+dB%`CtHb%)Bpeg literal 0 HcmV?d00001 diff --git a/UnitTests/testdata/budget_exhaustion/deep_nested_content_streams.pdf b/UnitTests/testdata/budget_exhaustion/deep_nested_content_streams.pdf new file mode 100644 index 0000000000000000000000000000000000000000..54f2c5a07938dd7967d6c93e9119fe3d211ccf2d GIT binary patch literal 2729 zcmchZ&2HN;49D+!3SM&DY{}n1(L>wi(hUuo9R>tDIEj*`Xskk3fNn3j!`@(fk&%j> zIkvnU20KU)m?otk|29cYW_Qzf{C&bEzkdJt$vF6+me1_!3cSzXUL|<9xyfJD7Q9)$ zl_dytc!cX~CW|#W()D@3(a*cztxH%0`QHR0+Zv@hvZXj)=CAuE>@jl7*F27N4 zu<+4e`1XhEJ<3uY^a_E9bf(p6E)6U^%m(j%d00Nnm9h7xdmkdAz`f~8!Prw(_4Gw$K+HpijrJON%eTHCYSdL})b-JU!YXA0bQ_`0{cIny4_?g$u) zGXq|C`+w+}^>8)^!@&LB2YxoJDnYaJIfNDC97HyDoJztv;;vcLChZwUHg+IV8uFEH zLNtV<4&{U1+CRhxEn(vr=WkkW7(R%E`KJ%WQ4~b5fs>82rVkW0Ie2va`FH+kR=6v^EqRx1nQ;JT9$Igrgu3Q`Y6a?b2$>hVsl>GtqtM2On literal 0 HcmV?d00001 diff --git a/UnitTests/testdata/budget_exhaustion/deep_recursive_object_graph.pdf b/UnitTests/testdata/budget_exhaustion/deep_recursive_object_graph.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7efc34e21392a2555a3db77d6294235471d368e1 GIT binary patch literal 593 zcma)4%TB{E5WM><_L3twb{-W{)dP=S01>s^q8v=K2|*=Bjw=;@k^_GLKf-RCl+s?h z7t7<>o!MOn+4b~HosUHD{qy-H6og=(Uc`6|^6~u*L0%bembM0&nHoFLM3}*3BCsu) z$oKyVTb{ga$`0~~=>=7>tJZ_sOXQd1M3!+#)UA%Z$Feb(b`5z*{8VWeU1+K|!_KbU zf()_K%-O{pJ>-(QDM*9`TTgX5h}prOF5d^;J8WzJ3|jMh=N+0?!8#$hdUGhU><}u! z?e>Je@uaVlpBQ0rVhodAJFLh}D#Bernj}#I73?rYHaMXD!%}MT08^S4dw}Wq7>g9G mW#D;dnkBl;Uzj%^2r13oT3%y6(#{*_H$Gz(iXgaon2KNN3Zd-) literal 0 HcmV?d00001 diff --git a/UnitTests/testdata/budget_exhaustion/long_running_render_work.pdf b/UnitTests/testdata/budget_exhaustion/long_running_render_work.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0e7e24219be6eeda8488927ecba15544989b6791 GIT binary patch literal 975 zcmeHGOHRWu5Z(I}v&j-1yLAdu)din!01>t9qApA`4M8PFwks7b$$}feMVLuL32+3k zSM%o0ys`Br$To2IxA&J2bg=K9|+!l(I_?gl$I@Z*0hQV$8 zk_O^QbMKaOj8I5=&4NT&u!+p{C>2mz3dx=dI@4)Dk2_bs44}yghU5~K(5B}~S zu){k`bY9QO>CVVPjcM0`D+Lq{qr1>&+y{%^_yq*ybKrr{QKA6gSpY|fBiVTc;Pm`{PAgaoA0*k_I`is zHkaGm`QUsT@YcP2In29j48;HE2YeZ~`Qh&R;I0COzi(&RKkQa#PY%R4{ehTfncL7W z^K5>aukW_6_MhF=*v4n`u1vn2@9lipKP(^G1>s6Bm;Hx#^XjfP&E|cZ1QYJocAkTa zxPmA1;{VgUKhC?`^*84|zCWzXy#0N^JfmA~kAJLP@-n-d0lJR^9>vq+d3F!?_othY z1uKG8!6v~Bmx7CZI*Fa0(HT5FhckG($7k?#1J0nbF{NY5$CQvMBd?6SGHMT{clXN3 zD$|xwKpp1et3d$%bqo9m}G78ElDx;{3qB4rgC@Q0_O3ElHqoj-M;qpFOmGOEg`Dx<25CS^1!qe&S} z%4kwXlQNo=(WHzfWi%-x8}BJ5}qqF zBoAh2YRphCiDj%qW_X&+5IUHlpD{zJBv!Ecm?0E0L*8J9R>llfl32x>VnIUB`LjyZ!tz32MKMD_XV$N+b}7AwN5`UL18N^LWOHVSeaw(3h(+`&W~g|~5X+e%@i9Y~ zAePXfm|@$*42hf>!X7i^2x1w1i5YfG%n-(z^>xiMA_R>qeJ!?v%@U784rhjl#|-I# zSVa$F*4J^Xh~PX9>5kZh%)ku$SZ3HFF+=)hhG2Kj{D`E$3|m-c*c~xL@MhNcl(w`w z4{zAL(y@k(5i?|NW{7jdJW>HOY+RXPPs9vyn^-`KW7gk$ANt?D4}Hz?p|AI?#rJbW zabIHpoAj}-2R`++*r&b^`hbEemyXWbrI>zm9zAYgj9> z{kh$k8o!@U_gat zw6}M=w@QkO`H?<85y{8r+q=-9z}-KJ=@jJM%QJ#Jx6y804YIH`21tppfZ0r7TQZT~ zzZ-gl_GwwRDaRL1qQeC8?wY+ z3-2~djIffl&zfXdvW+w;K+YaU%KSbkUt?Rx2S`u39->G4)McQ+*F+mhf=8$XuRYL) z0QMd$p}+|8HXN83lNqSs8`B258ni#ygnaNBVLGM!|6plONgc50ZL>w+eb2IaMKBbE WbDZq3V~Ezr&Qq3ZktCP5bMXZg=YClL literal 0 HcmV?d00001 diff --git a/UnitTests/tst_budgetcorpustest.cpp b/UnitTests/tst_budgetcorpustest.cpp new file mode 100644 index 000000000..3d25f3d5e --- /dev/null +++ b/UnitTests/tst_budgetcorpustest.cpp @@ -0,0 +1,354 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// gh-243: a committed corpus of synthetic adversarial PDF fixtures, one per +// pdf::PDFProcessingBudget dimension named in the issue, generated by +// scripts/resource_envelope/budget_exhaustion_corpus.py and driven from the +// manifest it writes alongside them +// (UnitTests/testdata/budget_exhaustion/manifest.json). This is the +// adversarial complement to the individual-API-call coverage in +// tst_budgetexhaustiontest.cpp: here, a real (if tiny) PDF file is read +// through pdf::PDFDocumentReader and pdf::PreflightEngine, the way a hostile +// upload actually would be, and every case asserts the run terminates within +// a bounded time and fails closed with the exact exceeded budget attributed +// -- never a hang, a crash, or a silent clean result. +// +// Manifest cases come in two shapes ("path" field): +// - "session": the fixture is a fully valid, readable document. The budget +// trips while pdf::PreflightEngine walks it with one pdf::PDFProcessingLimits +// field (or, for the raster-probe fixture, one preflight check parameter) +// tightened past what the fixture's shape demands. The structured +// checks[].budget.{kind,pool,limit,attempted} fields +// (docs/RESOURCE_BUDGETS.md) are asserted directly. +// - "reader": the budget trips inside pdf::PDFDocumentReader itself, before +// a document exists (deep PDF object nesting, a pathological object +// count). pdf::PDFDocumentReader does not expose the structured +// pdf::PDFBudgetExceeded detail on failure, only its formatted message, so +// these cases parse the kind name and the "attempted N, limit M" numbers +// out of getErrorMessage() (see PDFBudgetExceededException's constructor +// in pdfprocessingbudget.cpp for the exact format). + +#include "pdfdocumentreader.h" +#include "pdfdocumentsession.h" +#include "pdfpreflightverdict.h" +#include "pdfprocessingbudget.h" +#include "preflightengine.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +// A generous ceiling: every fixture is deliberately tiny and trips its +// tightened budget almost immediately. This bounds catastrophic regressions +// (a hang, an accidental unbounded loop) without being a tight perf assertion. +constexpr int BOUNDED_MS = 20000; + +QString corpusDir() +{ + return QStringLiteral(BUDGET_CORPUS_DIR); +} + +pdf::PDFProcessingLimits limitsFromOverrides(const QJsonObject& overrides) +{ + pdf::PDFProcessingLimits limits = pdf::PDFProcessingLimits::conservativeDefaults(); + if (overrides.contains(QStringLiteral("maxDecompressionRatio"))) + { + limits.maxDecompressionRatio = static_cast(overrides.value(QStringLiteral("maxDecompressionRatio")).toDouble()); + } + if (overrides.contains(QStringLiteral("maxCumulativeDecodedBytes"))) + { + limits.maxCumulativeDecodedBytes = static_cast(overrides.value(QStringLiteral("maxCumulativeDecodedBytes")).toDouble()); + } + if (overrides.contains(QStringLiteral("maxRecursiveContentDepth"))) + { + limits.maxRecursiveContentDepth = static_cast(overrides.value(QStringLiteral("maxRecursiveContentDepth")).toInt()); + } + if (overrides.contains(QStringLiteral("maxRenderOperations"))) + { + limits.maxRenderOperations = static_cast(overrides.value(QStringLiteral("maxRenderOperations")).toDouble()); + } + if (overrides.contains(QStringLiteral("maxObjectDepth"))) + { + limits.maxObjectDepth = static_cast(overrides.value(QStringLiteral("maxObjectDepth")).toInt()); + } + if (overrides.contains(QStringLiteral("maxObjectsVisited"))) + { + limits.maxObjectsVisited = static_cast(overrides.value(QStringLiteral("maxObjectsVisited")).toDouble()); + } + return limits; +} + +// Every fixture tightens exactly one limit (or, for the raster-probe case, +// one check parameter) to the value the corpus test must observe echoed back +// as checks[].budget.limit / the reader's error message. +qint64 expectedLimitFor(const QJsonObject& testCase) +{ + const QJsonObject limitsObject = testCase.value(QStringLiteral("limits")).toObject(); + if (!limitsObject.isEmpty()) + { + return static_cast(limitsObject.constBegin().value().toDouble()); + } + + const QJsonArray checks = testCase.value(QStringLiteral("profile")).toObject().value(QStringLiteral("checks")).toArray(); + if (!checks.isEmpty()) + { + const QJsonObject check = checks.constFirst().toObject(); + if (check.contains(QStringLiteral("max_raster_pixels"))) + { + return static_cast(check.value(QStringLiteral("max_raster_pixels")).toDouble()); + } + } + + return -1; +} + +QByteArray readFixture(const QString& filename) +{ + QFile file(QDir(corpusDir()).filePath(filename)); + if (!file.open(QIODevice::ReadOnly)) + { + return QByteArray(); + } + return file.readAll(); +} + +} // namespace + +class BudgetCorpusTest : public QObject +{ + Q_OBJECT + +private slots: + void initTestCase(); + + void sessionBudgetFixturesReportIncomplete_data(); + void sessionBudgetFixturesReportIncomplete(); + + void readerBudgetFixturesFailClosed_data(); + void readerBudgetFixturesFailClosed(); + + void everyScopedDimensionHasAFixture(); + +private: + QJsonArray m_cases; +}; + +void BudgetCorpusTest::initTestCase() +{ + const QString manifestPath = QDir(corpusDir()).filePath(QStringLiteral("manifest.json")); + QFile manifestFile(manifestPath); + QVERIFY2(manifestFile.open(QIODevice::ReadOnly), qPrintable(QStringLiteral("Cannot open manifest '%1'").arg(manifestPath))); + + QJsonParseError parseError; + const QJsonDocument document = QJsonDocument::fromJson(manifestFile.readAll(), &parseError); + QVERIFY2(parseError.error == QJsonParseError::NoError, qPrintable(parseError.errorString())); + QVERIFY2(document.isObject(), "manifest.json must contain a top-level JSON object"); + + m_cases = document.object().value(QStringLiteral("cases")).toArray(); + QVERIFY2(!m_cases.isEmpty(), "manifest.json has no fixture cases"); +} + +void BudgetCorpusTest::sessionBudgetFixturesReportIncomplete_data() +{ + QTest::addColumn("pdf"); + QTest::addColumn("limits"); + QTest::addColumn("profile"); + QTest::addColumn("expectedKind"); + QTest::addColumn("expectedPool"); + QTest::addColumn("expectedLimit"); + + for (const QJsonValue& value : m_cases) + { + const QJsonObject entry = value.toObject(); + if (entry.value(QStringLiteral("path")).toString() != QStringLiteral("session")) + { + continue; + } + + const QJsonObject expect = entry.value(QStringLiteral("expected")).toObject(); + const QString id = entry.value(QStringLiteral("id")).toString(); + QTest::newRow(qPrintable(id)) << entry.value(QStringLiteral("pdf")).toString() + << entry.value(QStringLiteral("limits")).toObject() + << entry.value(QStringLiteral("profile")).toObject() + << expect.value(QStringLiteral("kind")).toString() + << expect.value(QStringLiteral("pool")).toString() + << expectedLimitFor(entry); + } +} + +void BudgetCorpusTest::sessionBudgetFixturesReportIncomplete() +{ + QFETCH(QString, pdf); + QFETCH(QJsonObject, limits); + QFETCH(QJsonObject, profile); + QFETCH(QString, expectedKind); + QFETCH(QString, expectedPool); + QFETCH(qint64, expectedLimit); + + const QByteArray bytes = readFixture(pdf); + QVERIFY2(!bytes.isEmpty(), qPrintable(QStringLiteral("Cannot read fixture '%1'").arg(pdf))); + + QElapsedTimer timer; + timer.start(); + + // The fixture itself must be an ordinary, fully readable document: the + // budget under test is the session's, tightened below, not the reader's. + auto noPassword = [](bool*) + { return QString(); }; + pdf::PDFDocumentReader reader(nullptr, noPassword, false, false); + pdf::PDFDocument document = reader.readFromBuffer(bytes); + QCOMPARE(int(reader.getReadingResult()), int(pdf::PDFDocumentReader::Result::OK)); + + pdf::PDFDocumentSession session(&document); + session.setProcessingLimits(limitsFromOverrides(limits)); + + pdf::PreflightEngine engine(&session); + const pdf::PreflightResult result = engine.run(profile); + + QVERIFY2(timer.elapsed() < BOUNDED_MS, "budget fixture must terminate within the bounded time budget"); + + QVERIFY2(!result.inspectionComplete, "a budget-exceeded run must never report a complete inspection"); + + const pdf::PreflightVerdict verdict = pdf::reducePreflightVerdict(result); + QCOMPARE(int(verdict.state), int(pdf::PreflightVerdictState::Incomplete)); + QVERIFY2(!verdict.isPass(), "a budget-exceeded check must never contribute a clean (pass) verdict"); + + bool foundExpectedBudget = false; + for (const pdf::PreflightCheckStatus& status : result.checkStatuses) + { + if (status.budgetKind == expectedKind) + { + QCOMPARE(status.status, QStringLiteral("incomplete")); + QCOMPARE(status.reason, QStringLiteral("budget-exceeded")); + QCOMPARE(status.budgetPool, expectedPool); + QCOMPARE(status.budgetLimit, expectedLimit); + QVERIFY2(status.budgetAttempted > status.budgetLimit, + "attempted must exceed the configured limit"); + foundExpectedBudget = true; + break; + } + } + QVERIFY2(foundExpectedBudget, + qPrintable(QStringLiteral("No check status reported budget kind '%1'").arg(expectedKind))); +} + +void BudgetCorpusTest::readerBudgetFixturesFailClosed_data() +{ + QTest::addColumn("pdf"); + QTest::addColumn("limits"); + QTest::addColumn("expectedKind"); + QTest::addColumn("expectedLimit"); + + for (const QJsonValue& value : m_cases) + { + const QJsonObject entry = value.toObject(); + if (entry.value(QStringLiteral("path")).toString() != QStringLiteral("reader")) + { + continue; + } + + const QJsonObject expect = entry.value(QStringLiteral("expected")).toObject(); + const QString id = entry.value(QStringLiteral("id")).toString(); + QTest::newRow(qPrintable(id)) << entry.value(QStringLiteral("pdf")).toString() + << entry.value(QStringLiteral("limits")).toObject() + << expect.value(QStringLiteral("kind")).toString() + << expectedLimitFor(entry); + } +} + +void BudgetCorpusTest::readerBudgetFixturesFailClosed() +{ + QFETCH(QString, pdf); + QFETCH(QJsonObject, limits); + QFETCH(QString, expectedKind); + QFETCH(qint64, expectedLimit); + + const QByteArray bytes = readFixture(pdf); + QVERIFY2(!bytes.isEmpty(), qPrintable(QStringLiteral("Cannot read fixture '%1'").arg(pdf))); + + QElapsedTimer timer; + timer.start(); + + auto noPassword = [](bool*) + { return QString(); }; + pdf::PDFDocumentReader reader(nullptr, noPassword, false, false, limitsFromOverrides(limits)); + pdf::PDFDocument document = reader.readFromBuffer(bytes); + Q_UNUSED(document); + + QVERIFY2(timer.elapsed() < BOUNDED_MS, "budget fixture must terminate within the bounded time budget"); + + // A budget trip while reading must fail the read outright: there is no + // document yet to report an incomplete inspection about, so failing + // closed here means the reader refuses to hand back a usable document. + QCOMPARE(int(reader.getReadingResult()), int(pdf::PDFDocumentReader::Result::Failed)); + + const QString message = reader.getErrorMessage(); + QVERIFY2(message.contains(expectedKind), qPrintable(QStringLiteral("Error message '%1' does not name budget kind '%2'").arg(message, expectedKind))); + + // PDFBudgetExceededException's message is "... exceeded: attempted A, limit L (...)." + // (see PDFBudgetExceededException's constructor in pdfprocessingbudget.cpp); + // parse it out to confirm the numbers, not just the kind name, are attributable. + QRegularExpression numbers(QStringLiteral("attempted (\\d+), limit (\\d+)")); + const QRegularExpressionMatch match = numbers.match(message); + QVERIFY2(match.hasMatch(), qPrintable(QStringLiteral("Error message '%1' does not carry attempted/limit numbers").arg(message))); + QCOMPARE(match.captured(2).toLongLong(), expectedLimit); + QVERIFY2(match.captured(1).toLongLong() > match.captured(2).toLongLong(), + "attempted must exceed the configured limit"); +} + +void BudgetCorpusTest::everyScopedDimensionHasAFixture() +{ + // gh-243's scope lists seven adversarial shapes; confirm the manifest + // still names all seven distinct pdf::PDFBudgetKind values rather than + // silently losing coverage to a future edit. + QSet kinds; + for (const QJsonValue& value : m_cases) + { + kinds.insert(value.toObject().value(QStringLiteral("expected")).toObject().value(QStringLiteral("kind")).toString()); + } + + const QStringList required = { + QStringLiteral("decompression-ratio"), + QStringLiteral("cumulative-decoded-bytes"), + QStringLiteral("recursive-content-depth"), + QStringLiteral("render-operations"), + QStringLiteral("render-pixels"), + QStringLiteral("object-depth"), + QStringLiteral("objects-visited"), + }; + for (const QString& kind : required) + { + QVERIFY2(kinds.contains(kind), qPrintable(QStringLiteral("No corpus fixture trips budget kind '%1'").arg(kind))); + } +} + +QTEST_MAIN(BudgetCorpusTest) +#include "tst_budgetcorpustest.moc" diff --git a/changes/cc-nice-noether-u5ie9s.md b/changes/cc-nice-noether-u5ie9s.md new file mode 100644 index 000000000..d63b7c5ee --- /dev/null +++ b/changes/cc-nice-noether-u5ie9s.md @@ -0,0 +1,19 @@ +Category: added +Audience: developers +Breaking-Change: no +Summary: Add gh-243's resource-exhaustion corpus: seven small (a few KB), deterministic, synthetic +adversarial PDF fixtures generated by scripts/resource_envelope/budget_exhaustion_corpus.py, one +per pdf::PDFProcessingBudget dimension named in the issue (a decompression bomb, cumulative +decoded bytes across many streams, a deep Form-XObject Do chain, an operator-heavy content stream, +an oversized declared page extent probed by the thin-parts check, a deeply nested object-array +literal, and a pathological indirect-object count). New UnitTestsBudgetCorpus reads each fixture +through PDFDocumentReader/PreflightEngine the way a real upload would be handled and asserts the +run terminates within a bounded time and fails closed with the exact exceeded budget attributed: +for the five checked through an already-parsed document, the structured +checks[].budget.{kind,pool,limit,attempted} fields and an Incomplete reducePreflightVerdict(); +for the two that are PDF-object-graph properties tripped inside PDFDocumentReader itself before a +document exists, a failed read whose error message names the same kind, limit, and attempted +value. This is the adversarial complement to the existing tst_budgetexhaustiontest.cpp +(direct-API coverage of every budget kind) and to gh-64's memory-safety fuzz corpus: not "does it +crash" but "does it fail closed with an attributable reason." Document the corpus in +docs/RESOURCE_BUDGETS.md. diff --git a/docs/RESOURCE_BUDGETS.md b/docs/RESOURCE_BUDGETS.md index 8c860af3c..b19eb7520 100644 --- a/docs/RESOURCE_BUDGETS.md +++ b/docs/RESOURCE_BUDGETS.md @@ -60,6 +60,45 @@ Synthetic exhaustion fixtures are generated in `UnitTestsBudgetExhaustion` (nested objects, raster size, evidence records, elapsed clock). Do not commit multi-GB binaries. +### Resource-exhaustion corpus (gh-243) + +`UnitTestsBudgetCorpus` is the adversarial complement: instead of calling +`PDFProcessingBudget` directly, it reads small (a few KB) synthetic hostile +PDF files through `PDFDocumentReader`/`PreflightEngine`, the way a real +upload would be handled, and asserts each run terminates within a bounded +time and fails closed with the exact exceeded budget attributed -- never a +hang, an OOM kill, or a clean result over unexamined content. The fixtures +and `UnitTests/testdata/budget_exhaustion/manifest.json` are generated by +`scripts/resource_envelope/budget_exhaustion_corpus.py`, one fixture per +adversarial shape: + +| Fixture | Shape | Budget kind tripped | +|---------|-------|----------------------| +| `decompression-bomb` | FlateDecode stream with an extreme decoded/compressed ratio | `decompression-ratio` | +| `cumulative-decoded-bytes` | Many pages, each with a small unfiltered stream; the sum exceeds the cap | `cumulative-decoded-bytes` | +| `deep-nested-content-streams` | A chain of Form XObjects invoking each other via `Do` | `recursive-content-depth` | +| `long-running-render-work` | A content stream with far more operator/operand tokens than the cap | `render-operations` | +| `raster-probe-pixel-budget` | A large declared page extent probed by the `thin-parts` check | `render-pixels` | +| `deep-recursive-object-graph` | One indirect object holding a deeply nested array literal | `object-depth` | +| `pathological-object-count` | Hundreds of trivial extra indirect objects | `objects-visited` | + +The first five trip their budget while `PreflightEngine` walks an +already-parsed document, so the corpus test asserts the structured +`checks[].budget.{kind,pool,limit,attempted}` fields directly. +`object-depth` and `objects-visited` are structural PDF-object-graph +properties instead: they are budgeted while `PDFDocumentReader` itself walks +every occupied cross-reference entry, before a document exists to run +`PreflightEngine` against, so the reader fails the read outright and the +test recovers the kind and the attempted/limit numbers from +`PDFDocumentReader::getErrorMessage()` (see `PDFBudgetExceededException`'s +constructor in `pdfprocessingbudget.cpp` for the exact format). Regenerate +the corpus with: + +``` +python3 -m scripts.resource_envelope.budget_exhaustion_corpus \ + --output-dir UnitTests/testdata/budget_exhaustion +``` + Under memory pressure, `PDFDocumentSession::shedPrefetchAndQuality()` shrinks compile and stream cache caps. The Quick `DocumentViewSession` owns a 256 MiB unified page-cache total, partitioned into compiled-page and admitted-surface diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index 6c4af6044..6669a1298 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -384,6 +384,7 @@ "UnitTestsBleedFixup", "UnitTestsBleedMarginProbe", "UnitTestsBleedStress", + "UnitTestsBudgetCorpus", "UnitTestsBudgetExhaustion", "UnitTestsCanvasParity", "UnitTestsContentEditor", diff --git a/scripts/resource_envelope/budget_exhaustion_corpus.py b/scripts/resource_envelope/budget_exhaustion_corpus.py new file mode 100644 index 000000000..92a03b98a --- /dev/null +++ b/scripts/resource_envelope/budget_exhaustion_corpus.py @@ -0,0 +1,371 @@ +"""Synthetic adversarial PDF corpus for pdf::PDFProcessingBudget (gh-243). + +Each fixture is a small, deterministic, hand-assembled PDF shaped to trip +exactly one `pdf::PDFBudgetKind` when read with the tightened limit recorded +for it in manifest.json -- not by being large, but by being the wrong shape +(a decompression bomb, a deeply nested object, thousands of tiny operators, +...). None of these files are third-party samples and none exceed a few +kilobytes: the point is that a hostile document does not need to be big to +be hostile, and Loupe must fail closed (report the exact exceeded budget) +rather than hang, get OOM-killed, or silently return a clean result. + +Two thirds of the manifest ("path": "session") trip their budget while +`pdf::PreflightEngine` walks an already-parsed document: the fixture is a +syntactically valid, fully readable PDF, and the corpus test tightens one +`pdf::PDFProcessingLimits` field (or, for the raster-probe fixture, one +preflight check parameter) before running a profile that touches page +content. The rest ("path": "reader") trip during `pdf::PDFDocumentReader` +itself, before a document exists at all -- those fixtures still parse as a +syntactically valid xref/trailer, but one object in the table is shaped to +blow the tightened limit while `pdf::PDFDocumentReader::readFromBuffer()` +walks every occupied entry. + +Regenerate with: + python3 -m scripts.resource_envelope.budget_exhaustion_corpus \ + --output-dir UnitTests/testdata/budget_exhaustion +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import zlib +from pathlib import Path + +SCHEMA_VERSION = 1 + + +def _object_body(number: int, body: bytes) -> bytes: + return f"{number} 0 obj\n".encode("ascii") + body + b"\nendobj\n" + + +def stream_body(extra_dict_entries: bytes, data: bytes) -> bytes: + """A stream object body: `<< /Length N >>\\nstream\\n\\nendstream`.""" + + prefix = b"<< " + extra_dict_entries + if extra_dict_entries: + prefix += b" " + return prefix + f"/Length {len(data)} >>\nstream\n".encode("ascii") + data + b"\nendstream" + + +def assemble_pdf(bodies: dict[int, bytes]) -> bytes: + """Assembles a minimal, syntactically valid PDF from object bodies. + + `bodies` maps an object number to its raw body (without the surrounding + "N 0 obj" / "endobj" markers). Object numbers must be the contiguous + range 1..max(bodies) so the cross-reference table is trivial to build, + and every entry -- reachable from the catalog or not -- lands in the + xref table, because pdf::PDFDocumentReader budgets every occupied entry + it walks, not only the ones the page tree references. + """ + + max_object = max(bodies) + if set(bodies) != set(range(1, max_object + 1)): + raise ValueError("object numbers must be contiguous starting at 1") + + pdf = bytearray(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] * (max_object + 1) + for number in range(1, max_object + 1): + offsets[number] = len(pdf) + pdf.extend(_object_body(number, bodies[number])) + + xref_offset = len(pdf) + pdf.extend(f"xref\n0 {max_object + 1}\n".encode("ascii")) + pdf.extend(b"0000000000 65535 f \n") + for number in range(1, max_object + 1): + pdf.extend(f"{offsets[number]:010d} 00000 n \n".encode("ascii")) + pdf.extend( + f"trailer\n<< /Size {max_object + 1} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("ascii") + ) + return bytes(pdf) + + +def build_page_document(pages_content: list[bytes], media_box: tuple[int, int, int, int] = (0, 0, 612, 792)) -> dict[int, bytes]: + """A minimal, fully readable N-page document; object 1 is the Catalog, object 2 the Pages node. + + Pages use only resource-free content operators (rg/re/f and similar), so + no /Resources entries are required. Returns the object body map; caller + assembles it (optionally after appending more objects). + """ + + bodies: dict[int, bytes] = {1: b"<< /Type /Catalog /Pages 2 0 R >>"} + media = " ".join(str(value) for value in media_box) + page_refs: list[int] = [] + next_object = 3 + for content in pages_content: + page_object = next_object + content_object = next_object + 1 + next_object += 2 + page_refs.append(page_object) + bodies[page_object] = ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [{media}]" + f" /Resources << /ProcSet [/PDF] >> /Contents {content_object} 0 R >>" + ).encode("ascii") + bodies[content_object] = stream_body(b"", content) + kids = " ".join(f"{reference} 0 R" for reference in page_refs) + bodies[2] = f"<< /Type /Pages /Kids [{kids}] /Count {len(page_refs)} >>".encode("ascii") + return bodies + + +def _sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +# --------------------------------------------------------------------------- +# Fixture builders. Each returns (pdf_bytes, manifest_case_without_pdf_or_sha). +# --------------------------------------------------------------------------- + + +def build_decompression_bomb() -> tuple[bytes, dict]: + """FlateDecode content stream with an extreme decoded/compressed ratio.""" + + payload = b"A" * 200_000 + compressed = zlib.compress(payload, level=9) + content_stream = stream_body(b"/Filter /FlateDecode", compressed) + bodies = build_page_document([b"0 0 0 rg 0 0 1 1 re f\n"]) + # Splice a compressed content stream into the page built by build_page_document + # (object 4 is the first page's content stream; see build_page_document). + bodies[4] = content_stream + pdf = assemble_pdf(bodies) + case = { + "id": "decompression-bomb", + "description": ( + "A single page whose content stream is a FlateDecode decompression " + "bomb: %d bytes of highly compressible payload compress to %d bytes " + "(ratio ~%d:1)." % (len(payload), len(compressed), len(payload) // max(1, len(compressed))) + ), + "path": "session", + "limits": {"maxDecompressionRatio": 40}, + "profile": { + "name": "budget-corpus-decompression-bomb", + "checks": [{"id": "color-inventory", "severity": "info"}], + }, + "expected": {"kind": "decompression-ratio", "pool": "decoded-streams"}, + } + return pdf, case + + +def build_cumulative_decoded_bytes() -> tuple[bytes, dict]: + """Many pages, each with a small unfiltered content stream; the *sum* exceeds the cap.""" + + page_count = 12 + single_page_content = (b"1 0 0 rg 0 0 1 1 re f\n" * 46) # ~1012 bytes, decoded 1:1 (no filter) + bodies = build_page_document([single_page_content] * page_count) + pdf = assemble_pdf(bodies) + case = { + "id": "cumulative-decoded-bytes", + "description": ( + "%d pages, each with an unfiltered ~%d byte content stream; no single " + "stream is large, but the cumulative decoded total across the " + "document exceeds a tightened cap." % (page_count, len(single_page_content)) + ), + "path": "session", + "limits": {"maxCumulativeDecodedBytes": 6000}, + "profile": { + "name": "budget-corpus-cumulative-decoded-bytes", + "checks": [{"id": "color-inventory", "severity": "info"}], + }, + "expected": {"kind": "cumulative-decoded-bytes", "pool": "decoded-streams"}, + } + return pdf, case + + +def build_deep_nested_content_streams() -> tuple[bytes, dict]: + """A chain of Form XObjects, each invoking the next via the Do operator.""" + + form_count = 12 + bodies: dict[int, bytes] = {1: b"<< /Type /Catalog /Pages 2 0 R >>"} + + # Object numbers: 3 = page, 4 = page content, 5.. = form dictionaries (one + # object per form; each form's content stream is embedded via the form's + # own /Length, so a form is itself the stream object). + first_form_object = 5 + form_objects = [first_form_object + index for index in range(form_count)] + + page_object = 3 + page_content_object = 4 + bodies[page_object] = ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200]" + f" /Resources << /ProcSet [/PDF] /XObject << /Fm0 {form_objects[0]} 0 R >> >>" + f" /Contents {page_content_object} 0 R >>" + ).encode("ascii") + bodies[page_content_object] = stream_body(b"", b"/Fm0 Do\n") + + for index, form_object in enumerate(form_objects): + is_last = index == len(form_objects) - 1 + if is_last: + resources = b"<< /ProcSet [/PDF] >>" + content = b"0 0 0 rg 0 0 1 1 re f\n" + else: + next_object = form_objects[index + 1] + resources = f"<< /ProcSet [/PDF] /XObject << /Fm{index + 1} {next_object} 0 R >> >>".encode("ascii") + content = f"/Fm{index + 1} Do\n".encode("ascii") + dict_entries = ( + b"/Type /XObject /Subtype /Form /BBox [0 0 200 200] /Resources " + resources + ) + bodies[form_object] = stream_body(dict_entries, content) + + bodies[2] = f"<< /Type /Pages /Kids [{page_object} 0 R] /Count 1 >>".encode("ascii") + pdf = assemble_pdf(bodies) + case = { + "id": "deep-nested-content-streams", + "description": ( + "A page whose content stream invokes a chain of %d nested Form " + "XObjects (each drawing the next via the Do operator)." % form_count + ), + "path": "session", + "limits": {"maxRecursiveContentDepth": 4}, + "profile": { + "name": "budget-corpus-deep-nested-content-streams", + "checks": [{"id": "color-inventory", "severity": "info"}], + }, + "expected": {"kind": "recursive-content-depth", "pool": "document-model"}, + } + return pdf, case + + +def build_long_running_render_work() -> tuple[bytes, dict]: + """A content stream with far more operator/operand tokens than the tightened cap.""" + + content = b"0 0 1 1 re f\n" * 40 # 5 tokens per repeat = 200 tokens + bodies = build_page_document([content]) + pdf = assemble_pdf(bodies) + case = { + "id": "long-running-render-work", + "description": ( + "A page content stream with 200 operator/operand tokens -- a stand-in " + "for a pathologically operation-heavy page that would otherwise take " + "an unbounded amount of processing to finish rendering." + ), + "path": "session", + "limits": {"maxRenderOperations": 40}, + "profile": { + "name": "budget-corpus-long-running-render-work", + "checks": [{"id": "color-inventory", "severity": "info"}], + }, + "expected": {"kind": "render-operations", "pool": "raster-tile"}, + } + return pdf, case + + +def build_raster_probe_pixel_budget() -> tuple[bytes, dict]: + """A page whose declared extent forces an oversized raster probe.""" + + media_box = (0, 0, 4000, 4000) + content = b"1 0 0 rg 0 0 4000 4000 re f\n" + bodies = build_page_document([content], media_box=media_box) + pdf = assemble_pdf(bodies) + case = { + "id": "raster-probe-pixel-budget", + "description": ( + "A page with a 4000x4000pt declared MediaBox and a single fill " + "spanning it; the 'thin-parts' check's raster probe is configured " + "with an unreachably small pixel budget." + ), + "path": "session", + "limits": {}, + "profile": { + "name": "budget-corpus-raster-probe-pixel-budget", + "checks": [ + { + "id": "thin-parts", + "severity": "info", + "min_effective_width_pt": 0.25, + "classes": ["thin-fill"], + "probe_dpi": 150, + "max_raster_pixels": 4, + } + ], + }, + "expected": {"kind": "render-pixels", "pool": "raster-tile"}, + } + return pdf, case + + +def build_deep_recursive_object_graph() -> tuple[bytes, dict]: + """A minimal document plus one object holding a deeply nested array literal.""" + + bodies = build_page_document([b"0 0 0 rg 0 0 1 1 re f\n"]) + extra_object = max(bodies) + 1 + depth = 40 + nested_array = (b"[" * depth) + b"0" + (b"]" * depth) + bodies[extra_object] = nested_array + pdf = assemble_pdf(bodies) + case = { + "id": "deep-recursive-object-graph", + "description": ( + "An otherwise-ordinary document with one extra indirect object whose " + "value is a %d-level nested array literal, unreachable from the " + "catalog -- every occupied xref entry is still parsed." % depth + ), + "path": "reader", + "limits": {"maxObjectDepth": 20}, + "expected": {"kind": "object-depth", "pool": "document-model"}, + } + return pdf, case + + +def build_pathological_object_count() -> tuple[bytes, dict]: + """A minimal document plus hundreds of trivial extra indirect objects.""" + + bodies = build_page_document([b"0 0 0 rg 0 0 1 1 re f\n"]) + next_object = max(bodies) + 1 + extra_object_count = 120 + for offset in range(extra_object_count): + bodies[next_object + offset] = b"null" + pdf = assemble_pdf(bodies) + case = { + "id": "pathological-object-count", + "description": ( + "An otherwise-ordinary document plus %d trivial extra indirect " + "objects (unreachable from the catalog) driving the document's " + "total visited-object count past a tightened cap." % extra_object_count + ), + "path": "reader", + "limits": {"maxObjectsVisited": 60}, + "expected": {"kind": "objects-visited", "pool": "document-model"}, + } + return pdf, case + + +BUILDERS = ( + build_decompression_bomb, + build_cumulative_decoded_bytes, + build_deep_nested_content_streams, + build_long_running_render_work, + build_raster_probe_pixel_budget, + build_deep_recursive_object_graph, + build_pathological_object_count, +) + + +def generate_corpus(output_dir: Path) -> dict: + output_dir.mkdir(parents=True, exist_ok=True) + cases = [] + for builder in BUILDERS: + pdf_bytes, case = builder() + filename = case["id"].replace("-", "_") + ".pdf" + (output_dir / filename).write_bytes(pdf_bytes) + case = dict(case) + case["pdf"] = filename + case["sha256"] = _sha256(pdf_bytes) + cases.append(case) + + manifest = {"schema_version": SCHEMA_VERSION, "cases": cases} + manifest_path = output_dir / "manifest.json" + manifest_path.write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8") + return manifest + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", type=Path, default=Path("UnitTests/testdata/budget_exhaustion")) + args = parser.parse_args() + manifest = generate_corpus(args.output_dir) + print(json.dumps({"cases": [case["id"] for case in manifest["cases"]]}, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/resource_envelope/test_budget_exhaustion_corpus.py b/scripts/resource_envelope/test_budget_exhaustion_corpus.py new file mode 100644 index 000000000..e383e46e6 --- /dev/null +++ b/scripts/resource_envelope/test_budget_exhaustion_corpus.py @@ -0,0 +1,136 @@ +from __future__ import annotations + +import hashlib +import re +import tempfile +import unittest +import zlib +from pathlib import Path + +from scripts.resource_envelope.budget_exhaustion_corpus import ( + BUILDERS, + assemble_pdf, + generate_corpus, +) + + +class AssemblePdfTest(unittest.TestCase): + def test_rejects_non_contiguous_object_numbers(self) -> None: + with self.assertRaises(ValueError): + assemble_pdf({1: b"<< >>", 3: b"<< >>"}) + + def test_xref_offsets_point_at_the_right_object(self) -> None: + pdf = assemble_pdf({1: b"<< /Type /Catalog >>", 2: b"42"}) + match = re.search(rb"startxref\r?\n(\d+)\r?\n%%EOF", pdf) + assert match is not None + xref_offset = int(match.group(1)) + self.assertEqual(pdf[xref_offset : xref_offset + 4], b"xref") + + header_match = re.match(rb"xref\r?\n0 (\d+)\r?\n", pdf[xref_offset:]) + assert header_match is not None + count = int(header_match.group(1)) + self.assertEqual(count, 3) + position = xref_offset + header_match.end() + for number in range(count): + entry = pdf[position : position + 20] + position += 20 + if number == 0: + continue + offset = int(entry[:10]) + self.assertEqual(pdf[offset : offset + len(f"{number} 0 obj".encode())], f"{number} 0 obj".encode()) + + +class BudgetExhaustionCorpusTest(unittest.TestCase): + def test_generation_is_deterministic(self) -> None: + with tempfile.TemporaryDirectory() as first_dir, tempfile.TemporaryDirectory() as second_dir: + first_manifest = generate_corpus(Path(first_dir)) + second_manifest = generate_corpus(Path(second_dir)) + self.assertEqual(first_manifest, second_manifest) + for case in first_manifest["cases"]: + first_bytes = (Path(first_dir) / case["pdf"]).read_bytes() + second_bytes = (Path(second_dir) / case["pdf"]).read_bytes() + self.assertEqual(first_bytes, second_bytes) + + def test_manifest_has_one_case_per_builder(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + self.assertEqual(len(manifest["cases"]), len(BUILDERS)) + + def test_every_case_is_small_and_hash_matches_file(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + for case in manifest["cases"]: + path = Path(directory) / case["pdf"] + data = path.read_bytes() + self.assertLess(len(data), 64 * 1024, f"{case['id']} fixture is unexpectedly large") + self.assertEqual(hashlib.sha256(data).hexdigest(), case["sha256"]) + + def test_every_case_has_required_manifest_fields(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + seen_kinds = set() + for case in manifest["cases"]: + for field in ("id", "description", "path", "expected", "limits"): + self.assertIn(field, case) + self.assertIn(case["path"], ("session", "reader")) + self.assertIn("kind", case["expected"]) + self.assertIn("pool", case["expected"]) + if case["path"] == "session": + self.assertIn("profile", case) + self.assertIn("checks", case["profile"]) + seen_kinds.add(case["expected"]["kind"]) + + required_kinds = { + "decompression-ratio", + "cumulative-decoded-bytes", + "recursive-content-depth", + "render-operations", + "render-pixels", + "object-depth", + "objects-visited", + } + self.assertEqual(seen_kinds, required_kinds) + + def test_decompression_bomb_ratio_exceeds_its_own_tightened_limit(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + case = next(item for item in manifest["cases"] if item["id"] == "decompression-bomb") + data = (Path(directory) / case["pdf"]).read_bytes() + stream_match = re.search(rb"stream\r?\n", data) + assert stream_match is not None + start = stream_match.end() + end = data.index(b"\nendstream", start) + compressed = data[start:end] + decoded = zlib.decompress(compressed) + ratio = len(decoded) / len(compressed) + self.assertGreater(ratio, case["limits"]["maxDecompressionRatio"]) + + def test_cumulative_decoded_bytes_case_exceeds_its_own_tightened_cap(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + case = next(item for item in manifest["cases"] if item["id"] == "cumulative-decoded-bytes") + data = (Path(directory) / case["pdf"]).read_bytes() + total_stream_bytes = sum( + len(match.group(1)) for match in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", data, re.DOTALL) + ) + self.assertGreater(total_stream_bytes, case["limits"]["maxCumulativeDecodedBytes"]) + + def test_deep_recursive_object_graph_nests_past_its_own_tightened_depth(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + case = next(item for item in manifest["cases"] if item["id"] == "deep-recursive-object-graph") + data = (Path(directory) / case["pdf"]).read_bytes() + deepest_run = max(len(run) for run in re.findall(rb"\[+", data)) + self.assertGreater(deepest_run, case["limits"]["maxObjectDepth"]) + + def test_pathological_object_count_exceeds_its_own_tightened_cap(self) -> None: + with tempfile.TemporaryDirectory() as directory: + manifest = generate_corpus(Path(directory)) + case = next(item for item in manifest["cases"] if item["id"] == "pathological-object-count") + data = (Path(directory) / case["pdf"]).read_bytes() + object_count = len(re.findall(rb"\d+ 0 obj", data)) + self.assertGreater(object_count, case["limits"]["maxObjectsVisited"]) + + +if __name__ == "__main__": + unittest.main() From c6e21fa596ebcc340d1990ae538bc0f4fbc9260b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:17:42 +0000 Subject: [PATCH 25/33] Regenerate Phase 5 Widgets evidence for the new UnitTestsBudgetCorpus target Adding UnitTestsBudgetCorpus to UnitTests/CMakeLists.txt changed the Widgets-free build's target count from 67 to 68; docs/generated/ phase5-widgets-inventory.json and its pinned test expectation were stale. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014oPh587Ls4UZTLwpxxQtpt --- docs/generated/phase5-widgets-inventory.json | 44 ++++++++++++++++++- .../ci/test_verify_phase5_widgets_contract.py | 2 +- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/generated/phase5-widgets-inventory.json b/docs/generated/phase5-widgets-inventory.json index fa1c300c2..adb0ee5e6 100644 --- a/docs/generated/phase5-widgets-inventory.json +++ b/docs/generated/phase5-widgets-inventory.json @@ -83,6 +83,7 @@ "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", + "UnitTests/CMakeLists.txt", "loupe-preflight/tools/CMakeLists.txt" ], "shell_ledger": "docs/loupe-shell.json", @@ -417,6 +418,7 @@ "UnitTestsBenchmarkIdentity", "UnitTestsBleedFixup", "UnitTestsBleedMarginProbe", + "UnitTestsBudgetCorpus", "UnitTestsBudgetExhaustion", "UnitTestsContentEditor", "UnitTestsContentProcessorLimits", @@ -997,6 +999,46 @@ "widgets_paths": [], "consumers": [] }, + { + "id": "UnitTestsBudgetCorpus", + "kind": "executable", + "cmake": "UnitTests/CMakeLists.txt", + "profile_enabled": false, + "profile_condition": "qualification target excluded from the product-surface manifest", + "install_rule": false, + "installed_in_profile": false, + "build_only_in_profile": false, + "direct_links": [ + "LoupeLibCore", + "Qt6::Core", + "Qt6::Gui", + "Qt6::Test" + ], + "direct_qt_modules": [ + "Core", + "Gui", + "Test" + ], + "transitive_targets": [ + "LoupeLibCore" + ], + "transitive_qt_modules": [ + "Sql", + "Svg", + "Xml" + ], + "qt_modules": [ + "Core", + "Gui", + "Sql", + "Svg", + "Test", + "Xml" + ], + "widgets_linkage": "none", + "widgets_paths": [], + "consumers": [] + }, { "id": "UnitTestsBudgetExhaustion", "kind": "executable", @@ -2939,7 +2981,7 @@ } ], "counts": { - "targets": 67, + "targets": 68, "installed_in_profile": 4, "build_only_in_profile": 3, "widgets_surfaces": 4, diff --git a/scripts/ci/test_verify_phase5_widgets_contract.py b/scripts/ci/test_verify_phase5_widgets_contract.py index 6da6c75cc..0351c6d07 100644 --- a/scripts/ci/test_verify_phase5_widgets_contract.py +++ b/scripts/ci/test_verify_phase5_widgets_contract.py @@ -31,7 +31,7 @@ def setUpClass(cls): def test_current_evidence_is_valid_and_complete(self): self.assertEqual(validate_contract(ROOT, self.inventory, self.disposition), []) - self.assertEqual(self.inventory["counts"]["targets"], 67) + self.assertEqual(self.inventory["counts"]["targets"], 68) self.assertEqual(self.inventory["counts"]["widgets_surfaces"], 4) self.assertEqual(self.inventory["counts"]["ui_forms"], 2) self.assertEqual(len(self.inventory["plugin_ui"]), 0) From 087969ce37ef9f30b39aba20825b64ad2c8ee8da Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 23:42:38 +0000 Subject: [PATCH 26/33] Register tst_budgetcorpustest.cpp with the core module boundary UnitTestsBudgetCorpus was only reachable via the coarse build_policy module (triggered by the UnitTests/CMakeLists.txt edit), so the CI agent-fast lane never built it before running clang-tidy on tst_budgetcorpustest.cpp, which failed with "tst_budgetcorpustest.moc file not found" -- the moc output only exists once its own target is built. List the new test file and target alongside the other individually-enumerated core-relevant UnitTests/tst_*.cpp files and targets (tst_bleedfixuptest.cpp, UnitTestsBudgetExhaustion, ...) so the fast lane builds and runs it like its siblings. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014oPh587Ls4UZTLwpxxQtpt --- agent-policy.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agent-policy.json b/agent-policy.json index f881002b7..81d81b8cd 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -56,6 +56,7 @@ "paths": [ "LoupeLibCore/**", "UnitTests/tst_bleedfixuptest.cpp", + "UnitTests/tst_budgetcorpustest.cpp", "UnitTests/tst_documentsessiontest.cpp", "UnitTests/tst_incrementalsavetest.cpp", "UnitTests/tst_overprinttest.cpp" @@ -65,6 +66,7 @@ "UnitTests", "UnitTestsBenchmarkIdentity", "UnitTestsBleedFixup", + "UnitTestsBudgetCorpus", "UnitTestsBudgetExhaustion", "UnitTestsConversionOracle", "UnitTestsDocumentSession", From a101d4814e36b94d0a8fa6024c2ceaedb9205f16 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 00:09:33 +0000 Subject: [PATCH 27/33] Fix UnitTestsBudgetCorpus build: QJsonArray has no constFirst() CI's first real build of the new target failed: 'const class QJsonArray' has no member named 'constFirst'; did you mean 'contains'? QJsonArray (unlike QList) doesn't have constFirst()/first(); use at(0), which is always available. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_014oPh587Ls4UZTLwpxxQtpt --- UnitTests/tst_budgetcorpustest.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/UnitTests/tst_budgetcorpustest.cpp b/UnitTests/tst_budgetcorpustest.cpp index 3d25f3d5e..8c5119be0 100644 --- a/UnitTests/tst_budgetcorpustest.cpp +++ b/UnitTests/tst_budgetcorpustest.cpp @@ -120,7 +120,7 @@ qint64 expectedLimitFor(const QJsonObject& testCase) const QJsonArray checks = testCase.value(QStringLiteral("profile")).toObject().value(QStringLiteral("checks")).toArray(); if (!checks.isEmpty()) { - const QJsonObject check = checks.constFirst().toObject(); + const QJsonObject check = checks.at(0).toObject(); if (check.contains(QStringLiteral("max_raster_pixels"))) { return static_cast(check.value(QStringLiteral("max_raster_pixels")).toDouble()); From e6261133b6ddf584cb5143cabe06bafbda841de9 Mon Sep 17 00:00:00 2001 From: michael berry Date: Tue, 1 Sep 2026 19:26:48 -0700 Subject: [PATCH 28/33] fix: stage Loupe.Quick from LoupeEditor build output (#503) qt_add_qml_module writes to CMAKE_CURRENT_BINARY_DIR/Loupe/Quick under LoupeEditor, not the top-level build tree. Unblocks Linux_AppImage and Windows_MSI Deploy Qt steps. Co-authored-by: Cursor Agent Co-authored-by: michael berry --- .github/workflows/LinuxInstall.yml | 2 +- .github/workflows/WindowsInstall.yml | 2 +- changes/cursor-package-boundary-fixes-ffa1.md | 4 ++++ scripts/ci/test_workflow_contracts.py | 4 ++-- 4 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 changes/cursor-package-boundary-fixes-ffa1.md diff --git a/.github/workflows/LinuxInstall.yml b/.github/workflows/LinuxInstall.yml index cb4cb7899..1de2bcc8e 100644 --- a/.github/workflows/LinuxInstall.yml +++ b/.github/workflows/LinuxInstall.yml @@ -205,7 +205,7 @@ jobs: evidence_dir="$RUNNER_TEMP/loupe-package-boundary-linux" mkdir -p "$evidence_dir" set -o pipefail - loupe_quick_src="$GITHUB_WORKSPACE/loupe/build/Loupe/Quick" + loupe_quick_src="$GITHUB_WORKSPACE/loupe/build/LoupeEditor/Loupe/Quick" qml_dest="install/usr/lib/qml/Loupe/Quick" if [ ! -d "$loupe_quick_src" ]; then echo "::error::Built Loupe.Quick QML module was not found: $loupe_quick_src" diff --git a/.github/workflows/WindowsInstall.yml b/.github/workflows/WindowsInstall.yml index 3c1405e1b..183612256 100644 --- a/.github/workflows/WindowsInstall.yml +++ b/.github/workflows/WindowsInstall.yml @@ -281,7 +281,7 @@ jobs: } } - $builtLoupeQuick = Join-Path $env:GITHUB_WORKSPACE "loupe\build\Loupe\Quick" + $builtLoupeQuick = Join-Path $env:GITHUB_WORKSPACE "loupe\build\LoupeEditor\Loupe\Quick" $qmlLoupeQuick = Join-Path $installBin "qml\Loupe\Quick" if (-not (Test-Path -LiteralPath $builtLoupeQuick)) { throw "Built Loupe.Quick QML module was not found: $builtLoupeQuick" diff --git a/changes/cursor-package-boundary-fixes-ffa1.md b/changes/cursor-package-boundary-fixes-ffa1.md new file mode 100644 index 000000000..55646ac0e --- /dev/null +++ b/changes/cursor-package-boundary-fixes-ffa1.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: maintainers +Breaking-Change: no +Summary: Stage Loupe.Quick from the LoupeEditor build directory where qt_add_qml_module emits it, unblocking Linux_AppImage and Windows_MSI package workflows. diff --git a/scripts/ci/test_workflow_contracts.py b/scripts/ci/test_workflow_contracts.py index e04665d44..5bd8204c9 100644 --- a/scripts/ci/test_workflow_contracts.py +++ b/scripts/ci/test_workflow_contracts.py @@ -80,9 +80,9 @@ def test_package_workflows_require_and_record_exact_source_sha(self): self.assertIn("LoupeEditor.exe", windows) self.assertIn("Qml2Imports=qml", windows) self.assertIn('Join-Path $installBin "qt.conf"', windows) - self.assertIn("build\\Loupe\\Quick", windows) + self.assertIn("build\\LoupeEditor\\Loupe\\Quick", windows) self.assertIn("plugins/sqldrivers", linux) - self.assertIn("build/Loupe/Quick", linux) + self.assertIn("build/LoupeEditor/Loupe/Quick", linux) self.assertIn("VCPKG_BINARY_SOURCES=clear;files", windows) self.assertIn("./vcpkg_installed", windows) self.assertIn("./vcpkg-binary-cache", windows) From dcffef22d4025028a5f338d2eea49be4a74c3a00 Mon Sep 17 00:00:00 2001 From: michael berry Date: Tue, 1 Sep 2026 19:27:14 -0700 Subject: [PATCH 29/33] fix: detect document forms via PDFForm in QuickDocumentModel (#501) PDFObject has no isValid() member; use PDFForm::parse to determine whether the catalog exposes an AcroForm or XFA form. Unblocks Linux_AppImage builds. Co-authored-by: Cursor Agent Co-authored-by: michael berry --- LoupeEditor/quickdocumentmodel.cpp | 1 + changes/cursor-quickdocumentmodel-form-fix-06ea.md | 4 ++++ 2 files changed, 5 insertions(+) create mode 100644 changes/cursor-quickdocumentmodel-form-fix-06ea.md diff --git a/LoupeEditor/quickdocumentmodel.cpp b/LoupeEditor/quickdocumentmodel.cpp index a08f3370e..08d15541d 100644 --- a/LoupeEditor/quickdocumentmodel.cpp +++ b/LoupeEditor/quickdocumentmodel.cpp @@ -6,6 +6,7 @@ #include "pdfdocumentcontext.h" #include "pdfdocumentsearch.h" #include "pdfdocumentsession.h" +#include "pdfform.h" #include "pdfoutline.h" #include "pdfpage.h" #include "pdfutils.h" diff --git a/changes/cursor-quickdocumentmodel-form-fix-06ea.md b/changes/cursor-quickdocumentmodel-form-fix-06ea.md new file mode 100644 index 000000000..678f4af77 --- /dev/null +++ b/changes/cursor-quickdocumentmodel-form-fix-06ea.md @@ -0,0 +1,4 @@ +Category: fixed +Audience: developers +Breaking-Change: no +Summary: Use PDFForm parsing to detect interactive forms in QuickDocumentModel instead of calling a nonexistent PDFObject::isValid(). From 5493a490ad56de83f2e7a5417cd2348b4521189b Mon Sep 17 00:00:00 2001 From: michael berry Date: Tue, 1 Sep 2026 19:29:05 -0700 Subject: [PATCH 30/33] test: concurrent revision-authority stress scenario (#236) (#505) * test: add concurrent revision-authority stress scenario Issue #236 requires that DocumentContext be the single revision authority for caches and asynchronous results, and holds the issue open until the concurrent stress scenario passes. The fence, the identity separation, and the revision-keyed session and job caches already landed; the acceptance scenario itself was the one criterion with no coverage. tst_documentsessiontest.cpp proves the fence in one orchestrated round, with every producer released after the mutation. This adds the acceptance scenario instead: render, preflight, thumbnail, and repair-plan jobs in flight together while the document is mutated at points the producers do not observe, and asserts the four correctness properties named in the issue - zero stale findings applied, zero stale tiles presented past an invalidation boundary, deterministic cancellation, and no cache serving a result for the wrong revision. A stress test that quietly stops exercising the fence is worse than none, so the non-vacuity checks are deterministic rather than timing-based: a final phase holds one producer per job kind inside its work function, mutates the document underneath all of them, and only then releases them, asserting both the consumer-side rejection and the scheduler-side Stale outcome. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MaL7wNZgrcZibFiBZenb6t * chore: refresh Phase 5 widgets evidence for the new test target The source_integrity job regenerates docs/generated/phase5-widgets-inventory.json and compares it against the tree; adding UnitTestsRevisionStress made the tracked copy stale (67 -> 68 targets). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MaL7wNZgrcZibFiBZenb6t * chore: expect 68 Phase 5 targets after adding UnitTestsRevisionStress The Phase 5 widgets contract test pins the generated target count, so a new test executable has to move it with the regenerated inventory. Verified by running the full scripts/ci unittest discovery (219 tests) locally. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01MaL7wNZgrcZibFiBZenb6t --------- Co-authored-by: Claude --- UnitTests/CMakeLists.txt | 15 + UnitTests/tst_revisionstresstest.cpp | 592 ++++++++++++++++++ agent-policy.json | 4 +- changes/feat-236-revision-stress.md | 4 + docs/REVISION_CONTEXT.md | 17 + docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md | 2 +- docs/UPSTREAM_DIVERGENCE.md | 2 +- docs/generated/architecture-catalog.json | 1 + docs/generated/phase5-widgets-inventory.json | 44 +- .../ci/test_verify_phase5_widgets_contract.py | 2 +- 10 files changed, 678 insertions(+), 5 deletions(-) create mode 100644 UnitTests/tst_revisionstresstest.cpp create mode 100644 changes/feat-236-revision-stress.md diff --git a/UnitTests/CMakeLists.txt b/UnitTests/CMakeLists.txt index b24a47922..13759b8f0 100644 --- a/UnitTests/CMakeLists.txt +++ b/UnitTests/CMakeLists.txt @@ -78,6 +78,21 @@ set_target_properties(UnitTestsJobScheduler PROPERTIES add_test(UnitTestsJobScheduler "${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_BIN_DIR}/UnitTestsJobScheduler") +add_executable(UnitTestsRevisionStress + tst_revisionstresstest.cpp +) + +target_link_libraries(UnitTestsRevisionStress PRIVATE LoupeLibCore Qt6::Core Qt6::Gui Qt6::Test) + +set_target_properties(UnitTestsRevisionStress PROPERTIES + WIN32_EXECUTABLE OFF + MACOSX_BUNDLE OFF + LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_LIB_DIR} + RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_BIN_DIR} +) + +add_test(UnitTestsRevisionStress "${CMAKE_BINARY_DIR}/${LOUPE_INSTALL_BIN_DIR}/UnitTestsRevisionStress") + include(${CMAKE_CURRENT_SOURCE_DIR}/phase4-tests.cmake) diff --git a/UnitTests/tst_revisionstresstest.cpp b/UnitTests/tst_revisionstresstest.cpp new file mode 100644 index 000000000..c5a773f83 --- /dev/null +++ b/UnitTests/tst_revisionstresstest.cpp @@ -0,0 +1,592 @@ +// MIT License +// +// Copyright (c) 2018-2025 Jakub Melka and Contributors +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +// Concurrent revision-authority stress scenario. +// +// tst_documentsessiontest.cpp proves the fence in isolation: one orchestrated +// round, all producers released after the mutation. This file runs the +// acceptance scenario instead - render, preflight, thumbnail, and repair-plan +// jobs in flight simultaneously while the document is mutated at points the +// producers do not observe - and asserts the four correctness properties: +// +// 1. zero stale findings applied; +// 2. zero stale tiles presented as current past an invalidation boundary; +// 3. deterministic cancellation (cancelled work is terminal, never success, +// and never publishes a result); +// 4. no cache ever returns a result for the wrong revision. +// +// "Zero stale" is a correctness requirement here, not a percentile: a single +// admitted stale result fails the test. + +#include "pdfdocumentbuilder.h" +#include "pdfdocumentcontext.h" +#include "pdfdocumentsession.h" +#include "pdfjobscheduler.h" + +#include + +#include +#include +#include +#include +#include +#include + +namespace +{ + +constexpr int RoundCount = 64; +constexpr int PageCount = 4; +constexpr int WorkerCount = 4; +constexpr int JobWaitTimeoutMs = 15000; + +/// The single place where an asynchronous result becomes visible state. +/// +/// Product consumers - the findings model, the tile presenter, the evidence +/// cache - all follow the same rule, so the stress test models them with one +/// type: publish() moves the fence, apply() admits a result only when the +/// result's complete revision still equals the fence, and lookup() refuses to +/// hand back an entry the fence has since superseded. +/// +/// One mutex covers the fence and the retained entries together. Without that, +/// a mutation on the owning thread and a result arriving on a worker could +/// interleave into an accepted stale entry - exactly the defect the revision +/// authority exists to make impossible. +class RevisionGate +{ +public: + explicit RevisionGate(pdf::PDFRevisionIdentity revision) : + m_current(std::move(revision)) + { + } + + /// Moves the fence. Everything computed against the previous revision is + /// dropped rather than reconciled, so nothing survives the boundary. + void publish(pdf::PDFRevisionIdentity revision) + { + std::lock_guard lock(m_mutex); + m_current = std::move(revision); + m_entries.clear(); + } + + /// Admits a result computed against `revision`. Returns whether it became + /// visible state. + bool apply(const QString& kind, int page, const pdf::PDFRevisionIdentity& revision) + { + std::lock_guard lock(m_mutex); + if (!(revision == m_current)) + { + ++m_rejected; + return false; + } + + m_entries.insert(entryKey(kind, page), revision); + ++m_applied; + return true; + } + + /// Cache read. A hit that does not carry the current revision is a defect, + /// not a miss to be reconciled - it is recorded and the entry is dropped. + std::optional lookup(const QString& kind, int page) + { + std::lock_guard lock(m_mutex); + const auto it = m_entries.constFind(entryKey(kind, page)); + if (it == m_entries.constEnd()) + { + return std::nullopt; + } + + if (!(it.value() == m_current)) + { + recordViolationLocked(QStringLiteral("cache returned %1 for %2, current is %3") + .arg(it.value().toString(), entryKey(kind, page), m_current.toString())); + m_entries.remove(entryKey(kind, page)); + return std::nullopt; + } + + return it.value(); + } + + /// Audits every retained entry against the fence. + void auditRetainedEntries() + { + std::lock_guard lock(m_mutex); + for (auto it = m_entries.constBegin(); it != m_entries.constEnd(); ++it) + { + if (!(it.value() == m_current)) + { + recordViolationLocked(QStringLiteral("stale entry %1 retained at revision %2, current is %3") + .arg(it.key(), it.value().toString(), m_current.toString())); + } + } + } + + void recordViolation(QString description) + { + std::lock_guard lock(m_mutex); + recordViolationLocked(std::move(description)); + } + + QStringList violations() const + { + std::lock_guard lock(m_mutex); + return m_violations; + } + + int appliedCount() const + { + std::lock_guard lock(m_mutex); + return m_applied; + } + +private: + static QString entryKey(const QString& kind, int page) + { + return QStringLiteral("%1/%2").arg(kind).arg(page); + } + + void recordViolationLocked(QString description) + { + // Bounded: a broken fence would otherwise produce thousands of lines + // and bury the first, most diagnosable failure. + if (m_violations.size() < 16) + { + m_violations.append(std::move(description)); + } + } + + mutable std::mutex m_mutex; + pdf::PDFRevisionIdentity m_current; + QHash m_entries; + QStringList m_violations; + int m_applied = 0; + int m_rejected = 0; +}; + +pdf::PDFDocument buildDocument() +{ + pdf::PDFDocumentBuilder builder; + for (int page = 0; page < PageCount; ++page) + { + builder.appendPage(QRectF(0, 0, 100, 100)); + } + return builder.build(); +} + +pdf::PDFArtifactIdentity buildArtifact(const QString& storageToken) +{ + pdf::PDFArtifactIdentity artifact; + artifact.sha256 = QString(64, QLatin1Char('b')); + artifact.size = 4096; + artifact.logicalName = QStringLiteral("revision-stress.pdf"); + artifact.storageToken = storageToken; + return artifact; +} + +struct JobKindSpec +{ + pdf::PDFJobKind kind; + pdf::PDFJobPriority priority; + const char* consumer; + const char* operationId; +}; + +constexpr JobKindSpec JobKinds[] = { + { pdf::PDFJobKind::Rendering, pdf::PDFJobPriority::VisiblePage, "tile", "render" }, + { pdf::PDFJobKind::Preflight, pdf::PDFJobPriority::Operator, "finding", "preflight" }, + { pdf::PDFJobKind::Thumbnail, pdf::PDFJobPriority::NearViewport, "tile", "thumbnail" }, + { pdf::PDFJobKind::Other, pdf::PDFJobPriority::Operator, "finding", "repair-plan" } +}; + +constexpr int JobKindCount = int(std::size(JobKinds)); + +} // namespace + +class RevisionStressTest : public QObject +{ + Q_OBJECT + +private slots: + void concurrentJobsNeverPublishStaleResults(); + void cancellationIsDeterministicAndPublishesNothing(); + void sessionCachesNeverServeSupersededRevisions(); +}; + +void RevisionStressTest::concurrentJobsNeverPublishStaleResults() +{ + pdf::PDFDocument document = buildDocument(); + pdf::PDFDocumentContext context(&document); + pdf::PDFJobScheduler scheduler(WorkerCount); + + const QString documentKey = context.getDocumentIdentity().documentId; + const pdf::PDFArtifactIdentity artifact = buildArtifact(documentKey); + + RevisionGate gate(context.getRevision()); + scheduler.setCurrentRevision(documentKey, context.getRevision().toString()); + + QStringList jobIds; + + for (int round = 0; round < RoundCount; ++round) + { + for (int index = 0; index < JobKindCount; ++index) + { + const JobKindSpec& kindSpec = JobKinds[index]; + const int page = (round + index) % PageCount; + + // The revision is captured with the submission, exactly as a product + // producer captures it, and travels with the result. + const pdf::PDFRevisionIdentity submittedRevision = context.getRevision(); + + pdf::PDFJobSpec spec; + spec.kind = kindSpec.kind; + spec.priority = kindSpec.priority; + spec.artifact = artifact; + spec.documentKey = documentKey; + spec.documentRevision = submittedRevision.toString(); + spec.operationId = QString::fromLatin1(kindSpec.operationId); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + const QString consumer = QString::fromLatin1(kindSpec.consumer); + jobIds.append(scheduler.submit(spec, + [&gate, submittedRevision, consumer, page, index](pdf::PDFJobContext& jobContext) + { + // Simulated work of varying length, so producers finish on + // both sides of the mutations happening on the owning thread. + for (int step = 0; step < (index % 3) + 1; ++step) + { + if (jobContext.isCancellationRequested()) + { + return; + } + std::this_thread::yield(); + } + + gate.apply(consumer, page, submittedRevision); + })); + } + + // Mutate while earlier rounds are still running. The mutation and the + // fence publication happen on the owning thread; producers never touch + // the context. + if (round % 2 == 0) + { + context.markModified(pdf::PDFModifiedDocument::PageContents); + const pdf::PDFRevisionIdentity currentRevision = context.getRevision(); + scheduler.setCurrentRevision(documentKey, currentRevision.toString()); + gate.publish(currentRevision); + } + else if (round % 5 == 0) + { + // A profile change fences profile-dependent entries without + // pretending the PDF bytes changed. + context.setEffectiveProfileIdentity(QStringLiteral("profile-%1").arg(round)); + const pdf::PDFRevisionIdentity currentRevision = context.getRevision(); + scheduler.setCurrentRevision(documentKey, currentRevision.toString()); + gate.publish(currentRevision); + } + + // Read back through the cache while producers are still active. + for (int page = 0; page < PageCount; ++page) + { + const std::optional tile = gate.lookup(QStringLiteral("tile"), page); + if (tile.has_value() && !context.isCurrent(tile.value())) + { + gate.recordViolation(QStringLiteral("tile cache served %1 outside the current revision") + .arg(tile.value().toString())); + } + } + } + + for (const QString& jobId : std::as_const(jobIds)) + { + QVERIFY2(scheduler.waitForFinished(jobId, JobWaitTimeoutMs), + qPrintable(QStringLiteral("job %1 did not reach a terminal state").arg(jobId))); + + const pdf::PDFJobSnapshot snapshot = scheduler.snapshot(jobId); + QVERIFY2(snapshot.status == pdf::PDFJobStatus::Succeeded || + snapshot.status == pdf::PDFJobStatus::Stale, + qPrintable(QStringLiteral("job %1 finished as %2") + .arg(jobId, QString::fromLatin1(pdf::getPDFJobStatusName(snapshot.status))))); + QCOMPARE(snapshot.documentKey, documentKey); + } + + gate.auditRetainedEntries(); + QVERIFY2(gate.violations().isEmpty(), qPrintable(gate.violations().join(QStringLiteral("; ")))); + + // A fence that rejected everything would satisfy every assertion above, so + // first pin down the other half of the rule: a result carrying the current + // revision is admitted, and reads it back as current. + const int appliedBeforeCurrentPhase = gate.appliedCount(); + QStringList currentJobIds; + + for (int index = 0; index < JobKindCount; ++index) + { + const JobKindSpec& kindSpec = JobKinds[index]; + const pdf::PDFRevisionIdentity submittedRevision = context.getRevision(); + + pdf::PDFJobSpec spec; + spec.jobId = QStringLiteral("current-%1").arg(index); + spec.kind = kindSpec.kind; + spec.priority = kindSpec.priority; + spec.artifact = artifact; + spec.documentKey = documentKey; + spec.documentRevision = submittedRevision.toString(); + spec.operationId = QString::fromLatin1(kindSpec.operationId); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + const QString consumer = QString::fromLatin1(kindSpec.consumer); + currentJobIds.append(scheduler.submit(spec, + [&gate, submittedRevision, consumer, index](pdf::PDFJobContext&) + { + if (!gate.apply(consumer, index, submittedRevision)) + { + gate.recordViolation(QStringLiteral("current result for %1/%2 was rejected") + .arg(consumer) + .arg(index)); + } + })); + } + + for (const QString& jobId : std::as_const(currentJobIds)) + { + QVERIFY(scheduler.waitForFinished(jobId, JobWaitTimeoutMs)); + QCOMPARE(scheduler.snapshot(jobId).status, pdf::PDFJobStatus::Succeeded); + } + + QCOMPARE(gate.appliedCount(), appliedBeforeCurrentPhase + JobKindCount); + for (int index = 0; index < JobKindCount; ++index) + { + const std::optional entry = + gate.lookup(QString::fromLatin1(JobKinds[index].consumer), index); + QVERIFY(entry.has_value()); + QVERIFY(context.isCurrent(entry.value())); + } + + QVERIFY2(gate.violations().isEmpty(), qPrintable(gate.violations().join(QStringLiteral("; ")))); + + // The churn above cannot guarantee, by timing alone, that a result was ever + // actually superseded mid-flight, and a stress test that silently stops + // exercising the fence is worse than no test. This phase forces it: one + // producer per job kind is held inside its work function, the document is + // mutated underneath all of them, and only then are they released. + const int appliedBeforeSupersession = gate.appliedCount(); + std::atomic_bool releaseProducers = false; + std::atomic_int heldProducers = 0; + std::atomic_int rejectedResults = 0; + QStringList heldJobIds; + + for (int index = 0; index < JobKindCount; ++index) + { + const JobKindSpec& kindSpec = JobKinds[index]; + const pdf::PDFRevisionIdentity submittedRevision = context.getRevision(); + + pdf::PDFJobSpec spec; + spec.jobId = QStringLiteral("superseded-%1").arg(index); + spec.kind = kindSpec.kind; + spec.priority = kindSpec.priority; + spec.artifact = artifact; + spec.documentKey = documentKey; + spec.documentRevision = submittedRevision.toString(); + spec.operationId = QString::fromLatin1(kindSpec.operationId); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + const QString consumer = QString::fromLatin1(kindSpec.consumer); + heldJobIds.append(scheduler.submit(spec, + [&gate, &releaseProducers, &heldProducers, &rejectedResults, submittedRevision, consumer, index](pdf::PDFJobContext&) + { + ++heldProducers; + while (!releaseProducers.load(std::memory_order_acquire)) + { + std::this_thread::yield(); + } + + if (!gate.apply(consumer, index, submittedRevision)) + { + ++rejectedResults; + } + })); + } + + QTRY_VERIFY_WITH_TIMEOUT(heldProducers.load(std::memory_order_acquire) == JobKindCount, 5000); + + const pdf::PDFRevisionIdentity supersededRevision = context.getRevision(); + context.markModified(pdf::PDFModifiedDocument::PageContents); + const pdf::PDFRevisionIdentity currentRevision = context.getRevision(); + QVERIFY(currentRevision.documentRevision > supersededRevision.documentRevision); + QVERIFY(currentRevision.cacheGeneration > supersededRevision.cacheGeneration); + scheduler.setCurrentRevision(documentKey, currentRevision.toString()); + gate.publish(currentRevision); + releaseProducers.store(true, std::memory_order_release); + + for (const QString& jobId : std::as_const(heldJobIds)) + { + QVERIFY(scheduler.waitForFinished(jobId, JobWaitTimeoutMs)); + + // Rejected twice over: by the consumer's fence check, and by the + // scheduler before the result is reported as a success. + const pdf::PDFJobSnapshot snapshot = scheduler.snapshot(jobId); + QCOMPARE(snapshot.status, pdf::PDFJobStatus::Stale); + QCOMPARE(snapshot.documentRevision, supersededRevision.toString()); + } + + QCOMPARE(rejectedResults.load(std::memory_order_acquire), JobKindCount); + QCOMPARE(gate.appliedCount(), appliedBeforeSupersession); + + gate.auditRetainedEntries(); + QVERIFY2(gate.violations().isEmpty(), qPrintable(gate.violations().join(QStringLiteral("; ")))); +} + +void RevisionStressTest::cancellationIsDeterministicAndPublishesNothing() +{ + pdf::PDFDocument document = buildDocument(); + pdf::PDFDocumentContext context(&document); + pdf::PDFJobScheduler scheduler(WorkerCount); + + const QString documentKey = context.getDocumentIdentity().documentId; + const pdf::PDFArtifactIdentity artifact = buildArtifact(documentKey); + + RevisionGate gate(context.getRevision()); + scheduler.setCurrentRevision(documentKey, context.getRevision().toString()); + + std::atomic_int startedJobs = 0; + std::atomic_int cancellationsObserved = 0; + QStringList jobIds; + + for (int index = 0; index < JobKindCount; ++index) + { + const JobKindSpec& kindSpec = JobKinds[index]; + const pdf::PDFRevisionIdentity submittedRevision = context.getRevision(); + + pdf::PDFJobSpec spec; + spec.jobId = QStringLiteral("cancelled-%1").arg(index); + spec.kind = kindSpec.kind; + spec.priority = kindSpec.priority; + spec.artifact = artifact; + spec.documentKey = documentKey; + spec.documentRevision = submittedRevision.toString(); + spec.operationId = QString::fromLatin1(kindSpec.operationId); + spec.staleResultPolicy = pdf::PDFJobStaleResultPolicy::Discard; + + const QString consumer = QString::fromLatin1(kindSpec.consumer); + jobIds.append(scheduler.submit(spec, + [&gate, &startedJobs, &cancellationsObserved, submittedRevision, consumer, index](pdf::PDFJobContext& jobContext) + { + ++startedJobs; + + // Runs until cancellation is observed, so the outcome + // does not depend on timing: this job never completes + // its work on its own. + while (!jobContext.isCancellationRequested()) + { + std::this_thread::yield(); + } + + // Publication is guarded by the cancellation check, as + // in a real producer. Cancelled work publishes nothing. + if (jobContext.isCancellationRequested()) + { + ++cancellationsObserved; + return; + } + + gate.apply(consumer, index, submittedRevision); + })); + } + + QTRY_VERIFY_WITH_TIMEOUT(startedJobs.load(std::memory_order_acquire) == JobKindCount, 5000); + + for (const QString& jobId : std::as_const(jobIds)) + { + QVERIFY(scheduler.cancel(jobId)); + } + + for (const QString& jobId : std::as_const(jobIds)) + { + QVERIFY(scheduler.waitForFinished(jobId, JobWaitTimeoutMs)); + + const pdf::PDFJobSnapshot snapshot = scheduler.snapshot(jobId); + QCOMPARE(snapshot.status, pdf::PDFJobStatus::Cancelled); + QVERIFY(snapshot.cancellationLatencyMs >= 0); + + // Cancellation is terminal: a second request finds nothing to cancel and + // the status does not drift afterwards. + QVERIFY(!scheduler.cancel(jobId)); + QCOMPARE(scheduler.snapshot(jobId).status, pdf::PDFJobStatus::Cancelled); + } + + // The jobs above return only after cancellation is observed and publish + // nothing on that path, so no cancelled producer ever became visible state. + QCOMPARE(cancellationsObserved.load(std::memory_order_acquire), JobKindCount); + QCOMPARE(gate.appliedCount(), 0); + gate.auditRetainedEntries(); + QVERIFY2(gate.violations().isEmpty(), qPrintable(gate.violations().join(QStringLiteral("; ")))); +} + +void RevisionStressTest::sessionCachesNeverServeSupersededRevisions() +{ + pdf::PDFDocument document = buildDocument(); + pdf::PDFDocumentContext context(&document); + + pdf::PDFDocumentSession* session = context.getSession(); + QVERIFY(session != nullptr); + QVERIFY(session->isValid()); + + for (int round = 0; round < 16; ++round) + { + const pdf::PDFRevisionIdentity beforeRevision = context.getRevision(); + QVERIFY(session->getRevision() == beforeRevision); + + const pdf::PDFPrecompiledPage* compiled = session->compilePage(size_t(round % PageCount)); + QVERIFY(compiled != nullptr); + QVERIFY(session->compiledCacheBytes() > 0); + + // A document mutation and a profile change are both invalidation + // boundaries: nothing compiled before them may be served afterwards. + if (round % 2 == 0) + { + context.markModified(pdf::PDFModifiedDocument::PageContents); + } + else + { + context.setEffectiveProfileIdentity(QStringLiteral("profile-%1").arg(round)); + } + + const pdf::PDFRevisionIdentity afterRevision = context.getRevision(); + QVERIFY(!(afterRevision == beforeRevision)); + QVERIFY(!context.isCurrent(beforeRevision)); + QVERIFY(context.isCurrent(afterRevision)); + + // The session follows the context, and its caches were dropped rather + // than reconciled against the new revision. + QVERIFY(session->getRevision() == afterRevision); + QVERIFY(!session->isCurrent(beforeRevision)); + QCOMPARE(session->compiledCacheBytes(), qsizetype(0)); + + const pdf::PDFPrecompiledPage* recompiled = session->compilePage(size_t(round % PageCount)); + QVERIFY(recompiled != nullptr); + QCOMPARE(session->compilePage(size_t(round % PageCount)), recompiled); + } +} + +QTEST_GUILESS_MAIN(RevisionStressTest) + +#include "tst_revisionstresstest.moc" diff --git a/agent-policy.json b/agent-policy.json index f881002b7..28a8064d7 100644 --- a/agent-policy.json +++ b/agent-policy.json @@ -58,7 +58,8 @@ "UnitTests/tst_bleedfixuptest.cpp", "UnitTests/tst_documentsessiontest.cpp", "UnitTests/tst_incrementalsavetest.cpp", - "UnitTests/tst_overprinttest.cpp" + "UnitTests/tst_overprinttest.cpp", + "UnitTests/tst_revisionstresstest.cpp" ], "targets": ["LoupeLibCore"], "tests": [ @@ -83,6 +84,7 @@ "UnitTestsPreflightProfileResolver", "UnitTestsPreflightVerdict", "UnitTestsProcessingBudget", + "UnitTestsRevisionStress", "UnitTestsSchemaEvolution", "UnitTestsStandardOracle", "UnitTestsWorkloadEnvelope" diff --git a/changes/feat-236-revision-stress.md b/changes/feat-236-revision-stress.md new file mode 100644 index 000000000..1cc11b435 --- /dev/null +++ b/changes/feat-236-revision-stress.md @@ -0,0 +1,4 @@ +Category: internal +Audience: maintainers and qualification operators +Breaking-Change: no +Summary: Add the concurrent revision-authority stress scenario for issue #236, covering simultaneous render, preflight, thumbnail, and repair-plan jobs across document mutations, deterministic cancellation, and revision-keyed cache reads. diff --git a/docs/REVISION_CONTEXT.md b/docs/REVISION_CONTEXT.md index 170f24023..b64cf732b 100644 --- a/docs/REVISION_CONTEXT.md +++ b/docs/REVISION_CONTEXT.md @@ -26,3 +26,20 @@ This is the deterministic unit-level form of the hostile-workload stress contract: render, preflight, thumbnail, and repair-plan producers may finish in any order, but only a result carrying the current revision may cross the presentation/cache boundary. + +`UnitTestsRevisionStress` runs the concurrent form of the same contract. +Render, preflight, thumbnail, and repair-plan jobs are in flight together +while the document is mutated and the effective profile changes at points the +producers do not observe, and the test asserts the four correctness +properties: zero stale findings applied, zero stale tiles presented past an +invalidation boundary, deterministic cancellation (cancelled work is terminal, +is never success, and publishes nothing), and no cache read returning a result +for the wrong revision. Zero stale results is a correctness requirement, not a +percentile, so a single admitted stale result fails the test. + +Timing alone cannot prove that the fence was exercised, so the test does not +rely on it: one phase submits results against the current revision and asserts +they are admitted and read back as current, and a second phase holds one +producer per job kind inside its work function, mutates the document underneath +all of them, and only then releases them - asserting both the consumer-side +rejection and the scheduler-side `Stale` outcome. diff --git a/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md b/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md index 7fadb3afb..68bdfac64 100644 --- a/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md +++ b/docs/SEMANTIC_TRUST_ENGINE_ACCEPTANCE.md @@ -15,7 +15,7 @@ Implementation evidence commit: `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Issue criterion | Implementation location | Test / audit | Windows evidence | Linux evidence | Exact SHA | Status | | --- | --- | --- | --- | --- | --- | --- | | #234 canonical reducer; PASS/FAIL/INCOMPLETE/ERROR, waivers, zero-finding budget exhaustion, distinct PdfTool exits | `LoupeLibCore/sources/pdfpreflightverdict.h`, `PdfTool/pdftoolpreflight.cpp`, `LoupeEditorPlugins/LoupePreflightPlugin/preflightreportmodel.cpp` and report dock | `UnitTestsPreflightVerdict`, `UnitTestsPreflightEngine`, `UnitTestsPreflightPlugin`, `UnitTestsOperatorAcceptance`; direct four-state PdfTool fixture matrix; semantic-trust source audit | 15/15 focused targets green; direct exits/states: pass 0, fail 1, incomplete 8, error 9; waiver/budget cases green | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — Windows candidate evidence green; Linux and merged-SHA evidence open | -| #236 one artifact/revision authority; complete revision-bound jobs and stale rejection under concurrent mutation | `LoupeLibCore/sources/pdfdocumentcontext.*`, `pdfjobscheduler.*`, cache-key types | `UnitTestsIdentitySeparation`, `UnitTestsDocumentSession`, `UnitTestsJobScheduler`; 32-round render/preflight/thumbnail/repair-plan stress | 15/15 focused targets green, including 32-round concurrent stale-result rejection | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — Windows candidate evidence green; Linux and merged-SHA evidence open | +| #236 one artifact/revision authority; complete revision-bound jobs and stale rejection under concurrent mutation | `LoupeLibCore/sources/pdfdocumentcontext.*`, `pdfjobscheduler.*`, cache-key types | `UnitTestsIdentitySeparation`, `UnitTestsDocumentSession`, `UnitTestsJobScheduler`, `UnitTestsRevisionStress`; 64-round concurrent render/preflight/thumbnail/repair-plan stress | 15/15 focused targets green, including 32-round concurrent stale-result rejection | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — Windows candidate evidence green; Linux and merged-SHA evidence open | | #237 one durable provenance chain; seven kinds, tamper detection, rollback append, retention, live PdfTool flows | `LoupeLibCore/sources/pdfoperationhistory.*`, `pdfoperationhistorystore.*`, `PdfTool/pdftoolpreflight.cpp`, `pdftoolrepair.cpp`, `pdftooladdbleed.cpp` | `UnitTestsOperationHistory`, `UnitTestsLifecycle`, `UnitTestsOperatorAcceptance::livePdfToolFlows_writeVerifiableProvenance`, independent SQLite probe, provenance source audit | 15/15 focused targets green; live preflight/add-bleed sidecars contain revision/profile/output digests and terminal status; SQLite integrity probe green | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Open — generic `repair --operation add-bleed` still returned `repair.unexpected-change`; Linux and merged-SHA evidence open | | #238 one scheduler submission boundary; no new unmanaged launches; typed GUI handoff and platform cancellation proof | `LoupeLibCore/sources/pdfjobscheduler.*`, `scripts/ci/check_unmanaged_async.py`, CI source-integrity jobs | `UnitTestsJobScheduler`, `UnitTestsWorkloadEnvelope`, unmanaged-async source audit | Scheduler/workload tests and source audit green; audit reports 13 known legacy product `QtConcurrent::run` call sites | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Blocked — product-facing unmanaged launches remain and Windows/Linux cancellation proof is not complete | | #239 explicit save policy; destructive operations cannot append incrementally; source remains immutable; recovered output not approved | `LoupeLibCore/sources/pdfsavepolicy.*`, writer policy integration, repair history | `UnitTestsRepairOperation`, `UnitTestsIncrementalSave`, live repair provenance; independent parser/signature validator required | Save-policy/repair tests green; signed annotation/metadata fixture preserves original signed prefix; no independent PDF parser/signature validator available | Not run in this session | `7912493e234f1abad3b90f3b813616aeb9d1fd63` | Blocked — independent parser/signature evidence absent | diff --git a/docs/UPSTREAM_DIVERGENCE.md b/docs/UPSTREAM_DIVERGENCE.md index ba8830d79..9187a2b54 100644 --- a/docs/UPSTREAM_DIVERGENCE.md +++ b/docs/UPSTREAM_DIVERGENCE.md @@ -23,7 +23,7 @@ and re-run the mapped tests. A clean merge is not verification. |------|----------------|----------|-------|-------| | Processing budgets | `PDFProcessingBudget` bounds decode, raster, and graph work; exhaustion is incomplete | No equivalent named pools | `UnitTestsProcessingBudget`, `UnitTestsBudgetExhaustion` | #242 / #243 | | Plugin ABI | Manifest ABI/capabilities inspected before `QPluginLoader::instance()`; packaged plugin dir only | Loads any plugin after `load()` | `UnitTestsPluginAbi` | #269 | -| Revision fence | `PDFRevisionIdentity` discards stale async/cache results | Viewer caches are not revision-fenced | `UnitTestsDocumentSession`, `UnitTestsJobScheduler` | #236 | +| Revision fence | `PDFRevisionIdentity` discards stale async/cache results | Viewer caches are not revision-fenced | `UnitTestsDocumentSession`, `UnitTestsJobScheduler`, `UnitTestsRevisionStress` | #236 | | Incremental save | Source digest mismatch refuses a silent rewrite | Writer may overwrite | `UnitTestsIncrementalSave` | #239 | | Render fidelity | Standard rendering reports cached overprint content as an explicit approximation; preflight and separation policies prohibit approximation | Standard renderer has no fidelity diagnostic | `UnitTestsOverprint` | #49 / #52 | diff --git a/docs/generated/architecture-catalog.json b/docs/generated/architecture-catalog.json index 6c4af6044..388ddaf2b 100644 --- a/docs/generated/architecture-catalog.json +++ b/docs/generated/architecture-catalog.json @@ -440,6 +440,7 @@ "UnitTestsRepairOperation", "UnitTestsRepairOperatorAcceptance", "UnitTestsResourceBudget", + "UnitTestsRevisionStress", "UnitTestsRgbToCmyk", "UnitTestsSafeFileWriter", "UnitTestsSchemaEvolution", diff --git a/docs/generated/phase5-widgets-inventory.json b/docs/generated/phase5-widgets-inventory.json index fa1c300c2..248628079 100644 --- a/docs/generated/phase5-widgets-inventory.json +++ b/docs/generated/phase5-widgets-inventory.json @@ -83,6 +83,7 @@ "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", "UnitTests/CMakeLists.txt", + "UnitTests/CMakeLists.txt", "loupe-preflight/tools/CMakeLists.txt" ], "shell_ledger": "docs/loupe-shell.json", @@ -453,6 +454,7 @@ "UnitTestsRepairOperation", "UnitTestsRepairOperatorAcceptance", "UnitTestsResourceBudget", + "UnitTestsRevisionStress", "UnitTestsRgbToCmyk", "UnitTestsSafeFileWriter", "UnitTestsSchemaEvolution", @@ -2578,6 +2580,46 @@ "widgets_paths": [], "consumers": [] }, + { + "id": "UnitTestsRevisionStress", + "kind": "executable", + "cmake": "UnitTests/CMakeLists.txt", + "profile_enabled": false, + "profile_condition": "qualification target excluded from the product-surface manifest", + "install_rule": false, + "installed_in_profile": false, + "build_only_in_profile": false, + "direct_links": [ + "LoupeLibCore", + "Qt6::Core", + "Qt6::Gui", + "Qt6::Test" + ], + "direct_qt_modules": [ + "Core", + "Gui", + "Test" + ], + "transitive_targets": [ + "LoupeLibCore" + ], + "transitive_qt_modules": [ + "Sql", + "Svg", + "Xml" + ], + "qt_modules": [ + "Core", + "Gui", + "Sql", + "Svg", + "Test", + "Xml" + ], + "widgets_linkage": "none", + "widgets_paths": [], + "consumers": [] + }, { "id": "UnitTestsRgbToCmyk", "kind": "executable", @@ -2939,7 +2981,7 @@ } ], "counts": { - "targets": 67, + "targets": 68, "installed_in_profile": 4, "build_only_in_profile": 3, "widgets_surfaces": 4, diff --git a/scripts/ci/test_verify_phase5_widgets_contract.py b/scripts/ci/test_verify_phase5_widgets_contract.py index 6da6c75cc..0351c6d07 100644 --- a/scripts/ci/test_verify_phase5_widgets_contract.py +++ b/scripts/ci/test_verify_phase5_widgets_contract.py @@ -31,7 +31,7 @@ def setUpClass(cls): def test_current_evidence_is_valid_and_complete(self): self.assertEqual(validate_contract(ROOT, self.inventory, self.disposition), []) - self.assertEqual(self.inventory["counts"]["targets"], 67) + self.assertEqual(self.inventory["counts"]["targets"], 68) self.assertEqual(self.inventory["counts"]["widgets_surfaces"], 4) self.assertEqual(self.inventory["counts"]["ui_forms"], 2) self.assertEqual(len(self.inventory["plugin_ui"]), 0) From ac702f8b9e2baf7069ed2839200f74d4f78c894c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 02:44:03 +0000 Subject: [PATCH 31/33] chore: add changelog fragment for PR 490 promotion fix Co-authored-by: michael berry --- changes/cursor-pr-490-promotion-fix-23e1.md | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changes/cursor-pr-490-promotion-fix-23e1.md diff --git a/changes/cursor-pr-490-promotion-fix-23e1.md b/changes/cursor-pr-490-promotion-fix-23e1.md new file mode 100644 index 000000000..692c49db3 --- /dev/null +++ b/changes/cursor-pr-490-promotion-fix-23e1.md @@ -0,0 +1,4 @@ +Category: internal +Audience: maintainers +Breaking-Change: no +Summary: Merge unstable into dev for PR 490, resolve Loop/Loupe rename conflicts, refresh Phase 5 widgets evidence and interaction-trace digests, and fix policy/source_integrity regressions blocking the 0.2.1 promotion. From 12083f81dff123517a25285c3abd80673c50c6fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 03:01:59 +0000 Subject: [PATCH 32/33] fix: reword changelog to satisfy loop identity contract Co-authored-by: michael berry --- changes/cursor-pr-490-promotion-fix-23e1.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changes/cursor-pr-490-promotion-fix-23e1.md b/changes/cursor-pr-490-promotion-fix-23e1.md index 692c49db3..c8836eed3 100644 --- a/changes/cursor-pr-490-promotion-fix-23e1.md +++ b/changes/cursor-pr-490-promotion-fix-23e1.md @@ -1,4 +1,4 @@ Category: internal Audience: maintainers Breaking-Change: no -Summary: Merge unstable into dev for PR 490, resolve Loop/Loupe rename conflicts, refresh Phase 5 widgets evidence and interaction-trace digests, and fix policy/source_integrity regressions blocking the 0.2.1 promotion. +Summary: Merge unstable into dev for PR 490, resolve legacy-to-Loop rename conflicts, refresh Phase 5 widgets evidence and interaction-trace digests, and fix policy/source_integrity regressions blocking the 0.2.1 promotion. From 6c628b41e44136821f08d5914bdee2ca1d671d02 Mon Sep 17 00:00:00 2001 From: mberrys Date: Tue, 1 Sep 2026 21:22:46 -0700 Subject: [PATCH 33/33] fix: apply P1-P2 codex fixes and linux test catalog drift (PR 516) - LoopLibCore: pass PDFProcessingBudget to PDFTextLayoutGenerator during searchDocumentText to enforce hostile-workload limits - LoopEditor QuickOutlineModel: expose page role from PDFOutlineItem destination and route via implemented goToPage/goToOutlinePage - LoopEditor DocumentPane/Host: make searchPanelVisible one-shot via acknowledgeSearchPanel to prevent repeated reveal on presentationChanged - UnitTests: bump catalog implemented count 16 -> 25 to match shell-implemented find/layout commands added in unstable --- LoopEditor/editorhost.cpp | 18 ++++++++++++++++++ LoopEditor/editorhost.h | 2 ++ LoopEditor/qml/DocumentPane.qml | 8 ++++++-- LoopEditor/quickdocumentmodel.cpp | 20 +++++++++++++++++++- LoopEditor/quickdocumentmodel.h | 1 + LoopLibCore/sources/pdfdocumentsearch.cpp | 3 ++- LoopLibCore/sources/pdftextlayoutgenerator.h | 5 +++-- UnitTests/tst_documentfacadetest.cpp | 2 +- 8 files changed, 52 insertions(+), 7 deletions(-) diff --git a/LoopEditor/editorhost.cpp b/LoopEditor/editorhost.cpp index e0b0109eb..dbfaca51d 100644 --- a/LoopEditor/editorhost.cpp +++ b/LoopEditor/editorhost.cpp @@ -225,6 +225,13 @@ void EditorHost::goToPage(int pageIndex) bumpPresentation(); } +void EditorHost::goToOutlinePage(int pageIndex) +{ + // Outline navigation reuses the implemented viewport path; separate entry + // keeps QML from depending on an unimplemented goToOutlineIndex. + goToPage(pageIndex); +} + void EditorHost::acknowledgeWorkspaceRequest() { if (m_workspaceRequest < 0) @@ -236,6 +243,17 @@ void EditorHost::acknowledgeWorkspaceRequest() Q_EMIT presentationChanged(); } +void EditorHost::acknowledgeSearchPanel() +{ + if (!m_searchPanelVisible) + { + return; + } + + m_searchPanelVisible = false; + Q_EMIT presentationChanged(); +} + QString EditorHost::preflightStateName() const { return preflightStateToString(m_preflight.state()); diff --git a/LoopEditor/editorhost.h b/LoopEditor/editorhost.h index 0ed35526b..794e3747d 100644 --- a/LoopEditor/editorhost.h +++ b/LoopEditor/editorhost.h @@ -159,7 +159,9 @@ class EditorHost final : public QObject /// the document stays open. Q_INVOKABLE void toggleCurrentPageFidelity(); Q_INVOKABLE void goToPage(int pageIndex); + Q_INVOKABLE void goToOutlinePage(int pageIndex); Q_INVOKABLE void acknowledgeWorkspaceRequest(); + Q_INVOKABLE void acknowledgeSearchPanel(); Q_INVOKABLE QVariantList commandDescriptors() const; Q_INVOKABLE bool isCommandEnabled(const QString& commandId) const; diff --git a/LoopEditor/qml/DocumentPane.qml b/LoopEditor/qml/DocumentPane.qml index 6b80ef757..f03119c9a 100644 --- a/LoopEditor/qml/DocumentPane.qml +++ b/LoopEditor/qml/DocumentPane.qml @@ -78,7 +78,9 @@ Item { width: outlineView.width text: model.display !== undefined ? model.display : title Accessible.name: text - onClicked: if (root.host && model.index !== undefined) root.host.goToOutlineIndex(model.index) + enabled: page >= 0 + onClicked: if (root.host && page >= 0) + root.host.goToOutlinePage(page) } Label { @@ -162,8 +164,10 @@ Item { Connections { target: root.host function onPresentationChanged() { - if (root.host && root.host.searchPanelVisible) + if (root.host && root.host.searchPanelVisible) { root.revealSearch() + root.host.acknowledgeSearchPanel() + } } } } diff --git a/LoopEditor/quickdocumentmodel.cpp b/LoopEditor/quickdocumentmodel.cpp index 08d15541d..3145431f3 100644 --- a/LoopEditor/quickdocumentmodel.cpp +++ b/LoopEditor/quickdocumentmodel.cpp @@ -1,6 +1,7 @@ // MIT License #include "quickdocumentmodel.h" +#include "pdfaction.h" #include "pdfcatalog.h" #include "pdfdocument.h" #include "pdfdocumentcontext.h" @@ -138,12 +139,29 @@ QVariant QuickOutlineModel::data(const QModelIndex& index, int role) const return node->item->getTitle(); if (role == HasChildrenRole) return !node->children.empty(); + if (role == PageRole) + { + const pdf::PDFAction* action = node->item->getAction(); + if (!action) + return -1; + if (action->getType() == pdf::ActionType::GoTo) + { + const auto* goTo = static_cast(action); + const pdf::PDFDestination& dest = goTo->getDestination(); + if (dest.isValid() && !dest.isNamedDestination()) + return static_cast(dest.getPageIndex()); + const pdf::PDFDestination& structDest = goTo->getStructureDestination(); + if (structDest.isValid() && !structDest.isNamedDestination()) + return static_cast(structDest.getPageIndex()); + } + return -1; + } return {}; } QHash QuickOutlineModel::roleNames() const { - return { { TitleRole, "title" }, { HasChildrenRole, "hasChildren" } }; + return { { TitleRole, "title" }, { HasChildrenRole, "hasChildren" }, { PageRole, "page" } }; } void QuickOutlineModel::build(Node* parent, const pdf::PDFOutlineItem* item) diff --git a/LoopEditor/quickdocumentmodel.h b/LoopEditor/quickdocumentmodel.h index b19e99624..6a39028d8 100644 --- a/LoopEditor/quickdocumentmodel.h +++ b/LoopEditor/quickdocumentmodel.h @@ -66,6 +66,7 @@ class QuickOutlineModel final : public QAbstractItemModel { TitleRole = Qt::UserRole + 1, HasChildrenRole, + PageRole, }; explicit QuickOutlineModel(QObject* parent = nullptr); diff --git a/LoopLibCore/sources/pdfdocumentsearch.cpp b/LoopLibCore/sources/pdfdocumentsearch.cpp index 88a1813d7..745b484f5 100644 --- a/LoopLibCore/sources/pdfdocumentsearch.cpp +++ b/LoopLibCore/sources/pdfdocumentsearch.cpp @@ -33,7 +33,8 @@ PDFDocumentSearchResult searchDocumentText(PDFDocumentContext* context, const PDFPage* page = catalog->getPage(pageIndex); PDFTextLayoutGenerator generator(features, page, document, session->getFontCache(), session->getCMS(), - session->getOptionalContentActivity(), QTransform(), meshQuality); + session->getOptionalContentActivity(), QTransform(), meshQuality, + session->getProcessingBudget()); generator.processContents(); const PDFTextFlows flows = PDFTextFlow::createTextFlows( generator.createTextLayout(), diff --git a/LoopLibCore/sources/pdftextlayoutgenerator.h b/LoopLibCore/sources/pdftextlayoutgenerator.h index 721948fdf..a1fbe41c5 100644 --- a/LoopLibCore/sources/pdftextlayoutgenerator.h +++ b/LoopLibCore/sources/pdftextlayoutgenerator.h @@ -37,8 +37,9 @@ class LOOPLIBCORESHARED_EXPORT PDFTextLayoutGenerator : public PDFPageContentPro const PDFCMS* cms, const PDFOptionalContentActivity* optionalContentActivity, QTransform pagePointToDevicePointMatrix, - const PDFMeshQualitySettings& meshQualitySettings) : - BaseClass(page, document, fontCache, cms, optionalContentActivity, pagePointToDevicePointMatrix, meshQualitySettings), + const PDFMeshQualitySettings& meshQualitySettings, + PDFProcessingBudget* processingBudget = nullptr) : + BaseClass(page, document, fontCache, cms, optionalContentActivity, pagePointToDevicePointMatrix, meshQualitySettings, processingBudget), m_features(features) { diff --git a/UnitTests/tst_documentfacadetest.cpp b/UnitTests/tst_documentfacadetest.cpp index 43af532f5..8d8a71f80 100644 --- a/UnitTests/tst_documentfacadetest.cpp +++ b/UnitTests/tst_documentfacadetest.cpp @@ -342,7 +342,7 @@ void DocumentFacadeTest::catalogLoadsTheWholeEditorActionSet() QVERIFY(descriptor.capability != pdfinteraction::CommandCapability::Unclassified); } } - QCOMPARE(implemented, 16); + QCOMPARE(implemented, 25); } void DocumentFacadeTest::catalogPublishesAvailabilityAtomically()