| 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 |