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
//! **Shared action bus** (BUS-1) — the *one* typed, observable outbox facets use
//! to hand side work to the host: a kanban [`dragdrop`](crate::dragdrop) move, a
//! [`time_axis`](crate::time_axis) scrub that should scroll linked facets, a
//! toolbar command. It is the common carrier the two other primitives' `Effect`s
//! flow through on their way to the host.
//!
//! Engine-agnostic (no egui, no rendering) and deterministic: every dispatched
//! [`BusAction`] gets a monotonic `seq`, so a headless test asserts the exact
//! order of what a facet emitted (FC-7). Following the Elm idiom ([`crate::elm`]),
//! it is a serializable `Model` mutated only through [`update`](ActionBus::update)
//! with a [`BusMsg`], and the host **drains** the queue each frame — the actions
//! themselves *are* the side work as data (FC-8). A primitive used by facets/hosts;
//! it does not `impl Facet`.
use std::collections::VecDeque;
use serde::{Deserialize, Serialize};
/// One action a facet handed to the host. `source` is the emitting facet's title
/// (or stable id), `kind` a stable verb (`"move"`, `"scrub"`, `"open"`, …), and
/// `payload` the arbitrary detail. `seq` is the monotonic dispatch order.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct BusAction {
pub seq: u64,
pub source: String,
pub kind: String,
pub payload: serde_json::Value,
}
/// Every input the bus accepts (FC-2) — the only legal way to mutate an
/// [`ActionBus`].
#[derive(Clone, Debug, PartialEq)]
pub enum BusMsg {
/// Enqueue an action from `source` of `kind` carrying `payload`.
Dispatch { source: String, kind: String, payload: serde_json::Value },
/// Drop every queued action without delivering it.
Clear,
}
/// The shared action outbox (BUS-1). A FIFO of [`BusAction`]s plus the monotonic
/// counter that stamps each dispatch.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct ActionBus {
queue: VecDeque<BusAction>,
/// Next `seq` to assign — only ever increases, even across drains.
next_seq: u64,
}
impl ActionBus {
pub fn new() -> Self {
Self::default()
}
/// Enqueue an action, returning its assigned `seq`. The ergonomic sibling of
/// `update(BusMsg::Dispatch { .. })`.
pub fn dispatch(
&mut self,
source: impl Into<String>,
kind: impl Into<String>,
payload: serde_json::Value,
) -> u64 {
let seq = self.next_seq;
self.next_seq += 1;
self.queue.push_back(BusAction { seq, source: source.into(), kind: kind.into(), payload });
seq
}
/// Number of undelivered actions.
pub fn len(&self) -> usize {
self.queue.len()
}
/// Whether the outbox is empty.
pub fn is_empty(&self) -> bool {
self.queue.is_empty()
}
/// Look at the next action without removing it.
pub fn peek(&self) -> Option<&BusAction> {
self.queue.front()
}
/// **Drain** every queued action in dispatch order — how the host reads what
/// the facets emitted this frame. The `seq` counter is *not* reset, so ids
/// stay unique for the whole session.
pub fn drain(&mut self) -> Vec<BusAction> {
self.queue.drain(..).collect()
}
/// **The single mutation path** (FC-2). Apply one [`BusMsg`]; returns the
/// assigned `seq` for a `Dispatch` (so a caller can correlate), `None` for
/// `Clear`.
pub fn update(&mut self, msg: BusMsg) -> Option<u64> {
match msg {
BusMsg::Dispatch { source, kind, payload } => Some(self.dispatch(source, kind, payload)),
BusMsg::Clear => {
self.queue.clear();
None
}
}
}
/// Observable state as JSON (the `state_json` discipline): the pending queue
/// and the next seq, so a headless test asserts what's outstanding.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"pending": self.queue.iter().collect::<Vec<_>>(),
"len": self.queue.len(),
"next_seq": self.next_seq,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dispatch_stamps_monotonic_seqs_and_preserves_order() {
let mut bus = ActionBus::new();
let a = bus.dispatch("kanban", "move", serde_json::json!({ "card": "SUPP-1" }));
let b = bus.dispatch("gantt", "scrub", serde_json::json!({ "t": 12.0 }));
assert_eq!((a, b), (0, 1));
assert_eq!(bus.len(), 2);
let drained = bus.drain();
assert_eq!(drained.iter().map(|x| x.seq).collect::<Vec<_>>(), vec![0, 1], "FIFO order");
assert_eq!(drained[0].kind, "move");
assert!(bus.is_empty(), "drain empties the queue");
}
#[test]
fn seq_keeps_climbing_across_drains() {
let mut bus = ActionBus::new();
bus.dispatch("a", "x", serde_json::Value::Null);
bus.drain();
let seq = bus.dispatch("b", "y", serde_json::Value::Null);
assert_eq!(seq, 1, "seq is not reset by a drain — ids stay unique");
}
#[test]
fn update_dispatch_and_clear() {
let mut bus = ActionBus::new();
let seq = bus.update(BusMsg::Dispatch {
source: "toast".into(),
kind: "open".into(),
payload: serde_json::json!({ "id": 7 }),
});
assert_eq!(seq, Some(0));
assert_eq!(bus.peek().unwrap().kind, "open");
assert_eq!(bus.update(BusMsg::Clear), None);
assert!(bus.is_empty(), "clear drops everything");
}
#[test]
fn state_json_and_serde_round_trip() {
let mut bus = ActionBus::new();
bus.dispatch("kanban", "move", serde_json::json!({ "to": "done" }));
let s = bus.state_json();
assert_eq!(s["len"], 1);
assert_eq!(s["pending"][0]["kind"], "move");
let json = serde_json::to_string(&bus).unwrap();
let back: ActionBus = serde_json::from_str(&json).unwrap();
assert_eq!(bus, back);
}
}