use std::collections::BTreeMap;
use chrono::{TimeZone, Utc};
use sley::{ObjectFormat as GitObjectFormat, ObjectId as GitObjectId};
use super::{
CompactError, decode_blob_frame, decode_state_frame, decode_tree_frame, encode_blob_frame,
encode_state_frame, encode_tree_frame, extract_state, extract_tree, is_state_frame,
state::{STATE_MAGIC, STATE_MAGIC_V1, encode_state_frame_hcs1},
};
use crate::object::{
Agent, Attribution, ChangeId, ChangeLineage, ChangeLineageKind, ContentHash, Principal,
SpoolId, State, StateId, Status, Tree, TreeEntry, Verification,
};
#[test]
fn tree_frame_round_trips_every_entry_kind_and_native_bytes() {
let content = ContentHash::from_bytes([1; 32]);
let nested = ContentHash::from_bytes([2; 32]);
let spool_state = StateId::from_bytes([3; 32]);
let sha1 = GitObjectId::from_raw(GitObjectFormat::Sha1, &[4; 20]).unwrap();
let sha256 = GitObjectId::from_raw(GitObjectFormat::Sha256, &[5; 32]).unwrap();
let trees = vec![
Tree::from_entries(vec![
TreeEntry::file("a", content, false).unwrap(),
TreeEntry::file("b", content, true).unwrap(),
TreeEntry::directory("c", nested).unwrap(),
TreeEntry::symlink("d", content).unwrap(),
TreeEntry::gitlink("e", sha1).unwrap(),
TreeEntry::gitlink("f", sha256).unwrap(),
TreeEntry::spoollink("g", SpoolId::parse("acme/child").unwrap(), spool_state).unwrap(),
]),
Tree::from_entries(vec![TreeEntry::file("same", content, false).unwrap()]),
];
let encoded = encode_tree_frame(&trees).unwrap();
let decoded = decode_tree_frame(&encoded).unwrap();
assert_eq!(decoded, trees);
for (actual, expected) in decoded.iter().zip(&trees) {
assert_eq!(actual.hash(), expected.hash());
assert_eq!(
rmp_serde::to_vec_named(actual).unwrap(),
rmp_serde::to_vec_named(expected).unwrap()
);
}
}
#[test]
fn state_frame_round_trips_every_fidelity_field_and_recomputes_id() {
let principal = Principal::new("Author", "author@example.com");
let committer = Principal::new("Committer", "committer@example.com");
let agent = Agent::new("openai", "gpt-test")
.with_session("session", "segment")
.with_policy("policy")
.with_thought_level("high")
.with_parent("agent-1");
let mut custom = BTreeMap::new();
custom.insert(
"nested".to_string(),
serde_json::json!({"bytes": [1, 2, 3]}),
);
let mut first = State::new(
ContentHash::from_bytes([10; 32]),
vec![StateId::from_bytes([11; 32])],
Attribution::with_agent(principal, agent),
);
first.change_id = ChangeId::from_bytes([12; 16]);
first.intent = Some("intent".to_string());
first.confidence = Some(f32::from_bits(0x7fc0_0123));
first.created_at = Utc.timestamp_opt(1_700_000_000, 123_456_789).unwrap();
first.authored_at = Some(Utc.timestamp_opt(1_699_999_900, 987_654_321).unwrap());
first.verification = Some(Verification {
tests_passed: Some(false),
tests_failed: Some(7),
coverage_pct: Some(92.5),
coverage_delta: Some(f32::from_bits(0xffc0_0456)),
lint_warnings: Some(3),
custom,
});
first.status = Status::Published;
first.provenance = Some(ContentHash::from_bytes([13; 32]));
first.committer = Some(committer);
first.authored_tz_offset = -7 * 3600;
first.committer_tz_offset = 12 * 3600 + 45 * 60;
first.raw_message = Some(b"raw\0message\xff".to_vec());
first.git_lossy = true;
first.extra_headers = vec![
(b"x-custom".to_vec(), b"first\xff".to_vec()),
(b"gpgsig".to_vec(), b"signed\n continuation".to_vec()),
];
first.lineage = vec![ChangeLineage {
kind: ChangeLineageKind::GitProjection,
source_change: ChangeId::from_bytes([14; 16]),
source_state: StateId::from_bytes([15; 32]),
}];
first.state_id = first.id();
let mut second = State::new(
ContentHash::from_bytes([20; 32]),
vec![first.state_id],
Attribution::human(Principal::new("Author", "author@example.com")),
);
second.created_at = Utc.timestamp_opt(1_700_000_001, 0).unwrap();
second.state_id = second.id();
let states = vec![first, second];
let encoded = encode_state_frame(&states).unwrap();
let decoded = decode_state_frame(&encoded).unwrap();
for (actual, expected) in decoded.iter().zip(&states) {
assert_eq!(actual.id(), expected.id());
assert_eq!(
rmp_serde::to_vec_named(actual).unwrap(),
rmp_serde::to_vec_named(expected).unwrap()
);
}
assert_eq!(
decoded[0]
.attribution
.agent
.as_ref()
.unwrap()
.thought_level
.as_deref(),
Some("high")
);
assert_eq!(
decoded[0]
.attribution
.agent
.as_ref()
.unwrap()
.parent
.as_deref(),
Some("agent-1")
);
assert_eq!(decoded[0].confidence.unwrap().to_bits(), 0x7fc0_0123);
assert_eq!(
decoded[0]
.verification
.as_ref()
.unwrap()
.coverage_delta
.unwrap()
.to_bits(),
0xffc0_0456
);
}
#[test]
fn compact_and_hash_include_thought_level_and_parent() {
let principal = Principal::new("Author", "author@example.com");
let created_at = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
let tree = ContentHash::from_bytes([70; 32]);
let mut without = State::new(
tree,
Vec::new(),
Attribution::with_agent(principal.clone(), Agent::new("anthropic", "opus")),
);
without.created_at = created_at;
without.state_id = without.id();
let mut with = State::new(
tree,
Vec::new(),
Attribution::with_agent(
principal,
Agent::new("anthropic", "opus")
.with_thought_level("high")
.with_parent("agent-1"),
),
);
with.created_at = created_at;
with.state_id = with.id();
assert_ne!(
without.id(),
with.id(),
"thought_level and parent must participate in the state hash"
);
let mut thought_only = State::new(
tree,
Vec::new(),
Attribution::with_agent(
Principal::new("Author", "author@example.com"),
Agent::new("anthropic", "opus").with_thought_level("high"),
),
);
thought_only.created_at = created_at;
thought_only.state_id = thought_only.id();
let mut parent_only = State::new(
tree,
Vec::new(),
Attribution::with_agent(
Principal::new("Author", "author@example.com"),
Agent::new("anthropic", "opus").with_parent("agent-1"),
),
);
parent_only.created_at = created_at;
parent_only.state_id = parent_only.id();
assert_ne!(
without.id(),
thought_only.id(),
"changing thought_level must change the state id"
);
assert_ne!(
without.id(),
parent_only.id(),
"changing parent must change the state id"
);
assert_ne!(
thought_only.id(),
parent_only.id(),
"thought_level and parent must not be interchangeable in the hash"
);
let encoded = encode_state_frame(&[with.clone()]).unwrap();
assert!(
encoded.starts_with(STATE_MAGIC),
"current encode must use the HCS2 cursor dictionary"
);
let decoded = decode_state_frame(&encoded).unwrap();
let agent = decoded[0].attribution.agent.as_ref().unwrap();
assert_eq!(agent.thought_level.as_deref(), Some("high"));
assert_eq!(agent.parent.as_deref(), Some("agent-1"));
assert_eq!(decoded[0].id(), with.id());
}
#[test]
fn hcs1_agent_frames_keep_the_five_field_dictionary() {
let created_at = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
let mut state = State::new(
ContentHash::from_bytes([70; 32]),
Vec::new(),
Attribution::with_agent(
Principal::new("Author", "author@example.com"),
Agent::new("anthropic", "opus"),
),
);
state.created_at = created_at;
state.state_id = state.pre_cursor_id();
let encoded = encode_state_frame_hcs1(&[state.clone()]).unwrap();
assert!(
encoded.starts_with(STATE_MAGIC_V1),
"format-4 packs keep HCS1 magic, got {:x?}",
&encoded[..4.min(encoded.len())]
);
assert!(is_state_frame(&encoded));
let decoded = decode_state_frame(&encoded).unwrap();
let agent = decoded[0].attribution.agent.as_ref().unwrap();
assert_eq!(agent.provider, "anthropic");
assert_eq!(agent.model, "opus");
assert!(agent.thought_level.is_none());
assert!(agent.parent.is_none());
assert!(
decoded[0].accepts_stored_id(&state.pre_cursor_id()),
"HCS1 decode must not desync into the seven-field dictionary"
);
let extracted = extract_state(&encoded, state.pre_cursor_id()).unwrap();
assert_eq!(extracted.state_id, state.pre_cursor_id());
}
#[test]
fn hcs2_repack_accepts_format4_agent_stored_id() {
let created_at = Utc.timestamp_opt(1_700_000_000, 0).unwrap();
let mut state = State::new(
ContentHash::from_bytes([71; 32]),
Vec::new(),
Attribution::with_agent(
Principal::new("Author", "author@example.com"),
Agent::new("anthropic", "opus"),
),
);
state.created_at = created_at;
let stored_id = state.pre_cursor_id();
state.state_id = stored_id;
let encoded = encode_state_frame(&[state.clone()]).unwrap();
assert!(
encoded.starts_with(STATE_MAGIC),
"current encode must stay HCS2, got {:x?}",
&encoded[..4.min(encoded.len())]
);
let decoded = decode_state_frame(&encoded).unwrap();
assert!(
decoded[0].accepts_stored_id(&stored_id),
"HCS2 decode must keep the accepted format-4 stored id"
);
let extracted = extract_state(&encoded, stored_id).unwrap();
assert_eq!(extracted.state_id, stored_id);
}
#[test]
fn corrupt_frame_byte_rejects_every_contained_object() {
let hash = ContentHash::from_bytes([21; 32]);
let trees = vec![
Tree::from_entries(vec![TreeEntry::file("a", hash, false).unwrap()]),
Tree::from_entries(vec![TreeEntry::file("b", hash, true).unwrap()]),
];
let mut encoded = encode_tree_frame(&trees).unwrap();
let corrupt_at = encoded.len() / 2;
encoded[corrupt_at] ^= 0x01;
for _ in &trees {
let error = decode_tree_frame(&encoded).unwrap_err();
assert!(error.to_string().contains("checksum mismatch"));
}
}
#[test]
fn extract_matches_decode_all_canonical_bytes() {
let content = ContentHash::from_bytes([31; 32]);
let trees = vec![
Tree::from_entries(vec![TreeEntry::file("first", content, false).unwrap()]),
Tree::from_entries(vec![TreeEntry::file("second", content, true).unwrap()]),
Tree::from_entries(vec![TreeEntry::directory("nested", content).unwrap()]),
];
let encoded = encode_tree_frame(&trees).unwrap();
let decoded = decode_tree_frame(&encoded).unwrap();
for (expected, decoded) in trees.iter().zip(&decoded) {
let extracted = extract_tree(&encoded, expected.hash()).unwrap();
assert_eq!(extracted, *decoded);
assert_eq!(extracted.hash(), expected.hash());
assert_eq!(
rmp_serde::to_vec_named(&extracted).unwrap(),
rmp_serde::to_vec_named(expected).unwrap()
);
}
}
#[test]
fn extract_state_matches_decode_all_canonical_bytes() {
let mut first = State::new(
ContentHash::from_bytes([41; 32]),
Vec::new(),
Attribution::human(Principal::new("Author", "author@example.com")),
);
first.intent = Some("first".to_string());
first.state_id = first.id();
let mut second = State::new(
ContentHash::from_bytes([42; 32]),
vec![first.state_id],
Attribution::human(Principal::new("Author", "author@example.com")),
);
second.intent = Some("second".to_string());
second.state_id = second.id();
let states = vec![first, second];
let encoded = encode_state_frame(&states).unwrap();
let decoded = decode_state_frame(&encoded).unwrap();
for (expected, decoded) in states.iter().zip(&decoded) {
let extracted = extract_state(&encoded, expected.id()).unwrap();
assert_eq!(extracted.id(), decoded.id());
assert_eq!(
rmp_serde::to_vec_named(&extracted).unwrap(),
rmp_serde::to_vec_named(expected).unwrap()
);
}
}
#[test]
fn extract_rejects_a_forged_typed_hash() {
let content = ContentHash::from_bytes([51; 32]);
let tree = Tree::from_entries(vec![TreeEntry::file("only", content, false).unwrap()]);
let encoded = encode_tree_frame(std::slice::from_ref(&tree)).unwrap();
let forged = ContentHash::compute_typed("tree", b"not this tree");
let error = extract_tree(&encoded, forged).unwrap_err();
assert!(matches!(error, CompactError::Missing));
assert_ne!(forged, tree.hash());
}
#[test]
fn extract_rejects_every_object_after_one_corrupt_frame_byte() {
let hash = ContentHash::from_bytes([61; 32]);
let trees = vec![
Tree::from_entries(vec![TreeEntry::file("a", hash, false).unwrap()]),
Tree::from_entries(vec![TreeEntry::file("b", hash, true).unwrap()]),
];
let mut encoded = encode_tree_frame(&trees).unwrap();
let corrupt_at = encoded.len() / 2;
encoded[corrupt_at] ^= 0x01;
for tree in &trees {
let error = extract_tree(&encoded, tree.hash()).unwrap_err();
assert!(
error.to_string().contains("checksum mismatch"),
"corrupt frame must fail typed extraction, got {error}"
);
}
}
#[test]
fn blob_frame_round_trips_offsets_lengths_and_typed_hashes() {
let bodies = [b"newest body".as_slice(), b"older body".as_slice(), &[]];
let encoded = encode_blob_frame(&bodies).unwrap();
let decoded = decode_blob_frame(&encoded).unwrap();
assert_eq!(decoded.len(), bodies.len());
for ((hash, actual), expected) in decoded.iter().zip(bodies) {
assert_eq!(*actual, expected);
assert_eq!(*hash, ContentHash::compute_typed("blob", expected));
}
}
#[test]
fn corrupt_blob_frame_byte_rejects_every_contained_object() {
let bodies = [b"first".as_slice(), b"second".as_slice()];
let mut encoded = encode_blob_frame(&bodies).unwrap();
let corrupt_at = encoded.len() / 2;
encoded[corrupt_at] ^= 0x01;
for _ in bodies {
let error = decode_blob_frame(&encoded).unwrap_err();
assert!(error.to_string().contains("checksum mismatch"));
}
}