Files
improvise/crates/improvise-command/src/ui/delivery_tests.rs
T

1587 lines
52 KiB
Rust

//! Delivery against the Eager path: the host publishes, a client folds, and the client's
//! held cells must end at what the grid draws. Evidence: `context/test-evidence/delivery.md`.
use std::collections::VecDeque;
use improvise_core::model::cell::{CellKey, CellValue};
use improvise_core::model::record::RecordHash;
use improvise_core::view::Axis;
use improvise_core::view::layout::GridLayout;
use improvise_core::workbook::Workbook;
use improvise_protocol::delivery::wire::{self, Cell, WireKey, WireValue};
use improvise_protocol::delivery::{
Ask, AxisChange, CorrectionBody, Edge, EdgeClient, EdgeError, Moved, Notice, Outcome, Reply,
Request, SessionToken, ShapeKey, Stamp, Synced, Window,
};
use improvise_protocol::{ClientFrame, ModelCommand, RecordCell, Rejection, ServerFrame};
use zset::ZSet;
use super::app::App;
use super::delivery::{InProcess, WireEdge, notice_over_the_wire, shape_of_view};
use super::host::Host;
use super::project::SessionId;
fn workbook() -> Workbook {
let mut wb = Workbook::new("Test");
wb.add_category("Region").unwrap();
for item in ["North", "South"] {
wb.add_item("Region", item).unwrap();
}
wb.add_item("_Measure", "Revenue").unwrap();
for (region, value) in [("North", 10.0), ("South", 20.0)] {
let key = CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]);
wb.model.set_cell(key, CellValue::Number(value));
}
wb.active_view_mut().set_axis("Region", Axis::Row);
wb.active_view_mut().set_axis("_Measure", Axis::Column);
wb
}
fn host_instance(instance: u64) -> Host {
let mut app = App::new(workbook(), None);
app.rebuild_layout();
let mut host = Host::with_instance(app, instance);
host.attach_notices();
host
}
fn host() -> Host {
host_instance(1)
}
fn shape() -> ShapeKey {
shape_of_view("Default")
}
fn everything() -> Window {
Window {
top: 0,
left: 0,
rows: 1000,
cols: 1000,
}
}
fn record(region: &str) -> RecordCell {
let key = CellKey::new(vec![
("Region".to_string(), region.to_string()),
("_Measure".to_string(), "Revenue".to_string()),
]);
RecordCell {
record: RecordHash::of(&key),
measure: Some("Revenue".to_string()),
}
}
fn set(region: &str, value: f64) -> ModelCommand {
ModelCommand::SetCell {
view: "Default".into(),
cell: record(region),
value: CellValue::Number(value),
}
}
fn add_item(item: &str) -> ModelCommand {
ModelCommand::AddItem {
category: "Region".into(),
item: item.into(),
}
}
/// A frame of caught-up commands: the first one's basis states every record the frame names.
fn frame(host: &Host, cmds: Vec<ModelCommand>) -> ClientFrame {
let mut wire: Vec<improvise_protocol::Command> = cmds
.into_iter()
.map(|c| crate::ui::reduce::caught_up(host.app(), c))
.collect();
let records: Vec<_> = wire
.iter()
.filter_map(|c| match c {
improvise_protocol::Command::Model { basis, .. } => Some(basis.records.clone()),
_ => None,
})
.flatten()
.collect();
if let Some(improvise_protocol::Command::Model { basis, .. }) = wire.first_mut() {
basis.records = records;
}
ClientFrame::Commands(wire)
}
fn send(host: &mut Host, id: SessionId, cmds: Vec<ModelCommand>) -> Vec<ServerFrame> {
let f = frame(host, cmds);
host.handle(id, f).into_iter().map(|a| a.msg).collect()
}
fn refused(answer: &[ServerFrame]) -> bool {
answer.iter().any(|m| matches!(m, ServerFrame::Rejected(_)))
}
/// What the grid draws, derived without the delivery module's own reader.
fn drawn(host: &Host, window: &Window) -> ZSet<Cell> {
drawn_view(host, "Default", window)
}
fn drawn_view(host: &Host, view: &str, window: &Window) -> ZSet<Cell> {
let wb = &host.app().model_state.workbook;
let layout = GridLayout::with_frozen_records(&wb.model, &wb.views[view], None);
let mut cells = ZSet::new();
for row in 0..layout.row_count() {
for col in 0..layout.col_count() {
let Some(key) = layout.cell_key(row, col) else {
continue;
};
if let Some(value) = layout.evaluate(&wb.model, &key)
&& window.contains(row as u128, col as u128)
{
cells.insert((WireKey::of(&key), WireValue::of(&value)), 1);
}
}
}
cells
}
#[derive(Clone, Copy, Debug, PartialEq)]
enum Fault {
DropRequest,
DropReply,
Garble,
}
/// A transport that loses things on a script and remembers every request that reached it.
struct Flaky<'a> {
host: &'a mut Host,
script: VecDeque<Option<Fault>>,
reached: Vec<Request>,
replies: Vec<Reply>,
}
impl<'a> Flaky<'a> {
fn new(host: &'a mut Host) -> Self {
Self {
host,
script: VecDeque::new(),
reached: Vec::new(),
replies: Vec::new(),
}
}
fn scripted(host: &'a mut Host, script: Vec<Option<Fault>>) -> Self {
Self {
script: script.into(),
..Self::new(host)
}
}
}
impl Edge for Flaky<'_> {
fn call(&mut self, request: Request) -> Result<Reply, EdgeError> {
let fault = self.script.pop_front().flatten();
if fault == Some(Fault::DropRequest) {
return Err(EdgeError::Failed);
}
self.reached.push(request.clone());
let reply = self.host.deliver(&request, 0);
self.replies.push(reply.clone());
match fault {
Some(Fault::DropReply) => Err(EdgeError::Failed),
Some(Fault::Garble) => Err(EdgeError::Unreadable),
_ => Ok(reply),
}
}
}
fn session() -> Option<SessionToken> {
Some(SessionToken("s-1".into()))
}
/// A client that has fetched everything the host holds.
fn settled(host: &mut Host) -> EdgeClient {
let mut client = EdgeClient::new(session());
client.track(shape(), everything());
let (state, _) = client.sync(&mut WireEdge(InProcess { host, now: 0 }), &shape(), 6);
assert_eq!(state, Synced::Settled);
client
}
fn connect(host: &mut Host) -> SessionId {
host.connect(10, 10).0
}
fn asks(reached: &[Request]) -> Vec<&Ask> {
reached.iter().map(|r| &r.ask).collect()
}
fn deliver_all(client: &mut EdgeClient, notices: &[Notice]) {
for n in notices {
client.on_notice(&notice_over_the_wire(n).expect("a notice crosses the wire"));
}
}
fn held_equals_drawn(client: &EdgeClient, host: &Host) {
let want = drawn(host, &everything());
assert_eq!(client.held(&shape()), Some(&want));
}
// ── one notice per committed command ───────────────────────────────────────
#[test]
fn a_frame_of_two_commands_publishes_one_notice_each_and_no_client_asks_for_a_correction() {
let mut host = host();
let id = connect(&mut host);
let mut client = settled(&mut host);
let before = host.delivery().stamp();
host.take_notices();
let answer = send(&mut host, id, vec![add_item("East"), set("North", 5.0)]);
assert!(!refused(&answer), "{answer:?}");
let notices = host.take_notices();
let stamps: Vec<u64> = notices.iter().map(|n| n.stamp().publication).collect();
assert_eq!(
stamps,
vec![before.publication + 1, before.publication + 2],
"one notice per committed command, numbered consecutively"
);
deliver_all(&mut client, &notices);
let mut edge = Flaky::new(&mut host);
let (state, _) = client.sync(&mut edge, &shape(), 6);
assert_eq!(state, Synced::Settled);
assert!(
asks(&edge.reached)
.iter()
.all(|a| !matches!(a, Ask::Correction { .. })),
"no correction was asked for: {:?}",
edge.reached
);
held_equals_drawn(&client, &host);
}
#[test]
fn a_command_that_moves_nothing_still_publishes_and_a_view_command_does_not() {
let mut host = host();
let id = connect(&mut host);
host.take_notices();
send(&mut host, id, vec![set("North", 1.0)]);
send(&mut host, id, vec![set("North", 1.0)]);
let same = host.take_notices();
assert_eq!(same.len(), 2);
assert_eq!(
same[1].moved,
Default::default(),
"an equal write moves nothing"
);
host.handle(
id,
ClientFrame::command(improvise_protocol::Command::SetSelected { row: 1, col: 0 }),
);
assert!(
host.take_notices().is_empty(),
"the cursor is the view store's"
);
}
#[test]
fn a_notice_names_the_moved_cells_including_a_formula_cell_it_did_not_write() {
let mut host = host();
let id = connect(&mut host);
send(
&mut host,
id,
vec![ModelCommand::AddFormula {
raw: "Double = Revenue * 2".into(),
target_category: "_Measure".into(),
}],
);
let _ = settled(&mut host);
host.take_notices();
send(&mut host, id, vec![set("North", 4.0)]);
let notice = host.take_notices().remove(0);
let Some(Moved::Keys(keys)) = notice.moved.get(&shape()) else {
panic!("the live shape must be listed: {notice:?}")
};
let measures: Vec<&str> = keys
.iter()
.flat_map(|k| k.0.iter())
.filter(|(c, _)| c == "_Measure")
.map(|(_, i)| i.as_str())
.collect();
assert!(
measures.contains(&"Revenue") && measures.contains(&"Double"),
"{keys:?}"
);
}
#[test]
fn a_structural_command_reports_the_axis_change_and_a_formula_the_flag() {
let mut host = host();
let id = connect(&mut host);
host.take_notices();
send(&mut host, id, vec![add_item("East")]);
send(
&mut host,
id,
vec![ModelCommand::RemoveItem {
category: "Region".into(),
item: "North".into(),
}],
);
send(
&mut host,
id,
vec![ModelCommand::AddFormula {
raw: "Double = Revenue * 2".into(),
target_category: "_Measure".into(),
}],
);
let notices = host.take_notices();
assert_eq!(
notices[0].axis,
vec![AxisChange::ItemInserted {
category: "Region".into(),
item: "East".into(),
at: 2
}]
);
assert_eq!(
notices[1].axis,
vec![AxisChange::ItemRemoved {
category: "Region".into(),
item: "North".into()
}]
);
assert!(notices[2].flags.formulas && !notices[1].flags.formulas);
}
// ── loss, restart, refusal ─────────────────────────────────────────────────
#[test]
fn a_dropped_notice_is_detected_and_a_correction_repairs_it() {
let mut host = host();
let id = connect(&mut host);
let mut client = settled(&mut host);
host.take_notices();
send(&mut host, id, vec![set("North", 1.0)]);
send(&mut host, id, vec![set("South", 2.0)]);
let notices = host.take_notices();
deliver_all(&mut client, &notices[1..]);
let mut edge = Flaky::new(&mut host);
let (state, _) = client.sync(&mut edge, &shape(), 6);
assert_eq!(state, Synced::Settled);
assert!(
matches!(
asks(&edge.reached)[0],
Ask::Correction { watermark: Some(_) }
),
"{:?}",
edge.reached
);
held_equals_drawn(&client, &host);
}
#[test]
fn a_reconnect_after_a_host_restart_asks_for_a_correction_from_the_old_watermark() {
let mut first = host_instance(1);
let id = connect(&mut first);
send(&mut first, id, vec![set("North", 9.0)]);
let mut client = settled(&mut first);
let old = client.watermark(&shape()).expect("settled");
let mut second = host_instance(2);
client.connection_lost();
let mut edge = Flaky::new(&mut second);
let (state, _) = client.sync(&mut edge, &shape(), 6);
assert_eq!(state, Synced::Settled);
assert_eq!(
asks(&edge.reached)[0],
&Ask::Correction {
watermark: Some(old)
}
);
assert_ne!(client.watermark(&shape()).unwrap().host, old.host);
held_equals_drawn(&client, &second);
}
#[test]
fn a_notice_from_a_restarted_host_asks_for_a_correction_too() {
let mut first = host_instance(1);
let mut client = settled(&mut first);
let mut second = host_instance(2);
let id = connect(&mut second);
send(&mut second, id, vec![set("North", 3.0)]);
deliver_all(&mut client, &second.take_notices());
let mut edge = Flaky::new(&mut second);
client.sync(&mut edge, &shape(), 6);
assert!(matches!(asks(&edge.reached)[0], Ask::Correction { .. }));
held_equals_drawn(&client, &second);
}
#[test]
fn a_refused_command_consumes_no_publication_number() {
let mut host = host();
let id = connect(&mut host);
for n in 0..crate::model::MAX_CATEGORIES - 1 {
host.app_mut()
.model_state
.workbook
.add_category(format!("Filler{n}"))
.unwrap();
}
host.app_mut().rebuild_layout();
let before = host.delivery().stamp();
host.take_notices();
let answer = send(
&mut host,
id,
vec![
add_item("West"),
ModelCommand::AddCategory("Overflow".into()),
],
);
assert!(
answer
.iter()
.any(|m| matches!(m, ServerFrame::Rejected(Rejection::MutationRefused))),
"the fixture must reach the refusal: {answer:?}"
);
assert_eq!(host.delivery().stamp(), before, "nothing was committed");
assert!(host.take_notices().is_empty());
send(&mut host, id, vec![set("North", 1.0)]);
assert_eq!(
host.take_notices()[0].stamp().publication,
before.publication + 1,
"the next commit takes the next number"
);
}
#[test]
fn a_refused_frame_is_never_read_back() {
let mut host = host();
let id = connect(&mut host);
let mut client = settled(&mut host);
let committed = drawn(&host, &everything());
let answer = send(
&mut host,
id,
vec![
set("North", 77.0),
ModelCommand::LoadModel("missing.improv".into()),
],
);
assert!(
answer
.iter()
.any(|m| matches!(m, ServerFrame::Rejected(Rejection::MutationRefused))),
"the write must have run before the refusal: {answer:?}"
);
let mut edge = Flaky::new(&mut host);
client.connection_lost();
client.sync(&mut edge, &shape(), 6);
assert_eq!(client.held(&shape()), Some(&committed));
let leaked = client
.held(&shape())
.unwrap()
.iter()
.any(|((_, v), _)| *v == WireValue::of(&CellValue::Number(77.0)));
assert!(!leaked, "a value the failed frame wrote was read");
}
#[test]
fn commands_before_a_refused_one_are_rolled_back_with_their_notices_under_adr_001() {
let mut host = host();
let id = connect(&mut host);
for n in 0..crate::model::MAX_CATEGORIES - 1 {
host.app_mut()
.model_state
.workbook
.add_category(format!("Filler{n}"))
.unwrap();
}
host.app_mut().rebuild_layout();
host.take_notices();
send(
&mut host,
id,
vec![
add_item("West"),
ModelCommand::AddCategory("Overflow".into()),
],
);
assert!(
host.take_notices().is_empty(),
"ADR 001 clause 4 undoes the whole frame; ADR 009 (via ADR 006) would keep `West`'s notice"
);
}
#[test]
fn a_dropped_connection_or_a_failed_request_triggers_a_correction_and_the_session_resumes() {
let mut host = host();
let id = connect(&mut host);
let mut client = settled(&mut host);
let at = client.watermark(&shape()).unwrap();
send(&mut host, id, vec![set("North", 8.0)]);
deliver_all(&mut client, &host.take_notices());
for fault in [Fault::DropRequest, Fault::DropReply, Fault::Garble] {
let mut edge = Flaky::scripted(&mut host, vec![Some(fault)]);
let (state, _) = client.sync(&mut edge, &shape(), 6);
assert_eq!(state, Synced::Failed, "{fault:?}");
assert!(
matches!(
client.wants(&shape()),
Some(improvise_protocol::delivery::Want::Correction { .. })
),
"{fault:?}: a failed request asks for a correction"
);
}
let mut edge = Flaky::new(&mut host);
let (state, _) = client.sync(&mut edge, &shape(), 6);
assert_eq!(state, Synced::Settled);
assert_eq!(
asks(&edge.reached)[0],
&Ask::Correction {
watermark: Some(at)
},
"it resumes from the watermark it held"
);
assert!(edge.reached.iter().all(|r| r.session == session()));
held_equals_drawn(&client, &host);
client.connection_lost();
let (state, _) = client.sync(&mut Flaky::new(&mut host), &shape(), 6);
assert_eq!(
state,
Synced::Settled,
"and a dropped connection settles too"
);
}
// ── the log, windows, eviction ─────────────────────────────────────────────
fn reply_for(
host: &mut Host,
ask: Ask,
window: Window,
) -> improvise_protocol::delivery::Correction {
let request = Request {
session: session(),
shape: shape(),
window,
ask,
};
match host.deliver(&request, 0) {
Reply::Correction(c) => *c,
other => panic!("expected a correction: {other:?}"),
}
}
#[test]
fn a_watermark_the_log_covers_gets_the_moved_keys_and_one_it_does_not_gets_the_window() {
let mut host = host();
host.limit_delivery(3, 4096);
let id = connect(&mut host);
let client = settled(&mut host);
let w = client.watermark(&shape()).unwrap();
for n in 0..2 {
send(&mut host, id, vec![set("North", n as f64)]);
}
let near = reply_for(
&mut host,
Ask::Correction { watermark: Some(w) },
everything(),
);
assert!(
matches!(near.body, CorrectionBody::Moved { .. }),
"{near:?}"
);
assert_eq!(near.from, Some(w));
for n in 2..6 {
send(&mut host, id, vec![set("North", n as f64)]);
}
assert_eq!(host.delivery().log_len(), 3, "the log is bounded");
let far = reply_for(
&mut host,
Ask::Correction { watermark: Some(w) },
everything(),
);
assert!(matches!(far.body, CorrectionBody::Window), "{far:?}");
assert_eq!(far.from, None);
}
#[test]
fn an_oversized_notice_says_correct_and_costs_every_reader_a_correction() {
let mut host = host();
host.limit_delivery(64, 1);
let id = connect(&mut host);
let mut client = settled(&mut host);
host.take_notices();
send(&mut host, id, vec![set("North", 1.0)]);
let small = host.take_notices();
assert!(matches!(small[0].moved.get(&shape()), Some(Moved::Keys(k)) if k.len() == 1));
let formula = ModelCommand::AddFormula {
raw: "Double = Revenue * 2".into(),
target_category: "_Measure".into(),
};
send(&mut host, id, vec![formula]);
let big = host.take_notices();
assert_eq!(big[0].moved.get(&shape()), Some(&Moved::Correct));
deliver_all(&mut client, &small);
deliver_all(&mut client, &big);
let mut edge = Flaky::new(&mut host);
client.sync(&mut edge, &shape(), 6);
assert!(matches!(asks(&edge.reached)[0], Ask::Correction { .. }));
held_equals_drawn(&client, &host);
}
#[test]
fn a_correction_carries_only_the_clients_window() {
let mut host = host();
let id = connect(&mut host);
let top_left = Window {
top: 0,
left: 0,
rows: 1,
cols: 1,
};
let all = reply_for(&mut host, Ask::Correction { watermark: None }, everything());
let one = reply_for(&mut host, Ask::Correction { watermark: None }, top_left);
assert_eq!(all.target.len(), 2);
assert_eq!(one.target.len(), 1, "{one:?}");
send(&mut host, id, vec![set("South", 5.0)]);
let w = host.delivery().stamp();
let _ = w;
let moved = reply_for(
&mut host,
Ask::Correction {
watermark: Some(Stamp {
publication: w.publication - 1,
..w
}),
},
top_left,
);
assert!(
moved.target.is_empty(),
"South is outside the window: {moved:?}"
);
}
#[test]
fn an_axis_change_that_shifts_an_unmoved_cell_into_the_window_refetches_the_window() {
let mut host = host();
let id = connect(&mut host);
let one = Window {
top: 0,
left: 0,
rows: 1,
cols: 1,
};
let mut client = EdgeClient::new(session());
client.track(shape(), one);
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
host.take_notices();
let gone = ModelCommand::RemoveItem {
category: "Region".into(),
item: "North".into(),
};
send(&mut host, id, vec![gone]);
let notices = host.take_notices();
assert_eq!(
notices[0]
.moved
.get(&shape())
.map(|m| matches!(m, Moved::Keys(k) if k.len() == 1)),
Some(true)
);
deliver_all(&mut client, &notices);
let mut edge = Flaky::new(&mut host);
client.sync(&mut edge, &shape(), 6);
assert!(
matches!(asks(&edge.reached)[0], Ask::Fetch { window: true, .. }),
"{:?}",
edge.reached
);
assert_eq!(client.held(&shape()), Some(&drawn(&host, &one)));
let south = WireKey(vec![
("Region".into(), "South".into()),
("_Measure".into(), "Revenue".into()),
]);
assert!(
client
.held(&shape())
.unwrap()
.iter()
.any(|((k, _), _)| *k == south)
);
}
#[test]
fn an_evicted_shape_is_announced_and_its_reader_corrects() {
let mut host = host();
let id = connect(&mut host);
let mut client = settled(&mut host);
assert!(host.delivery().is_live(&shape()));
let gone = host.evict_idle(100, 50);
assert_eq!(gone, vec![shape()]);
assert!(!host.delivery().is_live(&shape()));
host.take_notices();
send(&mut host, id, vec![set("North", 2.0)]);
let notices = host.take_notices();
assert!(notices[0].evicted.contains(&shape()));
deliver_all(&mut client, &notices);
assert!(matches!(
client.wants(&shape()),
Some(improvise_protocol::delivery::Want::Correction { .. })
));
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
held_equals_drawn(&client, &host);
assert!(
host.delivery().is_live(&shape()),
"naming it again revives it"
);
}
#[test]
fn a_shape_naming_no_view_is_answered_with_nothing_and_never_goes_live() {
let mut host = host();
let ghost = shape_of_view("Nope");
let c = {
let request = Request {
session: None,
shape: ghost.clone(),
window: everything(),
ask: Ask::Correction { watermark: None },
};
match host.deliver(&request, 0) {
Reply::Correction(c) => *c,
other => panic!("{other:?}"),
}
};
assert!(c.target.is_empty());
assert!(!host.delivery().is_live(&ghost));
}
#[test]
fn a_document_swap_is_a_new_epoch_behind_every_old_watermark() {
let storage = crate::storage::MemoryFs::new();
let mut other = Workbook::new("Other");
other.add_category("Product").unwrap();
other.add_item("Product", "Widget").unwrap();
other.add_item("_Measure", "Units").unwrap();
other.active_view_mut().set_axis("Product", Axis::Row);
other.active_view_mut().set_axis("_Measure", Axis::Column);
crate::persistence::save_to(&storage, &other, std::path::Path::new("other.improv")).unwrap();
let mut app = App::new(workbook(), None);
app.storage = Box::new(storage);
app.rebuild_layout();
let mut host = Host::with_instance(app, 1);
host.attach_notices();
let id = connect(&mut host);
let mut client = settled(&mut host);
let old = client.watermark(&shape()).unwrap();
host.take_notices();
let load = ModelCommand::LoadModel("other.improv".into());
let answer = send(&mut host, id, vec![load]);
assert!(!refused(&answer), "{answer:?}");
let notices = host.take_notices();
let now = notices
.last()
.expect("the swap is a committed command")
.stamp();
assert!(old.is_behind(&now));
assert_ne!(old.generation, now.generation);
assert!(notices[0].flags.formulas && notices[0].flags.views);
deliver_all(&mut client, &notices);
let mut edge = Flaky::new(&mut host);
client.sync(&mut edge, &shape(), 6);
assert!(matches!(asks(&edge.reached)[0], Ask::Correction { .. }));
held_equals_drawn(&client, &host);
}
#[test]
fn a_pull_only_client_follows_the_log_without_a_correction() {
let mut host = host();
let id = connect(&mut host);
let mut client = settled(&mut host);
send(&mut host, id, vec![set("North", 6.0)]);
send(&mut host, id, vec![set("South", 7.0)]);
let mut edge = Flaky::new(&mut host);
// The client has seen no pushes, so its horizon is its watermark.
let (state, _) = client.poll(&mut edge, &shape());
assert_eq!(state, Synced::Settled);
assert!(matches!(asks(&edge.reached)[0], Ask::Notices { .. }));
assert!(
asks(&edge.reached)
.iter()
.all(|a| !matches!(a, Ask::Correction { .. }))
);
held_equals_drawn(&client, &host);
}
#[test]
fn a_request_can_be_retried_and_answers_the_same() {
let mut host = host();
let id = connect(&mut host);
let client = settled(&mut host);
send(&mut host, id, vec![set("North", 6.0)]);
let w = client.watermark(&shape()).unwrap();
let a = reply_for(
&mut host,
Ask::Correction { watermark: Some(w) },
everything(),
);
let b = reply_for(
&mut host,
Ask::Correction { watermark: Some(w) },
everything(),
);
assert_eq!(a, b);
}
// ── lossless through the wire ──────────────────────────────────────────────
#[test]
fn values_keep_every_bit_from_the_cell_to_the_client() {
let mut host = host();
let id = connect(&mut host);
let mut client = settled(&mut host);
let values = [
0.1 + 0.2,
-0.0,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NAN,
1e308,
5e-324,
];
for (n, v) in values.iter().enumerate() {
let region = ["North", "South"][n % 2];
send(&mut host, id, vec![set(region, *v)]);
deliver_all(&mut client, &host.take_notices());
let mut edge = WireEdge(Flaky::new(&mut host));
assert_eq!(client.sync(&mut edge, &shape(), 6).0, Synced::Settled);
let held = client.held(&shape()).unwrap();
let got = held.iter().find_map(|((k, v), _)| {
(k.0.contains(&("Region".to_string(), region.to_string()))).then(|| v.to_cell_value())
});
let Some(CellValue::Number(got)) = got else {
panic!("{region} holds no number after {v:?}")
};
match v.is_nan() {
true => assert!(got.is_nan()),
false => assert_eq!(got.to_bits(), v.to_bits(), "{v:?} became {got:?}"),
}
}
}
// ── cost: what a commit does when nobody is listening ─────────────────────
/// An item whose every comparison is counted, so the cost is a number and not a clock.
#[derive(Clone, Debug)]
struct Counted(usize);
thread_local! {
static COMPARISONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
impl PartialEq for Counted {
fn eq(&self, o: &Self) -> bool {
COMPARISONS.with(|c| c.set(c.get() + 1));
self.0 == o.0
}
}
impl Eq for Counted {}
impl std::hash::Hash for Counted {
fn hash<H: std::hash::Hasher>(&self, h: &mut H) {
self.0.hash(h);
}
}
#[test]
fn diffing_a_category_compares_each_item_a_bounded_number_of_times() {
use super::delivery::diff_items;
for n in [1000usize, 2000, 4000] {
let was: Vec<Counted> = (0..n).map(Counted).collect();
let mut now = was.clone();
now.remove(n / 2);
now.push(Counted(n + 1));
COMPARISONS.with(|c| c.set(0));
let diff = diff_items(&was, &now);
let comparisons = COMPARISONS.with(|c| c.get());
assert_eq!(diff.removed.len() + diff.inserted.len(), 2);
assert!(
comparisons <= 8 * n,
"{n} items cost {comparisons} comparisons"
);
}
}
#[test]
fn a_commit_with_nothing_live_and_no_transport_reads_nothing() {
use super::delivery::STRUCTURE_READS;
let mut app = App::new(workbook(), None);
app.rebuild_layout();
let mut host = Host::with_instance(app, 1);
let id = connect(&mut host);
STRUCTURE_READS.with(|c| c.set(0));
for n in 0..3 {
send(&mut host, id, vec![set("North", n as f64)]);
}
assert_eq!(STRUCTURE_READS.with(|c| c.get()), 0);
assert_eq!(
host.delivery().stamp().publication,
3,
"the count still moves"
);
assert_eq!(host.delivery().log_len(), 0);
assert!(host.take_notices().is_empty());
}
#[test]
fn a_shape_that_goes_live_after_idle_commits_still_hears_the_axis_changes() {
let mut app = App::new(workbook(), None);
app.rebuild_layout();
let mut host = Host::with_instance(app, 1);
let id = connect(&mut host);
send(&mut host, id, vec![set("North", 1.0)]);
let mut client = settled(&mut host);
host.attach_notices();
send(&mut host, id, vec![add_item("East")]);
let notices = host.take_notices();
assert_eq!(notices[0].axis.len(), 1, "{:?}", notices[0]);
deliver_all(&mut client, &notices);
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
held_equals_drawn(&client, &host);
}
#[test]
fn the_outbox_and_the_clone_a_frame_takes_are_bounded() {
let mut host = host();
host.limit_delivery(4, 4096);
let id = connect(&mut host);
let mut client = settled(&mut host);
for n in 0..20 {
send(&mut host, id, vec![set("North", n as f64)]);
}
let queued = host.take_notices();
assert_eq!(
queued.len(),
4,
"a transport that never drains keeps the newest four"
);
deliver_all(&mut client, &queued);
let mut edge = Flaky::new(&mut host);
client.sync(&mut edge, &shape(), 6);
assert!(
matches!(asks(&edge.reached)[0], Ask::Correction { .. }),
"the gap is seen"
);
held_equals_drawn(&client, &host);
}
// ── view geometry moves positions without moving a key ────────────────────
fn transpose() -> ModelCommand {
ModelCommand::TransposeAxes {
view: "Default".into(),
}
}
fn south_window() -> Window {
Window {
top: 1,
left: 0,
rows: 1,
cols: 1,
}
}
#[test]
fn a_transpose_empties_a_partial_window_of_the_cell_that_left_it() {
let mut host = host();
let id = connect(&mut host);
let mut client = EdgeClient::new(session());
client.track(shape(), south_window());
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
assert_eq!(client.held(&shape()).map(|h| h.len()), Some(1));
host.take_notices();
send(&mut host, id, vec![transpose()]);
let notices = host.take_notices();
assert!(notices[0].flags.views);
deliver_all(&mut client, &notices);
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
assert_eq!(client.held(&shape()), Some(&drawn(&host, &south_window())));
assert_eq!(client.held(&shape()).map(|h| h.len()), Some(0));
}
#[test]
fn a_dropped_views_notice_is_answered_with_the_window_not_an_empty_key_list() {
let mut host = host();
let id = connect(&mut host);
let mut client = EdgeClient::new(session());
client.track(shape(), south_window());
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
send(&mut host, id, vec![transpose()]);
client.connection_lost();
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
assert_eq!(client.held(&shape()), Some(&drawn(&host, &south_window())));
}
#[test]
fn a_fetch_covers_only_the_notices_it_named_when_a_later_commit_is_already_in_the_state() {
let mut host = host();
let id = connect(&mut host);
let mut client = settled(&mut host);
host.take_notices();
send(&mut host, id, vec![set("North", 1.0)]);
send(&mut host, id, vec![set("South", 2.0)]);
let notices = host.take_notices();
deliver_all(&mut client, &notices[..1]);
let mut edge = Flaky::new(&mut host);
client.sync(&mut edge, &shape(), 6);
assert_eq!(
client.watermark(&shape()),
Some(notices[0].stamp()),
"through, not at"
);
deliver_all(&mut client, &notices[1..]);
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
held_equals_drawn(&client, &host);
}
#[test]
fn a_reply_held_across_a_host_restart_cannot_pull_the_client_back() {
let mut first = host_instance(1);
let id = connect(&mut first);
let mut client = settled(&mut first);
send(&mut first, id, vec![set("North", 99.0)]);
let mut lost = Flaky::scripted(&mut first, vec![Some(Fault::DropReply)]);
client.connection_lost();
client.sync(&mut lost, &shape(), 6);
let in_flight = lost.replies.remove(0);
let mut second = host_instance(2);
client.connection_lost();
client.sync(&mut Flaky::new(&mut second), &shape(), 6);
held_equals_drawn(&client, &second);
let at = client.watermark(&shape()).unwrap();
let Reply::Correction(late) = in_flight else {
panic!("the lost reply was a correction")
};
assert_eq!(client.apply(&late).0, Outcome::Duplicate);
assert_eq!(client.watermark(&shape()), Some(at));
held_equals_drawn(&client, &second);
let (state, _) = client.sync(&mut Flaky::new(&mut second), &shape(), 6);
assert_eq!(state, Synced::Settled);
}
#[test]
fn a_late_reply_from_a_host_the_client_never_joined_cannot_strand_it() {
let window = Window {
top: 0,
left: 0,
rows: 1,
cols: 1,
};
let mut first = host_instance(1);
let mut client = EdgeClient::new(session());
client.track(shape(), window);
client.sync(&mut Flaky::new(&mut first), &shape(), 6);
let mut second = host_instance(2);
let id = connect(&mut second);
send(&mut second, id, vec![set("South", 1.0)]);
let mut lost = Flaky::scripted(&mut second, vec![Some(Fault::DropReply)]);
client.connection_lost();
client.sync(&mut lost, &shape(), 6);
let late = lost.replies.remove(0);
let mut third = host_instance(3);
client.connection_lost();
client.sync(&mut Flaky::new(&mut third), &shape(), 6);
let Reply::Correction(late) = late else {
panic!("the lost reply was a correction")
};
client.apply(&late);
client.connection_lost();
let (state, _) = client.sync(&mut Flaky::new(&mut third), &shape(), 6);
assert_eq!(
state,
Synced::Settled,
"the live host's answers must be taken"
);
assert_eq!(client.held(&shape()), Some(&drawn(&third, &window)));
}
// ── the whole thing, at random ─────────────────────────────────────────────
mod random {
use proptest::prelude::*;
use super::*;
#[derive(Debug, Clone)]
enum Op {
Set(bool, usize),
Both(usize, usize),
Clear(bool),
Add(usize),
Remove(usize),
Formula,
Transpose,
TightLimits,
Evict,
Swap,
Restart,
}
fn op() -> impl Strategy<Value = Op> {
prop_oneof![
6 => (any::<bool>(), 0usize..8).prop_map(|(r, v)| Op::Set(r, v)),
4 => (0usize..8, 0usize..8).prop_map(|(a, b)| Op::Both(a, b)),
2 => any::<bool>().prop_map(Op::Clear),
3 => (0usize..3).prop_map(Op::Add),
2 => (0usize..3).prop_map(Op::Remove),
1 => Just(Op::Formula),
1 => Just(Op::Transpose),
1 => Just(Op::TightLimits),
1 => Just(Op::Evict),
1 => Just(Op::Swap),
1 => Just(Op::Restart),
]
}
const VALUES: [f64; 8] = [
1.0,
2.5,
-0.0,
0.0,
f64::INFINITY,
f64::NEG_INFINITY,
f64::NAN,
0.30000000000000004,
];
const EXTRA: [&str; 3] = ["East", "West", "North"];
fn region(north: bool) -> &'static str {
["South", "North"][north as usize]
}
fn commands(op: &Op) -> Vec<ModelCommand> {
match op {
Op::Both(a, b) => vec![set("North", VALUES[*a]), set("South", VALUES[*b])],
other => command(other).into_iter().collect(),
}
}
fn command(op: &Op) -> Option<ModelCommand> {
Some(match op {
Op::Both(..) => return None,
Op::Set(north, v) => set(region(*north), VALUES[*v]),
Op::Clear(north) => ModelCommand::ClearCell {
view: "Default".into(),
cell: record(region(*north)),
},
Op::Add(i) => add_item(EXTRA[*i]),
Op::Remove(i) => ModelCommand::RemoveItem {
category: "Region".into(),
item: EXTRA[*i].into(),
},
Op::Formula => ModelCommand::AddFormula {
raw: "Double = Revenue * 2".into(),
target_category: "_Measure".into(),
},
Op::Transpose => transpose(),
Op::Swap => ModelCommand::LoadModel("other.improv".into()),
Op::TightLimits | Op::Evict | Op::Restart => return None,
})
}
/// How a step's notices reach the client.
#[derive(Debug, Clone, Copy)]
enum Arrival {
Dropped,
Now,
Twice,
Split,
Late(usize),
}
#[derive(Debug, Clone)]
struct Weather {
arrival: Arrival,
lose_connection: bool,
sync: Vec<Option<u8>>,
replay: Option<usize>,
}
fn weather() -> impl Strategy<Value = Weather> {
let arrival = prop_oneof![
2 => Just(Arrival::Dropped),
4 => Just(Arrival::Now),
1 => Just(Arrival::Twice),
4 => Just(Arrival::Split),
3 => (1usize..4).prop_map(Arrival::Late),
];
(
arrival,
0u8..8,
proptest::collection::vec(proptest::option::of(0u8..3), 0..3),
proptest::option::of(0usize..16),
)
.prop_map(|(arrival, lose, sync, replay)| Weather {
arrival,
lose_connection: lose == 0,
sync,
replay,
})
}
fn fault(code: u8) -> Fault {
[Fault::DropRequest, Fault::DropReply, Fault::Garble][code as usize]
}
fn windows() -> impl Strategy<Value = Window> {
(0u128..2, 0u128..2, 1u128..6, 1u128..4).prop_map(|(top, left, rows, cols)| Window {
top,
left,
rows,
cols,
})
}
/// A host over `wb` that can load a second document, as a restarted host would be.
fn host_over(wb: Workbook, instance: u64) -> Host {
let storage = crate::storage::MemoryFs::new();
let mut other = Workbook::new("Other");
other.add_category("Product").unwrap();
other.add_item("Product", "Widget").unwrap();
other.add_item("_Measure", "Units").unwrap();
other.active_view_mut().set_axis("Product", Axis::Row);
other.active_view_mut().set_axis("_Measure", Axis::Column);
let mut second = other.active_view().clone();
second.name = "Second".into();
other.views.insert("Second".into(), second);
crate::persistence::save_to(&storage, &other, std::path::Path::new("other.improv"))
.unwrap();
let mut wb = wb;
if !wb.views.contains_key("Second") {
let mut second = wb.active_view().clone();
second.name = "Second".into();
wb.views.insert("Second".into(), second);
}
let mut app = App::new(wb, None);
app.storage = Box::new(storage);
app.rebuild_layout();
let mut host = Host::with_instance(app, instance);
host.attach_notices();
host
}
fn arrive(
client: &mut EdgeClient,
held_back: &mut Vec<(usize, Notice)>,
step: usize,
w: &Weather,
fresh: Vec<Notice>,
) {
for (i, n) in fresh.into_iter().enumerate() {
match w.arrival {
Arrival::Split if i == 0 => deliver_all(client, &[n]),
Arrival::Split => held_back.push((step + 1, n)),
Arrival::Dropped => {}
Arrival::Now => deliver_all(client, &[n]),
Arrival::Twice => deliver_all(client, &[n.clone(), n]),
Arrival::Late(k) => held_back.push((step + k, n)),
}
}
let (due, later): (Vec<_>, Vec<_>) = held_back.drain(..).partition(|(at, _)| *at <= step);
*held_back = later;
let mut notices: Vec<Notice> = due.into_iter().map(|(_, n)| n).collect();
if step % 2 == 1 {
notices.reverse();
}
deliver_all(client, &notices);
}
/// Epochs the client has applied a correction from, oldest first.
fn note_epoch(client: &EdgeClient, epochs: &mut Vec<(u64, u64)>) {
if let Some(w) = client.watermark(&shape()) {
let now = (w.host, w.generation);
if !epochs.contains(&now) {
epochs.push(now);
}
}
}
/// A late reply arrives out of band. One from an epoch the client was in and has left is refused;
/// one from an epoch it never joined may legitimately move it, and the next answer brings it back.
fn replay(
client: &mut EdgeClient,
c: &improvise_protocol::delivery::Correction,
epochs: &mut Vec<(u64, u64)>,
) -> Result<(), TestCaseError> {
let theirs = (c.through.host, c.through.generation);
let current = client.watermark(&shape()).map(|w| (w.host, w.generation));
let left = epochs.contains(&theirs) && current != Some(theirs);
let (outcome, _) = client.apply(c);
if left {
prop_assert_eq!(
outcome,
Outcome::Duplicate,
"a reply from the left epoch {:?}",
theirs
);
}
note_epoch(client, epochs);
Ok(())
}
/// The shrunk history of the second livelock: two swaps, a transpose and a limit change, with
/// the first swap's notice delivered three steps late and an old reply replayed at the end.
#[test]
fn a_late_notice_from_an_older_generation_cannot_rewind_the_client() {
let window = Window {
top: 0,
left: 0,
rows: 1,
cols: 1,
};
let mut host = host_over(workbook(), 1);
let id = connect(&mut host);
let mut client = EdgeClient::new(session());
client.track(shape(), window);
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
host.take_notices();
let mut held_back: Vec<(usize, Notice)> = Vec::new();
let mut old: Vec<Reply> = Vec::new();
let plan = [
(Some(0), Arrival::Late(3), vec![Some(0)], None),
(Some(0), Arrival::Dropped, vec![], None),
(Some(1), Arrival::Now, vec![], None),
(None, Arrival::Dropped, vec![], Some(6)),
];
for (step, (op, arrival, sync, replay)) in plan.into_iter().enumerate() {
match op {
Some(0) => {
send(
&mut host,
id,
vec![ModelCommand::LoadModel("other.improv".into())],
);
}
Some(_) => {
send(&mut host, id, vec![transpose()]);
}
None => host.limit_delivery(2, 1),
}
let w = Weather {
arrival,
lose_connection: true,
sync: sync.clone(),
replay,
};
let fresh = host.take_notices();
arrive(&mut client, &mut held_back, step, &w, fresh);
client.connection_lost();
if let Some(Reply::Correction(c)) = replay.and_then(|i| old.get(i % old.len().max(1))) {
client.apply(c);
}
let script = sync.iter().map(|f| f.map(fault)).collect();
let mut edge = Flaky::scripted(&mut host, script);
client.sync(&mut edge, &shape(), 6);
old.append(&mut edge.replies);
}
let (state, _) = client.poll(&mut Flaky::new(&mut host), &shape());
assert_eq!(state, Synced::Settled, "an old notice must not queue work");
assert_eq!(client.held(&shape()), Some(&drawn(&host, &window)));
}
proptest! {
#[test]
fn a_corrected_state_is_the_target_and_correcting_it_again_is_the_zero_zset(
steps in proptest::collection::vec((op(), weather()), 1..12),
window in windows(),
) {
let mut instance = 1;
let mut host = host_over(workbook(), instance);
let mut id = connect(&mut host);
let mut client = EdgeClient::new(session());
client.track(shape(), window);
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
host.take_notices();
let mut held_back: Vec<(usize, Notice)> = Vec::new();
let mut old: Vec<Reply> = Vec::new();
let mut epochs: Vec<(u64, u64)> = Vec::new();
for (step, (op, weather)) in steps.iter().enumerate() {
match (commands(op), op) {
(cmds, _) if !cmds.is_empty() => {
send(&mut host, id, cmds);
}
(_, Op::TightLimits) => host.limit_delivery(2, 1),
(_, Op::Evict) => {
host.evict_idle(10, 0);
}
_ => {
instance += 1;
let wb = host.app().model_state.workbook.clone();
host = host_over(wb, instance);
id = connect(&mut host);
held_back.clear();
}
}
let fresh = host.take_notices();
arrive(&mut client, &mut held_back, step, weather, fresh);
if weather.lose_connection {
client.connection_lost();
}
if let Some(i) = weather.replay.filter(|_| !old.is_empty()) {
match &old[i % old.len()] {
Reply::Correction(c) => replay(&mut client, c, &mut epochs)?,
Reply::Notices(list) => deliver_all(&mut client, list),
}
}
let script = weather.sync.iter().map(|f| f.map(fault)).collect();
let mut edge = Flaky::scripted(&mut host, script);
client.sync(&mut edge, &shape(), 6);
old.append(&mut edge.replies);
note_epoch(&client, &mut epochs);
let moved_host = matches!(op, Op::Restart | Op::Swap);
if let (true, Some(Reply::Correction(c))) = (moved_host, old.get(step % old.len().max(1))) {
replay(&mut client, c, &mut epochs)?;
}
}
let rest: Vec<Notice> = held_back.drain(..).map(|(_, n)| n).collect();
deliver_all(&mut client, &rest);
let (state, _) = client.poll(&mut WireEdge(Flaky::new(&mut host)), &shape());
prop_assert_eq!(state, Synced::Settled);
let want = drawn(&host, &window);
prop_assert_eq!(client.held(&shape()), Some(&want));
let full = reply_for(&mut host, Ask::Correction { watermark: None }, window);
let correction = &wire::decode(&full.target) - client.held(&shape()).unwrap();
prop_assert!(correction.is_zero(), "correcting a correct state: {correction:?}");
let before = client.held(&shape()).cloned();
let (outcome, _) = client.apply(&full);
match outcome {
Outcome::Applied { delta } => prop_assert!(delta.is_zero()),
Outcome::Duplicate => {}
other => prop_assert!(false, "{other:?}"),
}
prop_assert_eq!(client.held(&shape()).cloned(), before);
}
}
/// Hosts come and go often here, so a client can skip one and meet its notices later.
fn churn() -> impl Strategy<Value = Op> {
prop_oneof![
4 => (any::<bool>(), 0usize..8).prop_map(|(r, v)| Op::Set(r, v)),
4 => Just(Op::Restart),
2 => Just(Op::Transpose),
2 => Just(Op::Swap),
1 => (0usize..3).prop_map(Op::Add),
1 => Just(Op::TightLimits),
]
}
fn window_pair() -> impl Strategy<Value = (Window, Window)> {
(windows(), windows())
}
proptest! {
/// Two shapes, notices late, repeated and reordered, a dead host's notices kept across
/// restarts, and stored notices and replies replayed on their own.
#[test]
fn two_shapes_survive_late_repeated_and_dead_messages(
steps in proptest::collection::vec(
(churn(), weather(), proptest::option::of(0usize..32)),
1..12,
),
(wa, wb) in window_pair(),
) {
let second = shape_of_view("Second");
let mut instance = 1;
let mut host = host_over(workbook(), instance);
let mut id = connect(&mut host);
let mut client = EdgeClient::new(session());
client.track(shape(), wa);
client.track(second.clone(), wb);
client.sync(&mut Flaky::new(&mut host), &shape(), 6);
client.sync(&mut Flaky::new(&mut host), &second, 6);
host.take_notices();
let mut held_back: Vec<(usize, Notice)> = Vec::new();
let mut pool: Vec<Notice> = Vec::new();
let mut old: Vec<Reply> = Vec::new();
for (step, (op, weather, ghost)) in steps.iter().enumerate() {
match (commands(op), op) {
(cmds, _) if !cmds.is_empty() => {
send(&mut host, id, cmds);
}
(_, Op::TightLimits) => host.limit_delivery(2, 1),
(_, Op::Evict) => {
host.evict_idle(10, 0);
}
_ => {
instance += 1;
let wb = host.app().model_state.workbook.clone();
host = host_over(wb, instance);
id = connect(&mut host);
}
}
let fresh = host.take_notices();
pool.extend(fresh.iter().cloned());
arrive(&mut client, &mut held_back, step, weather, fresh);
if let Some(i) = ghost.filter(|_| !pool.is_empty()) {
let n = &pool[i % pool.len()];
client.on_notice(n);
client.on_notice(&n.for_shape(&shape()));
}
if let Some(i) = weather.replay.filter(|_| !old.is_empty()) {
match &old[i % old.len()] {
Reply::Correction(c) => {
client.apply(c);
}
Reply::Notices(list) => deliver_all(&mut client, list),
}
}
if weather.lose_connection {
client.connection_lost();
}
let script = weather.sync.iter().map(|f| f.map(fault)).collect();
let mut edge = Flaky::scripted(&mut host, script);
client.sync(&mut edge, &shape(), 6);
client.sync(&mut edge, &second, 6);
old.append(&mut edge.replies);
}
let rest: Vec<Notice> = held_back.drain(..).map(|(_, n)| n).collect();
deliver_all(&mut client, &rest);
for (name, window, view) in [(shape(), wa, "Default"), (second, wb, "Second")] {
let (state, _) = client.poll(&mut WireEdge(Flaky::new(&mut host)), &name);
prop_assert_eq!(state, Synced::Settled);
prop_assert_eq!(client.held(&name), Some(&drawn_view(&host, view, &window)));
}
}
}
}