fix(themes): PR #120 round 2 -- snapshot/baseline reset contract

Finding 1: a BufferSnapshot wipes the frontend's buffer-scoped
render state (spans, decorations, adornments, minimap summary,
completion popup), but the producer's per-buffer emission baselines
survived the switch -- on an unchanged A -> B -> A round trip,
last_summary[A]'s key still matched and the daemon emitted nothing,
so the frontend never regained A's themed minimap (or A's
StatusFacts: the band kept B's name) until an edit, republish, or
theme mutation happened to move the key.

The fix is the general contract, not a minimap special case:
SemanticRenderState::on_buffer_snapshot_sent(buffer_id) kills every
buffer-scoped baseline for that buffer (spans + style gate,
decorations, adornments, summary, status, search/menu prompts,
completion popup), called wherever the daemon writes a snapshot --
the active-buffer-follow path and the F29 upgrade broadcast; the
attach bootstrap constructs its session state fresh. Deliberately
surviving: the bufferless ThemeFacts pair, the global minibuffer
baseline, the per-frontend gutter mode, the revision-keyed diag
line cache, and other buffers' baselines.

Evidence: a producer round-trip acceptance test (themed summary and
StatusFacts return at the SAME generation; identical payload), a
real-daemon wire test driving A -> B -> A via dispatched keys
(runtime bite: times out against pre-fix daemon.rs), a Rust unit
pinning the reset's scope, and a GPU test where the re-shipped
summary restores the first visit's pixels exactly (frontend half --
no GPU code change, coverage only). The semantic_render.rs bite is
compile-fail (the hook is absent pre-fix), disclosed as weaker.
The protocol doc's composition section now states the snapshot
reset contract on both sides of the wire.

Finding 2: the acceptance-suite manifest header now lists the true
item split (1-19, 24-26, 28-29 here; 20-23, 27, 30 in the GPU
suite). Framing revision 6 folds the round; acceptance items 28-30.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoiEyuPjoBhvwACf8HAnLB
This commit is contained in:
Levi Neuwirth 2026-07-15 10:13:20 +01:00
parent 3083458cb0
commit d91ff1a9e2
6 changed files with 506 additions and 4 deletions

View File

@ -116,6 +116,18 @@ layout-local frontend consumes). Peer cursors reuse the existing
is: one capability bit, ~five instance→frontend interpretation
variants, and one frontend→instance `Viewport` variant.
**`BufferSnapshot` resets buffer-scoped interpretation state.** A
frontend receiving a snapshot drops everything it holds for the
named buffer — spans, decorations, adornments, minimap summary,
completion popup — and rebuilds from the frames that follow; the
instance mirrors this by invalidating its per-buffer emission
baselines whenever it writes a snapshot, so the frontend's
post-snapshot viewport declaration receives authoritative re-sends
even when nothing changed daemon-side (the unchanged-generation
A → B → A revisit). Bufferless facts (`ThemeFacts`, the minibuffer
prompt) and per-frontend state (the gutter mode) survive snapshots
on both sides.
## Capability and version mechanics
Identical pattern to `crdt_replica`:

View File

