1use blockworx_doc::{
7 commit::Commit,
8 document::FoldError,
9 rev::Rev,
10 session::{ClientSession, Host, Nonce, SessionError, UndoRefusal},
11};
12
13type JournalStep = fn(&mut ClientSession, Nonce) -> Result<Nonce, UndoRefusal>;
17
18#[derive(Debug, thiserror::Error)]
20pub enum SeedError {
21 #[error("the seed log does not fold: {0}")]
22 Refused(#[from] FoldError),
23 #[error("the host's own welcome did not land: {0}")]
24 Welcome(#[from] SessionError),
25}
26
27#[derive(Default)]
34pub struct LocalHost {
35 host: Host,
36 session: ClientSession,
37 submitted: Vec<Nonce>,
41}
42
43impl LocalHost {
44 pub fn new(commits: Vec<Commit>) -> Result<Self, SeedError> {
50 let mut host = Host::default();
51 for commit in commits {
52 host.ingest(&commit)?;
53 }
54 let session = ClientSession::welcome(host.commits_after(Rev::ZERO))?;
55 Ok(Self {
56 host,
57 session,
58 submitted: Vec::new(),
59 })
60 }
61
62 pub fn submit(&mut self, commit: Commit) -> Result<Nonce, FoldError> {
68 let nonce = self.session.submit(commit)?;
69 self.submitted.push(nonce);
70 self.ship();
71 Ok(nonce)
72 }
73
74 fn drain_submitted(&mut self) -> Vec<Nonce> {
75 std::mem::take(&mut self.submitted)
76 }
77
78 fn journal(&mut self, edit: Nonce, step: JournalStep) -> Result<Nonce, UndoRefusal> {
81 let nonce = step(&mut self.session, edit)?;
82 self.ship();
83 Ok(nonce)
84 }
85
86 fn ship(&mut self) {
89 let Some((nonce, outbound)) = self
90 .session
91 .last_submission()
92 .map(|(nonce, commit)| (nonce, commit.clone()))
93 else {
94 return;
95 };
96 let answered = match self.host.accept(outbound) {
97 Ok(accepted) => {
98 let rev = self.host.publish(accepted);
99 self.session.committed(nonce, rev)
100 }
101 Err(refusal) => {
108 tracing::error!("the in-process host refused a predicted commit: {refusal}");
109 self.session.rejected(nonce)
110 }
111 };
112 if let Err(refusal) = answered {
113 tracing::error!("the local session and its own host disagree: {refusal}");
114 }
115 }
116
117 pub fn session(&self) -> &ClientSession {
118 &self.session
119 }
120}
121
122pub enum Link {
129 Local(Box<LocalHost>),
130}
131
132impl Link {
133 pub fn local(host: LocalHost) -> Self {
134 Link::Local(Box::new(host))
135 }
136
137 pub fn submit(&mut self, commit: Commit) -> Result<Nonce, FoldError> {
140 match self {
141 Link::Local(local) => local.submit(commit),
142 }
143 }
144
145 pub fn undo(&mut self, edit: Nonce) -> Result<Nonce, UndoRefusal> {
152 self.journal(edit, ClientSession::undo)
153 }
154
155 pub fn redo(&mut self, edit: Nonce) -> Result<Nonce, UndoRefusal> {
158 self.journal(edit, ClientSession::redo)
159 }
160
161 fn journal(&mut self, edit: Nonce, step: JournalStep) -> Result<Nonce, UndoRefusal> {
162 match self {
163 Link::Local(local) => local.journal(edit, step),
164 }
165 }
166
167 pub fn drain_submitted(&mut self) -> Vec<Nonce> {
170 match self {
171 Link::Local(local) => local.drain_submitted(),
172 }
173 }
174
175 pub fn session(&self) -> &ClientSession {
176 match self {
177 Link::Local(local) => local.session(),
178 }
179 }
180
181 pub fn summary(&self) -> String {
185 match self {
186 Link::Local(_) => "nothing persisted".into(),
190 }
191 }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::path::Scope;
198 use blockworx_doc::{
199 block_model::{BlockInit, BlockUpdate, Icon, LabelInit},
200 fixtures::{block_id, commit},
201 geometry::{FracVal, GridPoint, GridRect, GridSize},
202 opcode::{Crud, OpCodes},
203 values::{LabelSide, Role},
204 };
205
206 fn rect(x: i32, y: i32) -> GridRect {
207 GridRect {
208 top_left: GridPoint { x, y },
209 size: GridSize { w: 4, h: 4 },
210 }
211 }
212
213 fn block_create(byte: u8) -> OpCodes {
214 OpCodes::Block(
215 block_id(byte),
216 Crud::Create(BlockInit {
217 parent: Scope::Root.wire_id(),
218 rect: rect(0, 0),
219 locked: false,
220 role: Role::default(),
221 title: LabelInit {
222 name: format!("b{byte}"),
223 side: LabelSide::default(),
224 offset: FracVal::default(),
225 hidden: false,
226 },
227 type_label: LabelInit {
228 name: String::new(),
229 side: LabelSide::default(),
230 offset: FracVal::default(),
231 hidden: false,
232 },
233 icon: Icon::default(),
234 }),
235 )
236 }
237
238 fn resize(byte: u8, to: GridRect) -> OpCodes {
239 OpCodes::Block(block_id(byte), Crud::Update(BlockUpdate::Rect(to)))
240 }
241
242 fn assert_converged(local: &LocalHost) {
245 assert!(
246 local.session.last_submission().is_none(),
247 "the queue drains before submit returns",
248 );
249 assert_eq!(
250 local.session.confirmed().content_hash(),
251 local.host.state().content_hash(),
252 "the session's confirmed document is the host's, byte for byte",
253 );
254 assert_eq!(
255 local.session.optimistic().content_hash(),
256 local.session.confirmed().content_hash(),
257 "with nothing pending, the prediction is the confirmed document",
258 );
259 assert_eq!(local.session.rev(), local.host.rev());
260 }
261
262 #[test]
263 fn an_empty_seed_is_the_default_host() {
264 let seeded = LocalHost::new(Vec::new()).expect("an empty log seeds");
265 let default = LocalHost::default();
266 assert_eq!(
267 seeded.session.confirmed().content_hash(),
268 default.session.confirmed().content_hash(),
269 );
270 assert_eq!(seeded.session.rev(), Rev::ZERO);
271 assert_converged(&seeded);
272 assert_converged(&default);
273 }
274
275 #[test]
276 fn a_seeded_log_welcomes_the_session_at_its_head() {
277 let log = vec![
278 commit("Added a block", vec![block_create(1)]),
279 commit("Added another", vec![block_create(2)]),
280 ];
281 assert_eq!(log.len(), 2, "precondition: the seed log is not empty");
282
283 let local = LocalHost::new(log).expect("the seed log folds");
284 assert_eq!(
285 local.session.rev().get(),
286 2,
287 "the welcome's head is the log's length",
288 );
289 assert_converged(&local);
290 assert!(
291 local.session.confirmed().block(&block_id(2)).is_some(),
292 "the seeded blocks are in the session's document",
293 );
294 }
295
296 #[test]
299 fn a_submission_is_acked_before_it_returns() {
300 let mut local =
301 LocalHost::new(vec![commit("Added a block", vec![block_create(1)])]).expect("it folds");
302 let seeded = local.session.rev();
303
304 local
305 .submit(commit("Moved it", vec![resize(1, rect(5, 5))]))
306 .expect("the edit folds");
307 assert_eq!(local.session.rev(), seeded.next(), "one commit, one rev");
308 assert_converged(&local);
309 assert_eq!(
310 *local
311 .session
312 .confirmed()
313 .block(&block_id(1))
314 .expect("the block is confirmed")
315 .as_ref()
316 .rect
317 .as_ref(),
318 rect(5, 5),
319 );
320
321 local
322 .submit(commit("Moved it again", vec![resize(1, rect(9, 9))]))
323 .expect("the second edit folds on the first");
324 assert_eq!(local.session.rev(), seeded.next().next());
325 assert_converged(&local);
326 assert_eq!(
327 *local
328 .session
329 .confirmed()
330 .block(&block_id(1))
331 .expect("the block is confirmed")
332 .as_ref()
333 .rect
334 .as_ref(),
335 rect(9, 9),
336 );
337 }
338
339 #[test]
343 fn an_edit_the_fold_refuses_never_reaches_the_host() {
344 let mut local = LocalHost::default();
345 let before = local.host.rev();
346
347 assert!(
348 local
349 .submit(commit("Moved a stranger", vec![resize(9, rect(1, 1))]))
350 .is_err(),
351 "the target is not in the document",
352 );
353 assert_eq!(local.host.rev(), before, "a refusal mints no rev");
354 assert_converged(&local);
355 }
356
357 #[test]
360 fn a_local_link_says_in_the_title_that_nothing_is_persisted() {
361 let link = Link::local(LocalHost::default());
362 assert_eq!(link.session().rev(), Rev::ZERO);
363 assert_eq!(link.summary(), "nothing persisted");
364 }
365
366 fn block_rect(link: &Link) -> GridRect {
368 *link
369 .session()
370 .confirmed()
371 .block(&block_id(1))
372 .expect("the block is confirmed")
373 .as_ref()
374 .rect
375 .as_ref()
376 }
377
378 #[test]
382 fn a_link_undoes_and_redoes_through_the_session_journal() {
383 let mut link = Link::local(
384 LocalHost::new(vec![commit("Added a block", vec![block_create(1)])]).expect("it folds"),
385 );
386 let (placed, moved) = (rect(0, 0), rect(5, 5));
387 assert_eq!(block_rect(&link), placed, "precondition: the seeded rect");
388 assert!(
389 !link.session().can_undo(),
390 "precondition: a welcome journals nothing",
391 );
392
393 link.submit(commit("Moved it", vec![resize(1, moved)]))
394 .expect("the edit folds");
395 assert_eq!(block_rect(&link), moved);
396 assert_eq!(link.session().rev().get(), 2);
397 assert!(link.session().can_undo());
398 assert!(!link.session().can_redo());
399
400 let edit = link.session().next_undo().expect("the edit is journalled");
401 link.undo(edit).expect("the inverse folds");
402 assert_eq!(block_rect(&link), placed, "undo put the block back");
403 assert_eq!(link.session().rev().get(), 3, "the inverse took a rev");
404 assert!(link.session().can_redo());
405 assert!(!link.session().can_undo(), "the past is spent");
406
407 let undone = link.session().next_redo().expect("the undo is journalled");
408 link.redo(undone).expect("the edit re-applies");
409 assert_eq!(block_rect(&link), moved, "redo moved it again");
410 assert_eq!(link.session().rev().get(), 4);
411 assert!(link.session().can_undo());
412 assert!(!link.session().can_redo(), "the future is spent");
413 }
414
415 #[test]
418 fn an_empty_journal_has_no_step_to_name() {
419 let mut link = Link::local(LocalHost::default());
420 assert!(!link.session().can_undo() && !link.session().can_redo());
421 assert_eq!(link.session().next_undo(), None);
422 assert_eq!(link.session().next_redo(), None);
423
424 let mut elsewhere = Link::local(LocalHost::default());
426 let foreign = elsewhere
427 .submit(commit("Placed it", vec![block_create(1)]))
428 .expect("the edit folds");
429 assert!(link.undo(foreign).is_err(), "nothing stands ready");
430 assert!(link.redo(foreign).is_err());
431 assert_eq!(link.session().rev(), Rev::ZERO, "no commit was sequenced");
432 }
433
434 #[test]
439 fn only_ordinary_edits_are_reported_to_the_editor() {
440 let mut link = Link::local(LocalHost::default());
441 let placed = link
442 .submit(commit("Placed it", vec![block_create(1)]))
443 .expect("the edit folds");
444 assert_eq!(
445 link.drain_submitted(),
446 vec![placed],
447 "the edit is reported once, by name",
448 );
449 assert!(
450 link.drain_submitted().is_empty(),
451 "and draining is what makes it a frame's worth",
452 );
453
454 link.undo(placed).expect("the inverse folds");
455 assert!(
456 link.drain_submitted().is_empty(),
457 "the undo's own submission is not a new edit to remember",
458 );
459 }
460}