use super::super::*;
impl App {
pub(in crate::lookout::app) fn take_pane_writes(&mut self) -> Vec<Sent> {
let Some(authority) = WriteAuthority::granted(self) else {
return Vec::new();
};
let Some(pane) = (match &mut self.body {
Body::ConfigPane(pane) => Some(pane),
Body::FlockTable
| Body::Settings(_)
| Body::Bleats(_)
| Body::Secrets(_)
| Body::Sheep(_) => None,
}) else {
return Vec::new();
};
let edits = pane.close();
if edits.is_empty() {
return Vec::new();
}
let mut ticket = self.next_write_ticket;
let sent = match pane.target().clone() {
PaneTarget::Dog { name, .. } => match pane.edited_section_with(&edits) {
SectionWrite::Written(toml) => {
let section = vec![Sent::SetDogSection {
name,
ticket,
toml,
authority,
}];
ticket += 1;
section
}
SectionWrite::Nothing => Vec::new(),
SectionWrite::Refused => {
self.notice = Some(Notice {
text: format!(
"{name}: its section in dogs.toml does not parse, or is not shaped for the edit"
),
grave: true,
});
return Vec::new();
}
},
PaneTarget::Sheep { name } => {
let writes = edits.into_writes();
let mut requests = Vec::with_capacity(writes.len());
for edit in writes {
let this = ticket;
ticket += 1;
requests.push(match edit {
PaneEdit::SetEnv { key, value } => Sent::SetEnv {
name: name.clone(),
ticket: this,
key,
value,
authority,
},
PaneEdit::Set { key, value } => Sent::ApplyField {
name: name.clone(),
ticket: this,
key,
value,
authority,
},
});
}
requests
}
PaneTarget::SheepDog { sheep, dog, .. } => {
let table = pane.edited_table_with(&edits);
let write = vec![Sent::SetSheepDogTable {
name: sheep,
dog,
ticket,
table: Some(table.into()),
authority,
}];
ticket += 1;
write
}
};
self.next_write_ticket = ticket;
sent
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lookout::app::testing::*;
#[test]
fn one_edit_reaches_the_wire_as_a_single_field_override() {
for key in [
"autorestart", "watch", "merge_logs", ] {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, key);
let _ = app.update(Msg::Key(KeyPress::Cycle));
assert_eq!(
app.config_pane().unwrap().edits().len(),
1,
"{key}: space files an edit and sends nothing"
);
let request = one_wire(close_writing(&mut app));
let Request::SetSheepField {
name,
key: sent,
value,
} = request
else {
panic!("{key}: expected SetSheepField, got {request:?}");
};
assert_eq!(name, "web", "{key}");
assert_eq!(sent, key, "{key}");
assert!(value.is_boolean(), "{key}: {value}");
}
}
#[test]
fn escape_sends_every_filed_edit_at_once() {
let mut app = fixtures::app_in_sheep_pane_with_two_edits();
let effect = close_writing(&mut app);
let Effect::SendAll(sent) = effect else {
panic!("wanted a batch, got {effect:?}");
};
assert_eq!(sent.len(), 2);
assert!(
app.config_pane().is_none(),
"the pane closes on the same key"
);
}
#[test]
fn escape_with_nothing_filed_closes_and_sends_nothing() {
let mut app = fixtures::app_in_sheep_pane();
let effect = app.update(Msg::Key(KeyPress::Escape));
assert!(matches!(effect, Effect::None), "{effect:?}");
assert!(app.config_pane().is_none());
}
#[test]
fn a_refused_write_notices_after_the_pane_has_closed() {
let mut app = fixtures::app_in_sheep_pane_with_two_edits();
let Effect::SendAll(mut sent) = close_writing(&mut app) else {
panic!("wanted a batch");
};
let first = sent.remove(0);
assert!(app.config_pane().is_none());
app.update(Msg::Replied {
sent: first,
result: Err(fixtures::a_refusal()),
});
let notice = app
.notice()
.map(ToString::to_string)
.expect("a refusal says so");
assert!(
notice.contains("cwd"),
"the notice names the field: {notice}"
);
}
#[test]
fn a_config_re_read_replaces_the_values_and_keeps_the_edits() {
let mut app = fixtures::app_in_sheep_pane_with_two_edits();
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Refresh)) else {
panic!("refresh asks the shepherd for the config again");
};
app.update(Msg::Replied {
sent,
result: Ok(Response::SheepConfig(Box::new(
fixtures::sheep_config_view(),
))),
});
assert_eq!(app.config_pane().unwrap().edits().len(), 2);
}
#[test]
fn a_batch_names_each_key_in_its_own_request() {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "autorestart");
let _ = app.update(Msg::Key(KeyPress::Cycle));
pane_to(&mut app, "watch");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let requests = wire_all(close_writing(&mut app));
let named: Vec<String> = requests
.iter()
.map(|request| match request {
Request::SetSheepField { key, .. } => key.clone(),
other => panic!("{other:?}"),
})
.collect();
assert_eq!(named, vec!["autorestart".to_owned(), "watch".to_owned()]);
}
#[test]
fn a_landed_write_re_reads_the_config_and_reports_what_the_shepherd_said() {
for (pending, wanted) in [(false, "set to false"), (true, "shep reload")] {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "autorestart");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let mut batch = wire_batch(close_writing(&mut app));
let effect = app.update(Msg::Replied {
sent: batch.remove(0),
result: Ok(Response::SheepFieldSet {
name: "web".to_owned(),
key: "autorestart".to_owned(),
pending,
warning: None,
}),
});
assert_eq!(
effect,
Effect::Send(Sent::SheepConfig {
name: "web".to_owned()
}),
"a landed write re-reads what the shepherd now holds"
);
let notice = app.notice().expect("the outcome is reported");
assert!(!notice.is_grave(), "{notice:?}");
assert!(notice.to_string().contains(wanted), "{notice:?}");
}
}
#[test]
fn a_path_warning_rides_the_same_notice_as_the_write() {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "cwd");
fixtures::type_into_the_open_editor(&mut app, "/does/not/exist");
app.update(Msg::Key(KeyPress::Escape));
let mut batch = wire_batch(app.update(Msg::Key(KeyPress::Continue)));
let effect = app.update(Msg::Replied {
sent: batch.remove(0),
result: Ok(Response::SheepFieldSet {
name: "web".to_owned(),
key: "cwd".to_owned(),
pending: true,
warning: Some("/does/not/exist does not exist yet".to_owned()),
}),
});
assert!(
matches!(effect, Effect::Send(Sent::SheepConfig { .. })),
"{effect:?}"
);
let notice = app.notice().expect("the outcome is reported");
assert!(!notice.is_grave(), "{notice:?}");
let text = notice.to_string();
assert!(text.contains("shep reload"), "{text}");
assert!(text.contains("does not exist yet"), "{text}");
}
#[test]
fn a_refused_write_is_reported_and_does_not_re_read() {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "autorestart");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let mut batch = wire_batch(close_writing(&mut app));
let effect = app.update(Msg::Replied {
sent: batch.remove(0),
result: Err(fixtures::a_refusal()),
});
assert_eq!(effect, Effect::None, "a refusal does not re-read");
let notice = app.notice().expect("the refusal is reported");
assert!(notice.is_grave());
assert!(
notice.to_string().contains("the store is locked"),
"{notice:?}"
);
}
#[test]
fn a_write_effects_debug_names_no_value() {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "cwd");
let _ = app.update(Msg::Key(KeyPress::Confirm));
for _ in 0..40 {
let _ = app.update(Msg::Key(KeyPress::TextBackspace));
}
for typed in "/home/ada/secret-project".chars() {
let _ = app.update(Msg::Key(KeyPress::TextChar(typed)));
}
let _ = app.update(Msg::Key(KeyPress::TextApply));
let field = close_writing(&mut app);
assert_eq!(
format!("{field:?}"),
"SendAll([ApplyField { name: \"web\", ticket: 0, key: \"cwd\", \
value: FieldValue(<string>), authority: WriteAuthority(()) }])"
);
let mut app = fixtures::app_in_sheep_pane_with_control();
let _ = app.update(Msg::Key(KeyPress::SelectLast));
let _ = app.update(Msg::Key(KeyPress::SelectUp));
let _ = app.update(Msg::Key(KeyPress::SelectUp));
assert_eq!(app.config_pane().unwrap().cursor(), Some(PaneRow::Env(0)));
let _ = app.update(Msg::Key(KeyPress::Confirm));
for typed in "hunter2".chars() {
let _ = app.update(Msg::Key(KeyPress::TextChar(typed)));
}
let _ = app.update(Msg::Key(KeyPress::TextApply));
let env = close_writing(&mut app);
assert_eq!(
format!("{env:?}"),
"SendAll([SetEnv { name: \"web\", ticket: 0, key: \"DB_HOST\", \
value: Some(EnvValue(<7 bytes>)), authority: WriteAuthority(()) }])"
);
}
#[test]
fn no_two_writes_share_a_ticket() {
let mut app = fixtures::app_in_sheep_pane_with_control();
pane_to(&mut app, "autorestart");
let _ = app.update(Msg::Key(KeyPress::Cycle));
pane_to(&mut app, "watch");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let first = wire_batch(close_writing(&mut app));
assert_ne!(first[0], first[1], "two entries are two tickets");
let _ = app.update(Msg::Key(KeyPress::Escape));
let _ = app.update(Msg::Key(KeyPress::Edit));
let _ = app.update(Msg::Replied {
sent: Sent::SheepConfig {
name: "web".to_owned(),
},
result: Ok(Response::SheepConfig(Box::new(
fixtures::sheep_config_view(),
))),
});
pane_to(&mut app, "autorestart");
let _ = app.update(Msg::Key(KeyPress::Cycle));
let second = wire_batch(close_writing(&mut app));
assert_ne!(
first[0], second[0],
"a second close does not reuse the first's tickets"
);
}
#[test]
fn a_dotted_dog_row_changes_one_line_of_its_section() {
let mut app = fixtures::app_in_dog_pane_with_a_nested_table();
pane_to(&mut app, "models.worker.model");
let _ = app.update(Msg::Key(KeyPress::Confirm));
for _ in 0..64 {
let _ = app.update(Msg::Key(KeyPress::TextBackspace));
}
for typed in "large".chars() {
let _ = app.update(Msg::Key(KeyPress::TextChar(typed)));
}
let _ = app.update(Msg::Key(KeyPress::TextApply));
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Escape)) else {
panic!("a dog close reads its section first");
};
let effect = app.update(Msg::Replied {
sent,
result: Ok(Response::DogSection {
toml: fixtures::nested_dog_section().into(),
}),
});
let mut batch = wire_batch(effect);
let Sent::SetDogSection { name, toml, .. } = batch.remove(0) else {
panic!("closing the pane sends the section: {batch:?}");
};
assert_eq!(name, "jobs");
let before = fixtures::nested_dog_section();
let sent = toml.as_str();
let changed: Vec<(usize, &str, &str)> = sent
.lines()
.zip(before.lines())
.enumerate()
.filter(|(_, pair)| pair.0 != pair.1)
.map(|(line, pair)| (line, pair.0, pair.1))
.collect();
assert_eq!(
changed,
vec![(6, "model = \"large\"", "model = \"gpt\"")],
"{sent}",
);
}
#[test]
fn a_dog_unset_the_section_already_carries_closes_without_a_notice() {
let mut app = fixtures::app_in_dog_pane_with_a_nested_table();
pane_to(&mut app, "models.worker.model");
let _ = app.update(Msg::Key(KeyPress::Remove));
assert_eq!(
app.config_pane().expect("the pane is open").edits().len(),
1,
"d on a dog row files the removal"
);
let removed = fixtures::nested_dog_section().replace("model = \"gpt\"\n", "");
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Escape)) else {
panic!("a dog close reads its section first");
};
let effect = app.update(Msg::Replied {
sent,
result: Ok(Response::DogSection {
toml: removed.clone().into(),
}),
});
assert!(matches!(effect, Effect::None), "{effect:?}");
assert!(
app.config_pane().is_some(),
"a moved section holds the pane"
);
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Escape)) else {
panic!("the second close reads again");
};
let effect = app.update(Msg::Replied {
sent,
result: Ok(Response::DogSection {
toml: removed.into(),
}),
});
assert!(matches!(effect, Effect::None), "{effect:?}");
assert!(
app.config_pane().is_none(),
"the close finishes: nothing to send is not a refusal"
);
assert_eq!(
app.notice().map(ToString::to_string),
None,
"the file parses and already says what the operator asked for"
);
}
#[test]
fn an_element_added_to_a_dogs_list_changes_one_line_of_its_section() {
let mut app = fixtures::app_in_dog_pane_with_lists();
pane_to(&mut app, "hosts");
let _ = app.update(Msg::Key(KeyPress::Confirm));
let _ = app.update(Msg::Key(KeyPress::SelectLast));
let _ = app.update(Msg::Key(KeyPress::Edit));
for typed in "c.example".chars() {
let _ = app.update(Msg::Key(KeyPress::TextChar(typed)));
}
let _ = app.update(Msg::Key(KeyPress::TextApply));
assert_eq!(
filed_value(&app, "hosts"),
serde_json::json!(["a.example", "b.example", "c.example"])
);
let _ = app.update(Msg::Key(KeyPress::Escape));
let Effect::Send(sent) = app.update(Msg::Key(KeyPress::Escape)) else {
panic!("a dog close reads its section first");
};
let effect = app.update(Msg::Replied {
sent,
result: Ok(Response::DogSection {
toml: fixtures::list_dog_section().into(),
}),
});
let mut batch = wire_batch(effect);
let Sent::SetDogSection { name, toml, .. } = batch.remove(0) else {
panic!("closing the pane sends the section: {batch:?}");
};
assert_eq!(name, "watch");
let before = fixtures::list_dog_section();
let sent = toml.as_str();
let changed: Vec<(usize, &str, &str)> = sent
.lines()
.zip(before.lines())
.enumerate()
.filter(|(_, pair)| pair.0 != pair.1)
.map(|(line, pair)| (line, pair.0, pair.1))
.collect();
assert_eq!(
changed,
vec![(
1,
"hosts = [\"a.example\", \"b.example\", \"c.example\"]",
"hosts = [\"a.example\", \"b.example\"]"
)],
"{sent}",
);
assert_eq!(sent.lines().count(), before.lines().count(), "{sent}");
}
#[test]
fn an_element_a_dogs_item_schema_refuses_files_nothing() {
let mut app = fixtures::app_in_dog_pane_with_lists();
pane_to(&mut app, "hosts");
let _ = app.update(Msg::Key(KeyPress::Confirm));
let _ = app.update(Msg::Key(KeyPress::SelectLast));
let _ = app.update(Msg::Key(KeyPress::Edit));
let _ = app.update(Msg::Key(KeyPress::TextChar('x')));
assert_eq!(app.update(Msg::Key(KeyPress::TextApply)), Effect::None);
assert_eq!(app.mode(), InputMode::Text, "the editor stays open");
let notice = app.notice().expect("a refusal answers");
assert!(notice.is_grave(), "{notice}");
assert!(notice.to_string().starts_with("hosts is \"x\""), "{notice}");
assert!(
app.config_pane()
.expect("the pane is open")
.edits()
.is_empty(),
"nothing was filed"
);
}
#[test]
fn enter_on_a_dogs_secret_list_or_map_opens_no_sub_screen() {
for key in ["keys", "labels"] {
let mut app = fixtures::app_in_dog_pane_with_lists();
pane_to(&mut app, key);
let _ = app.update(Msg::Key(KeyPress::Confirm));
let pane = app.config_pane().expect("the pane is open");
assert!(pane.list().is_none(), "{key}");
let notice = app.notice().expect("a locked row answers").to_string();
assert!(notice.contains("no editor in this pane"), "{key}: {notice}");
}
}
}