use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::time::Duration;
use proptest::prelude::*;
use frame::io::lock::FileLock;
use frame::io::project_io;
use frame::model::config::TrackConfig;
use frame::model::project::Project;
use frame::ops::ids::Mint;
use frame::ops::task_ops::{self, InsertPosition};
use frame::ops::{inbox_ops, recover};
use frame::tui::app::{App, Mode};
#[path = "support/tree_checks.rs"]
mod tree_checks;
#[path = "support/tui_steps.rs"]
mod tui_steps;
use tree_checks::{id_tally, present, unsettled};
use tui_steps::{ACTIONS, ActionKind, Step, apply_step, fixture, flush_and_save, live_task_ids};
#[derive(Debug, Clone, Copy)]
enum Event {
Tui(Step),
CliBegin,
CliOp(CliOp),
CliCommit,
Watch,
Retry,
}
#[derive(Debug, Clone, Copy)]
enum CliOp {
AddTask { track: usize },
EditOwned { which: usize },
Capture,
TrackNew,
TrackArchive { which: usize },
}
fn arb_event() -> impl Strategy<Value = Event> {
prop_oneof![
8 => arb_tui_step().prop_map(Event::Tui),
3 => Just(Event::CliBegin),
4 => prop_oneof![
2 => (0usize..2).prop_map(|track| CliOp::AddTask { track }),
1 => (0usize..8).prop_map(|which| CliOp::EditOwned { which }),
1 => Just(CliOp::Capture),
2 => Just(CliOp::TrackNew),
2 => (0usize..4).prop_map(|which| CliOp::TrackArchive { which }),
].prop_map(Event::CliOp),
3 => Just(Event::CliCommit),
5 => Just(Event::Watch),
1 => Just(Event::Retry),
]
}
fn arb_tui_step() -> impl Strategy<Value = Step> {
let actions: Vec<ActionKind> = ACTIONS
.iter()
.copied()
.filter(|a| *a != ActionKind::TrackDelete)
.collect();
(0..actions.len(), 0usize..64, 0u8..26).prop_map(move |(a, target, text)| Step {
action: actions[a],
target,
text,
})
}
struct Window {
lock: FileLock,
project: Project,
doc: toml_edit::DocumentMut,
dirty_config: bool,
dirty_tracks: BTreeSet<String>,
dirty_inbox: bool,
pending: Vec<Claim>,
retired: Vec<String>,
pending_tracks: Vec<TrackConfig>,
pending_archive_files: Vec<(String, String)>,
}
#[derive(Debug, Clone)]
struct Claim {
title: String,
task_id: Option<String>,
at_risk: bool,
}
struct TrackClaim {
committed: TrackConfig,
absorbed: TrackConfig,
superseded: bool,
}
impl TrackClaim {
fn new(row: TrackConfig) -> Self {
TrackClaim {
absorbed: row.clone(),
committed: row,
superseded: false,
}
}
fn expected(&self) -> &str {
&self.absorbed.state
}
}
struct Cli {
root: PathBuf,
frame_dir: PathBuf,
window: Option<Window>,
claims: Vec<Claim>,
owned_ids: Vec<String>,
tracks_claimed: BTreeMap<String, TrackClaim>,
committed: BTreeMap<String, Vec<String>>,
tui_edited: BTreeSet<String>,
retired_by_tui: Vec<String>,
seq: usize,
}
impl Cli {
fn new(root: &Path) -> Self {
Cli {
root: root.to_path_buf(),
frame_dir: root.join("frame"),
window: None,
claims: Vec::new(),
owned_ids: Vec::new(),
tracks_claimed: BTreeMap::new(),
committed: BTreeMap::new(),
tui_edited: BTreeSet::new(),
retired_by_tui: Vec::new(),
seq: 0,
}
}
fn next_title(&mut self) -> String {
self.seq += 1;
format!("cli task {}", self.seq)
}
fn begin(&mut self) {
if self.window.is_some() {
return;
}
let Ok(lock) = FileLock::acquire_default(&self.frame_dir) else {
return;
};
let Ok(mut project) = project_io::load_project(&self.root) else {
return;
};
if recover::recover_pending(&mut project).is_some() {
let Ok(reloaded) = project_io::load_project(&self.root) else {
return;
};
project = reloaded;
}
let Ok((_, doc)) = frame::io::config_io::read_config(&self.frame_dir) else {
return;
};
self.window = Some(Window {
lock,
project,
doc,
dirty_config: false,
dirty_tracks: BTreeSet::new(),
dirty_inbox: false,
pending: Vec::new(),
retired: Vec::new(),
pending_tracks: Vec::new(),
pending_archive_files: Vec::new(),
});
}
fn op(&mut self, op: CliOp) {
let title = match op {
CliOp::AddTask { .. } | CliOp::Capture => Some(self.next_title()),
CliOp::EditOwned { .. } => Some(self.next_title()),
CliOp::TrackNew | CliOp::TrackArchive { .. } => None,
};
let track_id = match op {
CliOp::TrackNew => {
self.seq += 1;
Some(format!("clitrack{}", self.seq))
}
_ => None,
};
let claimed: Vec<String> = self.tracks_claimed.keys().cloned().collect();
let owned = self.owned_ids.clone();
let Some(window) = self.window.as_mut() else {
return;
};
let frame_dir = window.project.frame_dir.clone();
match op {
CliOp::AddTask { track } => {
let title = title.unwrap();
let tracks = &window.project.config.tracks;
if tracks.is_empty() {
return;
}
let track_id = tracks[track % tracks.len()].id.clone();
let Some(prefix) = window.project.config.ids.prefixes.get(&track_id).cloned()
else {
return;
};
let Some(entry) = window
.project
.tracks
.iter_mut()
.find(|(id, _)| *id == track_id)
else {
return;
};
let mint = Mint::new(&frame_dir, &track_id, &prefix, None);
if let Ok(id) =
task_ops::add_task(&mut entry.1, title.clone(), InsertPosition::Bottom, mint)
{
window.dirty_tracks.insert(track_id);
window.pending.push(Claim {
title,
task_id: Some(id),
at_risk: false,
});
}
}
CliOp::EditOwned { which } => {
if owned.is_empty() {
return;
}
let id = owned[which % owned.len()].clone();
let title = title.unwrap();
for (track_id, track) in window.project.tracks.iter_mut() {
let Some(old) = task_ops::find_task_in_track(track, &id) else {
continue;
};
let old_title = old.title.clone();
if task_ops::edit_title(track, &id, title.clone()).is_ok() {
window.dirty_tracks.insert(track_id.clone());
window.pending.retain(|c| c.title != old_title);
window.retired.push(old_title);
window.pending.push(Claim {
title,
task_id: Some(id),
at_risk: false,
});
}
break;
}
}
CliOp::Capture => {
let title = title.unwrap();
let Some(inbox) = window.project.inbox.as_mut() else {
return;
};
inbox_ops::add_inbox_item(inbox, title.clone(), Vec::new(), None);
window.dirty_inbox = true;
window.pending.push(Claim {
title,
task_id: None,
at_risk: false,
});
}
CliOp::TrackNew => {
let track_id = track_id.unwrap();
let Ok(track) = frame::ops::track_ops::new_track(
&frame_dir,
&mut window.doc,
&mut window.project.config,
&track_id,
&format!("CLI Track {track_id}"),
) else {
return;
};
window.project.tracks.push((track_id.clone(), track));
window.dirty_config = true;
if let Some(row) = window
.project
.config
.tracks
.iter()
.find(|tc| tc.id == track_id)
{
window.pending_tracks.push(row.clone());
}
}
CliOp::TrackArchive { which } => {
let candidates: Vec<String> = window
.project
.config
.tracks
.iter()
.filter(|tc| {
tc.state == "active"
&& (claimed.contains(&tc.id)
|| window.pending_tracks.iter().any(|row| row.id == tc.id))
})
.map(|tc| tc.id.clone())
.collect();
if candidates.is_empty() {
return;
}
let track_id = candidates[which % candidates.len()].clone();
let Some(file) = window
.project
.config
.tracks
.iter()
.find(|tc| tc.id == track_id)
.map(|tc| tc.file.clone())
else {
return;
};
if window.dirty_tracks.contains(&track_id)
&& let Some((_, track)) =
window.project.tracks.iter().find(|(id, _)| id == &track_id)
&& project_io::save_track(&frame_dir, &file, track).is_ok()
{
window.dirty_tracks.remove(&track_id);
}
if frame::ops::track_ops::archive_track(
&mut window.doc,
&mut window.project.config,
&track_id,
)
.is_err()
{
return;
}
window.project.tracks.retain(|(id, _)| id != &track_id);
window.dirty_config = true;
if let Some(row) = window
.project
.config
.tracks
.iter()
.find(|tc| tc.id == track_id)
{
window.pending_tracks.push(row.clone());
}
window.pending_archive_files.push((track_id, file));
}
}
}
fn commit(&mut self) {
let Some(window) = self.window.take() else {
return;
};
let mut ok = true;
for track_id in &window.dirty_tracks {
let Some(file) = track_file(&window.project, track_id) else {
ok = false;
continue;
};
let Some((_, track)) = window.project.tracks.iter().find(|(id, _)| id == track_id)
else {
ok = false;
continue;
};
if project_io::save_track(&window.project.frame_dir, &file, track).is_err() {
ok = false;
}
}
if window.dirty_inbox
&& let Some(inbox) = window.project.inbox.as_ref()
&& project_io::save_inbox(&window.project.frame_dir, inbox).is_err()
{
ok = false;
}
if window.dirty_config
&& frame::io::config_io::write_config(&window.project.frame_dir, &window.doc).is_err()
{
ok = false;
}
for (track_id, file) in &window.pending_archive_files {
if frame::ops::track_ops::archive_track_file(&window.project.frame_dir, track_id, file)
.is_err()
{
ok = false;
}
}
drop(window.lock);
if !ok {
return;
}
for title in &window.retired {
self.claims.retain(|c| &c.title != title);
}
for mut claim in window.pending {
if let Some(id) = &claim.task_id {
claim.at_risk = self.tui_edited.contains(id);
if !self.owned_ids.contains(id) {
self.owned_ids.push(id.clone());
}
for (_, track) in &window.project.tracks {
if let Some(task) = task_ops::find_task_in_track(track, id) {
self.committed.insert(id.clone(), own_lines(task));
break;
}
}
}
self.claims.push(claim);
}
for row in window.pending_tracks {
self.tracks_claimed
.insert(row.id.clone(), TrackClaim::new(row));
}
}
fn observe_tui_step(&mut self, before: &TuiView, after: &TuiView) {
let mut retired: Vec<String> = Vec::new();
let mut edited: Vec<String> = Vec::new();
let mut absorbed: Vec<String> = Vec::new();
for claim in &self.claims {
match &claim.task_id {
Some(id) => {
let (was, now) = (before.tasks.get(id), after.tasks.get(id));
if was == now {
continue;
}
if now == self.committed.get(id) {
absorbed.push(id.clone());
continue;
}
edited.push(id.clone());
if was == self.committed.get(id) {
retired.push(claim.title.clone());
}
}
None => {
let n = |m: &BTreeMap<String, usize>| m.get(&claim.title).copied().unwrap_or(0);
if n(&after.inbox) < n(&before.inbox) {
retired.push(claim.title.clone());
}
}
}
}
for id in absorbed {
self.tui_edited.remove(&id);
}
self.tui_edited.extend(edited);
self.retired_by_tui.extend(retired.iter().cloned());
self.claims.retain(|c| !retired.contains(&c.title));
self.observe_tui_tracks(before, after);
}
fn observe_tui_tracks(&mut self, before: &TuiView, after: &TuiView) {
let mut restated: Vec<(String, TrackConfig)> = Vec::new();
let mut reabsorbed: Vec<String> = Vec::new();
for (id, claim) in &self.tracks_claimed {
let (was, now) = (before.tracks.get(id), after.tracks.get(id));
if was == now {
continue;
}
if now == Some(&claim.committed) {
reabsorbed.push(id.clone());
continue;
}
if was == Some(&claim.absorbed)
&& let Some(now) = now
{
restated.push((id.clone(), now.clone()));
}
}
for id in reabsorbed {
if let Some(claim) = self.tracks_claimed.get_mut(&id) {
claim.absorbed = claim.committed.clone();
claim.superseded = false;
}
}
for (id, row) in restated {
if let Some(claim) = self.tracks_claimed.get_mut(&id) {
claim.absorbed = row;
claim.superseded = true;
}
}
}
}
fn own_lines(task: &frame::model::task::Task) -> Vec<String> {
let mut bare = task.clone();
bare.subtasks.clear();
frame::parse::serialize_tasks(std::slice::from_ref(&bare), 0)
}
struct TuiView {
tasks: BTreeMap<String, Vec<String>>,
inbox: BTreeMap<String, usize>,
tracks: BTreeMap<String, TrackConfig>,
}
fn tui_view(app: &App, cli: &Cli) -> TuiView {
let mut tasks = BTreeMap::new();
for id in &cli.owned_ids {
for (_, track) in &app.project.tracks {
if let Some(task) = task_ops::find_task_in_track(track, id) {
tasks.insert(id.clone(), own_lines(task));
break;
}
}
}
let mut inbox: BTreeMap<String, usize> = BTreeMap::new();
if let Some(items) = app.project.inbox.as_ref() {
for item in &items.items {
*inbox.entry(item.title.clone()).or_default() += 1;
}
}
let mut tracks = BTreeMap::new();
for id in cli.tracks_claimed.keys() {
if let Some(row) = app.project.config.tracks.iter().find(|tc| &tc.id == id) {
tracks.insert(id.clone(), row.clone());
}
}
TuiView {
tasks,
inbox,
tracks,
}
}
fn track_file(project: &Project, track_id: &str) -> Option<String> {
project
.config
.tracks
.iter()
.find(|t| t.id == track_id)
.map(|t| t.file.clone())
}
fn steer(app: &App, step: &Step, cli: &Cli) -> Option<Step> {
let mut step = *step;
match step.action.surface() {
tui_steps::Surface::Task => {
if step.target.is_multiple_of(3) {
let live = live_task_ids(app);
let owned: Vec<usize> = live
.iter()
.enumerate()
.filter(|(_, id)| cli.owned_ids.contains(id))
.map(|(i, _)| i)
.collect();
if !owned.is_empty() {
step.target = owned[(step.target / 3) % owned.len()];
}
}
}
tui_steps::Surface::Inbox => {}
tui_steps::Surface::Tracks
if matches!(
step.action,
ActionKind::TrackShelve | ActionKind::TrackArchive
) =>
{
if step.target.is_multiple_of(3) {
let aimable: Vec<usize> = app
.tracks_view_order()
.iter()
.enumerate()
.filter(|(_, id)| cli.tracks_claimed.contains_key(**id))
.filter(|(_, id)| {
app.project
.config
.tracks
.iter()
.find(|tc| &tc.id == *id)
.is_some_and(|tc| tc.state == "active" || tc.state == "shelved")
})
.map(|(i, _)| i)
.collect();
if !aimable.is_empty() {
step.target = aimable[(step.target / 3) % aimable.len()];
}
}
}
tui_steps::Surface::Tracks => {
let allowed: Vec<usize> = app
.tracks_view_order()
.iter()
.enumerate()
.filter(|(_, id)| !cli.tracks_claimed.contains_key(**id))
.map(|(i, _)| i)
.collect();
if allowed.is_empty() {
return None;
}
step.target = allowed[step.target % allowed.len()];
}
}
Some(step)
}
#[test]
fn steering_aims_in_view_order_not_config_order() {
let tmp = fixture();
let project = project_io::load_project(tmp.path()).expect("project loads");
let mut app = App::new(project);
app.project.config.tracks.push(TrackConfig {
id: "clitrack1".to_string(),
name: "CLI Track".to_string(),
state: "active".to_string(),
file: "tracks/clitrack1.md".to_string(),
});
app.project.config.tracks[0].state = "archived".to_string();
assert_eq!(app.tracks_view_order(), vec!["side", "clitrack1", "main"]);
let mut cli = Cli::new(tmp.path());
cli.tracks_claimed.insert(
"clitrack1".to_string(),
TrackClaim::new(app.project.config.tracks[2].clone()),
);
for target in 0..12 {
let step = Step {
action: ActionKind::TrackRename,
target,
text: 0,
};
let steered = steer(&app, &step, &cli).expect("two unclaimed tracks are left");
assert_ne!(
app.tracks_view_order()[steered.target],
"clitrack1",
"target {target} was steered onto the track the CLI claimed"
);
}
}
fn snapshot(frame_dir: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
let mut out = BTreeMap::new();
let mut stack = vec![frame_dir.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Ok(bytes) = std::fs::read(&path) {
out.insert(path, bytes);
}
}
}
out
}
fn deliver(app: &mut App, frame_dir: &Path, last: &mut BTreeMap<PathBuf, Vec<u8>>) {
let now = snapshot(frame_dir);
let changed: Vec<PathBuf> = now
.iter()
.filter(|(path, bytes)| last.get(*path) != Some(*bytes))
.map(|(path, _)| path.clone())
.collect();
*last = now;
if !changed.is_empty() {
app.reload_changed_files(&changed);
}
}
struct Verdict {
lost_by_cli: Vec<String>,
lost_by_tui: Vec<String>,
duplicate_ids: Vec<String>,
unsettled: Option<String>,
still_unsaved: Vec<String>,
tracks_missing_row: Vec<String>,
tracks_wrong_state: Vec<String>,
tracks_content_misplaced: Vec<String>,
unjustified_setaside: Vec<String>,
unidentified_setaside: Vec<String>,
duplicated_titles: Vec<String>,
log_satisfied: Vec<String>,
retired_by_tui: Vec<String>,
tracks_checked: Vec<String>,
tracks_superseded: Vec<String>,
config_conflicts: Vec<String>,
tui_statuses: Vec<String>,
}
fn judge(app: &App, frame_dir: &Path, cli: &Cli) -> Verdict {
let (titles, _) = present(frame_dir);
let text = tree_checks::all_text(frame_dir);
let log: Vec<_> = frame::io::recovery::read_recovery_entries(frame_dir, None, None)
.into_iter()
.filter(|e| e.category == frame::io::recovery::RecoveryCategory::Write)
.collect();
let mut lost_by_cli = Vec::new();
let mut unjustified_setaside = Vec::new();
let mut unidentified_setaside = Vec::new();
let mut log_satisfied = Vec::new();
for claim in &cli.claims {
if titles.contains(&claim.title) || text.contains(&claim.title) {
continue;
}
let Some(entry) = log
.iter()
.find(|e| e.body.contains(&claim.title) || e.description.contains(&claim.title))
else {
lost_by_cli.push(claim.title.clone());
continue;
};
log_satisfied.push(claim.title.clone());
if !claim.at_risk {
unjustified_setaside.push(claim.title.clone());
continue;
}
if let Some(id) = &claim.task_id
&& !frame::io::recovery::entry_names(entry, id)
{
unidentified_setaside.push(format!("{} (task {id})", claim.title));
}
}
let mut title_tally: BTreeMap<String, usize> = BTreeMap::new();
for task in tree_checks::all_tasks(frame_dir) {
if !task.title.trim().is_empty() {
*title_tally.entry(task.title.clone()).or_default() += 1;
}
}
let duplicated_titles: Vec<String> = cli
.claims
.iter()
.filter(|c| c.task_id.is_some())
.filter(|c| title_tally.get(&c.title).is_some_and(|n| *n > 1))
.map(|c| c.title.clone())
.collect();
let mut in_memory: Vec<String> = Vec::new();
for (_, track) in &app.project.tracks {
for task in tree_checks::tasks_of(track) {
if !task.title.trim().is_empty() {
in_memory.push(task.title.clone());
}
}
}
if let Some(inbox) = &app.project.inbox {
for item in &inbox.items {
if !item.title.trim().is_empty() {
in_memory.push(item.title.clone());
}
}
}
let lost_by_tui = in_memory
.into_iter()
.filter(|t| !titles.contains(t) && !text.contains(t))
.collect();
let mut tracks_missing_row = Vec::new();
let mut tracks_wrong_state = Vec::new();
let mut tracks_content_misplaced = Vec::new();
match frame::io::config_io::read_config(frame_dir) {
Ok((config, _)) => {
for (id, claim) in &cli.tracks_claimed {
let Some(tc) = config.tracks.iter().find(|tc| &tc.id == id) else {
tracks_missing_row.push(id.clone());
continue;
};
if tc.state != claim.expected() {
tracks_wrong_state.push(format!(
"{id}: owed {:?}, found {:?}",
claim.expected(),
tc.state
));
}
let path = if tc.state == "archived" {
frame_dir.join("archive/_tracks").join(format!("{id}.md"))
} else {
frame_dir.join(&tc.file)
};
if !path.exists() {
tracks_content_misplaced.push(format!(
"{id}: {:?} names {}",
tc.state,
path.display()
));
}
}
}
Err(_) => tracks_missing_row.extend(cli.tracks_claimed.keys().cloned()),
}
let mut seen = BTreeSet::new();
let mut duplicate_ids = Vec::new();
for id in id_tally(frame_dir) {
if !seen.insert(id.clone()) {
duplicate_ids.push(id);
}
}
Verdict {
lost_by_cli,
lost_by_tui,
duplicate_ids,
unsettled: unsettled(frame_dir),
still_unsaved: app.unsaved.keys().map(|t| t.label().to_string()).collect(),
tracks_missing_row,
tracks_wrong_state,
tracks_content_misplaced,
unjustified_setaside,
unidentified_setaside,
duplicated_titles,
log_satisfied,
retired_by_tui: cli.retired_by_tui.clone(),
tracks_checked: cli.tracks_claimed.keys().cloned().collect(),
tracks_superseded: cli
.tracks_claimed
.iter()
.filter(|(_, claim)| claim.superseded)
.map(|(id, _)| id.clone())
.collect(),
config_conflicts: log
.iter()
.filter(|e| e.description.contains("in project.toml"))
.map(|e| e.description.clone())
.collect(),
tui_statuses: Vec::new(),
}
}
fn run(schedule: &[Event]) -> Result<Verdict, String> {
frame::io::lock::cap_waits(Duration::from_millis(20));
let tmp = fixture();
let root = tmp.path();
let frame_dir = root.join("frame");
let project = project_io::load_project(root).expect("project loads");
let mut app = App::new(project);
let mut cli = Cli::new(root);
let mut delivered = snapshot(&frame_dir);
let mut statuses: Vec<String> = Vec::new();
for event in schedule {
match event {
Event::Tui(step) => {
if let Some(step) = steer(&app, step, &cli) {
let before = tui_view(&app, &cli);
app.status_message = None;
apply_step(&mut app, &step);
if let Some(said) = app.status_message.clone() {
statuses.push(said);
}
if app.mode != Mode::Navigate {
return Err(format!("step {step:?} left the app in {:?}", app.mode));
}
let after = tui_view(&app, &cli);
cli.observe_tui_step(&before, &after);
}
}
Event::CliBegin => cli.begin(),
Event::CliOp(op) => cli.op(*op),
Event::CliCommit => cli.commit(),
Event::Watch => deliver(&mut app, &frame_dir, &mut delivered),
Event::Retry => app.force_retry_unsaved(),
}
}
cli.commit();
deliver(&mut app, &frame_dir, &mut delivered);
flush_and_save(&mut app);
app.force_retry_unsaved();
deliver(&mut app, &frame_dir, &mut delivered);
let mut verdict = judge(&app, &frame_dir, &cli);
verdict.tui_statuses = statuses;
Ok(verdict)
}
#[test]
fn a_track_archived_after_an_add_still_carries_the_task() {
let schedule = [
Event::CliBegin,
Event::CliOp(CliOp::TrackNew),
Event::CliCommit,
Event::CliBegin,
Event::CliOp(CliOp::AddTask { track: 2 }),
Event::CliOp(CliOp::TrackArchive { which: 0 }),
];
let verdict = run(&schedule).expect("schedule runs");
assert!(
verdict.lost_by_cli.is_empty(),
"a task added before its track was archived went with the file: {:?}",
verdict.lost_by_cli
);
assert_c5_clean(&verdict);
}
fn assert_c5_clean(verdict: &Verdict) {
assert!(
!verdict.tracks_checked.is_empty(),
"no track was claimed, so C5 asked nothing and the rest of this proves nothing"
);
let mut said = Vec::new();
if !verdict.tracks_missing_row.is_empty() {
said.push(format!(
"row gone from project.toml: {:?}",
verdict.tracks_missing_row
));
}
if !verdict.tracks_wrong_state.is_empty() {
said.push(format!(
"not the state it is owed: {:?}",
verdict.tracks_wrong_state
));
}
if !verdict.tracks_content_misplaced.is_empty() {
said.push(format!(
"the row points at a file that is not there: {:?}",
verdict.tracks_content_misplaced
));
}
assert!(said.is_empty(), "{}", said.join("\n"));
}
fn at_cli_track(action: ActionKind) -> Event {
Event::Tui(Step {
action,
target: 0,
text: 0,
})
}
#[test]
fn a_shelve_does_not_erase_a_track_another_process_created() {
let verdict = run(&[
Event::CliBegin,
Event::Tui(Step {
action: ActionKind::TrackShelve,
target: 0,
text: 0,
}),
Event::CliOp(CliOp::TrackNew),
])
.expect("schedule runs");
assert_c5_clean(&verdict);
}
#[test]
fn a_shelve_of_a_track_the_tui_had_absorbed_restates_the_claim() {
let verdict = run(&[
Event::CliBegin,
Event::CliOp(CliOp::TrackNew),
Event::CliCommit,
Event::Watch,
at_cli_track(ActionKind::TrackShelve),
])
.expect("schedule runs");
assert_eq!(
verdict.tracks_superseded,
vec!["clitrack1".to_string()],
"the informed branch did not fire, so the rest of this proves nothing"
);
assert_c5_clean(&verdict);
}
#[test]
fn a_shelve_that_did_not_see_the_archive_does_not_undo_it() {
let verdict = run(&[
Event::CliBegin,
Event::CliOp(CliOp::TrackNew),
Event::CliCommit,
Event::Watch,
Event::CliBegin,
at_cli_track(ActionKind::TrackShelve),
Event::CliOp(CliOp::TrackArchive { which: 0 }),
Event::CliCommit,
Event::Watch,
])
.expect("schedule runs");
assert!(
!verdict.config_conflicts.is_empty(),
"no config conflict was provoked, so no merge decided anything"
);
assert!(
verdict.tracks_superseded.is_empty(),
"this shelve was stale, so it restates nothing: {:?}",
verdict.tracks_superseded
);
assert_c5_clean(&verdict);
}
#[test]
fn an_archive_of_an_already_archived_track_does_not_overwrite_it() {
let verdict = run(&[
Event::CliBegin,
Event::CliOp(CliOp::TrackNew),
Event::CliCommit,
Event::CliBegin,
Event::CliOp(CliOp::AddTask { track: 2 }),
Event::CliCommit,
Event::Watch,
Event::CliBegin,
Event::CliOp(CliOp::AddTask { track: 2 }),
Event::CliOp(CliOp::TrackArchive { which: 0 }),
Event::CliCommit,
at_cli_track(ActionKind::TrackArchive),
])
.expect("schedule runs");
assert!(
verdict
.tui_statuses
.iter()
.any(|s| s.contains("already archived by another process")),
"the archive step did not reach the guard: {:?}",
verdict.tui_statuses
);
assert!(
verdict.lost_by_cli.is_empty(),
"the CLI wrote this task and archived it, and a stale archive moved its own copy over it: {:?}",
verdict.lost_by_cli
);
assert!(
verdict.duplicate_ids.is_empty(),
"{:?}",
verdict.duplicate_ids
);
assert_c5_clean(&verdict);
}
fn at_cli_task(action: ActionKind) -> Event {
Event::Tui(Step {
action,
target: 0,
text: 0,
})
}
#[test]
fn a_delete_of_a_task_the_tui_had_absorbed_retires_the_claim() {
let verdict = run(&[
Event::CliBegin,
Event::CliOp(CliOp::AddTask { track: 0 }),
Event::CliCommit,
Event::Watch,
at_cli_task(ActionKind::DeleteTask),
])
.expect("schedule runs");
assert_eq!(
verdict.retired_by_tui,
vec!["cli task 1".to_string()],
"the informed branch did not fire, so the rest of this proves nothing"
);
assert!(
verdict.lost_by_cli.is_empty(),
"a task the TUI knowingly deleted is not a lost update: {:?}",
verdict.lost_by_cli
);
assert!(
verdict.unjustified_setaside.is_empty(),
"nothing raced here, so nothing belongs in the log: {:?}",
verdict.unjustified_setaside
);
}
#[test]
fn a_stale_edit_sends_the_cli_version_to_the_recovery_log() {
let verdict = run(&[
Event::CliBegin,
Event::CliOp(CliOp::AddTask { track: 0 }),
Event::CliCommit,
Event::Watch,
Event::CliBegin,
at_cli_task(ActionKind::EditTitle),
Event::CliOp(CliOp::EditOwned { which: 0 }),
Event::CliCommit,
])
.expect("schedule runs");
assert_eq!(
verdict.log_satisfied,
vec!["cli task 2".to_string()],
"no version reached the recovery log, so no conflict was provoked"
);
assert!(
verdict.lost_by_cli.is_empty(),
"the log is an acceptable home for a contested version: {:?}",
verdict.lost_by_cli
);
assert!(
verdict.unjustified_setaside.is_empty(),
"this race is what entitles the log to hold it: {:?}",
verdict.unjustified_setaside
);
assert!(
verdict.unidentified_setaside.is_empty(),
"and it has to say which task it came from: {:?}",
verdict.unidentified_setaside
);
assert!(
verdict.duplicated_titles.is_empty(),
"one side survives, not both: {:?}",
verdict.duplicated_titles
);
}
#[test]
fn an_inbox_capture_the_tui_deleted_is_not_a_lost_update() {
let verdict = run(&[
Event::CliBegin,
Event::CliOp(CliOp::Capture),
Event::CliCommit,
Event::Watch,
Event::Tui(Step {
action: ActionKind::InboxDelete,
target: 2,
text: 0,
}),
])
.expect("schedule runs");
assert_eq!(
verdict.retired_by_tui,
vec!["cli task 1".to_string()],
"the inbox branch did not fire, so the rest of this proves nothing"
);
assert!(
verdict.lost_by_cli.is_empty(),
"an item the TUI triaged away is not a lost update: {:?}",
verdict.lost_by_cli
);
assert!(
verdict.unjustified_setaside.is_empty(),
"and the inbox merge sets nothing aside: {:?}",
verdict.unjustified_setaside
);
}
#[test]
fn a_version_nobody_contested_never_reaches_the_recovery_log() {
let verdict = run(&[
Event::CliBegin,
Event::CliOp(CliOp::AddTask { track: 0 }),
Event::CliCommit,
Event::CliBegin,
Event::Tui(Step {
action: ActionKind::EditTitle,
target: 1,
text: 0,
}),
Event::CliOp(CliOp::EditOwned { which: 0 }),
Event::CliCommit,
Event::Tui(Step {
action: ActionKind::SetDone,
target: 1,
text: 0,
}),
])
.expect("schedule runs");
assert!(
verdict.unjustified_setaside.is_empty(),
"the TUI never touched this task, so no merge may set its version aside: {:?}",
verdict.unjustified_setaside
);
assert!(
verdict.log_satisfied.is_empty(),
"with nothing in dispute the log should be empty of claims entirely: {:?}",
verdict.log_satisfied
);
assert!(verdict.lost_by_cli.is_empty(), "{:?}", verdict.lost_by_cli);
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(96))]
#[test]
fn p8_two_writers_do_not_lose_acknowledged_work(
schedule in prop::collection::vec(arb_event(), 1..28)
) {
let verdict = match run(&schedule) {
Ok(verdict) => verdict,
Err(detail) => return Err(TestCaseError::fail(format!("{detail}\nschedule: {schedule:?}"))),
};
prop_assert!(
verdict.still_unsaved.is_empty(),
"the TUI is still holding unsaved work at quiesce: {:?}\nschedule: {schedule:?}",
verdict.still_unsaved
);
prop_assert!(
verdict.lost_by_cli.is_empty(),
"the CLI wrote these and they are gone: {:?}\nschedule: {schedule:?}",
verdict.lost_by_cli
);
prop_assert!(
verdict.tracks_missing_row.is_empty(),
"the CLI configured these tracks and project.toml no longer has a row for them: {:?}\nschedule: {schedule:?}",
verdict.tracks_missing_row
);
prop_assert!(
verdict.tracks_wrong_state.is_empty(),
"these tracks are not in the state they are owed: {:?}\nschedule: {schedule:?}",
verdict.tracks_wrong_state
);
prop_assert!(
verdict.tracks_content_misplaced.is_empty(),
"these rows point at a file that is not there, so load_project drops the track: {:?}\nschedule: {schedule:?}",
verdict.tracks_content_misplaced
);
prop_assert!(
verdict.unjustified_setaside.is_empty(),
"the merge put these in the recovery log with no concurrent edit to justify it: {:?}\nschedule: {schedule:?}",
verdict.unjustified_setaside
);
prop_assert!(
verdict.unidentified_setaside.is_empty(),
"these survive only in the recovery log, in an entry that does not name the task: {:?}\nschedule: {schedule:?}",
verdict.unidentified_setaside
);
prop_assert!(
verdict.duplicated_titles.is_empty(),
"these titles are in the project more than once: {:?}\nschedule: {schedule:?}",
verdict.duplicated_titles
);
prop_assert!(
verdict.lost_by_tui.is_empty(),
"the TUI believes these are in the project and they are not on disk: {:?}\nschedule: {schedule:?}",
verdict.lost_by_tui
);
prop_assert!(
verdict.duplicate_ids.is_empty(),
"these ids were handed out twice: {:?}\nschedule: {schedule:?}",
verdict.duplicate_ids
);
if let Some(detail) = verdict.unsettled {
return Err(TestCaseError::fail(format!("{detail}\nschedule: {schedule:?}")));
}
}
}