use std::collections::{HashMap, HashSet};
use frame::model::{SectionKind, Task, TaskId, Token, Track};
use frame::ops::task_ops::{InsertPosition, add_subtask, add_task};
use frame::parse::parse_track;
use proptest::prelude::*;
use proptest::sample::{select, subsequence};
const PREFIX: &str = "EFF";
const POOL: [&str; 6] = ["a", "b", "c", "d", "e", "f"];
#[derive(Debug, Clone)]
enum AncestorOp {
Top(Option<Token>),
Sub(usize, Option<Token>),
}
#[derive(Debug, Clone)]
enum ActorOp {
Top,
Sub(usize),
}
#[derive(Debug, Clone)]
struct Plan {
ancestor_ops: Vec<AncestorOp>,
actors: Vec<(Option<Token>, Vec<ActorOp>)>,
}
fn arb_ns() -> impl Strategy<Value = Option<Token>> {
prop_oneof![
1 => Just(None),
4 => select(POOL.to_vec()).prop_map(|s| Some(Token::new(s).unwrap())),
]
}
fn arb_ancestor_op() -> impl Strategy<Value = AncestorOp> {
prop_oneof![
arb_ns().prop_map(AncestorOp::Top),
(0usize..1000, arb_ns()).prop_map(|(sel, ns)| AncestorOp::Sub(sel, ns)),
]
}
fn arb_actor_op() -> impl Strategy<Value = ActorOp> {
prop_oneof![
2 => Just(ActorOp::Top),
3 => (0usize..1000).prop_map(ActorOp::Sub),
]
}
fn arb_plan() -> impl Strategy<Value = Plan> {
(2usize..=5usize)
.prop_flat_map(|k| {
(
subsequence(POOL.to_vec(), k..=k),
any::<bool>(),
prop::collection::vec(arb_ancestor_op(), 0..=8),
prop::collection::vec(prop::collection::vec(arb_actor_op(), 0..=10), k..=k),
)
})
.prop_map(|(letters, use_null, ancestor_ops, actor_ops)| {
let mut tokens: Vec<Option<Token>> = letters
.into_iter()
.map(|s| Some(Token::new(s).unwrap()))
.collect();
if use_null {
tokens[0] = None;
}
let actors = tokens.into_iter().zip(actor_ops).collect();
Plan {
ancestor_ops,
actors,
}
})
}
#[derive(Debug, Clone)]
struct MintRecord {
id: String,
parent: Option<String>,
}
struct ActorResult {
token: Option<Token>,
track: Track,
minted: Vec<MintRecord>,
}
fn base_track() -> Track {
parse_track("# Sim\n\n## Backlog\n\n## Done\n")
}
fn collect_task_ids(task: &Task, out: &mut Vec<String>) {
if let Some(id) = &task.id {
out.push(id.to_string());
}
for sub in &task.subtasks {
collect_task_ids(sub, out);
}
}
fn collect_ids(track: &Track) -> Vec<String> {
let mut out = Vec::new();
for kind in [SectionKind::Backlog, SectionKind::Parked, SectionKind::Done] {
for task in track.section_tasks(kind) {
collect_task_ids(task, &mut out);
}
}
out
}
fn collect_parents(task: &Task, out: &mut Vec<(String, usize)>) {
if task.depth < 2
&& let Some(id) = &task.id
{
out.push((id.to_string(), task.depth));
}
for sub in &task.subtasks {
collect_parents(sub, out);
}
}
fn addable_parents(track: &Track) -> Vec<(String, usize)> {
let mut out = Vec::new();
for kind in [SectionKind::Backlog, SectionKind::Parked, SectionKind::Done] {
for task in track.section_tasks(kind) {
collect_parents(task, &mut out);
}
}
out
}
fn build_ancestor(ops: &[AncestorOp]) -> Track {
let mut track = base_track();
for op in ops {
match op {
AncestorOp::Top(ns) => {
add_task(
&mut track,
"task".into(),
InsertPosition::Bottom,
PREFIX,
ns.as_ref(),
)
.expect("ancestor add_task");
}
AncestorOp::Sub(sel, ns) => {
let parents = addable_parents(&track);
if parents.is_empty() {
add_task(
&mut track,
"task".into(),
InsertPosition::Bottom,
PREFIX,
ns.as_ref(),
)
.expect("ancestor add_task (sub fallback)");
} else {
let (pid, _) = &parents[sel % parents.len()];
add_subtask(&mut track, pid, "task".into(), ns.as_ref())
.expect("ancestor add_subtask");
}
}
}
}
track
}
fn apply_actor(ancestor: &Track, token: &Option<Token>, ops: &[ActorOp]) -> ActorResult {
let mut track = ancestor.clone();
let mut addable = addable_parents(&track);
let mut minted = Vec::new();
for op in ops {
match op {
ActorOp::Top => {
let id = add_task(
&mut track,
"task".into(),
InsertPosition::Bottom,
PREFIX,
token.as_ref(),
)
.expect("actor add_task");
addable.push((id.clone(), 0));
minted.push(MintRecord { id, parent: None });
}
ActorOp::Sub(sel) => {
if addable.is_empty() {
let id = add_task(
&mut track,
"task".into(),
InsertPosition::Bottom,
PREFIX,
token.as_ref(),
)
.expect("actor add_task (sub fallback)");
addable.push((id.clone(), 0));
minted.push(MintRecord { id, parent: None });
} else {
let (pid, pdepth) = addable[sel % addable.len()].clone();
let id = add_subtask(&mut track, &pid, "task".into(), token.as_ref())
.expect("actor add_subtask");
if pdepth + 1 < 2 {
addable.push((id.clone(), pdepth + 1));
}
minted.push(MintRecord {
id,
parent: Some(pid),
});
}
}
}
}
ActorResult {
token: token.clone(),
track,
minted,
}
}
fn graft_children(merged: &mut Task, ancestor: &Task, actor: &Task) {
for actor_child in &actor.subtasks {
let cid = actor_child.id.as_deref();
match ancestor.subtasks.iter().find(|t| t.id.as_deref() == cid) {
Some(anc_child) => {
if let Some(merged_child) =
merged.subtasks.iter_mut().find(|t| t.id.as_deref() == cid)
{
graft_children(merged_child, anc_child, actor_child);
}
}
None => merged.subtasks.push(actor_child.clone()),
}
}
}
fn merge(ancestor: &Track, actors: &[ActorResult]) -> Track {
let mut merged = ancestor.clone();
for actor in actors {
for kind in [SectionKind::Backlog, SectionKind::Parked, SectionKind::Done] {
let actor_tasks: Vec<Task> = actor.track.section_tasks(kind).to_vec();
let anc_tasks: Vec<Task> = ancestor.section_tasks(kind).to_vec();
let Some(merged_tasks) = merged.section_tasks_mut(kind) else {
continue;
};
for at in &actor_tasks {
let aid = at.id.as_deref();
match anc_tasks.iter().find(|t| t.id.as_deref() == aid) {
Some(anc) => {
if let Some(m) = merged_tasks.iter_mut().find(|t| t.id.as_deref() == aid) {
graft_children(m, anc, at);
}
}
None => merged_tasks.push(at.clone()),
}
}
}
}
merged
}
fn ancestor_top_base(ancestor: &Track, token: Option<&Token>) -> u32 {
let mut max = 0;
for kind in [SectionKind::Backlog, SectionKind::Parked, SectionKind::Done] {
let mut stack: Vec<&Task> = ancestor.section_tasks(kind).iter().collect();
while let Some(t) = stack.pop() {
if let Some(id) = &t.id
&& let Some(n) = id.top_level_number(PREFIX, token)
{
max = max.max(n);
}
stack.extend(t.subtasks.iter());
}
}
max
}
fn ancestor_child_base(ancestor: &Track, parent: &str, token: Option<&Token>) -> u32 {
let parent_id = TaskId::parse(parent);
let mut max = 0;
for kind in [SectionKind::Backlog, SectionKind::Parked, SectionKind::Done] {
let mut stack: Vec<&Task> = ancestor.section_tasks(kind).iter().collect();
while let Some(t) = stack.pop() {
if let Some(id) = &t.id
&& let Some(n) = id.child_number_of(&parent_id, token)
{
max = max.max(n);
}
stack.extend(t.subtasks.iter());
}
}
max
}
fn check(plan: Plan) -> Result<(), TestCaseError> {
let ancestor = build_ancestor(&plan.ancestor_ops);
let actors: Vec<ActorResult> = plan
.actors
.iter()
.map(|(token, ops)| apply_actor(&ancestor, token, ops))
.collect();
prop_assert!(actors.len() >= 2, "expected K >= 2 actors");
let namespaces: HashSet<Option<String>> = actors
.iter()
.map(|a| a.token.as_ref().map(|t| t.as_str().to_string()))
.collect();
prop_assert_eq!(
namespaces.len(),
actors.len(),
"actor namespaces must be pairwise distinct"
);
let ancestor_ids: HashSet<String> = collect_ids(&ancestor).into_iter().collect();
for actor in &actors {
let additions: HashSet<String> = collect_ids(&actor.track)
.into_iter()
.filter(|id| !ancestor_ids.contains(id))
.collect();
let recorded: HashSet<String> = actor.minted.iter().map(|m| m.id.clone()).collect();
prop_assert_eq!(
additions,
recorded,
"recorded mints must equal the actor's track additions"
);
}
let merged = merge(&ancestor, &actors);
let merged_ids = collect_ids(&merged);
let unique: HashSet<&String> = merged_ids.iter().collect();
if unique.len() != merged_ids.len() {
let mut seen = HashSet::new();
let dups: Vec<&String> = merged_ids.iter().filter(|id| !seen.insert(*id)).collect();
return Err(TestCaseError::fail(format!(
"duplicate id(s) in merged result: {dups:?}"
)));
}
let mut expected: HashSet<String> = ancestor_ids.clone();
for actor in &actors {
for m in &actor.minted {
expected.insert(m.id.clone());
}
}
prop_assert_eq!(
&unique
.iter()
.map(|s| (*s).clone())
.collect::<HashSet<String>>(),
&expected,
"merged ids must be ancestor ids ∪ minted ids"
);
let mut groups: HashMap<(Option<String>, Option<String>), Vec<u32>> = HashMap::new();
for actor in &actors {
let token = actor.token.as_ref();
let ns_key = actor.token.as_ref().map(|t| t.as_str().to_string());
for m in &actor.minted {
let id = TaskId::parse(&m.id);
prop_assert_eq!(
id.leaf_token(),
token,
"namespace leak: {} not in actor namespace",
m.id
);
let number = match &m.parent {
None => {
let n = id.top_level_number(PREFIX, token);
prop_assert!(
n.is_some(),
"top-level id not well-formed / out of namespace: {}",
m.id
);
n.unwrap()
}
Some(parent) => {
let parent_id = TaskId::parse(parent);
let n = id.child_number_of(&parent_id, token);
prop_assert!(
n.is_some(),
"child id not well-formed / out of namespace: {} under {}",
m.id,
parent
);
n.unwrap()
}
};
prop_assert!(number >= 1, "minted number must be positive: {}", m.id);
groups
.entry((m.parent.clone(), ns_key.clone()))
.or_default()
.push(number);
}
}
for ((parent, ns_key), numbers) in &groups {
let token = ns_key.as_ref().map(|s| Token::new(s.as_str()).unwrap());
let base = match parent {
None => ancestor_top_base(&ancestor, token.as_ref()),
Some(p) => ancestor_child_base(&ancestor, p, token.as_ref()),
};
let mut sorted = numbers.clone();
sorted.sort_unstable();
let expected: Vec<u32> = (base + 1..=base + numbers.len() as u32).collect();
prop_assert_eq!(
sorted,
expected,
"non-dense sequence for parent={:?} namespace={:?}",
parent,
ns_key
);
}
Ok(())
}
proptest! {
#[test]
fn merged_minting_preserves_invariants(plan in arb_plan()) {
check(plan)?;
}
}
#[test]
fn same_namespace_isolated_views_collide() {
let ancestor = base_track();
let ops = vec![ActorOp::Top, ActorOp::Top];
let a = apply_actor(&ancestor, &None, &ops);
let b = apply_actor(&ancestor, &None, &ops);
let a_ids: HashSet<&String> = a.minted.iter().map(|m| &m.id).collect();
let collision = b.minted.iter().any(|m| a_ids.contains(&m.id));
assert!(
collision,
"two isolated actors in the same namespace must collide — \
this is the bug the per-actor namespace prevents"
);
}