diff --git a/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs index 612858424..294157e4d 100644 --- a/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/node/node_controller.rs @@ -292,15 +292,41 @@ impl NodeController { /// they name: consumed/config slots on the def root and produced slots /// on the state root (bindings live at node-def roots since M0). fn apply_binding_facts(&mut self) { - let facts = self - .slots + let facts = self.authored_binding_facts(); + for slot in &mut self.slots { + slot.apply_binding_facts(&facts); + } + } + + /// Authored binding facts as the synced def root currently tells them. + fn authored_binding_facts(&self) -> Vec { + self.slots .iter() .find(|slot| matches!(slot.address().root, ProjectSlotRoot::Def)) .map(SlotController::binding_facts) - .unwrap_or_default(); + .unwrap_or_default() + } + + /// Re-derive and re-apply binding facts for this subtree, reading the + /// synced facts *through* the pending-edit mirror: `overrides` carries + /// the buffered/acked `bindings[…]` edits the synced view has not caught + /// up with yet, so an authored bind/unbind presents immediately instead + /// of lagging the passive read cadence. Callers follow up with the + /// graph-default overlay (`ProjectController:: + /// apply_default_binding_overlay`), since this resets every slot's + /// binding state before re-filling. + pub(in crate::app::project) fn refresh_binding_facts( + &mut self, + overrides: &crate::app::project::slot::BindingFactOverrides, + ) { + let mut facts = self.authored_binding_facts(); + overrides.apply_to(&self.address, &mut facts); for slot in &mut self.slots { slot.apply_binding_facts(&facts); } + for child in &mut self.children { + child.refresh_binding_facts(overrides); + } } /// Apply one graph-derived default binding fact to this node's roots diff --git a/lp-app/lpa-studio-core/src/app/project/project_controller.rs b/lp-app/lpa-studio-core/src/app/project/project_controller.rs index 363440cc4..bc2f21961 100644 --- a/lp-app/lpa-studio-core/src/app/project/project_controller.rs +++ b/lp-app/lpa-studio-core/src/app/project/project_controller.rs @@ -6,7 +6,8 @@ use lpa_client::{CancelSignal, ProgressDeadline}; use crate::app::project::format_lp_value; use crate::app::project::slot::{ - AssetEditEntry, AssetEditKey, AssetEditState, SlotEditEntry, SlotEditEntrySource, SlotEditJoin, + AssetEditEntry, AssetEditKey, AssetEditState, BindingFactEditOp, BindingFactOverrides, + SlotEditEntry, SlotEditEntrySource, SlotEditJoin, }; use crate::app::studio::refresh_cadence::{VERDICT_CHASE_INTERVAL, VERDICT_CHASE_TICKS}; use crate::core::notice::UiNotices; @@ -890,7 +891,7 @@ impl ProjectController { self.slot_shapes = view.slots.registry.clone(); self.root_shape_ids = view.slots.root_shapes.clone(); reconcile_root_nodes(&mut self.root_nodes, view); - self.apply_default_binding_overlay(); + self.refresh_binding_presentation(); if let Some(target) = self.active_editor_target.clone() { self.focus_editor_target(&target); } @@ -898,6 +899,64 @@ impl ProjectController { Ok(()) } + /// Re-derive every node's per-slot binding presentation: authored facts + /// from the synced def roots, read *through* the pending-edit mirror + /// ([`Self::binding_fact_overrides`]), then the graph-default overlay on + /// whatever the authored pass left unwired. + /// + /// Runs after every applied project view and after every acked + /// `bindings[…]` mutation — the synced view only reflects a binding edit + /// on the next passive read, so without the ack-time refresh an authored + /// bind/unbind would present stale (default origin, no Unbind) for up to + /// a full read cycle. + fn refresh_binding_presentation(&mut self) { + let overrides = self.binding_fact_overrides(); + for node in &mut self.root_nodes { + node.refresh_binding_facts(&overrides); + } + self.apply_default_binding_overlay(); + } + + /// The pending `bindings[…]` edits fact derivation must read through: + /// the overlay mirror's acked edits (reverse-mapped to slot addresses + /// like [`Self::slot_edit_join`] does) shadowed by the local edit buffer + /// (whose entries are newer; `Failed` entries changed nothing on the + /// server and are skipped). + fn binding_fact_overrides(&self) -> BindingFactOverrides { + let mut overrides = BindingFactOverrides::default(); + if let Some(sync) = &self.sync { + let nodes_by_artifact = self.nodes_by_def_artifact(); + for (artifact, path, op) in sync.overlay_slot_edits() { + let op = match op { + lpc_model::SlotEditOp::AssignValue(value) => { + BindingFactEditOp::Assign(value.clone()) + } + lpc_model::SlotEditOp::Remove => BindingFactEditOp::Remove, + // Structural creation carries no endpoint: no fact yet. + lpc_model::SlotEditOp::EnsurePresent => continue, + }; + let Some(nodes) = nodes_by_artifact.get(artifact) else { + continue; + }; + for node in nodes { + overrides.insert(node.clone(), path.clone(), op.clone()); + } + } + } + for (address, edit) in &self.edit_buffer { + if edit.is_failed() || address.root != ProjectSlotRoot::Def { + continue; + } + let op = match &edit.op { + PendingEditOp::SetValue { value } => BindingFactEditOp::Assign(value.clone()), + PendingEditOp::RemoveValue => BindingFactEditOp::Remove, + PendingEditOp::EnsurePresent | PendingEditOp::MoveEntry { .. } => continue, + }; + overrides.insert(address.node.clone(), address.path.clone(), op); + } + overrides + } + /// Overlay graph-derived default bindings onto per-slot indicators: every /// effective binding with `origin: default` marks its owning slot as /// bound/publishing with a default-origin endpoint (DEF badge, popover @@ -1820,9 +1879,10 @@ impl ProjectController { .ok_or_else(|| UiError::Project("project sync is not initialized".to_string()))?; let result = self.apply_project_view(sync.project_view()); self.sync = Some(sync); - // The overlay reads the binding graph through `self.sync`, which was - // taken out during the view apply — run it now that it is restored. - self.apply_default_binding_overlay(); + // The binding presentation reads the overlay mirror and the binding + // graph through `self.sync`, which was taken out during the view + // apply — run it now that it is restored. + self.refresh_binding_presentation(); result } @@ -2256,6 +2316,16 @@ impl ProjectController { { sync.apply_acked_edits(&accepted, mutation.overlay_revision); } + // A stored `bindings[…]` edit changes which facts read as authored, + // and the synced slot tree only learns that on the next passive read + // — re-derive the per-slot binding presentation from the updated + // mirror now so the popover flips immediately. + if accepted + .iter() + .any(|(command, _)| mutation_touches_bindings(&command.mutation)) + { + self.refresh_binding_presentation(); + } rejections } @@ -2457,6 +2527,15 @@ impl ProjectController { { sync.apply_acked_edits(&accepted, mutation.overlay_revision); } + // A `ClearArtifact` drops the artifact's whole mirror entry — slot + // edits included when the artifact carried them — so binding + // presentation re-derives here exactly like on slot-edit acks. + if accepted + .iter() + .any(|(command, _)| mutation_touches_bindings(&command.mutation)) + { + self.refresh_binding_presentation(); + } rejections } @@ -2606,6 +2685,26 @@ impl ProjectAssetContentRun { /// Human-readable text for a rejection: the server message when present, /// else the stable reason category. +/// True when an accepted mutation may have changed the stored `bindings[…]` +/// edits somewhere — the trigger for re-deriving per-slot binding +/// presentation from the mirror. Whole-overlay/artifact clears cannot name a +/// path, so they count conservatively. +fn mutation_touches_bindings(mutation: &MutationOp) -> bool { + fn under_bindings(path: &lpc_model::SlotPath) -> bool { + matches!( + path.segments().first(), + Some(SlotPathSegment::Field(name)) if name.as_str() == "bindings" + ) + } + match mutation { + MutationOp::PutSlotEdit { edit, .. } => under_bindings(&edit.path), + MutationOp::RemoveSlotEdit { path, .. } => under_bindings(path), + MutationOp::MoveSlotEntry { from, to, .. } => under_bindings(from) || under_bindings(to), + MutationOp::ClearArtifact { .. } | MutationOp::Clear => true, + MutationOp::SetArtifactBody { .. } => false, + } +} + fn rejection_text(rejection: &MutationRejection) -> String { if rejection.message.is_empty() { format!("{:?}", rejection.reason) @@ -3979,6 +4078,150 @@ mod tests { assert!(authoring.authored.is_none()); } + /// The graph fixture behind the declared-default binding tests: `time` + /// consumes `bus:time` as a slot-declared default. + fn default_time_wiring_graph() -> lpc_wire::WireBindingGraph { + let node = lpc_model::NodeId::new(1); + lpc_wire::WireBindingGraph { + revision: Revision::new(2), + bindings: vec![lpc_wire::WireEffectiveBinding { + owner: node, + node, + slot: Some(SlotPath::parse("time").unwrap()), + direction: lpc_wire::WireBindingDirection::Consumes, + endpoint: lpc_wire::WireBindingEndpoint::Bus { + channel: "time".to_string(), + }, + origin: lpc_wire::WireBindingOrigin::Default, + priority: -1000, + kind: lpc_model::Kind::Instant, + }], + channels: Vec::new(), + } + } + + fn orbit_def_address(path: &str) -> crate::ProjectSlotAddress { + crate::ProjectSlotAddress::new( + node_address("/demo.project/orbit.shader"), + ProjectSlotRoot::def(), + SlotPath::parse(path).unwrap(), + ) + } + + #[test] + fn acked_bind_gesture_reads_authored_before_any_refresh() { + // The popover's bind gesture on a slot whose wiring is a declared + // default, targeting the SAME channel the default names: the graph + // looks unchanged, only the origin flips. The synced view and the + // graph snapshot lag on the passive read cadence, so the acked + // `bindings[…]` edits alone must flip the presentation (authored + // origin, Unbind affordance) — no refresh runs in this test. + let (mut project, mut client, _sent) = ready_project_with_scripted_client(vec![ + mutation_response(1, vec![accepted(1)], 3), + mutation_response(2, vec![accepted(2)], 4), + mutation_response(3, vec![accepted(3)], 5), + ]); + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_bound_slots_without_bindings(&mut view, 1, Revision::new(2)); + project.apply_project_view(&view).unwrap(); + project.set_node_def_artifacts(BTreeMap::from([(NodeId::new(1), edit_artifact())])); + project + .sync_mut() + .unwrap() + .set_binding_graph_for_test(default_time_wiring_graph()); + project.apply_default_binding_overlay(); + + let nodes = project.ui_nodes(); + let time = config_slot(&nodes, "Time"); + assert!(time.authoring.as_ref().unwrap().authored.is_none()); + let crate::UiSlotSourceState::Bound(endpoint) = &time.source else { + panic!("default wiring reads bound"); + }; + assert!(endpoint.default_origin); + + // The popover's gesture sequence: entry, endpoint option, endpoint. + let endpoint_address = orbit_def_address("bindings[time].source.some"); + for op in [ + crate::SlotEditOp::EnsurePresent { + address: orbit_def_address("bindings[time]"), + }, + crate::SlotEditOp::EnsurePresent { + address: endpoint_address.clone(), + }, + crate::SlotEditOp::SetValue { + address: endpoint_address, + value: LpValue::String("bus:time".to_string()), + }, + ] { + block_on_ready(project.apply_slot_edit(&mut client, op)).unwrap(); + } + + let nodes = project.ui_nodes(); + let time = config_slot(&nodes, "Time"); + let authoring = time.authoring.as_ref().expect("authoring"); + assert_eq!( + authoring.authored.as_ref().map(|e| e.label.as_str()), + Some("bus:time"), + "the acked bind reads authored (Retarget/Unbind) immediately" + ); + let crate::UiSlotSourceState::Bound(endpoint) = &time.source else { + panic!("time stays bound"); + }; + assert!( + !endpoint.default_origin, + "origin flips to authored without waiting for a passive refresh" + ); + } + + #[test] + fn acked_unbind_restores_default_presentation_before_any_refresh() { + // The reverse gesture: unbinding an authored entry the synced view + // still carries must drop the authored presentation on the ack and + // let the declared default (graph overlay) take over immediately. + let (mut project, mut client, _sent) = + ready_project_with_scripted_client(vec![mutation_response(1, vec![accepted(1)], 3)]); + let mut view = single_node_view(1, NodeRuntimeStatus::Ok); + install_bound_slots(&mut view, 1, Revision::new(2)); + project.apply_project_view(&view).unwrap(); + project.set_node_def_artifacts(BTreeMap::from([(NodeId::new(1), edit_artifact())])); + project + .sync_mut() + .unwrap() + .set_binding_graph_for_test(default_time_wiring_graph()); + project.apply_default_binding_overlay(); + + let nodes = project.ui_nodes(); + let time = config_slot(&nodes, "Time"); + assert!( + time.authoring.as_ref().unwrap().authored.is_some(), + "the synced bindings entry reads authored" + ); + + // Unbind: RemoveValue on the bindings entry, exactly as the popover + // dispatches it. + block_on_ready(project.apply_slot_edit( + &mut client, + crate::SlotEditOp::RemoveValue { + address: orbit_def_address("bindings[time]"), + }, + )) + .unwrap(); + + let nodes = project.ui_nodes(); + let time = config_slot(&nodes, "Time"); + assert!( + time.authoring.as_ref().unwrap().authored.is_none(), + "the acked unbind drops the authored entry immediately" + ); + let crate::UiSlotSourceState::Bound(endpoint) = &time.source else { + panic!("the declared default takes over, so the slot stays bound"); + }; + assert!( + endpoint.default_origin, + "presentation falls back to the declared default on the ack" + ); + } + #[test] fn channel_choices_merge_observed_and_well_known() { let mut view = single_node_view(1, NodeRuntimeStatus::Ok); diff --git a/lp-app/lpa-studio-core/src/app/project/slot/mod.rs b/lp-app/lpa-studio-core/src/app/project/slot/mod.rs index af05fec0a..0cc0b317c 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/mod.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/mod.rs @@ -16,6 +16,7 @@ pub mod slot_edit_op; pub use pending_edit::{PendingEdit, PendingEditOp, PendingEditPhase}; pub use project_slot_address::ProjectSlotAddress; pub use project_slot_root::ProjectSlotRoot; +pub(in crate::app::project) use slot_binding_fact::{BindingFactEditOp, BindingFactOverrides}; pub use slot_binding_fact::{SlotBindingFact, SlotBindingFactKind}; pub use slot_controller::{SlotController, SlotControllerState, SlotKind}; pub(in crate::app::project) use slot_edit_join::{ diff --git a/lp-app/lpa-studio-core/src/app/project/slot/slot_binding_fact.rs b/lp-app/lpa-studio-core/src/app/project/slot/slot_binding_fact.rs index 3f2edc7a5..74fbb87a3 100644 --- a/lp-app/lpa-studio-core/src/app/project/slot/slot_binding_fact.rs +++ b/lp-app/lpa-studio-core/src/app/project/slot/slot_binding_fact.rs @@ -1,6 +1,12 @@ //! Authored binding facts extracted from a node's def slot tree. -use crate::UiBindingEndpoint; +use std::collections::BTreeMap; + +use lpc_model::{LpValue, SlotPath, SlotPathSegment}; + +use crate::{ + ProjectNodeAddress, UiBindingEndpoint, UiSlotValue, app::project::format_slot_map_key, +}; /// One authored binding parsed from a node's root `bindings` map. /// @@ -26,3 +32,151 @@ pub enum SlotBindingFactKind { /// The slot is fed an authored literal (`"value": …`). Literal(UiBindingEndpoint), } + +impl SlotBindingFactKind { + /// The `BindingDef` field this fact kind is authored under. + fn field_name(&self) -> &'static str { + match self { + Self::Source(_) => "source", + Self::Target(_) => "target", + Self::Literal(_) => "value", + } + } +} + +/// Pending binding edits (edit buffer + overlay mirror) that shadow the +/// synced view's authored binding facts, keyed by node and `bindings[…]` +/// path. +/// +/// The synced slot tree only reflects a `bindings` edit after the next +/// passive project read, so fact derivation reads *through* these overrides: +/// a just-acked bind/retarget/unbind flips the per-slot presentation +/// (authored vs default, Unbind affordance) immediately instead of lagging +/// the read cadence. Only paths under the def root's `bindings` field are +/// retained. +#[derive(Debug, Default)] +pub(in crate::app::project) struct BindingFactOverrides { + by_node: BTreeMap>, +} + +/// One pending edit that can shadow authored binding facts. Structural +/// `EnsurePresent`s carry no endpoint and change no fact, so they are never +/// stored. +#[derive(Clone, Debug, PartialEq)] +pub(in crate::app::project) enum BindingFactEditOp { + /// A value assignment at the path (a bind/retarget endpoint, usually). + Assign(LpValue), + /// A removal at the path (an unbind, an option toggled off, …). + Remove, +} + +impl BindingFactOverrides { + /// Record `op` at `path` for `node` when the path is a `bindings[…]` + /// path; later inserts at the same path win (the callers insert the + /// overlay mirror first, then the edit buffer). + pub(in crate::app::project) fn insert( + &mut self, + node: ProjectNodeAddress, + path: SlotPath, + op: BindingFactEditOp, + ) { + let under_bindings = matches!( + path.segments().first(), + Some(SlotPathSegment::Field(name)) if name.as_str() == "bindings" + ); + if !under_bindings { + return; + } + self.by_node.entry(node).or_default().insert(path, op); + } + + /// Shadow `facts` (the synced view's authored facts for `node`) with the + /// node's pending binding edits, shallow paths first so a removed entry + /// does not outlive a deeper re-assignment. + pub(in crate::app::project) fn apply_to( + &self, + node: &ProjectNodeAddress, + facts: &mut Vec, + ) { + let Some(edits) = self.by_node.get(node) else { + return; + }; + for (path, op) in edits { + apply_binding_edit(facts, path, op); + } + } +} + +/// Fold one pending `bindings[…]` edit into the authored fact list. Only the +/// shapes the binding editors produce are meaningful; anything deeper or +/// oddly-shaped changes nothing (the next project read reconciles it). +fn apply_binding_edit(facts: &mut Vec, path: &SlotPath, op: &BindingFactEditOp) { + // segments[0] is the `bindings` field itself (checked at insert). + let rest = &path.segments()[1..]; + match (rest, op) { + // The whole map removed: no authored facts survive. + ([], BindingFactEditOp::Remove) => facts.clear(), + // `bindings[key]` removed: an unbind drops every fact for the slot. + ([SlotPathSegment::Key(key)], BindingFactEditOp::Remove) => { + let slot = format_slot_map_key(key); + facts.retain(|fact| fact.slot != slot); + } + // `bindings[key].source` / `.source.some` removed: that side only. + ( + [ + SlotPathSegment::Key(key), + SlotPathSegment::Field(field), + tail @ .., + ], + BindingFactEditOp::Remove, + ) if binding_field(field.as_str()) && endpoint_tail(tail) => { + let slot = format_slot_map_key(key); + facts.retain(|fact| fact.slot != slot || fact.kind.field_name() != field.as_str()); + } + // `bindings[key].source.some` assigned: bind/retarget that side. + ( + [ + SlotPathSegment::Key(key), + SlotPathSegment::Field(field), + SlotPathSegment::Field(some), + ], + BindingFactEditOp::Assign(value), + ) if binding_field(field.as_str()) && some.as_str() == "some" => { + let slot = format_slot_map_key(key); + facts.retain(|fact| fact.slot != slot || fact.kind.field_name() != field.as_str()); + let endpoint = binding_edit_endpoint(value); + let kind = match field.as_str() { + "source" => SlotBindingFactKind::Source(endpoint), + "target" => SlotBindingFactKind::Target(endpoint), + _ => SlotBindingFactKind::Literal(endpoint), + }; + facts.push(SlotBindingFact { slot, kind }); + } + _ => {} + } +} + +/// True for the `BindingDef` fields that carry one side of a binding. +fn binding_field(name: &str) -> bool { + matches!(name, "source" | "target" | "value") +} + +/// True when `tail` addresses the binding field itself or its option payload. +fn endpoint_tail(tail: &[SlotPathSegment]) -> bool { + match tail { + [] => true, + [SlotPathSegment::Field(some)] => some.as_str() == "some", + _ => false, + } +} + +/// Endpoint carried by a pending binding-side assignment, mirroring +/// `SlotController::binding_endpoint`: endpoint strings bind as-is, anything +/// else displays as an authored literal. +fn binding_edit_endpoint(value: &LpValue) -> UiBindingEndpoint { + match value { + LpValue::String(endpoint) => UiBindingEndpoint::new(endpoint.clone()), + other => UiBindingEndpoint::new(UiSlotValue::from_lp_value(other).display) + .with_detail("literal value"), + } +} diff --git a/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs b/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs index c97b24b72..11c79cfaf 100644 --- a/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs +++ b/lp-app/lpa-studio-core/src/app/studio/studio_edit_e2e_tests.rs @@ -1181,6 +1181,94 @@ fn option_toggle_off_then_on_ends_clean_from_acks_alone() { ); } +#[test] +fn bind_and_unbind_gestures_present_authored_state_from_acks_alone() { + // The slot detail popover's binding story (D26 follow-up): the bind + // gesture (EnsurePresent entry → EnsurePresent endpoint option → + // SetValue) and the unbind gesture (RemoveValue on the entry) must flip + // the per-slot binding presentation — authored endpoint, Unbind + // affordance — from the mutation acks alone. No RefreshProject runs in + // this test: before the ack-time re-derivation, the presentation lagged + // the passive read cadence by up to tens of seconds. + let server = Rc::new(RefCell::new(edit_e2e_server())); + let io = InProcessServerIo { + server: Rc::clone(&server), + inbox: Rc::new(RefCell::new(VecDeque::new())), + sent: Rc::new(RefCell::new(Vec::new())), + }; + let client = StudioServerClient::from_io_for_test("in-process", Box::new(io)); + let controller = StudioController::connected_with_client_for_test(client); + let (mut actor, handle) = StudioActor::new(controller, |_| core::future::ready(())); + let mut view = handle.view; + + handle + .tx + .send(project_action(ProjectOp::ConnectRunningProject)); + drive(actor.run_one_batch_for_test()); + let snapshot = view.try_recv().expect("connect emits a snapshot"); + let color_order = find_slot(&snapshot, "color_order"); + let authoring = color_order + .authoring + .as_ref() + .expect("bindable def row carries authoring"); + assert!(authoring.authored.is_none(), "nothing is bound yet"); + let color_order_address = color_order + .address + .clone() + .expect("color order slot carries an address"); + + // The popover's bind gesture, exactly as BindingAuthoringSection + // dispatches it. + let entry_address = child_address(&color_order_address, "bindings[color_order]"); + let endpoint_address = child_address(&color_order_address, "bindings[color_order].source.some"); + handle.tx.send(ensure_present_action(entry_address.clone())); + handle + .tx + .send(ensure_present_action(endpoint_address.clone())); + handle.tx.send(set_value_action( + endpoint_address, + LpValue::String("bus:wave".to_string()), + )); + drive(actor.run_one_batch_for_test()); + let snapshot = view.try_recv().expect("bind gesture emits a snapshot"); + + let color_order = find_slot(&snapshot, "color_order"); + let authoring = color_order.authoring.as_ref().expect("authoring"); + assert_eq!( + authoring.authored.as_ref().map(|e| e.label.as_str()), + Some("bus:wave"), + "the acked bind reads authored (Retarget/Unbind) with no refresh in between" + ); + assert!( + matches!(&color_order.source, crate::UiSlotSourceState::Bound(endpoint) + if endpoint.label == "bus:wave" && !endpoint.default_origin), + "the row presents the authored wiring, not default-origin: {:?}", + color_order.source + ); + + // Unbind from the same popover: the entry removal must clear the + // authored presentation from its ack alone as well. + handle.tx.send(remove_value_action(entry_address)); + drive(actor.run_one_batch_for_test()); + let snapshot = view.try_recv().expect("unbind emits a snapshot"); + + let color_order = find_slot(&snapshot, "color_order"); + assert!( + color_order + .authoring + .as_ref() + .expect("authoring") + .authored + .is_none(), + "the acked unbind drops the authored entry with no refresh in between" + ); + assert!( + matches!(color_order.source, crate::UiSlotSourceState::Direct), + "with no declared default, the slot reads direct again: {:?}", + color_order.source + ); +} + #[test] fn removing_an_added_and_edited_entry_ends_clean_from_the_ack_alone() { // Mirror fidelity for the subtree-clearing structural remove: add a map