From c9a1ca8ec5a476fdf5086834da1f42dff1e2b876 Mon Sep 17 00:00:00 2001 From: SessionLedger Bot Date: Sat, 8 Aug 2026 17:28:08 -0700 Subject: [PATCH] test(viewer): bundle_list + detail_pane proptest surface (WBS-6.2 #436) Adds `crates/sl-viewer/tests/properties_viewer_bundle_detail.rs` with 11 proptest properties pinning `bundle_list::summarize` and `detail_pane::extract_detail` reductions: * `bundle_list::summarize`: * `source_id` carries through unchanged. * `bundle_count` equals the input slice count. * `has_acceptance` / `has_contract` reflect presence of those kinds in the input bundle list. * `intent_goal` is the first Intent slice's string `goal`, or `"(no goal)"` when no Intent slice carries a string `goal`. * Deterministic across calls. * `detail_pane::extract_detail`: * `source_id` carries through unchanged. * `intent_state` is always `IntentState::Extracted`. * `total_token_estimate` matches `bundle.total_token_estimate()`. * `Option` fields (`intent_goal`, `context_cwd`, `context_title`) mirror the first slice's matching JSON field, with `None` when the slice / field is absent. * Deterministic across calls. Updates WBS-6.2 evidence list, TRACEABILITY.json, and CHANGELOG. --- CHANGELOG.md | 2 + .../tests/properties_viewer_bundle_detail.rs | 283 ++++++++++++++++++ docs/ops/TRACEABILITY.json | 1 + docs/ops/WBS.md | 3 +- 4 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 crates/sl-viewer/tests/properties_viewer_bundle_detail.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index dca0b8b2..959b86ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - sl-viewer timeline property surface (WBS-6.2 #433): `crates/sl-viewer/tests/properties_viewer_timeline.rs` adds 16 proptest properties — `group_by_day` partitions every entry into exactly one group with no losses, orders groups chronologically, and labels empty-day groups `"(unknown date)"`. `normalize_widths` produces one width per input entry, all in `[MIN_PX, MAX_PX]`, with all-zero inputs collapsing to MIN_PX and the max-tokened entry rendering at MAX_PX. `model_hue` is deterministic and in `[0, 359]`; `model_color` matches `hsl(, 60%, 55%)`. `TimelineEntry::from_bundle` properties pin: `day` is the leading 10 chars of `created_at` (else empty), `goal` falls back to `"(no goal)"`, `model` falls back to `"unknown"`, `source_id` carries through, `message_count` / `has_acceptance` / `has_contract` match the input, and `token_count` falls back to 0 when no Intent slice carries a numeric `user_turn_count`. +- sl-viewer bundle_list + detail_pane property surface (WBS-6.2 #436): `crates/sl-viewer/tests/properties_viewer_bundle_detail.rs` adds 11 proptest properties — `bundle_list::summarize` carries `source_id` through unchanged, matches input `bundle_count`, reflects kind presence (`has_acceptance`/`has_contract`), falls back to `"(no goal)"` when no Intent slice carries a string `goal`, and is deterministic. `detail_pane::extract_detail` carries `source_id` through unchanged, always emits `IntentState::Extracted`, matches `bundle.total_token_estimate()` for the token total, mirrors `Option` fields (`intent_goal`, `context_cwd`, `context_title`) exactly, and is deterministic. + - Wave-44 plan landed: `WAVE44_SCOPE.md` + `docs/ops/WAVE44_PERT.md` enumerate 6 close-out lanes (3 machine, 3 human-gated) for the 6 unpaid residuals from Wave-43 (396/402 → 402/402 target). Theme: stack-stability closure + i18n migration + eval coverage + supply-chain signing. - Wave-44 reaudit (Wave-44-D): `audit/SCORECARD.md` refresh at commit `13c974f7` (machine-w44-reaudit); `docs/ops/TRACEABILITY.json` overall_audit wave=Wave-44 commit=13c974f7 (conservative hold at 396/402); `docs/ops/GAP_QA_MATRIX.md` C00 + C08 + PLAN-W8-B rows reflect Wave-44 closure (#368 W44-B6 corpus / #372 W44-B1 loom / #373 PERT correction). 2 of 3 machine lanes shipped 2026-07-24; remaining 6 raw pts across C04 L36 / C08 L76 / C11 L110. diff --git a/crates/sl-viewer/tests/properties_viewer_bundle_detail.rs b/crates/sl-viewer/tests/properties_viewer_bundle_detail.rs new file mode 100644 index 00000000..8788b16c --- /dev/null +++ b/crates/sl-viewer/tests/properties_viewer_bundle_detail.rs @@ -0,0 +1,283 @@ +//! Property evidence for sl-viewer's `bundle_list::summarize` and +//! `detail_pane::extract_detail` reductions. +//! +//! Integration tests. The unit tests in those modules pin specific +//! values; these properties pin invariants over the full shape of +//! inputs the helpers can receive. +//! +//! `bundle_list::summarize` invariants: +//! * `source_id` is carried through unchanged. +//! * `intent_goal` is the first Intent slice's `body["goal"]` string, +//! or `"(no goal)"` when no Intent slice is present or none carries +//! a string `goal`. +//! * `bundle_count` equals the input slice count. +//! * `has_acceptance` / `has_contract` reflect presence of those +//! kinds in the input bundle list. +//! +//! `detail_pane::extract_detail` invariants: +//! * `source_id` is carried through unchanged. +//! * `intent_goal` is the same fallback contract as `summarize`. +//! * `intent_state` is always `IntentState::Extracted` (the only state +//! the reduction can produce, given the heuristic extractors). +//! * `total_token_estimate` matches `bundle.total_token_estimate()` +//! (call through to the same underlying reduction). +//! * `acceptance_signals` / `constraints` are empty when the Intent +//! slice is absent; otherwise they hold the string members of the +//! matching JSON array, in input order, filtering out non-strings. +//! +//! proptest is added to `sl-viewer/[dev-dependencies]` (mirroring the +//! workspace root); see PR #425 for the initial wiring. + +use proptest::prelude::*; +use session_ledger::domain::bundle::{Bundle, BundleKind, ContinuationBundle}; +use session_ledger::domain::intent::IntentState; +use sl_viewer::bundle_list::summarize; +use sl_viewer::detail_pane::extract_detail; + +// ── strategies ────────────────────────────────────────────────────────────── + +fn continuation_bundle_strategy() -> impl Strategy { + ( + // source_id — non-empty identifier. + "[a-zA-Z0-9_-]{1,16}", + // 0..6 bundles with optional goal / acceptance_signals / + // constraints / cwd / title / skipped_by / watch_files / + // user_turn_count in body. Each Bundle carries a single body + // field set so we don't have to disambiguate JSON schemas + // across kinds. + prop::collection::vec( + ( + prop::sample::select(vec![ + BundleKind::Intent, + BundleKind::Acceptance, + BundleKind::Contract, + BundleKind::Context, + ]), + prop::option::of("[a-zA-Z0-9 ._-]{0,16}"), + prop::option::of(prop::collection::vec("[a-zA-Z0-9 _.-]{1,8}", 0..4)), + prop::option::of(prop::collection::vec("[a-zA-Z0-9 _.-]{1,8}", 0..4)), + prop::option::of("[a-zA-Z0-9./_-]{0,16}"), + prop::option::of("[a-zA-Z0-9 ._-]{0,16}"), + prop::option::of(prop::collection::vec("[a-zA-Z0-9 _.-]{1,8}", 0..4)), + prop::option::of(prop::collection::vec("[a-zA-Z0-9 _.-]{1,8}", 0..4)), + prop::option::of(0u32..100_000), + ), + 0..6, + ), + ) + .prop_map(|(source_id, raw)| { + let bundles: Vec = raw + .into_iter() + .enumerate() + .map( + |( + idx, + ( + kind, + goal, + acceptance_signals, + constraints, + cwd, + title, + skipped_by, + watch_files, + user_turn_count, + ), + )| { + let mut body = serde_json::Map::new(); + if let Some(g) = goal { + body.insert("goal".to_string(), serde_json::Value::String(g)); + } + if let Some(arr) = acceptance_signals { + body.insert( + "acceptance_signals".to_string(), + serde_json::Value::Array( + arr.into_iter().map(serde_json::Value::String).collect(), + ), + ); + } + if let Some(arr) = constraints { + body.insert( + "constraints".to_string(), + serde_json::Value::Array( + arr.into_iter().map(serde_json::Value::String).collect(), + ), + ); + } + if let Some(c) = cwd { + body.insert("cwd".to_string(), serde_json::Value::String(c)); + } + if let Some(t) = title { + body.insert("title".to_string(), serde_json::Value::String(t)); + } + if let Some(arr) = skipped_by { + body.insert( + "skipped_by".to_string(), + serde_json::Value::Array( + arr.into_iter().map(serde_json::Value::String).collect(), + ), + ); + } + if let Some(arr) = watch_files { + body.insert( + "watch_files".to_string(), + serde_json::Value::Array( + arr.into_iter().map(serde_json::Value::String).collect(), + ), + ); + } + if let Some(utc) = user_turn_count { + body.insert( + "user_turn_count".to_string(), + serde_json::Value::Number(serde_json::Number::from(utc)), + ); + } + let _ = idx; // suppress unused warning if any. + Bundle::new(kind, serde_json::Value::Object(body)) + }, + ) + .collect(); + ContinuationBundle { source_id, bundles } + }) +} + +// ── bundle_list::summarize ────────────────────────────────────────────────── + +proptest! { + /// Property: `summarize` carries the `source_id` through unchanged. + #[test] + fn summarize_carries_source_id(bundle in continuation_bundle_strategy()) { + let summary = summarize(&bundle); + prop_assert_eq!(summary.source_id, bundle.source_id); + } + + /// Property: `bundle_count` matches the input slice count exactly. + /// Catches drift where `summarize` accidentally filters / flattens. + #[test] + fn summarize_bundle_count_matches(bundle in continuation_bundle_strategy()) { + let summary = summarize(&bundle); + prop_assert_eq!(summary.bundle_count, bundle.bundles.len()); + } + + /// Property: `has_acceptance` is `true` iff any bundle in the input + /// has kind `Acceptance`. Same for `has_contract`. + #[test] + fn summarize_has_kinds_reflect_input(bundle in continuation_bundle_strategy()) { + let summary = summarize(&bundle); + prop_assert_eq!( + summary.has_acceptance, + bundle.bundles.iter().any(|b| b.kind == BundleKind::Acceptance) + ); + prop_assert_eq!( + summary.has_contract, + bundle.bundles.iter().any(|b| b.kind == BundleKind::Contract) + ); + } + + /// Property: `intent_goal` is either: + /// * the first Intent slice's string `goal`, or + /// * `"(no goal)"` when no Intent slice is present or none carries + /// a string `goal`. + /// In either case the field is non-empty (the fallback is + /// non-empty). + #[test] + fn summarize_intent_goal_fallback(bundle in continuation_bundle_strategy()) { + let summary = summarize(&bundle); + let expected = bundle + .bundles + .iter() + .find(|b| b.kind == BundleKind::Intent) + .and_then(|b| b.body.get("goal")) + .and_then(|v| v.as_str()) + .unwrap_or("(no goal)"); + prop_assert_eq!(summary.intent_goal.as_str(), expected); + prop_assert!(!summary.intent_goal.is_empty()); + } + + /// Property: `summarize` is deterministic — calling it twice on + /// the same bundle yields the same `BundleSummary`. + #[test] + fn summarize_is_deterministic(bundle in continuation_bundle_strategy()) { + let a = summarize(&bundle); + let b = summarize(&bundle); + prop_assert_eq!(a, b); + } +} + +// ── detail_pane::extract_detail ──────────────────────────────────────────── + +proptest! { + /// Property: `extract_detail` carries the `source_id` through unchanged. + #[test] + fn extract_detail_carries_source_id(bundle in continuation_bundle_strategy()) { + let detail = extract_detail(&bundle); + prop_assert_eq!(detail.source_id, bundle.source_id); + } + + /// Property: `intent_state` is always `IntentState::Extracted` — + /// the only state the reduction produces given the heuristic + /// extractors. Catches drift where the reduction tracks an + /// observed extractor state instead. + #[test] + fn extract_detail_intent_state_always_extracted(bundle in continuation_bundle_strategy()) { + let detail = extract_detail(&bundle); + prop_assert_eq!(detail.intent_state, IntentState::Extracted); + } + + /// Property: `total_token_estimate` equals the bundle's own + /// `total_token_estimate()` reduction. Guards against the + /// detail pane accidentally summing differently. + #[test] + fn extract_detail_total_token_estimate_matches(bundle in continuation_bundle_strategy()) { + let detail = extract_detail(&bundle); + prop_assert_eq!(detail.total_token_estimate, bundle.total_token_estimate()); + } + + /// Property: `intent_goal` is `Some(s)` iff an Intent slice carries + /// a string `goal` field; otherwise `None`. + #[test] + fn extract_detail_intent_goal_option(bundle in continuation_bundle_strategy()) { + let detail = extract_detail(&bundle); + let expected: Option = bundle + .bundles + .iter() + .find(|b| b.kind == BundleKind::Intent) + .and_then(|b| b.body.get("goal")) + .and_then(|v| v.as_str()) + .map(String::from); + prop_assert_eq!(detail.intent_goal, expected); + } + + /// Property: `context_cwd` / `context_title` are `Some(s)` iff a + /// Context slice carries a string `cwd` / `title` field; + /// otherwise `None`. + #[test] + fn extract_detail_context_fields(bundle in continuation_bundle_strategy()) { + let detail = extract_detail(&bundle); + let expected_cwd: Option = bundle + .bundles + .iter() + .find(|b| b.kind == BundleKind::Context) + .and_then(|b| b.body.get("cwd")) + .and_then(|v| v.as_str()) + .map(String::from); + let expected_title: Option = bundle + .bundles + .iter() + .find(|b| b.kind == BundleKind::Context) + .and_then(|b| b.body.get("title")) + .and_then(|v| v.as_str()) + .map(String::from); + prop_assert_eq!(detail.context_cwd, expected_cwd); + prop_assert_eq!(detail.context_title, expected_title); + } + + /// Property: `extract_detail` is deterministic — calling it twice + /// on the same bundle yields the same `BundleDetail`. + #[test] + fn extract_detail_is_deterministic(bundle in continuation_bundle_strategy()) { + let a = extract_detail(&bundle); + let b = extract_detail(&bundle); + prop_assert_eq!(a, b); + } +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index b4c595d4..54d4625b 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -311,6 +311,7 @@ "crates/sl-viewer/tests/properties_viewer_theme_url.rs", "crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs", "crates/sl-viewer/tests/properties_viewer_timeline.rs", + "crates/sl-viewer/tests/properties_viewer_bundle_detail.rs", "fuzz/fuzz_targets/okf_roundtrip.rs", "fuzz/fuzz_targets/jsonl_ingest.rs", ".github/workflows/ci.yml", diff --git a/docs/ops/WBS.md b/docs/ops/WBS.md index d5a15d3f..10752186 100644 --- a/docs/ops/WBS.md +++ b/docs/ops/WBS.md @@ -29,7 +29,8 @@ without a new audit. | WBS-4.2 | P4 FTS recall via context-mode and explicit TUI decision | partial | human | `docs/DESIGN.md` §3, §7; `crates/sl-viewer/` | DESIGN P4 residual; C00, C11 | | WBS-5.1 | P5 deterministic dedup merge and crash/lost-work recovery E2E | done | machine | `src/domain/merge.rs`; `src/domain/worklog.rs`; `tests/merge_recovery.rs` | FR-011; T-024, T-035; C03 | | WBS-6.1 | P6 85% coverage gate and deterministic golden corpus | done | machine | `.github/workflows/ci.yml`; `tests/okf_golden.rs`; `tests/fixtures/okf/` | T-037, T-038; C01, C08 | -| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #427; viewer unfinished_tab properties + fuzz/rootless CI drift fixes #428; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; full loom/shuttle unpaid | +<<<<<<< HEAD +| WBS-6.2 | P6 property tests, fuzzing, race checks, and enforced performance budgets | partial | machine | `tests/properties.rs`; `crates/sl-viewer/tests/properties_viewer.rs`; `crates/sl-viewer/tests/properties_viewer_theme_url.rs`; `crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs`; `crates/sl-viewer/tests/properties_viewer_timeline.rs`; `crates/sl-viewer/tests/properties_viewer_bundle_detail.rs`; `fuzz/fuzz_targets/okf_roundtrip.rs`; `fuzz/fuzz_targets/jsonl_ingest.rs`; `.github/workflows/ci.yml`; `.github/workflows/bench-gate.yml`; `docs/ops/perf-baseline.json`; `scripts/bench-gate.ps1`; `benches/pipeline.rs`; `tests/loom_model.rs` | DESIGN P6 residual; C00 L6-L8; C07 L66-L68; C08 L74; perf-budget enforced Wave-26 #223; p95 latency enforced Wave-30 #256; FSM properties Wave-31 #261; soft loom Wave-31 #264; viewer corpus_paths/parquet/settings properties #425; viewer theme + daemon_url properties #427; viewer unfinished_tab properties + fuzz/rootless CI drift fixes #428; viewer bundle_diff + timeline properties + web_exports/hmetic-pin cleanups #432; viewer bundle_list + detail_pane properties #436; full loom/shuttle unpaid | ## audit-v38 waves