use super::{
LifecycleStore, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata, accept_memory,
archive_memory, clear_projection_cache, latest_state_by_scope, latest_state_by_state,
ledger_file_name, lifecycle_root_from_config, pending_review_entries, project_latest_state,
promote_memory_to_canonical, propose_ai_memory, read_events_for_record, read_projection,
read_projection_with_cache_hit, read_projection_with_source, record_manual_memory,
review_queue_for_scope, wakeup_ready_entries, wakeup_ready_for_scope,
};
use crate::domain::{MemoryLedgerAction, MemoryLifecycleState, MemoryScope, MemorySourceKind};
use std::fs;
use std::path::Path;
use tempfile::tempdir;
#[test]
fn lifecycle_store_should_append_and_read_entries() {
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let manual = record_manual_memory(
&store,
RecordMemoryRequest {
title: "简洁输出".to_string(),
summary: "用户偏好简洁回复".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:gui".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: Some("internal".to_string()),
metadata: TransitionMetadata {
actor: Some("long".to_string()),
reason: Some("manual capture".to_string()),
evidence_refs: vec!["obsidian://preferences".to_string()],
},
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let proposal = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先跑 smoke 再收口单测".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: Some("spool".to_string()),
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let entries = store.read_all().unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].record.state, MemoryLifecycleState::Accepted);
assert_eq!(entries[1].record.state, MemoryLifecycleState::Candidate);
assert_eq!(entries[0].source_kind, MemorySourceKind::Manual);
assert_eq!(entries[1].source_kind, MemorySourceKind::AiProposal);
assert_eq!(entries[0].action, MemoryLedgerAction::RecordManual);
assert_eq!(entries[1].action, MemoryLedgerAction::ProposeAi);
assert_eq!(entries[0].schema_version, "memory-ledger.v1");
assert_eq!(manual.scope_key, "long");
assert_eq!(proposal.scope_key, "long");
assert_eq!(proposal.record.project_id.as_deref(), Some("spool"));
assert_eq!(manual.record.sensitivity.as_deref(), Some("internal"));
assert_eq!(manual.metadata.actor.as_deref(), Some("long"));
assert_eq!(manual.metadata.reason.as_deref(), Some("manual capture"));
assert_eq!(manual.metadata.evidence_refs.len(), 1);
assert_ne!(manual.record_id, proposal.record_id);
}
#[test]
fn lifecycle_store_should_return_empty_when_ledger_missing() {
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
assert!(store.read_all().unwrap().is_empty());
}
#[test]
fn lifecycle_helpers_should_expose_default_paths() {
let root = lifecycle_root_from_config(Path::new("/tmp/config"));
assert_eq!(root, Path::new("/tmp/config/.spool"));
assert_eq!(ledger_file_name(), "memory-ledger.jsonl");
}
#[test]
fn lifecycle_transitions_should_append_events_and_project_latest_state() {
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let proposal = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先跑 smoke 再收口单测".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let accepted = accept_memory(&store, &proposal.record_id).unwrap();
let canonical = promote_memory_to_canonical(&store, &proposal.record_id).unwrap();
let archived = archive_memory(&store, &proposal.record_id).unwrap();
let events = read_events_for_record(&store, &proposal.record_id).unwrap();
assert_eq!(events.len(), 4);
assert_eq!(events[0].action, MemoryLedgerAction::ProposeAi);
assert_eq!(events[1].action, MemoryLedgerAction::Accept);
assert_eq!(events[2].action, MemoryLedgerAction::PromoteToCanonical);
assert_eq!(events[3].action, MemoryLedgerAction::Archive);
assert_eq!(accepted.record_id, proposal.record_id);
assert_eq!(canonical.record_id, proposal.record_id);
assert_eq!(archived.record_id, proposal.record_id);
let latest = project_latest_state(&store, &proposal.record_id)
.unwrap()
.unwrap();
assert_eq!(latest.state, MemoryLifecycleState::Archived);
}
#[test]
fn invalid_transition_should_not_append_event() {
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let accepted = record_manual_memory(
&store,
RecordMemoryRequest {
title: "简洁输出".to_string(),
summary: "用户偏好简洁回复".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:gui".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let result = accept_memory(&store, &accepted.record_id);
assert!(result.is_err());
assert_eq!(
read_events_for_record(&store, &accepted.record_id)
.unwrap()
.len(),
1
);
}
#[test]
fn lifecycle_projections_should_use_latest_state() {
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let manual = record_manual_memory(
&store,
RecordMemoryRequest {
title: "简洁输出".to_string(),
summary: "用户偏好简洁回复".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:gui".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let proposal = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先跑 smoke 再收口单测".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
assert_eq!(pending_review_entries(&store).unwrap().len(), 1);
assert_eq!(wakeup_ready_entries(&store).unwrap().len(), 1);
let _ = accept_memory(&store, &proposal.record_id).unwrap();
assert_eq!(pending_review_entries(&store).unwrap().len(), 0);
assert_eq!(wakeup_ready_entries(&store).unwrap().len(), 2);
let _ = archive_memory(&store, &manual.record_id).unwrap();
assert_eq!(wakeup_ready_entries(&store).unwrap().len(), 1);
}
#[test]
fn lifecycle_queries_should_filter_by_scope_and_state() {
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let _ = record_manual_memory(
&store,
RecordMemoryRequest {
title: "用户偏好".to_string(),
summary: "偏好简洁".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:gui".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let project = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "项目约束".to_string(),
summary: "保持本地优先".to_string(),
memory_type: "constraint".to_string(),
scope: MemoryScope::Project,
source_ref: "session:2".to_string(),
project_id: Some("spool".to_string()),
user_id: None,
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
assert_eq!(
latest_state_by_scope(&store, MemoryScope::User, "long")
.unwrap()
.len(),
1
);
assert_eq!(
latest_state_by_scope(&store, MemoryScope::Project, "spool")
.unwrap()
.len(),
1
);
assert_eq!(
latest_state_by_state(&store, MemoryLifecycleState::Candidate)
.unwrap()
.len(),
1
);
assert_eq!(
review_queue_for_scope(&store, MemoryScope::Project, "spool")
.unwrap()
.len(),
1
);
assert_eq!(
wakeup_ready_for_scope(&store, MemoryScope::Project, "spool")
.unwrap()
.len(),
0
);
let _ = accept_memory(&store, &project.record_id).unwrap();
assert_eq!(
review_queue_for_scope(&store, MemoryScope::Project, "spool")
.unwrap()
.len(),
0
);
assert_eq!(
wakeup_ready_for_scope(&store, MemoryScope::Project, "spool")
.unwrap()
.len(),
1
);
}
#[test]
fn in_memory_projection_should_reuse_single_snapshot_for_queries() {
clear_projection_cache();
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let proposal = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先跑 smoke 再收口单测".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let _ = accept_memory(&store, &proposal.record_id).unwrap();
let projection = read_projection(&store).unwrap();
assert_eq!(projection.latest_entries().len(), 1);
assert_eq!(
projection
.latest_by_record_id(&proposal.record_id)
.unwrap()
.record
.state,
MemoryLifecycleState::Accepted
);
assert_eq!(projection.pending_review().len(), 0);
assert_eq!(projection.wakeup_ready().len(), 1);
}
#[test]
fn projection_cache_should_reuse_projection_for_repeated_reads() {
clear_projection_cache();
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let _ = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先跑 smoke 再收口单测".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let (_first, first_hit) = read_projection_with_cache_hit(&store).unwrap();
let (_second, second_hit) = read_projection_with_cache_hit(&store).unwrap();
assert!(!first_hit);
assert!(second_hit);
}
#[test]
fn projection_cache_should_invalidate_after_append_and_stay_fresh() {
clear_projection_cache();
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let proposal = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先跑 smoke 再收口单测".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let (first, first_hit) = read_projection_with_cache_hit(&store).unwrap();
assert_eq!(
first
.latest_by_record_id(&proposal.record_id)
.unwrap()
.record
.state,
MemoryLifecycleState::Candidate
);
assert!(!first_hit);
let _ = accept_memory(&store, &proposal.record_id).unwrap();
let (second, second_hit) = read_projection_with_cache_hit(&store).unwrap();
assert_eq!(
second
.latest_by_record_id(&proposal.record_id)
.unwrap()
.record
.state,
MemoryLifecycleState::Accepted
);
assert!(!second_hit);
}
#[test]
fn persistent_projection_snapshot_should_be_used_after_memory_cache_clears() {
clear_projection_cache();
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let proposal = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先跑 smoke 再收口单测".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let (_first, first_source) = read_projection_with_source(&store).unwrap();
assert_eq!(first_source, "rebuilt");
assert!(store.projection_snapshot_path().exists());
clear_projection_cache();
let (second, second_source) = read_projection_with_source(&store).unwrap();
assert_eq!(second_source, "persistent");
assert_eq!(
second
.latest_by_record_id(&proposal.record_id)
.unwrap()
.record
.state,
MemoryLifecycleState::Candidate
);
}
#[test]
fn corrupted_persistent_projection_snapshot_should_fallback_to_rebuild() {
clear_projection_cache();
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let _ = propose_ai_memory(
&store,
ProposeMemoryRequest {
title: "测试偏好".to_string(),
summary: "先跑 smoke 再收口单测".to_string(),
memory_type: "workflow".to_string(),
scope: MemoryScope::User,
source_ref: "session:1".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let (_first, first_source) = read_projection_with_source(&store).unwrap();
assert_eq!(first_source, "rebuilt");
fs::write(store.projection_snapshot_path(), b"{not-json").unwrap();
clear_projection_cache();
let (_second, second_source) = read_projection_with_source(&store).unwrap();
assert_eq!(second_source, "rebuilt");
}
#[test]
fn lifecycle_store_should_deserialize_legacy_entries_without_metadata() {
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
fs::write(
store.ledger_path(),
r#"{"schema_version":"memory-ledger.v1","recorded_at":"unix:1","record_id":"legacy-1","scope_key":"long","action":"propose_ai","source_kind":"ai_proposal","record":{"title":"测试偏好","summary":"先 smoke 再收口","memory_type":"workflow","scope":"user","state":"candidate","origin":{"source_kind":"ai_proposal","source_ref":"session:1"},"project_id":null,"user_id":"long","sensitivity":null}}"#,
)
.unwrap();
let entries = store.read_all().unwrap();
assert_eq!(entries.len(), 1);
assert!(entries[0].metadata.actor.is_none());
assert!(entries[0].metadata.reason.is_none());
assert!(entries[0].metadata.evidence_refs.is_empty());
}
#[test]
fn read_all_should_skip_malformed_lines_without_dropping_valid_entries() {
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
let good = record_manual_memory(
&store,
RecordMemoryRequest {
title: "good".to_string(),
summary: "valid line".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:test".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
use std::io::Write;
let mut file = std::fs::OpenOptions::new()
.append(true)
.open(store.ledger_path())
.unwrap();
writeln!(file, "{{\"truncated\": ").unwrap();
writeln!(file, "this is not json at all").unwrap();
writeln!(file, "<<<<<<< HEAD").unwrap();
drop(file);
clear_projection_cache();
let recovered = record_manual_memory(
&store,
RecordMemoryRequest {
title: "recovered".to_string(),
summary: "after corruption".to_string(),
memory_type: "preference".to_string(),
scope: MemoryScope::User,
source_ref: "manual:test".to_string(),
project_id: None,
user_id: Some("long".to_string()),
sensitivity: None,
metadata: TransitionMetadata::default(),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
},
)
.unwrap();
let entries = store.read_all().unwrap();
assert_eq!(entries.len(), 2, "two valid entries must survive");
let ids: Vec<&str> = entries.iter().map(|e| e.record_id.as_str()).collect();
assert!(ids.contains(&good.record_id.as_str()));
assert!(ids.contains(&recovered.record_id.as_str()));
}
#[test]
fn read_all_should_return_empty_when_every_line_is_malformed() {
let temp = tempdir().unwrap();
let store = LifecycleStore::new(temp.path());
fs::write(
store.ledger_path(),
"this is not json\n{ also broken \nyet another bad line\n",
)
.unwrap();
let entries = store.read_all().unwrap();
assert!(entries.is_empty());
}