3fcd407b08
improvise-mdfc: an untouched commit seeded the reopened editor from the cache even while an earlier write from this client was unanswered, so Tab Tab at the rightmost column stored 505 where the TUI stores 5095. `advanced` now awaits when this frame writes or any in-flight frame is awaited. The lockstep test sends three Tabs before answering any. improvise-4cb5: `settle_awaited_seed` also requires an editing mode, so an Esc before the answer no longer leaves the seed in Normal's buffer. improvise-mgis: `OpenedEditor::awaiting` loses `serde(default)`; a frame missing it fails to parse as the missing field (protocol.md §3, 1wl0). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
562 lines
21 KiB
Rust
562 lines
21 KiB
Rust
//! Everything one client knows, and the fold that keeps it current.
|
|
//! The two properties this dispatch owes: `context/code/clients.md`.
|
|
|
|
use crate::{
|
|
BasisCache, CategoryTreeCache, Command, DocumentCache, FormulaCache, GridCache, Projection,
|
|
Rejection, Snapshot, TileBarCache, ViewListCache, ViewState, WidgetCache, reduce_view,
|
|
settled_mode,
|
|
};
|
|
use serde::Serialize;
|
|
|
|
/// One client's whole state: the portable session slice plus one cache per
|
|
/// widget.
|
|
#[derive(Debug, Default, Serialize)]
|
|
pub struct ClientState {
|
|
pub view: ViewState,
|
|
pub grid: GridCache,
|
|
pub categories: CategoryTreeCache,
|
|
pub formulas: FormulaCache,
|
|
pub views: ViewListCache,
|
|
pub tiles: TileBarCache,
|
|
/// Name, dirty marker and where the host keeps it (improvise-z754).
|
|
pub document: DocumentCache,
|
|
/// The picture of the document this client may claim to have composed
|
|
/// against (ADR 001). Not drawn; folded like any other projection.
|
|
#[serde(skip)]
|
|
pub basis: BasisCache,
|
|
/// The last basis rejection this client was told about. Kept as a value
|
|
/// rather than a sentence, and session-local — `context/code/clients.md`.
|
|
#[serde(skip)]
|
|
pub last_rejection: Option<Rejection>,
|
|
/// How much of the grid this client last told the host it can display, and
|
|
/// the second half of resolving a cursor key. Not derivable: clients.md.
|
|
#[serde(skip)]
|
|
pub window: (usize, usize),
|
|
/// Bumped by every fold. A DOM renderer polls this instead of
|
|
/// re-serializing the whole state to find out whether anything moved.
|
|
pub revision: u64,
|
|
/// The view slice as the host has answered it: everything this client sent
|
|
/// and heard back about, and nothing it is still waiting on.
|
|
#[serde(skip)]
|
|
pub settled: ViewState,
|
|
/// Every frame sent since, oldest first, so a refusal can drop one and
|
|
/// replay the rest. Why a queue: `context/code/clients.md`.
|
|
#[serde(skip)]
|
|
pub in_flight: std::collections::VecDeque<Sent>,
|
|
}
|
|
|
|
/// One frame this client has sent. `awaited` is true exactly when the host
|
|
/// will answer it, which is exactly when it can be refused — `clients.md`.
|
|
#[derive(Debug, Clone, PartialEq)]
|
|
pub struct Sent {
|
|
pub cmds: Vec<Command>,
|
|
pub awaited: bool,
|
|
}
|
|
|
|
impl ClientState {
|
|
/// Replace everything with what the host says is current, folding the seed
|
|
/// in arrival order. Why `window` is not reset: `context/code/clients.md`.
|
|
pub fn bootstrap(&mut self, snapshot: Snapshot) {
|
|
self.view = snapshot.view;
|
|
self.last_rejection = None;
|
|
self.grid = GridCache::default();
|
|
self.categories = CategoryTreeCache::default();
|
|
self.formulas = FormulaCache::default();
|
|
self.views = ViewListCache::default();
|
|
self.tiles = TileBarCache::default();
|
|
self.document = DocumentCache::default();
|
|
self.basis = BasisCache::default();
|
|
// A rebase replaces the document, so every frame still in flight names
|
|
// a picture that has gone; there is nothing left to put back.
|
|
self.in_flight.clear();
|
|
self.settled = self.view.clone();
|
|
for projection in &snapshot.seed {
|
|
self.apply_projection(projection);
|
|
}
|
|
self.settle_mode();
|
|
self.settle_editor();
|
|
self.revision += 1;
|
|
}
|
|
|
|
/// Fold one command. Total and deterministic: a command naming something
|
|
/// the client does not have is dropped, never a panic.
|
|
pub fn fold(&mut self, cmd: &Command) {
|
|
match cmd {
|
|
Command::Project(projection) => self.apply_projection(projection),
|
|
other => reduce_view(&mut self.view, other),
|
|
}
|
|
self.settle_mode();
|
|
self.settle_editor();
|
|
self.revision += 1;
|
|
}
|
|
|
|
/// Fold a batch this client is about to **send**, keeping it so a refusal
|
|
/// can drop it. The one door for an outgoing frame — `clients.md`.
|
|
pub fn fold_outgoing(&mut self, cmds: &[Command]) {
|
|
let awaited = cmds.iter().any(|c| matches!(c, Command::Model { .. }));
|
|
self.in_flight.push_back(Sent {
|
|
cmds: cmds.to_vec(),
|
|
awaited,
|
|
});
|
|
self.settle_sent();
|
|
for cmd in cmds {
|
|
self.fold(cmd);
|
|
}
|
|
}
|
|
|
|
/// The host applied the oldest awaited frame whole, so it is the settled
|
|
/// slice's now.
|
|
pub fn accepted(&mut self) {
|
|
if let Some(sent) = self.in_flight.pop_front() {
|
|
for cmd in &sent.cmds {
|
|
reduce_view(&mut self.settled, cmd);
|
|
}
|
|
}
|
|
self.settle_sent();
|
|
self.settle_editor();
|
|
self.revision += 1;
|
|
}
|
|
|
|
/// The host applied **none** of the oldest awaited frame (ADR 001 clause
|
|
/// 4), so neither does this client. Why the whole slice: `clients.md`.
|
|
pub fn refused(&mut self, rejection: Rejection) {
|
|
// A refused *save* answers no `Commands` frame, so it must not consume
|
|
// one's entry — `Rejection::refused_an_edit` is that question.
|
|
if rejection.refused_an_edit() {
|
|
self.in_flight.pop_front();
|
|
self.replay();
|
|
}
|
|
self.last_rejection = Some(rejection);
|
|
self.settle_mode();
|
|
self.settle_editor();
|
|
self.revision += 1;
|
|
}
|
|
|
|
/// The settled slice plus every frame still in flight, in order. What a
|
|
/// dropped frame leaves behind is the *rest* of them, not nothing.
|
|
fn replay(&mut self) {
|
|
self.view = self.settled.clone();
|
|
let pending: Vec<Command> = self
|
|
.in_flight
|
|
.iter()
|
|
.flat_map(|sent| sent.cmds.iter().cloned())
|
|
.collect();
|
|
for cmd in &pending {
|
|
reduce_view(&mut self.view, cmd);
|
|
}
|
|
}
|
|
|
|
/// A frame the host will not answer settles as soon as nothing older is
|
|
/// waiting: only the front may settle, or the order would be lost.
|
|
fn settle_sent(&mut self) {
|
|
while self.in_flight.front().is_some_and(|sent| !sent.awaited) {
|
|
let sent = self.in_flight.pop_front().expect("just peeked");
|
|
for cmd in &sent.cmds {
|
|
reduce_view(&mut self.settled, cmd);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Settle the `Normal` pair against the grid this client **holds**, after
|
|
/// every fold. Observed, never composed — `context/code/clients.md`.
|
|
fn settle_mode(&mut self) {
|
|
self.view.mode = settled_mode(&self.view.mode, self.grid.records);
|
|
}
|
|
|
|
/// An editor that arrived without its buffer is seeded here, so what is
|
|
/// drawn and what Enter commits read one buffer (improvise-ktlk).
|
|
fn settle_editor(&mut self) {
|
|
let absent = self
|
|
.view
|
|
.mode
|
|
.minibuffer()
|
|
.filter(|_| self.view.mode.is_editing())
|
|
.map(|mb| mb.buffer_key.clone())
|
|
.filter(|key| !self.view.buffers.contains_key(key));
|
|
if let (Some(key), Some(seed)) = (absent, crate::resolve::edit_seed(self)) {
|
|
self.view.buffers.insert(key, seed.clone());
|
|
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
|
|
}
|
|
self.settle_awaited_seed();
|
|
}
|
|
|
|
/// An editor opened after a write takes its seed from the grid the host
|
|
/// answered with, never from the cache the write made stale (improvise-3uzg).
|
|
fn settle_awaited_seed(&mut self) {
|
|
let waiting = self.view.mode.is_editing()
|
|
&& self
|
|
.view
|
|
.opened_editor
|
|
.as_ref()
|
|
.is_some_and(|opened| opened.awaiting && !opened.touched);
|
|
let answered = !self.in_flight.iter().any(|sent| sent.awaited);
|
|
if let (true, true, Some(seed)) = (waiting, answered, crate::resolve::edit_seed(self)) {
|
|
self.view.buffers.insert("edit".to_string(), seed.clone());
|
|
self.view.opened_editor = Some(crate::OpenedEditor::on(seed));
|
|
}
|
|
}
|
|
|
|
/// The widget dispatch: a projection names the widget it is addressed to.
|
|
/// The `CategoryTreeCache`/`TileBarCache` overlap is deliberate (gotcha 32).
|
|
fn apply_projection(&mut self, projection: &Projection) {
|
|
match projection {
|
|
Projection::Grid(u) => self.grid.apply(u),
|
|
Projection::CategoryTree(u) => self.categories.apply(u),
|
|
Projection::Formulas(u) => self.formulas.apply(u),
|
|
Projection::ViewList(u) => self.views.apply(u),
|
|
Projection::TileBar(u) => self.tiles.apply(u),
|
|
Projection::Document(u) => self.document.apply(u),
|
|
Projection::Basis(u) => self.basis.apply(u),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::AxisFrame;
|
|
use crate::{CategoryRow, CategoryTreeUpdate, CellDisplay, GridUpdate, TileEntry, ViewSummary};
|
|
use improvise_core::view::Axis;
|
|
|
|
fn folded(cmds: &[Command]) -> ClientState {
|
|
let mut state = ClientState::default();
|
|
for cmd in cmds {
|
|
state.fold(cmd);
|
|
}
|
|
state
|
|
}
|
|
|
|
fn grid_projections() -> Vec<Command> {
|
|
vec![
|
|
Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
|
|
"North", "South",
|
|
])))),
|
|
Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
|
|
"Revenue", "Cost",
|
|
])))),
|
|
Command::Project(Projection::Grid(GridUpdate::ColWidths(vec![8, 8]))),
|
|
Command::Project(Projection::Grid(GridUpdate::Cells(vec![
|
|
((0, 0), CellDisplay::number("1200")),
|
|
((1, 1), CellDisplay::text("pending")),
|
|
]))),
|
|
]
|
|
}
|
|
|
|
/// Gotcha 33, carried across *this* crate's dispatch rather than the
|
|
/// protocol's. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn a_cell_projection_that_overtakes_its_labels_is_lost() {
|
|
let early = folded(&[
|
|
Command::Project(Projection::Grid(GridUpdate::Cells(vec![(
|
|
(0, 0),
|
|
CellDisplay::number("1200"),
|
|
)]))),
|
|
Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
|
|
"North",
|
|
])))),
|
|
Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
|
|
"Revenue",
|
|
])))),
|
|
]);
|
|
assert_eq!(
|
|
early.grid.cell(0, 0),
|
|
Some(&CellDisplay::default()),
|
|
"a Cells update that arrived before its frame must be dropped, not replayed"
|
|
);
|
|
|
|
let ordered = folded(&[
|
|
Command::Project(Projection::Grid(GridUpdate::RowFrame(AxisFrame::flat([
|
|
"North",
|
|
])))),
|
|
Command::Project(Projection::Grid(GridUpdate::ColFrame(AxisFrame::flat([
|
|
"Revenue",
|
|
])))),
|
|
Command::Project(Projection::Grid(GridUpdate::Cells(vec![(
|
|
(0, 0),
|
|
CellDisplay::number("1200"),
|
|
)]))),
|
|
]);
|
|
assert_eq!(
|
|
ordered.grid.cell(0, 0),
|
|
Some(&CellDisplay::number("1200")),
|
|
"the same three updates in frame-first order do land"
|
|
);
|
|
}
|
|
|
|
/// `doctrine(fold_tests_must_test_order_not_the_axiom)`: a bootstrap from a
|
|
/// compaction lands where the whole stream does. Evidence file.
|
|
#[test]
|
|
fn bootstrapping_from_a_compaction_lands_where_the_stream_lands() {
|
|
let mut live = folded(&grid_projections());
|
|
live.fold(&Command::Project(Projection::CategoryTree(
|
|
CategoryTreeUpdate::Categories(vec![CategoryRow {
|
|
name: "Region".into(),
|
|
items: vec!["North".into(), "South".into()],
|
|
axis: Axis::Row,
|
|
}]),
|
|
)));
|
|
live.fold(&Command::Project(Projection::Formulas(vec![
|
|
"Profit = Revenue - Cost".into(),
|
|
])));
|
|
live.fold(&Command::Project(Projection::TileBar(vec![TileEntry {
|
|
category: "Region".into(),
|
|
regular: true,
|
|
axis: Axis::Row,
|
|
}])));
|
|
live.fold(&Command::Project(Projection::ViewList(vec![ViewSummary {
|
|
name: "Default".into(),
|
|
axes: vec![("Region".into(), Axis::Row)],
|
|
}])));
|
|
live.fold(&Command::SetSelected { row: 1, col: 1 });
|
|
|
|
let snapshot = Snapshot {
|
|
view: live.view.clone(),
|
|
seed: live
|
|
.grid
|
|
.seed()
|
|
.into_iter()
|
|
.map(Projection::Grid)
|
|
.chain([
|
|
Projection::CategoryTree(CategoryTreeUpdate::Categories(
|
|
live.categories.categories.clone(),
|
|
)),
|
|
Projection::Formulas(live.formulas.formulas.clone()),
|
|
Projection::ViewList(live.views.views.clone()),
|
|
Projection::TileBar(live.tiles.tiles.clone()),
|
|
])
|
|
.collect(),
|
|
};
|
|
|
|
let mut fresh = ClientState::default();
|
|
fresh.bootstrap(snapshot);
|
|
|
|
assert_eq!(fresh.grid, live.grid, "grid must survive the compaction");
|
|
assert_eq!(fresh.categories, live.categories);
|
|
assert_eq!(fresh.formulas, live.formulas);
|
|
assert_eq!(fresh.views, live.views);
|
|
assert_eq!(fresh.tiles, live.tiles);
|
|
assert_eq!(fresh.view.cursor().selected, (1, 1));
|
|
}
|
|
|
|
/// A bootstrap arriving mid-stream is a rebase: the old caches must not
|
|
/// show through it (gotcha 39).
|
|
#[test]
|
|
fn a_mid_stream_bootstrap_discards_what_came_before() {
|
|
let mut state = folded(&grid_projections());
|
|
state.bootstrap(Snapshot {
|
|
view: ViewState::default(),
|
|
seed: vec![],
|
|
});
|
|
assert_eq!(
|
|
state.grid,
|
|
GridCache::default(),
|
|
"the previous document's grid must not survive a rebase"
|
|
);
|
|
assert!(state.view.cursors.is_empty());
|
|
}
|
|
|
|
/// The two halves do not leak into each other: `reduce_view` folds only
|
|
/// the view slice, and a projection never reaches it.
|
|
#[test]
|
|
fn a_projection_and_a_view_command_touch_disjoint_halves() {
|
|
let mut state = folded(&grid_projections());
|
|
let view_before = format!("{:?}", state.view);
|
|
state.fold(&Command::Project(Projection::Formulas(vec![
|
|
"x = 1".into(),
|
|
])));
|
|
assert_eq!(
|
|
format!("{:?}", state.view),
|
|
view_before,
|
|
"a projection is not the view slice's business"
|
|
);
|
|
|
|
let grid_before = state.grid.clone();
|
|
state.fold(&Command::SetStatus("saved".into()));
|
|
assert_eq!(state.grid, grid_before, "a view command reaches no cache");
|
|
assert_eq!(state.view.status_msg, "saved");
|
|
}
|
|
|
|
/// A mutating frame, composed the only way there is (gotcha 43), paired
|
|
/// with the view command that rides along with it.
|
|
fn commit_like(status: &str) -> Vec<Command> {
|
|
let mut cache = crate::BasisCache::default();
|
|
crate::WidgetCache::apply(
|
|
&mut cache,
|
|
&crate::basis::published(0, 0, Default::default()),
|
|
);
|
|
vec![
|
|
Command::Model {
|
|
basis: cache.claim([]).expect("seeded"),
|
|
cmd: crate::ModelCommand::CleanEmptyRecords,
|
|
},
|
|
Command::SetStatus(status.into()),
|
|
]
|
|
}
|
|
|
|
/// Two frames in flight and the **first** refused: the second was applied
|
|
/// by the host and must survive. See `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn refusing_one_of_two_frames_in_flight_keeps_the_other() {
|
|
let mut state = ClientState::default();
|
|
state.fold_outgoing(&commit_like("first"));
|
|
state.fold_outgoing(&commit_like("second"));
|
|
|
|
state.refused(Rejection::LayoutMoved);
|
|
state.accepted();
|
|
|
|
let mut only_second = ClientState::default();
|
|
only_second.fold_outgoing(&commit_like("second"));
|
|
assert_eq!(
|
|
state.view, only_second.view,
|
|
"dropping the refused frame must leave the client where a client \
|
|
that had only ever sent the accepted one stands"
|
|
);
|
|
assert!(
|
|
state.in_flight.is_empty(),
|
|
"both frames were answered; nothing may still be held"
|
|
);
|
|
}
|
|
|
|
/// A write, then the editor its advance reopens with the seed still to come.
|
|
fn write_then_awaiting_editor() -> Vec<Command> {
|
|
let mut frame = commit_like("written");
|
|
frame.extend([
|
|
Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: String::new(),
|
|
},
|
|
Command::SetOpenedEditor(Some(crate::OpenedEditor::awaiting())),
|
|
Command::ChangeMode(crate::AppMode::editing()),
|
|
]);
|
|
frame
|
|
}
|
|
|
|
/// One row, one column, the cell's stored text `edit_text`.
|
|
fn answered_with(state: &mut ClientState, edit_text: &str) {
|
|
let mut cell = CellDisplay::number(edit_text);
|
|
cell.edit_text = Some(edit_text.into());
|
|
for cmd in [
|
|
GridUpdate::RowFrame(AxisFrame::flat(["North"])),
|
|
GridUpdate::ColFrame(AxisFrame::flat(["Revenue"])),
|
|
GridUpdate::Cells(vec![((0, 0), cell)]),
|
|
] {
|
|
state.fold(&Command::Project(Projection::Grid(cmd)));
|
|
}
|
|
}
|
|
|
|
/// BUG (improvise-3uzg) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn an_awaited_seed_waits_for_the_answer_and_never_overwrites_typing() {
|
|
let mut state = ClientState::default();
|
|
answered_with(&mut state, "2");
|
|
state.fold_outgoing(&write_then_awaiting_editor());
|
|
assert_eq!(state.view.buffers.get("edit").map(String::as_str), Some(""));
|
|
answered_with(&mut state, "3");
|
|
assert_eq!(
|
|
state.view.buffers.get("edit").map(String::as_str),
|
|
Some(""),
|
|
"seeded while the write was still unanswered"
|
|
);
|
|
state.accepted();
|
|
assert_eq!(
|
|
state.view.buffers.get("edit").map(String::as_str),
|
|
Some("3"),
|
|
"the answered editor must open on the host's grid"
|
|
);
|
|
assert_eq!(
|
|
state.view.opened_editor,
|
|
Some(crate::OpenedEditor::on("3".into()))
|
|
);
|
|
|
|
let mut typed_ahead = ClientState::default();
|
|
answered_with(&mut typed_ahead, "2");
|
|
typed_ahead.fold_outgoing(&write_then_awaiting_editor());
|
|
typed_ahead.fold_outgoing(&[
|
|
Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: "5".into(),
|
|
},
|
|
Command::SetOpenedEditor(crate::resolve::edited(
|
|
typed_ahead.view.opened_editor.as_ref(),
|
|
"edit",
|
|
)),
|
|
]);
|
|
answered_with(&mut typed_ahead, "3");
|
|
typed_ahead.accepted();
|
|
assert_eq!(
|
|
typed_ahead.view.buffers.get("edit").map(String::as_str),
|
|
Some("5"),
|
|
"a seed arriving after the user typed must not overwrite the typing"
|
|
);
|
|
}
|
|
|
|
/// BUG (improvise-4cb5) — see `context/test-evidence/clients.md`.
|
|
#[test]
|
|
fn an_awaited_seed_arriving_after_escape_leaves_the_buffer_empty() {
|
|
let mut state = ClientState::default();
|
|
answered_with(&mut state, "2");
|
|
state.fold_outgoing(&write_then_awaiting_editor());
|
|
state.fold_outgoing(&[
|
|
Command::SetBuffer {
|
|
name: "edit".into(),
|
|
value: String::new(),
|
|
},
|
|
Command::ChangeMode(crate::AppMode::Normal),
|
|
]);
|
|
answered_with(&mut state, "3");
|
|
state.accepted();
|
|
assert_eq!(state.view.mode, crate::AppMode::Normal);
|
|
assert_eq!(
|
|
state.view.buffers.get("edit").map(String::as_str),
|
|
Some(""),
|
|
"the late seed was written into the buffer of an editor Esc had closed"
|
|
);
|
|
}
|
|
|
|
/// A frame the host does not answer must not consume an answer, or the
|
|
/// queue addresses the wrong frame. Evidence file.
|
|
#[test]
|
|
fn a_frame_the_host_will_not_answer_settles_without_one() {
|
|
let mut state = ClientState::default();
|
|
state.fold_outgoing(&[Command::SetStatus("typing".into())]);
|
|
assert!(
|
|
state.in_flight.is_empty(),
|
|
"a frame carrying no Command::Model is never refused, so holding \
|
|
it would make the next answer pop the wrong entry"
|
|
);
|
|
assert_eq!(state.settled.status_msg, "typing");
|
|
|
|
state.fold_outgoing(&commit_like("committed"));
|
|
state.refused(Rejection::LayoutMoved);
|
|
assert_eq!(
|
|
state.view.status_msg, "typing",
|
|
"the refusal drops the commit and keeps what already settled"
|
|
);
|
|
}
|
|
|
|
/// A refused *save* answers no `Commands` frame (improvise-368h), so it
|
|
/// must not consume one's entry. Evidence file.
|
|
#[test]
|
|
fn a_refused_save_does_not_roll_back_a_frame_it_never_answered() {
|
|
let mut state = ClientState::default();
|
|
state.fold_outgoing(&commit_like("committed"));
|
|
|
|
state.refused(Rejection::NoDocumentPath);
|
|
|
|
assert_eq!(state.in_flight.len(), 1, "the commit is still unanswered");
|
|
assert_eq!(
|
|
state.view.status_msg, "committed",
|
|
"a save that could not be written undid an edit it has no bearing on"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn every_fold_bumps_the_revision_so_a_renderer_can_poll_it() {
|
|
let mut state = ClientState::default();
|
|
assert_eq!(state.revision, 0);
|
|
state.fold(&Command::SetStatus("a".into()));
|
|
state.fold(&Command::Project(Projection::Formulas(vec![])));
|
|
assert_eq!(state.revision, 2);
|
|
}
|
|
}
|