diff --git a/CHANGELOG.md b/CHANGELOG.md index 7830915d..effc78f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ Follows [Keep a Changelog](https://keepachangelog.com/); versioning is [SemVer]( - sl-viewer search/memory property surface (WBS-6.2 #435): `crates/sl-viewer/tests/properties_viewer_search_memory.rs` adds 12 proptest properties — `search_view::build_query` trims each field, emits a field iff its post-trim value is non-empty, encodes the documented break-character set (` `, `,`, `#`, `&`, `=`, `+`), and always emits `limit=` whose value is the parsed input or the documented `"50"` fallback. `advanced_filter_active_count` counts `min_tokens`/`tags` non-empty fields, treats `"50"` as the default for `limit`, and is trim-invariant. `memory_tab::to_wiki_page` carries `session_id` and `title` through unchanged and is deterministic across calls; `all_wiki_pages_from_sessions` produces exactly one page per input session, in input order. +- sl-viewer history_tab property surface (WBS-6.2 #444): `crates/sl-viewer/tests/properties_viewer_history.rs` adds 15 proptest properties — `history_tab::to_timeline_entry` carries `summary.id` / `summary.title` / `summary.message_count` / `summary.intent_state` (= `Extracted`), `corpus`, and `cwd` through unchanged; `message_previews` is capped at 3 (empty when input has no messages); `total_messages` matches input. `unfinished` is `false` for empty sessions, `false` when the last message content (case-insensitive) contains one of the six documented done-phrases ("looks good", "approved", "ship it", "all good", "thanks", "done"), and `true` otherwise. `to_timeline_entry` is deterministic. `all_timeline_entries` produces one entry per input session, sorts by `total_messages` descending (newest-first), every session id appears exactly once, 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_history.rs b/crates/sl-viewer/tests/properties_viewer_history.rs new file mode 100644 index 00000000..0ea52f76 --- /dev/null +++ b/crates/sl-viewer/tests/properties_viewer_history.rs @@ -0,0 +1,280 @@ +//! Property evidence for sl-viewer's `history_tab::to_timeline_entry` +//! and `history_tab::all_timeline_entries` reductions. +//! +//! Integration tests. The unit tests in `history_tab.rs` pin specific +//! values; these properties pin invariants over the full shape of +//! inputs the helpers can receive. +//! +//! `history_tab::to_timeline_entry` invariants: +//! * `summary.id` is the session id; `summary.title` is the session +//! title (mirrors `Option` identity); `summary.message_count` +//! equals the session's `messages.len()`; `summary.intent_state` is +//! always `IntentState::Extracted`. +//! * `corpus` and `cwd` are carried through unchanged. +//! * `message_previews` has at most 3 entries (the documented cap) +//! and is empty when the session has no messages. The first 3 +//! messages are previewed, in input order. +//! * `total_messages` equals `session.messages.len()`. +//! * `unfinished` is `false` when the session has no messages, and +//! `false` when the last message content (case-insensitive) contains +//! one of the documented "done" phrases ("looks good", "approved", +//! "ship it", "all good", "thanks", "done"); otherwise `true`. +//! * Deterministic across calls. +//! +//! `history_tab::all_timeline_entries` invariants: +//! * Output length matches input length. +//! * The output is sorted by `total_messages` descending (newest-first +//! by message count, per the documented comment). +//! * Every session's `id` appears in the output exactly once. +//! +//! 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::intent::IntentState; +use session_ledger::domain::session::{Corpus, Message, Role, Session}; +use sl_viewer::history_tab::{all_timeline_entries, to_timeline_entry}; + +// ── strategies ────────────────────────────────────────────────────────────── + +/// `Message` strategy — role + content + optional ts_ms. +fn message_strategy() -> impl Strategy { + ( + prop::sample::select(vec![ + Role::User, + Role::Assistant, + Role::Subagent, + Role::Tool, + Role::System, + ]), + prop::string::string_regex("[ -~]{0,120}").expect("valid regex"), + prop::option::of(0i64..1_000_000_000_000i64), + ) + .prop_map(|(role, content, ts_ms)| { + let mut m = Message::new(role, content); + m.ts_ms = ts_ms; + m + }) +} + +/// `Session` strategy — id + 0..8 messages + optional title + corpus. +fn session_strategy() -> impl Strategy { + ( + // session_id — non-empty, identifier-shaped. + prop::string::string_regex("[a-zA-Z0-9_-]{1,16}").expect("valid regex"), + // 0..8 messages. + prop::collection::vec(message_strategy(), 0..8), + // title — `Option`. + prop::option::of( + prop::string::string_regex("[A-Za-z0-9 ._-]{0,40}").expect("valid regex"), + ), + // corpus — pick one of the documented variants. + prop::sample::select(vec![ + Corpus::Forge, + Corpus::Codex, + Corpus::ClaudeCode, + Corpus::Cursor, + Corpus::FactoryDroid, + Corpus::ChatGptWeb, + Corpus::ClaudeWeb, + Corpus::GeminiWeb, + ]), + // cwd — `Option`. + prop::option::of( + prop::string::string_regex("[/a-zA-Z0-9._-]{0,40}").expect("valid regex"), + ), + ) + .prop_map(|(id, messages, title, corpus, cwd)| { + let mut s = Session::new(id, corpus); + s.messages = messages; + s.title = title; + s.cwd = cwd; + s + }) +} + +// ── history_tab::to_timeline_entry ────────────────────────────────────────── + +proptest! { + /// Property: `summary.id` is the session id. + #[test] + fn to_timeline_entry_carries_session_id(session in session_strategy()) { + let entry = to_timeline_entry(&session); + prop_assert_eq!(&entry.summary.id, &session.id); + } + + /// Property: `summary.title` is the session title (mirrors + /// `Option` identity — `None` stays `None`). + #[test] + fn to_timeline_entry_carries_title(session in session_strategy()) { + let entry = to_timeline_entry(&session); + prop_assert_eq!(entry.summary.title, session.title); + } + + /// Property: `summary.message_count` equals the session's + /// `messages.len()`. + #[test] + fn to_timeline_entry_message_count_matches(session in session_strategy()) { + let entry = to_timeline_entry(&session); + prop_assert_eq!(entry.summary.message_count, session.messages.len()); + } + + /// Property: `summary.intent_state` is always `IntentState::Extracted` + /// — the only state the reduction can produce given the heuristic + /// extractors. + #[test] + fn to_timeline_entry_intent_state_always_extracted(session in session_strategy()) { + let entry = to_timeline_entry(&session); + prop_assert_eq!(entry.summary.intent_state, IntentState::Extracted); + } + + /// Property: `corpus` and `cwd` are carried through unchanged. + #[test] + fn to_timeline_entry_carries_corpus_and_cwd(session in session_strategy()) { + let entry = to_timeline_entry(&session); + prop_assert_eq!(entry.corpus, session.corpus); + prop_assert_eq!(entry.cwd, session.cwd); + } + + /// Property: `message_previews` has at most 3 entries (the + /// documented cap) and is empty when the session has no messages. + /// When non-empty, the previews cover the first N ≤ 3 messages, + /// in input order. + #[test] + fn to_timeline_entry_message_previews_capped(session in session_strategy()) { + let entry = to_timeline_entry(&session); + prop_assert!(entry.message_previews.len() <= 3); + let expected = session.messages.len().min(3); + prop_assert_eq!(entry.message_previews.len(), expected); + } + + /// Property: `total_messages` equals `session.messages.len()`. + #[test] + fn to_timeline_entry_total_messages_matches(session in session_strategy()) { + let entry = to_timeline_entry(&session); + prop_assert_eq!(entry.total_messages, session.messages.len()); + } + + /// Property: `unfinished` is `false` when the session has no + /// messages (empty sessions aren't "in-progress"). + #[test] + fn to_timeline_entry_unfinished_false_for_empty( + (id, corpus) in ( + prop::string::string_regex("[a-zA-Z0-9_-]{1,8}").expect("valid regex"), + prop::sample::select(vec![Corpus::Forge, Corpus::ClaudeCode]), + ) + ) { + let session = Session::new(id, corpus); + let entry = to_timeline_entry(&session); + prop_assert!(!entry.summary.unfinished); + } + + /// Property: `unfinished` is `false` when the last message's + /// content (case-insensitive) contains one of the documented + /// "done" phrases — "looks good", "approved", "ship it", + /// "all good", "thanks", "done". + #[test] + fn to_timeline_entry_unfinished_false_for_done_phrase(phrase in prop::sample::select(vec![ + "looks good", "approved", "ship it", "all good", "thanks", "done", + "Looks Good", "APPROVED", "Ship It", "All Good", "THANKS", "DONE", + ])) { + // Build a session whose last message content == phrase. + let mut session = Session::new("sess-1", Corpus::Forge); + let mut msg = Message::new(Role::Assistant, phrase.to_owned()); + msg.ts_ms = Some(0); + session.messages.push(msg); + let entry = to_timeline_entry(&session); + prop_assert!(!entry.summary.unfinished, "phrase {phrase:?} should mark session as finished"); + } + + /// Property: `unfinished` is `true` when the session has + /// messages and the last message content does NOT contain any + /// done-phrase substring (case-insensitive). + #[test] + fn to_timeline_entry_unfinished_true_for_non_done_last( + content in prop::string::string_regex("[A-Za-z0-9 ]{3,40}").expect("valid regex"), + ) { + // Filter out any content that happens to match a done phrase. + let lower = content.to_lowercase(); + let matches_done = ["looks good", "approved", "ship it", "all good", "thanks", "done"] + .iter().any(|p| lower.contains(p)); + prop_assume!(!matches_done); + prop_assume!(!content.is_empty()); + + let mut session = Session::new("sess-1", Corpus::Forge); + let mut msg = Message::new(Role::User, content.clone()); + msg.ts_ms = Some(0); + session.messages.push(msg); + let entry = to_timeline_entry(&session); + prop_assert!( + entry.summary.unfinished, + "session with non-done last message {content:?} should be unfinished", + ); + } + + /// Property: `to_timeline_entry` is deterministic — applying it + /// twice to the same session yields the same entry. + #[test] + fn to_timeline_entry_is_deterministic(session in session_strategy()) { + let a = to_timeline_entry(&session); + let b = to_timeline_entry(&session); + prop_assert_eq!(a, b); + } +} + +// ── history_tab::all_timeline_entries ─────────────────────────────────────── + +proptest! { + /// Property: output length equals input length. + #[test] + fn all_timeline_entries_length_matches( + sessions in prop::collection::vec(session_strategy(), 0..6), + ) { + let entries = all_timeline_entries(&sessions); + prop_assert_eq!(entries.len(), sessions.len()); + } + + /// Property: the output is sorted by `total_messages` descending + /// (newest-first by message count, per the documented comment). + /// Tied entries remain in stable-sort input order. + #[test] + fn all_timeline_entries_sorted_by_message_count_desc( + sessions in prop::collection::vec(session_strategy(), 1..8), + ) { + let entries = all_timeline_entries(&sessions); + for win in entries.windows(2) { + prop_assert!( + win[0].total_messages >= win[1].total_messages, + "entry {} ({} msgs) should sort before entry {} ({} msgs)", + 0, + win[0].total_messages, + 1, + win[1].total_messages, + ); + } + } + + /// Property: every session's id appears in the output exactly once. + #[test] + fn all_timeline_entries_unique_ids( + sessions in prop::collection::vec(session_strategy(), 1..6), + ) { + let entries = all_timeline_entries(&sessions); + let mut ids: Vec<_> = entries.iter().map(|e| e.summary.id.clone()).collect(); + ids.sort(); + let mut unique = ids.clone(); + unique.dedup(); + prop_assert_eq!(ids.len(), unique.len(), "duplicate ids in output: {:?}", ids); + } + + /// Property: `all_timeline_entries` is deterministic — applying + /// it twice to the same slice yields the same `Vec`. + #[test] + fn all_timeline_entries_is_deterministic( + sessions in prop::collection::vec(session_strategy(), 0..6), + ) { + let a = all_timeline_entries(&sessions); + let b = all_timeline_entries(&sessions); + prop_assert_eq!(a, b); + } +} diff --git a/docs/ops/TRACEABILITY.json b/docs/ops/TRACEABILITY.json index dd6dd0a1..a0c0dca0 100644 --- a/docs/ops/TRACEABILITY.json +++ b/docs/ops/TRACEABILITY.json @@ -312,6 +312,7 @@ "crates/sl-viewer/tests/properties_viewer_unfinished_tab.rs", "crates/sl-viewer/tests/properties_viewer_timeline.rs", "crates/sl-viewer/tests/properties_viewer_search_memory.rs", + "crates/sl-viewer/tests/properties_viewer_history.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 86ed93fe..50c4e55e 100644 --- a/docs/ops/WBS.md +++ b/docs/ops/WBS.md @@ -29,7 +29,7 @@ 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`; `crates/sl-viewer/tests/properties_viewer_search_memory.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_diff properties #434; viewer search/memory properties #435; full loom/shuttle unpaid | +| 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_search_memory.rs`; `crates/sl-viewer/tests/properties_viewer_history.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_diff properties #434; viewer search/memory properties #435; viewer history_tab properties #444; full loom/shuttle unpaid | ## audit-v38 waves