#![allow(dead_code)]
use std::fs;
use std::path::Path;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use proptest::prelude::*;
use frame::tui::app::{App, Mode, View};
use frame::tui::input::handle_key;
pub fn create_fixture(root: &Path) {
let frame = root.join("frame");
fs::create_dir_all(frame.join("tracks")).unwrap();
fs::write(frame.join(".actor"), "null\n").unwrap();
fs::write(
frame.join("project.toml"),
"\
[project]
name = \"undo-fixture\"
[agent]
cc_focus = \"main\"
[[tracks]]
id = \"main\"
name = \"Main Track\"
state = \"active\"
file = \"tracks/main.md\"
[[tracks]]
id = \"side\"
name = \"Side Track\"
state = \"active\"
file = \"tracks/side.md\"
[ids.prefixes]
main = \"M\"
side = \"S\"
",
)
.unwrap();
fs::write(
frame.join("tracks/main.md"),
"\
# Main Track
## Backlog
- [ ] `M-001` First task #core
- added: 2025-05-01
- [>] `M-002` Second task
- added: 2025-05-02
- [ ] `M-003` Task with subtasks
- added: 2025-05-03
- [ ] `M-003.1` Sub one
- added: 2025-05-03
- [>] `M-003.2` Sub two
- added: 2025-05-03
- dep: M-003.1
## Parked
- [~] `M-010` Parked idea
- added: 2025-04-15
## Done
- [x] `M-000` Setup project
- added: 2025-04-20
- resolved: 2025-04-25
",
)
.unwrap();
fs::write(
frame.join("tracks/side.md"),
"\
# Side Track
## Backlog
- [ ] `S-001` Side task one
- added: 2025-05-01
- dep: M-003.1
## Parked
## Done
- [x] `S-000` Side done
- added: 2025-04-01
- resolved: 2025-04-02
",
)
.unwrap();
fs::write(
frame.join("inbox.md"),
"\
# Inbox
- Bug in parser #bug
reviewed these on Tuesday
- Think about design
",
)
.unwrap();
}
pub fn fixture() -> tempfile::TempDir {
let tmp = tempfile::TempDir::new().unwrap();
create_fixture(tmp.path());
let frame_dir = tmp.path().join("frame");
frame::io::actors::resolve_actor_token(&frame_dir).expect("claim actor token");
let project = frame::io::project_io::load_project(tmp.path()).expect("project loads");
frame::io::config_io::write_config_from_struct(&frame_dir, &project.config)
.expect("write config");
tmp
}
pub fn frame_tree(root: &Path) -> Vec<(String, String)> {
let frame = root.join("frame");
let mut out = Vec::new();
let mut stack = vec![frame.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let name = path.file_name().unwrap().to_string_lossy().to_string();
if frame::io::project_io::LOCAL_ONLY_FRAME_FILES.contains(&name.as_str()) {
continue;
}
let rel = path
.strip_prefix(&frame)
.unwrap()
.to_string_lossy()
.to_string();
out.push((rel, fs::read_to_string(&path).unwrap_or_default()));
}
}
out.sort();
out
}
pub fn tree_diff(expected: &[(String, String)], actual: &[(String, String)]) -> Option<String> {
for (path, want) in expected {
match actual.iter().find(|(p, _)| p == path) {
None => return Some(format!("{path} is missing")),
Some((_, got)) if got != want => {
let mut lines = vec![format!("{path} differs:")];
let want_lines: Vec<&str> = want.lines().collect();
let got_lines: Vec<&str> = got.lines().collect();
for i in 0..want_lines.len().max(got_lines.len()) {
let w = want_lines.get(i).copied();
let g = got_lines.get(i).copied();
if w == g {
continue;
}
lines.push(format!(" line {}: want {w:?}", i + 1));
lines.push(format!(" got {g:?}"));
}
return Some(lines.join("\n"));
}
Some(_) => {}
}
}
for (path, _) in actual {
if !expected.iter().any(|(p, _)| p == path) {
return Some(format!("{path} is unexpected"));
}
}
None
}
pub fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
pub fn press_char(app: &mut App, c: char) {
let mods = if c.is_ascii_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
handle_key(app, key(KeyCode::Char(c), mods));
}
pub fn press(app: &mut App, code: KeyCode) {
handle_key(app, key(code, KeyModifiers::NONE));
}
pub fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
press_char(app, c);
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Surface {
Task,
Inbox,
Tracks,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ActionKind {
SetDone,
SetTodo,
ToggleBlocked,
ToggleParked,
CycleState,
ToggleCc,
EditTitle,
AddTask,
AddSubtask,
InsertAfter,
MoveDown,
MoveUp,
DeleteTask,
Indent,
Outdent,
CrossTrackMove,
InboxAdd,
InboxEditTitle,
InboxDelete,
InboxMoveDown,
InboxTriage,
TrackAdd,
TrackRename,
TrackShelve,
TrackMoveDown,
TrackMoveUp,
TrackCcFocus,
TrackDelete,
TrackArchive,
}
impl ActionKind {
pub fn surface(self) -> Surface {
use ActionKind::*;
match self {
InboxAdd | InboxEditTitle | InboxDelete | InboxMoveDown | InboxTriage => Surface::Inbox,
TrackAdd | TrackRename | TrackShelve | TrackMoveDown | TrackMoveUp | TrackCcFocus
| TrackDelete | TrackArchive => Surface::Tracks,
_ => Surface::Task,
}
}
}
pub const ACTIONS: &[ActionKind] = &[
ActionKind::SetDone,
ActionKind::SetTodo,
ActionKind::ToggleBlocked,
ActionKind::ToggleParked,
ActionKind::CycleState,
ActionKind::ToggleCc,
ActionKind::EditTitle,
ActionKind::AddTask,
ActionKind::AddSubtask,
ActionKind::InsertAfter,
ActionKind::MoveDown,
ActionKind::MoveUp,
ActionKind::DeleteTask,
ActionKind::Indent,
ActionKind::Outdent,
ActionKind::CrossTrackMove,
ActionKind::InboxAdd,
ActionKind::InboxEditTitle,
ActionKind::InboxDelete,
ActionKind::InboxMoveDown,
ActionKind::InboxTriage,
ActionKind::TrackAdd,
ActionKind::TrackRename,
ActionKind::TrackShelve,
ActionKind::TrackMoveDown,
ActionKind::TrackMoveUp,
ActionKind::TrackCcFocus,
ActionKind::TrackDelete,
ActionKind::TrackArchive,
];
#[derive(Clone, Copy, Debug)]
pub struct Step {
pub action: ActionKind,
pub target: usize,
pub text: u8,
}
pub fn arb_step() -> impl Strategy<Value = Step> {
(0..ACTIONS.len(), 0usize..64, 0u8..26).prop_map(|(a, target, text)| Step {
action: ACTIONS[a],
target,
text,
})
}
pub fn live_task_ids(app: &App) -> Vec<String> {
fn walk(tasks: &[frame::model::task::Task], out: &mut Vec<String>) {
for task in tasks {
if let Some(id) = &task.id {
out.push(id.to_string());
}
walk(&task.subtasks, out);
}
}
use frame::model::SectionKind;
let mut out = Vec::new();
for (_, track) in &app.project.tracks {
for section in [SectionKind::Backlog, SectionKind::Parked, SectionKind::Done] {
walk(track.section_tasks(section), &mut out);
}
}
out
}
pub fn apply_step(app: &mut App, step: &Step) -> bool {
match step.action.surface() {
Surface::Task => {
let targets = live_task_ids(app);
if targets.is_empty() {
return false;
}
let target = targets[step.target % targets.len()].clone();
if !app.jump_to_task(&target) {
return false;
}
}
Surface::Inbox => {
let count = app.project.inbox.as_ref().map_or(0, |i| i.items.len());
if count == 0 {
return false;
}
app.view = View::Inbox;
app.inbox_cursor = step.target % count;
}
Surface::Tracks => {
let count = app.tracks_view_order().len();
if count == 0 {
return false;
}
app.view = View::Tracks;
app.tracks_cursor = step.target % count;
}
}
let text = format!("{}{}", (b'a' + step.text % 26) as char, step.text % 10);
match step.action {
ActionKind::SetDone => press_char(app, 'x'),
ActionKind::SetTodo => press_char(app, 'o'),
ActionKind::ToggleBlocked => press_char(app, 'b'),
ActionKind::ToggleParked => press_char(app, '~'),
ActionKind::CycleState => press_char(app, ' '),
ActionKind::ToggleCc => press_char(app, 'c'),
ActionKind::EditTitle => return typed(app, 'e', &text),
ActionKind::AddTask => return typed(app, 'a', &text),
ActionKind::AddSubtask => return typed(app, 'A', &text),
ActionKind::InsertAfter => return typed(app, '-', &text),
ActionKind::MoveDown => return moved(app, 'j'),
ActionKind::MoveUp => return moved(app, 'k'),
ActionKind::DeleteTask => return palette(app, "delete task", "Delete "),
ActionKind::Indent => return moved(app, 'l'),
ActionKind::Outdent => return moved(app, 'h'),
ActionKind::CrossTrackMove => {
press_char(app, 'M');
if app.mode != Mode::Triage {
return false;
}
press(app, KeyCode::Enter);
press_char(app, 'b');
}
ActionKind::InboxAdd => return typed(app, 'a', &text),
ActionKind::InboxEditTitle => return typed(app, 'e', &text),
ActionKind::InboxDelete => {
press_char(app, 'x');
if app.mode != Mode::Confirm {
return false;
}
press_char(app, 'y');
}
ActionKind::InboxMoveDown => return moved(app, 'j'),
ActionKind::InboxTriage => {
press(app, KeyCode::Enter);
if app.mode != Mode::Triage {
return false;
}
press(app, KeyCode::Enter);
press_char(app, 'b');
}
ActionKind::TrackAdd => return typed(app, 'a', &text),
ActionKind::TrackRename => return typed(app, 'e', &text),
ActionKind::TrackShelve => press_char(app, 's'),
ActionKind::TrackMoveDown => return moved(app, 'j'),
ActionKind::TrackMoveUp => return moved(app, 'k'),
ActionKind::TrackCcFocus => press_char(app, 'C'),
ActionKind::TrackDelete => return palette(app, "delete track", "Delete "),
ActionKind::TrackArchive => return palette(app, "archive track", "Archive "),
}
if app.mode != Mode::Navigate {
press(app, KeyCode::Esc);
return false;
}
true
}
pub fn typed(app: &mut App, trigger: char, text: &str) -> bool {
press_char(app, trigger);
if app.mode != Mode::Edit {
return bail(app);
}
type_str(app, text);
press(app, KeyCode::Enter);
app.mode == Mode::Navigate || bail(app)
}
pub fn moved(app: &mut App, direction: char) -> bool {
press_char(app, 'm');
if app.mode != Mode::Move {
return bail(app);
}
press_char(app, direction);
press(app, KeyCode::Enter);
app.mode == Mode::Navigate || bail(app)
}
pub fn palette(app: &mut App, label: &str, expect: &str) -> bool {
press_char(app, '>');
if app.mode != Mode::Command {
return bail(app);
}
type_str(app, label);
press(app, KeyCode::Enter);
let matched = app.mode == Mode::Confirm
&& app
.confirm_state
.as_ref()
.is_some_and(|c| c.message.starts_with(expect));
if !matched {
return bail(app);
}
press_char(app, 'y');
app.mode == Mode::Navigate || bail(app)
}
pub fn bail(app: &mut App) -> bool {
for _ in 0..3 {
if app.mode == Mode::Navigate {
break;
}
press(app, KeyCode::Esc);
}
false
}
pub fn flush_and_save(app: &mut App) -> Vec<String> {
let flushed = app.flush_all_pending_moves();
for track_id in &flushed {
app.save_track_logged(track_id);
}
flushed
}