返回 CodeWhale
config_theme_nav_pty.rs
根目录 / crates / tui / tests / cucumber / config_theme_nav_pty.rs
1 //! The `/config` theme editor must survive the arrow keys that drive it.
2 //!
3 //! Every arrow key inside the open theme editor live-previews the highlighted
4 //! choice, and the preview is a `ConfigUpdated` event that sends the host
5 //! through `refresh_config_view_if_open`. That refresh used to rebuild the
6 //! view from `ConfigView::new_for_app`, which drops the transient `editing`
7 //! state: the first arrow key closed the editor, the next one fell through to
8 //! the non-editing key map (where Left/Right switch category) and the
9 //! highlight snapped back to the top. Through a real terminal that reads as
10 //! "the panel closes as soon as I press a direction key".
11 //!
12 //! This drives the whole path — terminal decoder, modal host, config write —
13 //! against the shipped binary, so a regression that only lives in the host
14 //! glue fails here even if the view-level unit test passes.
15 //!
16 //! The expected screen is pinned by `src/tui/goldens/edit_theme_{80x24,120x32}.txt`.
17
18 use std::time::Duration;
19
20 use super::qa_harness::{
21 harness::{Harness, SealedWorkspace, make_sealed_workspace},
22 keys,
23 };
24
25 /// The open editor's own title. It only renders while `editing` is set, so it
26 /// is the exact signal the bug used to clear.
27 const EDITOR_TITLE: &str = "Edit Theme [theme]";
28 /// The editor's key legend, likewise editor-only.
29 const EDITOR_LEGEND: &str = "or click choose";
30 /// The selection marker on a choice row.
31 const CHOICE_CURSOR: char = '▸';
32
33 fn spawn(workspace: &SealedWorkspace) -> Harness {
34 Harness::builder(Harness::cargo_bin("codewhale-tui"))
35 .cwd(workspace.workspace())
36 .clear_env()
37 .seal_home(workspace.home())
38 .env("CODEWHALE_DISABLE_MODELS_DEV_FETCH", "1")
39 .env("CODEWHALE_NO_UPDATE_CHECK", "1")
40 .env("CODEWHALE_TELEMETRY", "0")
41 .env("NO_ANIMATIONS", "1")
42 .env("RUST_LOG", "warn")
43 .args([
44 "--workspace",
45 workspace.workspace().to_str().unwrap(),
46 "--no-project-config",
47 "--fresh",
48 ])
49 .size(24, 80)
50 .spawn()
51 .expect("start TUI")
52 }
53
54 /// The 1-based index of the highlighted choice, read from the `▸` marker.
55 ///
56 /// The golden numbers the rows, so this ties the marker to the label the user
57 /// sees rather than to a row offset that a layout change could move.
58 fn highlighted_choice(tui: &mut Harness) -> (usize, String) {
59 let lines: Vec<String> = (0..tui.frame().rows())
60 .map(|row| tui.frame().row(row))
61 .collect();
62 let line = lines
63 .iter()
64 .find(|line| line.contains(CHOICE_CURSOR))
65 .unwrap_or_else(|| panic!("no highlighted choice on screen:\n{}", tui.frame().text()));
66 let after = line.split_once(CHOICE_CURSOR).unwrap().1.trim();
67 let (number, label) = after
68 .split_once('.')
69 .unwrap_or_else(|| panic!("highlighted row has no `N. label` form: {line:?}"));
70 let number = number
71 .trim()
72 .parse::<usize>()
73 .unwrap_or_else(|_| panic!("highlighted row has no numeric index: {line:?}"));
74 (number, label.trim().to_string())
75 }
76
77 /// Fail with the whole screen when the editor is not open.
78 fn assert_editor_open(tui: &mut Harness, context: &str) {
79 for needle in [EDITOR_TITLE, EDITOR_LEGEND] {
80 assert!(
81 tui.frame().contains(needle),
82 "{context}: the theme editor is gone (missing {needle:?}):\n{}",
83 tui.frame().text()
84 );
85 }
86 }
87
88 #[test]
89 fn theme_editor_survives_every_arrow_key() {
90 let workspace = make_sealed_workspace().expect("sealed workspace");
91 let mut tui = spawn(&workspace);
92 let timeout = Duration::from_secs(15);
93
94 tui.wait_for_text("Type a message", timeout).unwrap();
95
96 // F2 is the advertised bind for the config shell.
97 tui.send(keys::key::f2()).unwrap();
98 tui.wait_for_text("Search: ", timeout).unwrap();
99
100 // Row 0 of the Appearance tab is Theme; the config golden pins that order.
101 tui.send(keys::key::enter()).unwrap();
102 tui.wait_for_text(EDITOR_TITLE, timeout).unwrap();
103 assert_editor_open(&mut tui, "after Enter");
104 let (opened_index, opened_label) = highlighted_choice(&mut tui);
105
106 // Down moves the highlight one choice and keeps the editor open. Before
107 // the fix this single key press was enough to close it.
108 tui.send(keys::key::down()).unwrap();
109 tui.wait_for_idle(Duration::from_millis(250), timeout)
110 .unwrap();
111 assert_editor_open(&mut tui, "after Down");
112 let (down_index, down_label) = highlighted_choice(&mut tui);
113 assert_ne!(
114 down_index,
115 opened_index,
116 "Down must move the theme highlight away from {opened_label:?}:\n{}",
117 tui.frame().text()
118 );
119 assert_eq!(
120 down_index,
121 opened_index + 1,
122 "Down must move exactly one choice"
123 );
124 assert_ne!(
125 down_label, opened_label,
126 "the label must follow the highlight"
127 );
128
129 // Up returns to where it started rather than snapping to the top — the
130 // snapped-to-row-0 reading is precisely the reported symptom.
131 tui.send(keys::key::up()).unwrap();
132 tui.wait_for_idle(Duration::from_millis(250), timeout)
133 .unwrap();
134 assert_editor_open(&mut tui, "after Up");
135 let (up_index, up_label) = highlighted_choice(&mut tui);
136 assert_eq!(
137 (up_index, up_label.as_str()),
138 (opened_index, opened_label.as_str()),
139 "Up must return to the opening highlight:\n{}",
140 tui.frame().text()
141 );
142
143 // The horizontal keys drive the same path and must also keep the editor.
144 for (label, key) in [
145 ("Right", keys::key::right()),
146 ("Left", keys::key::left()),
147 ("PageDown", keys::key::page_down()),
148 ("PageUp", keys::key::page_up()),
149 ] {
150 tui.send(key).unwrap();
151 tui.wait_for_idle(Duration::from_millis(250), timeout)
152 .unwrap();
153 assert_editor_open(&mut tui, label);
154 }
155
156 // A digit jumps straight to a choice without closing the editor.
157 tui.send(keys::key::ch('5')).unwrap();
158 tui.wait_for_idle(Duration::from_millis(250), timeout)
159 .unwrap();
160 assert_editor_open(&mut tui, "after digit jump");
161 let (jump_index, _) = highlighted_choice(&mut tui);
162 assert_eq!(jump_index, 5, "digit 5 must highlight the fifth choice");
163
164 // Esc cancels the edit: the editor closes, the panel stays.
165 tui.send(keys::key::esc()).unwrap();
166 tui.wait_for(
167 |frame| !frame.contains(EDITOR_TITLE),
168 Duration::from_secs(5),
169 )
170 .unwrap();
171 assert!(
172 tui.frame().contains("Search: "),
173 "Esc must leave the config panel open:\n{}",
174 tui.frame().text()
175 );
176
177 tui.shutdown();
178 }
179
179 lines RUST