1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
//! What the sheep pane asks for and arms: the editor `e` opens and the verb
//! `x`/`R`/`L` arm, both against the pane's own pinned sheep.
use super::super::*;
impl App {
/// `e`'s own handler from inside the sheep pane: targets the pane's own
/// pinned sheep, never [`Self::selected_row`]/[`Self::selected_name`].
///
/// The same reasoning [`Self::arm_sheep_pane`]'s own doc gives:
/// `Msg::Snapshot` reseats the dashboard's selection whatever screen is
/// showing. Refuses instead of substituting.
pub(in crate::lookout::app) fn ask_for_sheep_pane_config(&mut self) -> Effect {
let Some(row) = self.sheep_pane_row() else {
self.notice = Some(Notice::grave(
"that sheep is no longer in the flock".to_string(),
));
return Effect::None;
};
self.ask_for_sheep_config(row.info.name.clone(), ConfigFor::Editor)
}
/// `x`/`R`/`L` from inside the sheep pane: arms a confirm against the
/// pane's own pinned sheep, never [`Self::selected`].
///
/// Arming against the selection here would be the same mistake
/// [`Self::sheep_pane_row`]'s own doc explains. The ladder is
/// [`Self::confirm_refusal`]'s own gate and link, then one action already
/// in flight or held, same order [`Self::arm`] uses.
pub(in crate::lookout::app) fn arm_sheep_pane(&mut self, verb: ActionVerb) -> Effect {
if let Some(text) = self.confirm_refusal() {
self.notice = Some(Notice::grave(text));
return Effect::None;
}
// [`Self::arm`]'s own pair, for the reason given there: a verb the
// close dialog is holding has not gone out yet, so `self.action` is
// still empty. These two are the whole set of doors that arm from a
// keypress.
if self.action.is_some() || self.held.is_some() {
self.notice = Some(Notice::grave("one action is already in flight".to_string()));
return Effect::None;
}
let Some(row) = self.sheep_pane_row() else {
self.notice = Some(Notice::grave(
"that sheep is no longer in the flock".to_string(),
));
return Effect::None;
};
self.action = Some(Action {
verb,
target: RowKey::Sheep(row.info.id),
name: row.info.name.clone(),
count: 1,
at: self.now,
stage: Stage::Armed,
});
Effect::None
}
}
#[cfg(test)]
mod tests {
use super::super::tests::fixture_with_two_sheep;
use super::*;
use crate::lookout::app::testing::*;
use crate::lookout::view::fixtures;
/// `e` inside the sheep pane opens the editor, not a refill of the pane
/// that asked. Both send `Request::SheepConfig`, so the reply has to
/// say which one it is for.
#[test]
fn e_inside_the_sheep_pane_opens_the_editor() {
let mut app = fixture_with_two_sheep();
let _ = app.update(Msg::Key(KeyPress::Confirm));
let _ = app.update(Msg::Key(KeyPress::Edit));
app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "alpha".to_string(),
},
result: Ok(Response::SheepConfig(Box::new(
fixtures::sheep_config_view(),
))),
});
assert!(matches!(app.body(), Body::ConfigPane(_)));
}
/// `allowed()`'s cursor is parked on `web`, id 1; `↵` pins the pane to
/// it.
fn allowed_in_the_sheep_pane() -> App {
let mut app = allowed();
let _ = app.update(Msg::Key(KeyPress::Confirm));
app
}
/// `x` arms against the pane's own pinned sheep (`web`, id 1), the same
/// target the dashboard's own `arm` would reach for the same cursor
/// position. The two agree here because nothing has moved the
/// selection out from under the pane yet.
#[test]
fn x_arms_a_confirm_against_the_panes_pinned_sheep() {
let mut app = allowed_in_the_sheep_pane();
assert_eq!(
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop))),
Effect::None
);
let armed = app.action().expect("armed");
assert_eq!(armed.verb, ActionVerb::Stop);
assert_eq!(armed.target, &RowKey::Sheep(1));
assert_eq!(armed.name, "web");
assert!(!armed.sent, "nothing has gone out");
assert!(
matches!(app.body(), Body::Sheep(_)),
"arming does not close the pane"
);
}
/// `↵` confirms the armed action from inside the pane, the same send
/// the dashboard's own `confirm` produces.
#[test]
fn confirm_inside_the_pane_sends_the_armed_action() {
let mut app = allowed_in_the_sheep_pane();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Confirm)) else {
panic!("Enter sends the armed action");
};
assert_eq!(
sent,
Sent::Action {
verb: ActionVerb::Restart,
target: RowKey::Sheep(1),
name: "web".to_string(),
}
);
assert!(
matches!(app.body(), Body::Sheep(_)),
"confirming does not close the pane"
);
}
/// Every key but `↵` and `q` cancels an action armed from inside the
/// pane, the rule [`App::on_armed_key`] applies on both screens. The pane
/// needs its own call, since `on_key` routes here before making it.
#[test]
fn any_other_key_cancels_an_action_armed_inside_the_pane() {
for key in [
KeyPress::Escape,
KeyPress::StepDown,
KeyPress::StepUp,
KeyPress::Edit,
] {
let mut app = allowed_in_the_sheep_pane();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(app.action().is_some(), "armed before {key:?}");
assert_eq!(
app.update(Msg::Key(key)),
Effect::None,
"{key:?} sent something"
);
assert!(app.action().is_none(), "{key:?} did not cancel");
assert!(
matches!(app.body(), Body::Sheep(_)),
"{key:?} must not also close the pane"
);
}
}
/// `--read-only` refuses the same way it refuses the dashboard's own
/// `x`/`R`/`L`, with the same sentence.
#[test]
fn x_refuses_under_read_only_from_inside_the_pane() {
let mut app = fixture_with_two_sheep();
let _ = app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(app.action().is_none());
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("read-only: from --read-only or lookout.allow_control")
);
}
/// The pinned sheep can leave the flock entirely while the pane stays
/// open on it (nothing but `Escape` closes it): arming then must refuse
/// rather than target whoever replaced it.
#[test]
fn arming_refuses_once_the_pinned_sheep_has_left_the_flock() {
let mut app = fixture_with_two_sheep();
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(matches!(app.body(), Body::Sheep(_)), "pinned to alpha");
app.set_control_for_tests(Control::Allowed);
app.update(Msg::Snapshot {
rows: vec![sheep(2, "bravo", ProcStatus::Online)],
at: Instant::now(),
});
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
assert!(
app.action().is_none(),
"refused rather than arming against bravo"
);
assert!(app.notice().is_some_and(Notice::is_grave));
}
/// `e` from inside the sheep pane targets the pane's own pinned sheep
/// (`alpha`, id 1), not the dashboard's selection: nothing has moved
/// the selection out from under the pane yet, so the two agree here,
/// but only [`Self::sheep_pane_row`] is asked.
#[test]
fn e_asks_for_the_panes_own_pinned_sheeps_config() {
let mut app = fixture_with_two_sheep();
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(matches!(app.body(), Body::Sheep(_)), "pinned to alpha");
let request = wire(app.update(Msg::Key(KeyPress::Edit)));
assert_eq!(
request,
Request::SheepConfig {
name: "alpha".to_string()
}
);
}
/// The regression the reviewer reproduced: pane pinned to `alpha`,
/// `Msg::Snapshot` reseats the dashboard's selection onto `bravo` once
/// `alpha` leaves the flock, and `e` must refuse rather than open
/// `bravo`'s config under a pane still titled `alpha`.
#[test]
fn e_refuses_once_the_pinned_sheep_has_left_the_flock() {
let mut app = fixture_with_two_sheep();
let _ = app.update(Msg::Key(KeyPress::Confirm));
assert!(matches!(app.body(), Body::Sheep(_)), "pinned to alpha");
app.update(Msg::Snapshot {
rows: vec![sheep(2, "bravo", ProcStatus::Online)],
at: Instant::now(),
});
assert_eq!(app.update(Msg::Key(KeyPress::Edit)), Effect::None);
assert!(app.notice().is_some_and(Notice::is_grave));
}
/// `arm` and `arm_sheep_pane` share their refusal ladder through
/// `confirm_refusal`, but nothing before this test exercised
/// `arm_sheep_pane`'s own copy of the "one already in flight" branch: a
/// hand-copied ladder that dropped it silently would still pass every
/// other sheep-pane test, since none of them arm twice.
#[test]
fn a_second_arm_from_inside_the_sheep_pane_refuses_while_one_is_in_flight() {
let mut app = allowed_in_the_sheep_pane();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Key(KeyPress::Confirm));
assert!(app.action().is_some_and(|action| action.sent), "in flight");
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("one action is already in flight")
);
let action = app.action().expect("the first one is untouched");
assert_eq!(action.verb, ActionVerb::Stop);
assert!(action.sent);
}
/// Both screens clear `self.notice` after [`App::on_armed_key`] and
/// before their own keymap: without it a refusal raised inside the pane
/// never cleared, kept overriding the pane's own key hints in
/// `status_line`, and survived `close_pane` back to the flock table.
#[test]
fn a_refusal_inside_the_sheep_pane_clears_on_the_next_keypress() {
let mut app = allowed_in_the_sheep_pane();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Key(KeyPress::Confirm));
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
assert!(app.notice().is_some(), "setup: the refusal is raised");
app.update(Msg::Key(KeyPress::SelectDown));
assert!(
app.notice().is_none(),
"the refusal outlived a keypress that was not the one that \
raised it"
);
}
/// The reviewer's own reachable state: an action sent (`Stage::Sent`, in
/// flight), then a filter typed down to zero rows clears the selection
/// (`reseat`'s own empty-flock-view branch), then an action key. Before
/// this fix, `confirm_refusal` checked "one already in flight" ahead of
/// `arm`'s own "nothing selected", so this exact sequence told the
/// operator the wrong thing (the in-flight action, not the empty
/// selection the keypress actually asked about).
#[test]
fn arm_with_nothing_selected_refuses_that_and_not_the_in_flight_action() {
let mut app = allowed();
app.update(Msg::Key(KeyPress::Action(ActionVerb::Stop)));
app.update(Msg::Key(KeyPress::Confirm));
assert!(app.action().is_some_and(|action| action.sent), "in flight");
app.update(Msg::Key(KeyPress::FilterStart));
for letter in ['z', 'z', 'z'] {
app.update(Msg::Key(KeyPress::TextChar(letter)));
}
app.update(Msg::Key(KeyPress::TextApply));
assert_eq!(app.rows().len(), 0, "the query matches nothing");
assert!(app.selected_row().is_none(), "reseat cleared the selection");
app.update(Msg::Key(KeyPress::Action(ActionVerb::Restart)));
assert_eq!(
app.notice().map(ToString::to_string).as_deref(),
Some("no sheep is selected"),
"the keypress asked whether it had a target, and it did not"
);
let action = app.action().expect("the first one is still in flight");
assert_eq!(action.verb, ActionVerb::Stop);
assert!(action.sent, "untouched by the refused second arm");
}
}