-
Notifications
You must be signed in to change notification settings - Fork 0
test(viewer): history_tab proptest surface (WBS-6.2 #444) #445
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String>` 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<Value = Message> { | ||
| ( | ||
| prop::sample::select(vec![ | ||
| Role::User, | ||
| Role::Assistant, | ||
| Role::Subagent, | ||
| Role::Tool, | ||
| Role::System, | ||
| ]), | ||
| prop::string::string_regex("[ -~]{0,120}").expect("valid regex"), | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
echo "== repo files of interest =="
git ls-files | grep -E '(^|/)(properties_viewer_history\.rs|history_tab\.rs|*.rs)$' | grep -E 'crates/sl-viewer/(tests/properties_viewer_history\.rs|src/history_tab\.rs)' || true
echo
echo "== outline history_tab.rs =="
ast-grep outline crates/sl-viewer/src/history_tab.rs --view expanded || true
echo
echo "== relevant tests =="
wc -l crates/sl-viewer/tests/properties_viewer_history.rs
sed -n '1,120p' crates/sl-viewer/tests/properties_viewer_history.rs
echo
echo "== relevant history_tab.rs =="
wc -l crates/sl-viewer/src/history_tab.rs
sed -n '1,240p' crates/sl-viewer/src/history_tab.rs
echo
echo "== search message_strategy and content slicing =="
rg -n "message_strategy|\\.content\\[\\.\\.\\.97\\]|\\.content\\[\\.\\.\\.|string_regex|to_json|SessionMessage|properties" crates/sl-viewer -SRepository: KooshaPari/SessionLedger Length of output: 19237 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
s = "\u00e9" * 60
byte_len = len(s.encode("utf-8"))
print(f"bytes={byte_len}")
for cut in [96, 97, 98]:
try:
print(f"slice ...{cut}: OK len={len(s[:cut])}")
except Exception as e:
print(f"slice ...{cut}: ERROR {type(e).__name__}: {e}")
PYRepository: KooshaPari/SessionLedger Length of output: 241 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
s = "é" * 60
byte_len = len(s.encode("utf-8"))
print(f"bytes={byte_len}")
for cut in range(90, 106):
try:
print(f"slice ...{cut}: OK len={len(s[:cut])}")
except Exception as e:
print(f"slice ...{cut}: ERROR {type(e).__name__}: {e}")
PYRepository: KooshaPari/SessionLedger Length of output: 546 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
import unicodedata
s = "".join(chr(ord("é") + i % 8) for i in range(80))
byte_len = len(s.encode("utf-8"))
print(f"bytes={byte_len}")
print(f"{s[45:55]!r}")
print([((ord(c), unicodedata.name(c, "?"))) for c in s[45:55]])
for cut in range(80, 106):
try:
print(f"slice ...{cut}: OK len={len(s[:cut])}")
except Exception as e:
print(f"slice ...{cut}: ERROR {type(e).__name__}: {e}")
# Find slices that do not end on codepoint boundaries while content is longer than 100 bytes.
bad = []
for cut in range(80, 106):
prefix = s[:cut]
if prefix.isascii() is False and (len(s.encode("utf-8")) > 100 and not prefix.isascii() or cut > len(s)):
pass
print("bad slices near 100-byte check:", [cut for cut in range(80, 106) if 'slice ...' in None])
PYRepository: KooshaPari/SessionLedger Length of output: 1398 🏁 Script executed: #!/bin/bash
set -euo pipefail
python3 - <<'PY'
import random
random.seed(1)
samples = []
for _ in range(10000):
n = random.randint(1, 100)
s = "".join(chr(0x80 + random.randint(0, 0x7F)) for _ in range(n))
if len(s.encode("utf-8")) > 100:
samples.append(s)
if len(samples) == 10:
break
print(len(samples), "samples with over-100-byte UTF-8 messages")
fails = []
for s in samples:
if len(s.encode("utf-8")) >= 100:
for cut in range(80, 106):
try:
s[:cut]
except UnicodeDecodeError as e:
fails.append((s.encode("utf-8").hex(), cut, str(e)))
print("finds:", len(fails))
for f in fails[:5]:
print(f)
PYRepository: KooshaPari/SessionLedger Length of output: 216 🌐 Web query:
💡 Result: In Rust, string slicing using the Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
rustc --version || true
tmp="$(mktemp -d)"
cat > "$tmp/slice_boundary.rs" <<'RS'
fn main() {
let s = "é".repeat(50);
println!("bytes={}", s.len());
for n in 94..99 {
std::panic::set_hook(Box::new(|_| {}));
let r = std::panic::catch_unwind(|| {
let trimmed = &s[..n];
println!("safe_n={n} trimmed_byte_len={}", trimmed.len());
});
if r.is_err() {
println!("panic_at_byte={n}");
}
}
}
RS
rustc "$tmp/slice_boundary.rs" -o "$tmp/bin"
"$tmp/bin"Repository: KooshaPari/SessionLedger Length of output: 505 Exercise Unicode truncation inputs.
🤖 Prompt for AI Agents |
||
| 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<Value = Session> { | ||
| ( | ||
| // 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<String>`. | ||
| 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<String>`. | ||
| 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<String>` 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); | ||
|
Comment on lines
+259
to
+267
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Define the duplicate-ID contract consistently. The generator permits duplicate input IDs.
📍 Affects 2 files
🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| /// Property: `all_timeline_entries` is deterministic — applying | ||
| /// it twice to the same slice yields the same `Vec<TimelineEntry>`. | ||
| #[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); | ||
| } | ||
| } | ||
|
Comment on lines
+33
to
+280
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win Fix the rustfmt gate. The supplied Trunk check reports a rustfmt failure for this new Rust file. Run 🤖 Prompt for AI AgentsSources: Coding guidelines, Linters/SAST tools |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Suggestion: The message strategy generates only printable ASCII content with a maximum length of 120 bytes, so it never exercises multibyte UTF-8 or longer messages. Consequently, the property suite cannot detect the production slicing panic for a multibyte message whose byte length exceeds 100 but whose byte 97 is not a character boundary. Generate arbitrary Unicode content and lengths beyond the truncation threshold. [possible bug]
Severity Level: Major⚠️
(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