@ -1,7 +1,35 @@
# Theme faces — framing (Arc 4 stage 1, themes)
**Revision 5 — 2026-07-14. Status: implemented on branch
`theme-faces` (PR #120); revision 5 folds PR round 1.**
**Revision 6 — 2026-07-15. Status: implemented on branch
`theme-faces` (PR #120); revision 6 folds PR round 2.**
Revision 6 (PR #120 round 2, findings 12): the producer gains the
**snapshot/baseline reset contract**
(`SemanticRenderState::on_buffer_snapshot_sent`). A `BufferSnapshot`
resets the receiving frontend's buffer-scoped render state wholesale
— spans, decorations, adornments, minimap summary, completion popup —
but the returned viewport only replaced the producer's declared
viewport, leaving every per-buffer emission baseline intact. On an
unchanged A → B → A round trip, `last_summary[A]`'s key still
matched, so the daemon emitted nothing and the frontend never
regained A's themed minimap (or A's `StatusFacts` — the band kept
showing B's name) until an edit, diagnostic republish, or theme
mutation happened to move the key. The daemon now invalidates every
buffer-scoped baseline for the snapshot's buffer wherever it writes
one — the active-buffer-follow path and the F29 upgrade broadcast;
the attach bootstrap needs no call because its session state is
constructed fresh — general contract, not a minimap special case
(finding 1). Deliberately surviving the reset: the bufferless
`ThemeFacts` pair (the frontend keeps its face table across
snapshots), the global minibuffer baseline, the per-frontend gutter
mode, and the revision-keyed diag line cache (a compute cache, not a
peer-state baseline); other buffers' baselines also survive, since
any buffer the frontend navigates to receives its own snapshot
first. Acceptance items 2830 cover the producer round trip (themed
summary and `StatusFacts` return at one generation), the real-daemon
wiring over the wire, and the GPU minimap's pixel-identical return.
The acceptance-suite manifest header now lists the true item split
(finding 2).
Revision 5 (PR #120 round 1, findings 14): accepting a
`FileStyleSummary` on the GPU now drops the cached minimap vertices —
@ -585,6 +613,24 @@ pub struct ThemeFace {
debug-name arm (`main.rs:5606-5628`); `ThemeFace` re-exported from
`pmacs-protocol/src/lib.rs`; pin tests at `protocol.rs:1710` and
the ladder test (accept 16, reject 17); postcard round-trip pin.
- **Snapshot/baseline reset contract** (round 2 finding 1): a
`BufferSnapshot` resets the receiving frontend's buffer-scoped
render state wholesale, so wherever the daemon writes one it calls
`SemanticRenderState::on_buffer_snapshot_sent(buffer_id)`, killing
every buffer-scoped emission baseline for that buffer — spans +
style gate, decorations, adornments, summary, status, search/menu
prompts, completion popup. Without this, an unchanged-generation
A → B → A round trip suppressed every re-send and the frontend
never regained A's themed minimap or `StatusFacts`. Call sites:
the active-buffer-follow path and the F29 upgrade broadcast; the
attach bootstrap constructs its session state fresh, so its sweep
needs no call. NOT reset: `last_face_epoch` / `last_theme_faces`
(this channel is bufferless — the frontend keeps its face table
across snapshots), the global minibuffer baseline, the
per-frontend gutter mode, the revision-keyed diag line cache, and
other buffers' baselines (each buffer's own snapshot precedes its
revisit). Resetting on a failed write is harmless — the failure
mode is one redundant re-send, never staleness.
- `docs/semantic-frontend-protocol.md` gains the channel's contract
section.
@ -886,3 +932,22 @@ Keybinding-driven tests dispatch keys, never `pmacs.command.invoke`.
(PR round 1 finding 1, `PMACS_REQUIRE_GPU=1`): two summaries at
one generation with different mark colors render different
frames — fails without the cache invalidation on accept.
28. **Snapshot round trip re-ships buffer-scoped state** (PR round 2
finding 1): with a themed diag mark on A, driving the producer
through the daemon's exact A → B → A sequence
(`on_buffer_snapshot_sent` + viewport re-declaration at each
switch) re-ships A's `FileStyleSummary` — identical themed
payload, UNCHANGED generation — and A's `StatusFacts`; an
unchanged tick before the trip stays suppressed. A Rust unit
pins the reset's scope: one buffer's baselines die, other
buffers' and the bufferless `ThemeFacts` pair survive.
29. **The daemon wiring, end to end** (PR round 2 finding 1, CRDT):
a real daemon session navigating A → B → A via dispatched keys
(the active-buffer-follow path writes the snapshots) re-ships
`FileStyleSummary` for A at its unchanged generation after the
revisit's viewport re-declaration.
30. **The GPU minimap returns** (PR round 2 finding 1,
`PMACS_REQUIRE_GPU=1`): after a real `BufferSnapshot` A → B → A
round trip (same text both buffers, isolating the minimap), the
stale minimap is gone, and applying the re-shipped summary at
the same generation restores the first visit's pixels exactly.

View File

@ -8710,6 +8710,73 @@ mod tests {
);
}
#[test]
fn headless_snapshot_round_trip_summary_restores_the_minimap() {
// PR #120 round 2 finding 1 (frontend half): a `BufferSnapshot`
// drops `current_summary` with the rest of the buffer-scoped
// state, so after an A → B → A round trip no stale minimap
// may survive — and when the daemon's baseline reset re-ships
// the summary at the unchanged generation, the first visit's
// pixels must return exactly.
let text = "alpha\nbeta\ngamma\ndelta\n";
let (w, h) = (400u32, 300u32);
let Some(mut state) = headless_or_skip(w, h, text) else {
return;
};
// Both buffers carry the same text so the frames differ by
// the minimap alone.
let snapshot = || -> Vec<u8> {
let doc = loro::LoroDoc::new();
doc.get_text(LORO_TEXT_CONTAINER)
.insert(0, text)
.expect("insert snapshot text");
doc.export(loro::ExportMode::Snapshot)
.expect("export snapshot")
};
let bid_a = BufferId::next();
let bid_b = BufferId::next();
let visit = |state: &mut State, bid: BufferId| {
let _ = state.apply_attach_message(InstanceMessage::BufferSnapshot {
buffer_id: bid,
crdt_snapshot: snapshot(),
});
state.view_range = (0, text.len() as u64);
};
let lines: Vec<CellStyle> = (0..4)
.map(|_| CellStyle {
fg: CellColor::Rgb(200, 40, 40),
..CellStyle::default()
})
.collect();
visit(&mut state, bid_a);
let _ = state.apply_attach_message(InstanceMessage::FileStyleSummary {
buffer_id: bid_a,
generation: 7,
lines: lines.clone(),
});
let first_visit = state.render_offscreen();
visit(&mut state, bid_b);
visit(&mut state, bid_a);
assert_ne!(
first_visit,
state.render_offscreen(),
"the round trip dropped the summary — no stale minimap"
);
let _ = state.apply_attach_message(InstanceMessage::FileStyleSummary {
buffer_id: bid_a,
generation: 7,
lines,
});
assert_eq!(
first_visit,
state.render_offscreen(),
"the re-shipped summary restores the first visit's minimap"
);
}
#[test]
fn own_wash_faces_color_local_rects_peers_keep_the_constant() {
// Acceptance 21 + 23: decode the emitted decoration vertex

View File

@ -984,6 +984,7 @@ fn dispatcher_loop(
upgraded,
&session_registry,
&mut streams,
&mut semantic_states,
);
// The broadcast just delivered this buffer to this
// frontend too; record it so the follow check below
@ -1022,6 +1023,14 @@ fn dispatcher_loop(
&& last_active_buffer_sent.get(fid) != Some(&active_now)
{
send_buffer_snapshot_to_frontend(editor, active_now, *fid, &mut streams);
// PR #120 round 2 — the snapshot just wiped
// this frontend's buffer-scoped render state;
// the emission baselines must die with it or
// an unchanged-generation revisit (A → B → A)
// suppresses every re-send.
if let Some(sem) = semantic_states.get_mut(fid) {
sem.on_buffer_snapshot_sent(active_now);
}
last_active_buffer_sent.insert(*fid, active_now);
}
}
@ -1863,6 +1872,7 @@ fn broadcast_buffer_snapshot_to_replicas(
buffer_id: crate::buffer::BufferId,
session_registry: &SessionRegistry,
streams: &mut HashMap<FrontendId, UnixStream>,
semantic_states: &mut HashMap<FrontendId, crate::semantic_render::SemanticRenderState>,
) {
let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else {
return;
@ -1881,6 +1891,12 @@ fn broadcast_buffer_snapshot_to_replicas(
if let Err(e) = write_message(stream, &msg) {
eprintln!("pmacs: F29 send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}");
}
// PR #120 round 2 — same reset contract as the follow path:
// the snapshot wiped this replica's buffer-scoped render
// state, so its emission baselines for the buffer die too.
if let Some(sem) = semantic_states.get_mut(fid) {
sem.on_buffer_snapshot_sent(buffer_id);
}
}
}

View File

@ -381,6 +381,44 @@ impl SemanticRenderState {
});
}
/// Snapshot/baseline reset contract (PR #120 round 2 finding 1).
///
/// A `BufferSnapshot` resets the receiving frontend's
/// buffer-scoped render state wholesale — spans, decorations,
/// adornments, minimap summary, completion popup (see the GPU's
/// `BufferSnapshot` arm) — so every buffer-scoped emission
/// baseline this producer holds for that buffer must die with the
/// send. Otherwise an unchanged-key revisit (the A → B → A round
/// trip at one CRDT generation) suppresses every re-send and the
/// frontend never regains the state until an edit, diagnostic
/// republish, or theme mutation happens to move the key.
///
/// Called by the daemon wherever it writes a `BufferSnapshot` to
/// this session's stream. Resetting when the write later fails is
/// harmless — the failure mode is one redundant re-send, never
/// staleness.
///
/// Deliberately NOT reset: `last_face_epoch` / `last_theme_faces`
/// (`ThemeFacts` is bufferless — the frontend keeps its face
/// table across snapshots), `last_minibuffer` (one global core
/// instance, not buffer-scoped), `last_line_numbers`
/// (per-frontend gutter mode, kept by the frontend across the
/// switch), and `diag_line_cache` (a revision-keyed compute
/// cache, not a peer-state baseline). Baselines for OTHER buffers
/// also survive — the snapshot names one buffer, and any buffer
/// the frontend navigates to receives its own snapshot first.
pub fn on_buffer_snapshot_sent(&mut self, buffer_id: BufferId) {
self.last_sent.remove(&buffer_id);
self.last_style_gate.remove(&buffer_id);
self.last_decorations.remove(&buffer_id);
self.last_adornments.remove(&buffer_id);
self.last_summary.remove(&buffer_id);
self.last_status.remove(&buffer_id);
self.last_search_prompt.remove(&buffer_id);
self.last_menu_prompt.remove(&buffer_id);
self.last_completion_popup.remove(&buffer_id);
}
/// Project one frame.
///
/// Returns up to three messages — [`InstanceMessage::StyleSpans`]
@ -2248,6 +2286,63 @@ mod tests {
);
}
#[test]
fn snapshot_reset_drops_one_buffers_baselines_and_keeps_the_rest() {
// PR #120 round 2 finding 1 — the reset contract's scope: a
// `BufferSnapshot` for buffer A kills A's buffer-scoped
// emission baselines (here: summary and status, the two the
// A → B → A round trip visibly strands) while OTHER buffers'
// baselines and the bufferless ThemeFacts pair survive.
let state = empty_state();
let mut s = local();
let a = active_buffer(&state);
let b = {
let core = state.core.borrow();
let mut reg = core.registry.borrow_mut();
reg.create("other")
};
s.set_viewport(a, ByteRange { start: 0, end: 64 }, 0);
let first = s.render_frame(&state);
assert!(
first
.iter()
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
"first frame ships A's summary"
);
s.set_viewport(b, ByteRange { start: 0, end: 64 }, 0);
let _ = s.render_frame(&state);
assert!(s.last_summary.contains_key(&a));
assert!(s.last_summary.contains_key(&b));
assert!(s.last_status.contains_key(&a));
let facts_baseline = s.last_theme_faces.clone();
assert!(facts_baseline.is_some(), "first frame shipped ThemeFacts");
s.on_buffer_snapshot_sent(a);
assert!(
!s.last_summary.contains_key(&a) && !s.last_status.contains_key(&a),
"A's baselines die with A's snapshot"
);
assert!(
s.last_summary.contains_key(&b),
"B's baselines survive A's snapshot"
);
assert_eq!(
s.last_theme_faces, facts_baseline,
"ThemeFacts is bufferless — the face table survives snapshots"
);
// And the behavioral consequence: revisiting A at the SAME
// generation re-ships the summary the frontend just dropped.
s.set_viewport(a, ByteRange { start: 0, end: 64 }, 0);
let back = s.render_frame(&state);
assert!(
back.iter()
.any(|m| matches!(m, InstanceMessage::FileStyleSummary { .. })),
"the unchanged-generation revisit re-ships A's summary"
);
}
#[test]
fn style_gate_differs_when_syntax_epoch_bumps() {
// Q#TH6: the gate is a pure function of the theme too — a

View File

@ -1,6 +1,7 @@
// theme_faces_acceptance.rs --- Themes Arc 4 stage 1 acceptance
// (docs/theme-faces-framing.md, acceptance items 119 and 24; the GPU
// routes 2023 live in pmacs-gpu's headless suite).
// (docs/theme-faces-framing.md, acceptance items 119, 2426, and
// 2829; the GPU routes — 2023, 27, and 30 — live in pmacs-gpu's
// headless suite).
//! Named UI faces (`ui` / `ui.*` theme entries) + the `ThemeFacts`
//! wire channel (protocol v16).
@ -232,6 +233,65 @@ fn attach_diags(state: &EditorState, diags: Vec<pmacs::diag::Diagnostic>) -> Str
uri
}
// --- Daemon wire helpers (item 29; CRDT suites only) -------------------------
#[cfg(feature = "crdt")]
fn wire_viewport(
stream: &mut std::os::unix::net::UnixStream,
fid: FrontendId,
buffer_id: pmacs::buffer::BufferId,
) {
pmacs::transport::write_message(
stream,
&pmacs::protocol::FrontendEvent::Viewport {
frontend_id: fid,
buffer_id,
visible: ByteRange {
start: 0,
end: 4096,
},
generation: 0,
},
)
.expect("write Viewport");
}
#[cfg(feature = "crdt")]
fn wire_key(
stream: &mut std::os::unix::net::UnixStream,
fid: FrontendId,
key: pmacs::protocol::Key,
) {
pmacs::transport::write_message(
stream,
&pmacs::protocol::FrontendEvent::Key(pmacs::protocol::KeyEvent {
frontend_id: fid,
key,
mods: pmacs::protocol::Modifiers::NONE,
timestamp_ns: 0,
}),
)
.expect("write Key");
}
/// Read wire messages until `pick` returns, or panic at the deadline.
#[cfg(feature = "crdt")]
fn wire_wait_for<T>(
stream: &mut std::os::unix::net::UnixStream,
what: &str,
mut pick: impl FnMut(InstanceMessage) -> Option<T>,
) -> T {
let deadline = Instant::now() + Duration::from_secs(5);
while Instant::now() < deadline {
if let Ok(msg) = pmacs::transport::read_message::<InstanceMessage>(stream)
&& let Some(t) = pick(msg)
{
return t;
}
}
panic!("timeout waiting for {what}");
}
// ---------------------------------------------------------------------------
// 1 — unset faces: byte-identical chrome; default_style never leaks
// ---------------------------------------------------------------------------
@ -827,6 +887,193 @@ fn v15_peers_get_no_face_derived_summary_marks() {
assert!(theme_facts_of(&frame).is_some());
}
// ---------------------------------------------------------------------------
// 28 + 29 — snapshot/baseline reset: the A → B → A round trip
// (PR #120 round 2 finding 1)
// ---------------------------------------------------------------------------
#[test]
fn snapshot_round_trip_restores_the_themed_summary_at_one_generation() {
// A `BufferSnapshot` wipes the frontend's buffer-scoped render
// state, so the producer's emission baselines must die with the
// send (`on_buffer_snapshot_sent`). Pre-fix, revisiting A at an
// unchanged generation matched `last_summary[A]` and the
// frontend never regained the themed minimap — or A's
// StatusFacts — until an edit, republish, or theme mutation
// happened to move the key.
use pmacs::diag::DiagnosticSeverity;
let mut state = editor();
type_str(&mut state, "boom\nfine\n");
attach_diags(&state, vec![diag(DiagnosticSeverity::Error)]);
exec(
&state,
r#"pmacs.theme.merge { ["ui.diag.error"] = { fg = 45 } }"#,
);
exec(&state, "_G.__A = pmacs.window.buffer()");
let summary_msg = |msgs: &[InstanceMessage]| {
msgs.iter().find_map(|m| match m {
InstanceMessage::FileStyleSummary {
generation, lines, ..
} => Some((*generation, lines.clone())),
_ => None,
})
};
let has_status = |msgs: &[InstanceMessage]| {
msgs.iter()
.any(|m| matches!(m, InstanceMessage::StatusFacts { .. }))
};
let viewport = ByteRange {
start: 0,
end: 1 << 20,
};
let a = active_buffer(&state);
let mut sem = semantic(&state);
let first = sem.render_frame(&state);
let (gen_a, themed) = summary_msg(&first).expect("first frame ships the summary");
assert_eq!(
themed[0].underline_color,
Color::Indexed(45),
"the mark is themed"
);
assert!(has_status(&first), "first frame ships StatusFacts");
let quiet = sem.render_frame(&state);
assert!(
summary_msg(&quiet).is_none() && !has_status(&quiet),
"an unchanged tick is suppressed"
);
// The daemon switches this session to B: it writes snapshot(B)
// (resetting B's baselines) and the frontend re-declares.
exec(
&state,
"local id = pmacs.instance.show(); pmacs.window.switch_buffer(id)",
);
let b = active_buffer(&state);
assert_ne!(a, b, "the instance buffer is a distinct buffer");
sem.on_buffer_snapshot_sent(b);
sem.set_viewport(b, viewport, 0);
let _ = sem.render_frame(&state);
// ... and back to A. Zero edits: same CRDT generation.
exec(&state, "pmacs.window.switch_buffer(_G.__A)");
assert_eq!(active_buffer(&state), a);
sem.on_buffer_snapshot_sent(a);
sem.set_viewport(a, viewport, 0);
let back = sem.render_frame(&state);
let (gen_back, lines_back) = summary_msg(&back).expect("the revisit re-ships the summary");
assert_eq!(
gen_back, gen_a,
"at the SAME generation — no edit forced it"
);
assert_eq!(lines_back, themed, "the identical themed payload returns");
assert!(has_status(&back), "StatusFacts returns with it");
}
#[cfg(feature = "crdt")]
#[test]
fn daemon_reships_the_summary_after_a_real_buffer_round_trip() {
// Item 29 — the daemon wiring for finding 1: a REAL daemon whose
// session navigates A → B → A (via dispatched keys, so the
// active-buffer-follow path writes the snapshots) must re-ship
// `FileStyleSummary` for A at its unchanged generation. Pre-fix,
// the producer baselines survived the snapshot and the revisit
// was summary-silent forever.
use common::daemon::{TestDaemon, build_default_caps};
use pmacs::protocol::{AttachRequest, FrontendCapabilities, Hello, Key};
use pmacs::transport::{read_message, write_message};
let daemon = TestDaemon::spawn_with_config(
r#"
pmacs.command.define {
name = "test.go-instance",
description = "themes round 2: switch to the instance buffer",
fn = function()
_G.__orig = pmacs.window.buffer()
local id = pmacs.instance.show()
pmacs.window.switch_buffer(id)
end,
}
pmacs.command.define {
name = "test.go-back",
description = "themes round 2: switch back to the original buffer",
fn = function() pmacs.window.switch_buffer(_G.__orig) end,
}
pmacs.keymap.bind { scope = "global", sequence = "<f6>", command = "test.go-instance" }
pmacs.keymap.bind { scope = "global", sequence = "<f7>", command = "test.go-back" }
"#,
);
let mut stream = daemon.connect();
stream
.set_read_timeout(Some(Duration::from_millis(250)))
.unwrap();
let hello: Hello = read_message(&mut stream).expect("read Hello");
let fid = hello.assigned_frontend_id;
write_message(
&mut stream,
&AttachRequest {
protocol_version: 16,
frontend_capabilities: FrontendCapabilities {
multi_frontend: true,
crdt_replica: true,
semantic_render: true,
..build_default_caps()
},
initial_size: CellSize::new(24, 80),
},
)
.expect("write AttachRequest");
// Learn buffer A from the bootstrap snapshot, declare, and note
// the first summary's generation.
let a = wire_wait_for(&mut stream, "bootstrap BufferSnapshot", |m| match m {
InstanceMessage::BufferSnapshot { buffer_id, .. } => Some(buffer_id),
_ => None,
});
wire_viewport(&mut stream, fid, a);
let gen_a = wire_wait_for(&mut stream, "first FileStyleSummary(A)", |m| match m {
InstanceMessage::FileStyleSummary {
buffer_id,
generation,
..
} if buffer_id == a => Some(generation),
_ => None,
});
// A → B: the follow path writes snapshot(B); re-declare for B.
wire_key(&mut stream, fid, Key::F(6));
let b = wire_wait_for(&mut stream, "BufferSnapshot(B)", |m| match m {
InstanceMessage::BufferSnapshot { buffer_id, .. } if buffer_id != a => Some(buffer_id),
_ => None,
});
wire_viewport(&mut stream, fid, b);
// B → A: the follow path writes snapshot(A) — the reset under
// test — and the revisit must re-ship A's summary, unchanged
// generation included.
wire_key(&mut stream, fid, Key::F(7));
wire_wait_for(&mut stream, "BufferSnapshot(A) on revisit", |m| match m {
InstanceMessage::BufferSnapshot { buffer_id, .. } if buffer_id == a => Some(()),
_ => None,
});
wire_viewport(&mut stream, fid, a);
let gen_back = wire_wait_for(&mut stream, "re-shipped FileStyleSummary(A)", |m| match m {
InstanceMessage::FileStyleSummary {
buffer_id,
generation,
..
} if buffer_id == a => Some(generation),
_ => None,
});
assert_eq!(
gen_back, gen_a,
"the revisit summary arrives at A's unchanged generation"
);
}
// ---------------------------------------------------------------------------
// 12 + 16 + 19 — ThemeFacts emission discipline; late join; set wipes
// ---------------------------------------------------------------------------