fix(agent): unify complete shared navigation chrome

This commit is contained in:
Fini 2026-07-22 22:17:45 +08:00
parent c9aae4dd11
commit 76b8ce43b8
2 changed files with 638 additions and 76 deletions

View file

@ -33,17 +33,13 @@
//!
//! ## Active-tab idempotency gap (0718-1-k3-1 postmortem, D)
//!
//! [`labels_already_unified`] only compares the tab LABEL set — it says
//! nothing about which tab is active. A model that draws every screen's nav
//! byte-for-byte identical (measured: three screens, one nav, one active
//! tab baked into all three) short-circuits `resolve_target` into `None`
//! before `retarget_active_tab` ever runs, so the wrong tab reads active on
//! every non-reference screen. [`active_tab_is_correct`] is the missing
//! second half of the idempotency check: labels matching is necessary but
//! not sufficient. When it disagrees, [`SyncTarget::RetargetActiveOnly`]
//! fixes just the active styling in place — no full nav replacement, so any
//! other authored drift on that screen's own nav (icons, ids) survives
//! untouched.
//! An earlier idempotency gate compared only tab LABELS, so equal labels hid
//! independently redrawn glyphs and wrappers. [`navs_already_unified`] now
//! compares ordered text/icon/event identity plus the complete outer chrome after
//! normalizing both navs to one active tab. [`active_tab_is_correct`] remains
//! the second half of the gate: when complete shared identity matches but
//! the active destination does not, [`SyncTarget::RetargetActiveOnly`] moves
//! only active styling on the target's otherwise-identical chrome.
//!
//! ## Detail-page Inject exemption (0718-1-k3-1 postmortem, product decision)
//!
@ -76,9 +72,11 @@ use jian_ops_schema::node::{PenNode, TextContent};
use op_editor_core::{EditorCommand, NodeId, PenNodeExt};
use crate::types::DocSink;
#[cfg(test)]
use crate::wire_screen_navigation::collect_nav_containers;
use crate::wire_screen_navigation::{
collect_nav_containers, collect_screen_candidates, first_text_content, labels_match,
normalize_label, screen_has_back_control_in_header, ScreenCandidate,
collect_nav_parts, collect_screen_candidates, first_text_content, labels_match,
screen_has_back_control_in_header, NavParts, ScreenCandidate,
};
/// What a non-reference screen needs, resolved read-only before any
@ -90,12 +88,27 @@ enum SyncTarget {
/// This screen has NO nav at all, but the reference nav declares a tab
/// for it — append a fresh clone as its last child (`InsertSubtree`).
Inject,
/// Labels already match the reference, but the ACTIVE tab doesn't sit
/// on this screen's own tab (the D bug — see the module doc). Carries
/// the TARGET's own live nav id + an owned clone of it (not the
/// Full shared identity already matches the reference, but the ACTIVE
/// tab doesn't sit on this screen's own tab (the D bug — see the module
/// doc). Carries the TARGET's own live nav id + an owned clone of it (not the
/// reference's), so the in-place fix only ever moves active styling —
/// any other authored drift on this screen's own nav survives.
RetargetActiveOnly(String, Box<PenNode>),
/// the target's already-matching outer chrome and actual-row path.
RetargetActiveOnly {
surface_id: String,
tab_row_path: Vec<usize>,
own_surface: Box<PenNode>,
},
}
/// Authoritative shared chrome captured from the document-order first
/// screen that owns a real tab row. `surface` is the complete outer chrome
/// copied on drift; `tab_row_path` locates the interactive row inside that
/// owned clone; `tab_row` is kept separately for read-only identity checks.
struct ReferenceNav {
surface: PenNode,
tab_row_path: Vec<usize>,
tab_row: PenNode,
canonical_label: String,
}
/// Entry point. No-ops when fewer than 2 screen-shaped top-level frames
@ -129,8 +142,8 @@ pub fn unify_shared_nav(sink: &mut dyn DocSink) {
match target {
SyncTarget::Replace(target_nav_id) => {
let mut clone = reference_nav.clone();
retarget_active_tab(&mut clone, &screen.name);
let mut clone = reference_nav.surface.clone();
retarget_active_tab_at_path(&mut clone, &reference_nav.tab_row_path, &screen.name);
stamp_chrome_role(&mut clone);
// `ReplaceSubtree` remaps every id in `clone` (root AND
// descendants) to fresh, non-colliding ids on apply
@ -145,8 +158,8 @@ pub fn unify_shared_nav(sink: &mut dyn DocSink) {
});
}
SyncTarget::Inject => {
let mut clone = reference_nav.clone();
retarget_active_tab(&mut clone, &screen.name);
let mut clone = reference_nav.surface.clone();
retarget_active_tab_at_path(&mut clone, &reference_nav.tab_row_path, &screen.name);
stamp_chrome_role(&mut clone);
// `InsertSubtree` APPENDS to the target parent's children
// (`cmd_insert_subtree`'s `slot.extend(nodes)`), so the
@ -164,16 +177,18 @@ pub fn unify_shared_nav(sink: &mut dyn DocSink) {
page_id: None,
});
}
SyncTarget::RetargetActiveOnly(target_nav_id, mut own_nav) => {
SyncTarget::RetargetActiveOnly {
surface_id,
tab_row_path,
mut own_surface,
} => {
// Mutate the TARGET's own live nav (captured read-only in
// `resolve_target`), not a reference clone — labels already
// matched, so a full replace would silently overwrite any
// authored drift beyond the active indicator this screen's
// own nav carries (icon glyphs, ids, extra chrome).
retarget_active_tab(&mut own_nav, &screen.name);
// `resolve_target`), not a reference clone — complete shared
// identity already matched, so only active placement differs.
retarget_active_tab_at_path(&mut own_surface, &tab_row_path, &screen.name);
sink.apply(EditorCommand::ReplaceSubtree {
node_id: NodeId::new(target_nav_id),
node: own_nav,
node_id: NodeId::new(surface_id),
node: own_surface,
drop_children: true,
page_id: None,
});
@ -190,27 +205,28 @@ pub fn unify_shared_nav(sink: &mut dyn DocSink) {
fn resolve_target(
sink: &dyn DocSink,
screen: &ScreenCandidate,
reference_nav: &PenNode,
reference_nav: &ReferenceNav,
) -> Option<SyncTarget> {
let root = op_editor_core::walkers::find_node(
sink.state().active_children(),
&NodeId::new(screen.id.clone()),
)?;
let mut navs = Vec::new();
collect_nav_containers(root, &mut navs);
collect_nav_parts(root, &mut navs);
match navs.into_iter().next() {
Some(target_nav) => {
if !labels_already_unified(target_nav, reference_nav) {
Some(SyncTarget::Replace(target_nav.id_str().to_string()))
} else if active_tab_is_correct(target_nav, &screen.name) {
None // truly idempotent: labels match AND active tab is right.
if !navs_already_unified(&target_nav, reference_nav) {
Some(SyncTarget::Replace(target_nav.surface.id_str().to_string()))
} else if active_tab_is_correct(target_nav.tab_row, &screen.name) {
None // truly idempotent: shared identity + active tab are right.
} else {
// Labels match but the active tab is wrong (the D bug) —
// fix in place rather than a full replace.
Some(SyncTarget::RetargetActiveOnly(
target_nav.id_str().to_string(),
Box::new(target_nav.clone()),
))
// Shared identity matches but the active tab is wrong (the
// D bug) — fix in place rather than a full replace.
Some(SyncTarget::RetargetActiveOnly {
surface_id: target_nav.surface.id_str().to_string(),
tab_row_path: target_nav.tab_row_path,
own_surface: Box::new(target_nav.surface.clone()),
})
}
}
None => {
@ -220,6 +236,7 @@ fn resolve_target(
// matching tab (a standalone detail page, say) is never forced
// to grow chrome it never asked for.
let eligible = reference_nav
.tab_row
.children()
.is_some_and(|tabs| find_tab_index_for_screen(tabs, &screen.name).is_some());
if !eligible {
@ -245,33 +262,105 @@ fn resolve_target(
fn find_reference_nav(
sink: &dyn DocSink,
screens: &[ScreenCandidate],
) -> Option<(String, PenNode)> {
) -> Option<(String, ReferenceNav)> {
for screen in screens {
let root = op_editor_core::walkers::find_node(
sink.state().active_children(),
&NodeId::new(screen.id.clone()),
)?;
let mut navs = Vec::new();
collect_nav_containers(root, &mut navs);
collect_nav_parts(root, &mut navs);
if let Some(nav) = navs.into_iter().next() {
return Some((screen.id.clone(), nav.clone()));
let canonical_label = nav
.tab_row
.children()
.and_then(|tabs| tabs.first())
.and_then(first_text_content)
.unwrap_or_default()
.to_string();
return Some((
screen.id.clone(),
ReferenceNav {
surface: nav.surface.clone(),
tab_row_path: nav.tab_row_path,
tab_row: nav.tab_row.clone(),
canonical_label,
},
));
}
}
None
}
/// Whether `target`'s nav already carries the SAME tab-label set (in order)
/// as `reference` — the idempotency gate. Active-tab correctness is
/// deliberately NOT part of this check (see the module doc): once the label
/// set matches, the screen is considered "already unified" even if a prior
/// run's active-tab detection degraded (see [`retarget_active_tab`]).
fn labels_already_unified(target: &PenNode, reference: &PenNode) -> bool {
nav_label_set(target) == nav_label_set(reference)
/// Whether a target already carries the reference nav's complete shared
/// identity. Ordered label/icon/event content must match, and the full outer
/// surface must have the same style/structure after both navs are
/// normalized to the same active tab. That normalization makes each
/// screen's legitimate active destination irrelevant while still catching
/// glyph, icon-size, padding, divider, wrapper, or inactive-style drift.
fn navs_already_unified(target: &NavParts<'_>, reference: &ReferenceNav) -> bool {
canonical_nav_identity(
target.surface,
&target.tab_row_path,
&reference.canonical_label,
) == canonical_nav_identity(
&reference.surface,
&reference.tab_row_path,
&reference.canonical_label,
)
}
fn tab_content_identity(nav: &PenNode) -> Vec<ContentSnapshot> {
nav.children()
.into_iter()
.flatten()
.map(snapshot_content)
.collect()
}
#[derive(Debug, PartialEq, Eq)]
struct CanonicalNavIdentity {
tabs: Vec<ContentSnapshot>,
style: String,
}
fn canonical_nav_identity(
surface: &PenNode,
tab_row_path: &[usize],
canonical_label: &str,
) -> CanonicalNavIdentity {
let mut normalized = surface.clone();
retarget_active_tab_at_path(&mut normalized, tab_row_path, canonical_label);
// Replace/Inject clones are stamped even when a name-matched authored
// reference has no role. Canonicalize the read-only fingerprints the
// same way, otherwise that legitimate clone would compare unequal to
// its roleless reference forever and churn ids on every cleanup run.
stamp_chrome_role(&mut normalized);
let tabs = node_at_path_mut(&mut normalized, tab_row_path)
.map(|tab_row| tab_content_identity(tab_row))
.unwrap_or_default();
CanonicalNavIdentity {
tabs,
style: style_fingerprint(&normalized),
}
}
fn retarget_active_tab_at_path(nav: &mut PenNode, tab_row_path: &[usize], screen_name: &str) {
if let Some(tab_row) = node_at_path_mut(nav, tab_row_path) {
retarget_active_tab(tab_row, screen_name);
}
}
fn node_at_path_mut<'a>(mut node: &'a mut PenNode, path: &[usize]) -> Option<&'a mut PenNode> {
for &index in path {
node = node.children_mut()?.get_mut(index)?;
}
Some(node)
}
/// Whether `nav`'s currently-active tab already sits on the tab matching
/// `screen_name` — the other half of the idempotency check
/// [`labels_already_unified`] deliberately leaves out (see the module doc's
/// [`navs_already_unified`] deliberately leaves out (see the module doc's
/// "Active-tab idempotency gap" section). Mirrors [`retarget_active_tab`]'s
/// own degrade conditions exactly (same helpers, same order) so a `false`
/// here reliably means `retarget_active_tab` will find a confident swap to
@ -293,15 +382,6 @@ fn active_tab_is_correct(nav: &PenNode, screen_name: &str) -> bool {
active_idx == target_idx
}
fn nav_label_set(nav: &PenNode) -> Vec<String> {
nav.children()
.into_iter()
.flatten()
.filter_map(first_text_content)
.map(normalize_label)
.collect()
}
/// Move the "active" tab styling from wherever the reference nav had it onto
/// the tab whose label matches `screen_name` — so a clone of the Home
/// screen's nav, dropped onto the Library screen, shows Library (not Home)
@ -403,6 +483,11 @@ fn blank_content_fields(value: &mut serde_json::Value) {
};
map.remove("id");
map.remove("name");
// Events belong to each semantic tab position (Trips, Explore, ...),
// not to its active/inactive visual treatment. Keeping them here would
// make four correctly wired tabs look like four different styles and
// break majority-based active-state detection.
map.remove("events");
if map.get("type").and_then(serde_json::Value::as_str) == Some("text") {
map.insert(
"content".to_string(),
@ -445,35 +530,83 @@ fn find_active_by_fingerprint(fingerprints: &[String]) -> Option<usize> {
}
/// Swap the WHOLE nodes at `a`/`b` (carrying styling — fills, extra
/// indicator children, everything), then restore each POSITION's own
/// original content (text / icon glyph, in document order) so the tab at
/// position `a` still reads as whatever it always represented, just with
/// the OTHER position's styling now attached.
/// indicator children, everything), then restore each POSITION's semantic
/// identity (name, events, text, icon glyph) so the tab at position `a`
/// still represents the same destination, just with the OTHER position's
/// styling now attached.
fn swap_tab_style(children: &mut [PenNode], a: usize, b: usize) {
let content_a = snapshot_content(&children[a]);
let content_b = snapshot_content(&children[b]);
let position_a = snapshot_tab_position(&children[a]);
let position_b = snapshot_tab_position(&children[b]);
children.swap(a, b);
restore_content(&mut children[a], &content_a);
restore_content(&mut children[b], &content_b);
restore_tab_position(&mut children[a], &position_a);
restore_tab_position(&mut children[b], &position_b);
}
/// A tab's content identity: every `Text` node's plain string and every
/// `IconFont` node's glyph name, in document (depth-first) order.
#[derive(Debug, PartialEq, Eq)]
struct ContentSnapshot {
texts: Vec<String>,
icon_names: Vec<String>,
events: Vec<serde_json::Value>,
}
/// Semantic identity anchored to one tab position. Active styling is moved
/// by swapping whole nodes, but these fields must stay with the destination
/// represented by that position.
struct TabPositionSnapshot {
nodes: Vec<NodePositionSnapshot>,
content: ContentSnapshot,
}
struct NodePositionSnapshot {
path: Vec<usize>,
name: Option<String>,
events: Option<jian_ops_schema::events::EventHandlers>,
}
fn snapshot_tab_position(node: &PenNode) -> TabPositionSnapshot {
let mut nodes = Vec::new();
collect_position_metadata(node, &mut Vec::new(), &mut nodes);
TabPositionSnapshot {
nodes,
content: snapshot_content(node),
}
}
fn collect_position_metadata(
node: &PenNode,
path: &mut Vec<usize>,
out: &mut Vec<NodePositionSnapshot>,
) {
out.push(NodePositionSnapshot {
path: path.clone(),
name: node.base().name.clone(),
events: node.events().cloned(),
});
for (index, child) in node.children().into_iter().flatten().enumerate() {
path.push(index);
collect_position_metadata(child, path, out);
path.pop();
}
}
fn snapshot_content(node: &PenNode) -> ContentSnapshot {
let mut snapshot = ContentSnapshot {
texts: Vec::new(),
icon_names: Vec::new(),
events: Vec::new(),
};
collect_content(node, &mut snapshot);
snapshot
}
fn collect_content(node: &PenNode, out: &mut ContentSnapshot) {
if let Some(events) = node.events() {
if let Ok(value) = serde_json::to_value(events) {
out.events.push(value);
}
}
match node {
PenNode::Text(t) => {
if let TextContent::Plain(s) = &t.content {
@ -499,6 +632,53 @@ fn restore_content(node: &mut PenNode, snapshot: &ContentSnapshot) {
restore_content_walk(node, &mut texts, &mut icons);
}
fn restore_tab_position(node: &mut PenNode, snapshot: &TabPositionSnapshot) {
clear_position_metadata(node);
for original in &snapshot.nodes {
if let Some(target) = node_at_path_mut(node, &original.path) {
target.base_mut().name.clone_from(&original.name);
set_events(target, original.events.clone());
}
}
restore_content(node, &snapshot.content);
}
fn clear_position_metadata(node: &mut PenNode) {
node.base_mut().name = None;
set_events(node, None);
if node.children().is_some() {
for child in node.children_mut().into_iter().flatten() {
clear_position_metadata(child);
}
}
}
fn set_events(node: &mut PenNode, events: Option<jian_ops_schema::events::EventHandlers>) {
match node {
PenNode::Frame(n) => n.events = events,
PenNode::Group(n) => n.events = events,
PenNode::Rectangle(n) => n.events = events,
PenNode::Ellipse(n) => n.events = events,
PenNode::Line(n) => n.events = events,
PenNode::Polygon(n) => n.events = events,
PenNode::Path(n) => n.events = events,
PenNode::Text(n) => n.events = events,
PenNode::TextInput(n) => n.events = events,
PenNode::Image(n) => n.events = events,
PenNode::IconFont(n) => n.events = events,
PenNode::TextArea(n) => n.events = events,
PenNode::Select(n) => n.events = events,
PenNode::Switch(n) => n.events = events,
PenNode::Checkbox(n) => n.events = events,
PenNode::Slider(n) => n.events = events,
PenNode::RadioGroup(n) => n.events = events,
PenNode::NumberInput(n) => n.events = events,
PenNode::Progress(n) => n.events = events,
PenNode::Tabs(n) => n.events = events,
PenNode::Ref(n) => n.events = events,
}
}
fn restore_content_walk<'a>(
node: &mut PenNode,
texts: &mut std::slice::Iter<'a, String>,
@ -519,8 +699,14 @@ fn restore_content_walk<'a>(
}
_ => {}
}
for child in node.children_mut().into_iter().flatten() {
restore_content_walk(child, texts, icons);
// `children_mut()` materializes an empty `children: []` on container
// variants. Only borrow it when children already exist; active-style
// normalization must not change optional-child structure merely by
// traversing a leaf rectangle such as an active indicator.
if node.children().is_some() {
for child in node.children_mut().into_iter().flatten() {
restore_content_walk(child, texts, icons);
}
}
}

View file

@ -11,7 +11,7 @@ use super::*;
// ---------------------------------------------------------------------
// D: active-tab idempotency gap (0718-1-k3-1 postmortem).
// `labels_already_unified` only compares the tab LABEL set — a model that
// The old label-only idempotency gate let a model that
// draws every screen's nav byte-for-byte identical (same active tab baked
// into all of them) used to short-circuit `resolve_target` into `None`
// before `retarget_active_tab` ever ran. These tests exercise the new
@ -39,7 +39,7 @@ fn two_screen_doc_identical_nav(active_index_on_library: usize) -> serde_json::V
fn labels_match_but_active_wrong_only_retargets_active_styling_in_place() {
// Library's own nav bakes "Home" (index 0) active — wrong for the
// Library screen, but same label set as the reference, so the OLD
// `labels_already_unified`-only gate would have skipped it entirely.
// label-only gate would have skipped it entirely.
let mut state = state_from(two_screen_doc_identical_nav(0));
run_pass(&mut state);
@ -89,7 +89,7 @@ fn drifted_labels_still_take_the_replace_path_with_correct_active_tab() {
// Regression against the D fix: a genuinely drifted label set (the
// pre-existing `two_screen_drifted_doc` fixture) can ONLY reach
// `resolve_target`'s `Replace` arm by construction — `RetargetActiveOnly`
// is reachable only when `labels_already_unified` is already true — so
// is reachable only when complete shared identity already matches — so
// this fixture alone proves the Replace path, no id inspection needed.
let mut state = state_from(two_screen_drifted_doc());
run_pass(&mut state);
@ -134,7 +134,7 @@ fn inject_path_still_activates_the_correct_tab_after_the_retarget_active_only_ad
/// shape): three screens, every one authored the SAME nav byte-for-byte
/// (the first tab baked active on ALL three — the real file's "Trips" tab
/// lighting up on Trips/Destination/Saved alike). Before this fix,
/// `labels_already_unified` alone read every screen as already-unified, so
/// the old label-only identity gate read every screen as already-unified, so
/// `retarget_active_tab` never ran on any of them.
#[test]
fn three_screen_byte_identical_nav_regression_each_screen_ends_up_with_its_own_active_tab() {
@ -182,6 +182,368 @@ fn three_screen_byte_identical_nav_regression_each_screen_ends_up_with_its_own_a
}
}
// ---------------------------------------------------------------------
// Nested outer -> inner bottom-nav regression (0722-1-gem postmortem).
// The real generated shape wraps the tab row in a full-width outer chrome
// surface (divider/background/padding). Both layers are nav-shaped, so the
// old pre-order `.next()` lookup selected the OUTER node for comparison.
// That node's direct children are divider + inner row, not tabs: labels
// therefore collapsed to the first nested label and glyph/metric drift was
// mistaken for an already-unified nav.
// ---------------------------------------------------------------------
fn nested_nav_tab_json(
id_prefix: &str,
label: &str,
icon: &str,
icon_size: f64,
route: &str,
active: bool,
) -> serde_json::Value {
let color = if active { ACTIVE } else { INACTIVE };
let mut children = vec![
serde_json::json!({
"type": "icon_font", "id": format!("{id_prefix}-icon"),
"iconFontName": icon, "width": icon_size, "height": icon_size,
"fill": [{"type":"solid", "color": color}]
}),
serde_json::json!({
"type": "text", "id": format!("{id_prefix}-label"), "content": label,
"fontSize": 12, "fill": [{"type":"solid", "color": color}]
}),
];
if active {
children.push(serde_json::json!({
"type": "ellipse", "id": format!("{id_prefix}-indicator"),
"width": 4, "height": 4, "fill": [{"type":"solid", "color": color}]
}));
}
serde_json::json!({
"type": "frame", "id": format!("{id_prefix}-tab"),
"width": "fill_container", "height": 56, "layout": "vertical",
"padding": [4, 0],
"events": {"onTap": [{"replace": format!("\"{route}\"")}]},
"children": children
})
}
struct NestedNavStyle<'a> {
icon_size: f64,
outer_height: f64,
outer_padding: [f64; 4],
outer_fill: &'a str,
divider_fill: &'a str,
inner_padding: [f64; 4],
inner_gap: f64,
}
fn nested_nav_json(
id_prefix: &str,
active_label: &str,
icons: [&str; 4],
routes: [&str; 4],
style: NestedNavStyle<'_>,
) -> serde_json::Value {
let labels = ["Trips", "Explore", "Saved", "Profile"];
let tabs = labels
.iter()
.zip(icons)
.zip(routes)
.enumerate()
.map(|(index, ((label, icon), route))| {
nested_nav_tab_json(
&format!("{id_prefix}-{index}"),
label,
icon,
style.icon_size,
route,
*label == active_label,
)
})
.collect::<Vec<_>>();
serde_json::json!({
"type": "frame", "id": format!("{id_prefix}-outer"),
"name": "Bottom Navigation Bar", "role": "bottom-tab-bar",
"width": "fill_container", "height": style.outer_height, "layout": "vertical",
"padding": style.outer_padding,
"fill": [{"type":"solid", "color": style.outer_fill}],
"children": [
{
"type": "rectangle", "id": format!("{id_prefix}-divider"),
"name": "Nav Divider", "width": "fill_container", "height": 1,
"fill": [{"type":"solid", "color": style.divider_fill}]
},
{
"type": "frame", "id": format!("{id_prefix}-inner"),
"name": "Tab Bar", "role": "tab-row", "width": "fill_container",
"height": 64, "layout": "horizontal", "gap": style.inner_gap,
"padding": style.inner_padding, "children": tabs
}
]
})
}
fn two_screen_nested_nav_chrome_drift_doc() -> serde_json::Value {
serde_json::json!({
"version": "1.0",
"children": [
screen_json(
"trips",
"Trips",
nested_nav_json(
"reference",
"Trips",
["luggage", "compass", "bookmark", "user"],
["/", "/explore", "/saved", "/profile"],
NestedNavStyle {
icon_size: 22.0,
outer_height: 88.0,
outer_padding: [8.0, 16.0, 8.0, 16.0],
outer_fill: "#FFFDFC",
divider_fill: "#E7E2DE",
inner_padding: [0.0, 4.0, 0.0, 4.0],
inner_gap: 8.0,
},
),
),
// Labels and active destination are already correct, but this
// independently redrawn nav drifted in glyphs AND full-surface
// geometry. A label-only idempotency gate must not preserve it.
screen_json(
"saved",
"Saved",
nested_nav_json(
"drifted",
"Saved",
["compass", "search", "heart", "user"],
[
"/target-trips",
"/target-explore",
"/target-saved",
"/target-profile",
],
NestedNavStyle {
icon_size: 20.0,
outer_height: 76.0,
outer_padding: [0.0, 24.0, 0.0, 24.0],
outer_fill: "#F4F4F5",
divider_fill: "#FF00AA",
inner_padding: [0.0, 12.0, 0.0, 12.0],
inner_gap: 20.0,
},
),
),
]
})
}
fn first_icon_name(node: &PenNode) -> Option<&str> {
if let PenNode::IconFont(icon) = node {
return Some(icon.icon_font_name.as_str());
}
node.children()?.iter().find_map(first_icon_name)
}
fn assert_saved_nested_nav_matches_reference(state: &op_editor_core::EditorState) {
let saved_root = find_by_id(state.active_children(), "saved").expect("Saved screen");
let outer = saved_root
.children()
.and_then(|children| children.first())
.expect("Saved keeps one complete outer nav surface");
let outer_json = serde_json::to_value(outer).expect("outer nav serializes");
assert_eq!(outer_json["height"], serde_json::json!(88.0));
assert_eq!(outer_json["fill"][0]["color"], serde_json::json!("#FFFDFC"));
assert_eq!(
outer_json["padding"],
serde_json::json!([8.0, 16.0, 8.0, 16.0]),
"outer surface padding must come from the reference nav, not just the tab row"
);
let outer_children = outer.children().expect("divider + inner tab row");
assert_eq!(outer_children.len(), 2, "copy the complete outer chrome");
let divider_json = serde_json::to_value(&outer_children[0]).expect("divider serializes");
assert_eq!(divider_json["height"], serde_json::json!(1.0));
assert_eq!(
divider_json["fill"][0]["color"],
serde_json::json!("#E7E2DE")
);
let inner = &outer_children[1];
assert_eq!(
labels_of(inner),
vec!["Trips", "Explore", "Saved", "Profile"]
);
let inner_json = serde_json::to_value(inner).expect("inner tab row serializes");
assert_eq!(
inner_json["padding"],
serde_json::json!([0.0, 4.0, 0.0, 4.0])
);
assert_eq!(inner_json["gap"], serde_json::json!(8.0));
let tabs = inner.children().expect("four tabs");
let icons = tabs
.iter()
.map(|tab| first_icon_name(tab).expect("tab icon"))
.collect::<Vec<_>>();
assert_eq!(icons, vec!["luggage", "compass", "bookmark", "user"]);
for tab in tabs {
let icon = tab
.children()
.and_then(|children| children.first())
.expect("icon is first tab child");
let icon_json = serde_json::to_value(icon).expect("icon serializes");
assert_eq!(icon_json["width"], serde_json::json!(22.0));
assert_eq!(icon_json["height"], serde_json::json!(22.0));
}
assert_eq!(first_fill(&tabs[0]).as_deref(), Some(INACTIVE));
assert_eq!(
first_fill(&tabs[2]).as_deref(),
Some(ACTIVE),
"the cloned reference chrome must retarget active styling to Saved"
);
}
#[test]
fn nested_nav_same_labels_but_glyph_size_and_padding_drift_replaces_complete_outer_chrome() {
let mut state = state_from(two_screen_nested_nav_chrome_drift_doc());
run_pass(&mut state);
assert_saved_nested_nav_matches_reference(&state);
}
#[test]
fn nested_nav_complete_outer_sync_is_idempotent_on_second_run() {
let mut state = state_from(two_screen_nested_nav_chrome_drift_doc());
run_pass(&mut state);
assert_saved_nested_nav_matches_reference(&state);
let once = serde_json::to_string(state.active_children()).expect("first pass snapshot");
run_pass(&mut state);
let twice = serde_json::to_string(state.active_children()).expect("second pass snapshot");
assert_eq!(once, twice, "nested nav sync must be a second-run no-op");
}
#[test]
fn nested_nav_retarget_preserves_reference_route_at_each_label_position() {
// Every reference tab is already wired to a distinct destination. The
// target's independently-authored routes deliberately disagree, so a
// passing result proves both halves of the contract: the complete nav
// came from the reference, and moving active styling from Trips to
// Saved did NOT move each tab's event along with that styling.
let mut state = state_from(two_screen_nested_nav_chrome_drift_doc());
run_pass(&mut state);
let saved_root = find_by_id(state.active_children(), "saved").expect("Saved screen");
let outer = saved_root
.children()
.and_then(|children| children.first())
.expect("complete outer nav");
let inner = outer
.children()
.and_then(|children| children.get(1))
.expect("inner tab row after divider");
let tabs = inner.children().expect("four tabs");
assert_eq!(
labels_of(inner),
vec!["Trips", "Explore", "Saved", "Profile"]
);
let routes = tabs
.iter()
.map(|tab| {
serde_json::to_value(tab).expect("tab serializes")["events"]["onTap"][0]["replace"]
.as_str()
.expect("replace route is a string literal")
.to_string()
})
.collect::<Vec<_>>();
assert_eq!(
routes,
vec!["\"/\"", "\"/explore\"", "\"/saved\"", "\"/profile\""],
"routes belong to label positions; active-style retargeting must not swap them"
);
assert_eq!(first_fill(&tabs[0]).as_deref(), Some(INACTIVE));
assert_eq!(first_fill(&tabs[2]).as_deref(), Some(ACTIVE));
}
#[test]
fn route_only_drift_still_adopts_reference_routes() {
let style = || NestedNavStyle {
icon_size: 22.0,
outer_height: 88.0,
outer_padding: [8.0, 16.0, 8.0, 16.0],
outer_fill: "#FFFDFC",
divider_fill: "#E7E2DE",
inner_padding: [0.0, 4.0, 0.0, 4.0],
inner_gap: 8.0,
};
let doc = serde_json::json!({
"version": "1.0",
"children": [
screen_json("trips", "Trips", nested_nav_json(
"reference", "Trips", ["luggage", "compass", "bookmark", "user"],
["/", "/explore", "/saved", "/profile"], style(),
)),
screen_json("saved", "Saved", nested_nav_json(
"target", "Saved", ["luggage", "compass", "bookmark", "user"],
["/wrong-1", "/wrong-2", "/wrong-3", "/wrong-4"], style(),
)),
]
});
let mut state = state_from(doc);
run_pass(&mut state);
let saved = find_by_id(state.active_children(), "saved").unwrap();
let tabs = saved.children().unwrap()[0].children().unwrap()[1]
.children()
.unwrap();
let routes = tabs
.iter()
.map(|tab| serde_json::to_value(tab).unwrap()["events"]["onTap"][0]["replace"].clone())
.collect::<Vec<_>>();
assert_eq!(
serde_json::Value::Array(routes),
serde_json::json!(["\"/\"", "\"/explore\"", "\"/saved\"", "\"/profile\""])
);
}
#[test]
fn active_retarget_preserves_descendant_routes_by_tab_position() {
let mut nav_json = nested_nav_json(
"reference",
"Trips",
["luggage", "compass", "bookmark", "user"],
["/", "/explore", "/saved", "/profile"],
NestedNavStyle {
icon_size: 22.0,
outer_height: 88.0,
outer_padding: [8.0, 16.0, 8.0, 16.0],
outer_fill: "#FFFDFC",
divider_fill: "#E7E2DE",
inner_padding: [0.0, 4.0, 0.0, 4.0],
inner_gap: 8.0,
},
);
for tab in nav_json["children"][1]["children"].as_array_mut().unwrap() {
let events = tab.as_object_mut().unwrap().remove("events").unwrap();
tab["children"][0]["events"] = events;
}
let mut nav: PenNode = serde_json::from_value(nav_json).unwrap();
retarget_active_tab_at_path(&mut nav, &[1], "Saved");
let tabs = nav.children().unwrap()[1].children().unwrap();
let routes = tabs
.iter()
.map(|tab| {
serde_json::to_value(&tab.children().unwrap()[0]).unwrap()["events"]["onTap"][0]
["replace"]
.clone()
})
.collect::<Vec<_>>();
assert_eq!(
serde_json::Value::Array(routes),
serde_json::json!(["\"/\"", "\"/explore\"", "\"/saved\"", "\"/profile\""])
);
}
// ---------------------------------------------------------------------
// Detail-page Inject exemption (0718-1-k3-1 postmortem, product decision).
// A push-in detail screen (back header, no corresponding bottom-nav tab)
@ -354,6 +716,20 @@ fn injected_nav_clone_gets_the_chrome_role_stamped() {
);
}
#[test]
fn roleless_reference_with_stamped_clone_is_idempotent_on_second_run() {
let mut state = state_from(home_plus_empty_library_doc());
run_pass(&mut state);
let once = serde_json::to_string(state.active_children()).expect("first pass snapshot");
run_pass(&mut state);
let twice = serde_json::to_string(state.active_children()).expect("second pass snapshot");
assert_eq!(
once, twice,
"the stamped clone must compare equal to its roleless reference after canonicalization"
);
}
#[test]
fn authored_reference_nav_role_is_never_touched() {
let mut state = state_from(home_plus_empty_library_doc());