Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d21977fca6 | ||
|
|
6018ae30b9 | ||
|
|
c7065db76e | ||
|
|
6520a89dc0 | ||
|
|
3a7221b80c | ||
|
|
ac69f555af | ||
|
|
603a59c178 | ||
|
|
0341bca846 |
@@ -1,4 +1,5 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.rs text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.lock text eol=lf
|
||||
@@ -89,3 +90,7 @@ harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-bl
|
||||
|
||||
# Preserve the pinned Iced dependency bytes and upstream manifest.
|
||||
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
|
||||
|
||||
# Final overrides preserve original validation bytes after generic text rules.
|
||||
harness/experiments/navigation-validation-20261002/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/reviews/navigation-validation-packet-20261002/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
|
||||
@@ -1,785 +0,0 @@
|
||||
//! Experimental observer of Command::Layout; no quality thresholds or candidate algorithm.
|
||||
use oa_core::{
|
||||
projection::{self, ProjectedView},
|
||||
routing, *,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
#[path = "auto_layout_probe/layered.rs"]
|
||||
mod layered;
|
||||
|
||||
type P = (f64, f64);
|
||||
type Rect = (f64, f64, f64, f64);
|
||||
const EPS: f64 = 0.0001;
|
||||
fn p(v: Position) -> P {
|
||||
(v.x as f64, v.y as f64)
|
||||
}
|
||||
fn sub(a: P, b: P) -> P {
|
||||
(a.0 - b.0, a.1 - b.1)
|
||||
}
|
||||
fn dot(a: P, b: P) -> f64 {
|
||||
a.0 * b.0 + a.1 * b.1
|
||||
}
|
||||
fn cross(a: P, b: P) -> f64 {
|
||||
a.0 * b.1 - a.1 * b.0
|
||||
}
|
||||
fn length(a: P) -> f64 {
|
||||
a.0.hypot(a.1)
|
||||
}
|
||||
fn rect(at: Position, size: Size) -> Rect {
|
||||
(
|
||||
at.x as f64,
|
||||
at.y as f64,
|
||||
(at.x + size.width) as f64,
|
||||
(at.y + size.height) as f64,
|
||||
)
|
||||
}
|
||||
fn overlap(a: Rect, b: Rect) -> f64 {
|
||||
let w = a.2.min(b.2) - a.0.max(b.0);
|
||||
let h = a.3.min(b.3) - a.1.max(b.1);
|
||||
if w > EPS && h > EPS { w * h } else { 0. }
|
||||
}
|
||||
fn contains(a: Rect, b: Rect) -> bool {
|
||||
a.0 <= b.0 + EPS && a.1 <= b.1 + EPS && a.2 + EPS >= b.2 && a.3 + EPS >= b.3
|
||||
}
|
||||
fn proper_cross(a: P, b: P, c: P, d: P) -> Option<P> {
|
||||
let u = sub(b, a);
|
||||
let v = sub(d, c);
|
||||
let denominator = cross(u, v);
|
||||
if denominator.abs() < 1e-9 {
|
||||
return None;
|
||||
}
|
||||
let t = cross(sub(c, a), v) / denominator;
|
||||
let s = cross(sub(c, a), u) / denominator;
|
||||
// Strict interiors: touches, T junctions and intersections at bends are not proper crossings.
|
||||
(t > 1e-9 && t < 1. - 1e-9 && s > 1e-9 && s < 1. - 1e-9)
|
||||
.then_some((a.0 + t * u.0, a.1 + t * u.1))
|
||||
}
|
||||
fn shared_length(a: P, b: P, c: P, d: P) -> f64 {
|
||||
let u = sub(b, a);
|
||||
let len = length(u);
|
||||
if len < EPS || cross(u, sub(c, a)).abs() / len > EPS || cross(u, sub(d, a)).abs() / len > EPS {
|
||||
return 0.;
|
||||
}
|
||||
let t = dot(sub(c, a), u) / len;
|
||||
let s = dot(sub(d, a), u) / len;
|
||||
(t.max(s).min(len) - t.min(s).max(0.)).max(0.)
|
||||
}
|
||||
// Closed-segment point intersections. A positive collinear interval is not a
|
||||
// point: it remains represented by the existing shared-length metric.
|
||||
fn point_contact(a: P, b: P, c: P, d: P) -> Option<P> {
|
||||
let u = sub(b, a);
|
||||
let v = sub(d, c);
|
||||
let on_segment = |at: P, start: P, end: P| {
|
||||
let delta = sub(end, start);
|
||||
let len = length(delta);
|
||||
if len < EPS {
|
||||
return length(sub(at, start)) < EPS;
|
||||
}
|
||||
cross(delta, sub(at, start)).abs() / len <= EPS
|
||||
&& dot(sub(at, start), delta) >= -EPS * len
|
||||
&& dot(sub(at, end), delta) <= EPS * len
|
||||
};
|
||||
if length(u) < EPS {
|
||||
return on_segment(a, c, d).then_some(a);
|
||||
}
|
||||
if length(v) < EPS {
|
||||
return on_segment(c, a, b).then_some(c);
|
||||
}
|
||||
let denominator = cross(u, v);
|
||||
if denominator.abs() < 1e-9 {
|
||||
if shared_length(a, b, c, d) > EPS {
|
||||
return None;
|
||||
}
|
||||
return [a, b, c, d]
|
||||
.into_iter()
|
||||
.find(|at| on_segment(*at, a, b) && on_segment(*at, c, d));
|
||||
}
|
||||
let t = cross(sub(c, a), v) / denominator;
|
||||
let s = cross(sub(c, a), u) / denominator;
|
||||
((-1e-9..=1. + 1e-9).contains(&t) && (-1e-9..=1. + 1e-9).contains(&s))
|
||||
.then_some((a.0 + t.clamp(0., 1.) * u.0, a.1 + t.clamp(0., 1.) * u.1))
|
||||
}
|
||||
fn point_contacts(a: &[P], b: &[P]) -> Vec<(P, bool)> {
|
||||
let mut unique = BTreeMap::new();
|
||||
for ab in a.windows(2) {
|
||||
for cd in b.windows(2) {
|
||||
if let Some(at) = point_contact(ab[0], ab[1], cd[0], cd[1]) {
|
||||
let key = ((at.0 / EPS).round() as i64, (at.1 / EPS).round() as i64);
|
||||
let proper = proper_cross(ab[0], ab[1], cd[0], cd[1]).is_some();
|
||||
let entry = unique.entry(key).or_insert((at, false));
|
||||
entry.1 |= proper;
|
||||
}
|
||||
}
|
||||
}
|
||||
unique.into_values().collect()
|
||||
}
|
||||
fn contact_role(points: &[P], at: P) -> &'static str {
|
||||
if points
|
||||
.first()
|
||||
.into_iter()
|
||||
.chain(points.last())
|
||||
.any(|tip| length(sub(*tip, at)) < EPS)
|
||||
{
|
||||
"route_endpoint"
|
||||
} else if points.iter().any(|vertex| length(sub(*vertex, at)) < EPS) {
|
||||
"internal_vertex"
|
||||
} else {
|
||||
"segment_interior"
|
||||
}
|
||||
}
|
||||
fn ancestor(view: &ProjectedView, owner: Id, mut node: Id) -> bool {
|
||||
for _ in 0..view.nodes.len() {
|
||||
let Some(parent) = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == node)
|
||||
.and_then(|n| n.visual_parent)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if parent == owner {
|
||||
return true;
|
||||
}
|
||||
node = parent;
|
||||
}
|
||||
false
|
||||
}
|
||||
fn terminal_points(
|
||||
edge: &projection::ProjectedEdge,
|
||||
points: &[Position],
|
||||
segment: usize,
|
||||
) -> Vec<(Id, P)> {
|
||||
let mut owners = Vec::new();
|
||||
if segment == 0 {
|
||||
owners.push((edge.source, p(points[0])));
|
||||
}
|
||||
if segment + 2 == points.len() {
|
||||
owners.push((edge.target, p(*points.last().unwrap())));
|
||||
}
|
||||
owners
|
||||
}
|
||||
fn inside_length(a: P, b: P, r: Rect) -> f64 {
|
||||
let delta = sub(b, a);
|
||||
let mut lo = 0_f64;
|
||||
let mut hi = 1_f64;
|
||||
for (origin, direction, min, max) in [
|
||||
(a.0, delta.0, r.0 + EPS, r.2 - EPS),
|
||||
(a.1, delta.1, r.1 + EPS, r.3 - EPS),
|
||||
] {
|
||||
if min >= max {
|
||||
return 0.;
|
||||
}
|
||||
if direction.abs() < 1e-9 {
|
||||
if origin <= min || origin >= max {
|
||||
return 0.;
|
||||
}
|
||||
} else {
|
||||
let t = (min - origin) / direction;
|
||||
let u = (max - origin) / direction;
|
||||
lo = lo.max(t.min(u));
|
||||
hi = hi.min(t.max(u));
|
||||
}
|
||||
}
|
||||
(hi - lo).max(0.) * length(delta)
|
||||
}
|
||||
fn header_height(project: &Project, diagram: Id, node: &projection::ProjectedNode) -> f64 {
|
||||
let h = if project.diagrams[&diagram]
|
||||
.projection
|
||||
.state_machine()
|
||||
.is_some()
|
||||
{
|
||||
projection::state_header_height(project, node.element)
|
||||
} else {
|
||||
64.
|
||||
};
|
||||
h.min(node.size.height) as f64
|
||||
}
|
||||
|
||||
fn metrics(project: &Project, diagram: Id) -> Value {
|
||||
let view = projection::project_view(project, diagram);
|
||||
let routed = routing::route_diagram(project, &project.diagrams[&diagram], &view);
|
||||
let edges: Vec<_> = routed.edges.values().collect();
|
||||
let definitions: BTreeMap<_, _> = view.edges.iter().map(|e| (e.id, e)).collect();
|
||||
let mut crossings = Vec::new();
|
||||
let mut shared = 0.;
|
||||
let mut excluded_shared = 0.;
|
||||
let mut excluded_stub_crossings = 0;
|
||||
let mut contacts = Vec::new();
|
||||
let mut common_terminal_contacts = 0;
|
||||
for (i, a) in edges.iter().enumerate() {
|
||||
for b in edges.iter().skip(i + 1) {
|
||||
let ap: Vec<_> = a.points.iter().copied().map(p).collect();
|
||||
let bp: Vec<_> = b.points.iter().copied().map(p).collect();
|
||||
let ad = definitions[&a.id];
|
||||
let bd = definitions[&b.id];
|
||||
for (at, proper) in point_contacts(&ap, &bp) {
|
||||
let common_terminal = [(ad.source, ap.first()), (ad.target, ap.last())]
|
||||
.iter()
|
||||
.any(|(owner, tip)| {
|
||||
tip.is_some_and(|tip| length(sub(*tip, at)) < EPS)
|
||||
&& [(bd.source, bp.first()), (bd.target, bp.last())]
|
||||
.iter()
|
||||
.any(|(other, tip)| {
|
||||
owner == other
|
||||
&& tip.is_some_and(|tip| length(sub(*tip, at)) < EPS)
|
||||
})
|
||||
});
|
||||
common_terminal_contacts += usize::from(common_terminal);
|
||||
contacts.push(json!({"edges":[a.id,b.id],"at":[at.0,at.1],
|
||||
"proper_crossing":proper,"roles":[contact_role(&ap,at),contact_role(&bp,at)],
|
||||
"common_physical_terminal":common_terminal}));
|
||||
}
|
||||
let mut unique = BTreeSet::new();
|
||||
for (ai, ab) in a.points.windows(2).enumerate() {
|
||||
for (bi, cd) in b.points.windows(2).enumerate() {
|
||||
let mut common_tips = Vec::new();
|
||||
for (owner, tip) in terminal_points(definitions[&a.id], &a.points, ai) {
|
||||
if terminal_points(definitions[&b.id], &b.points, bi)
|
||||
.iter()
|
||||
.any(|(other, at)| *other == owner && length(sub(*at, tip)) < EPS)
|
||||
{
|
||||
common_tips.push(tip);
|
||||
}
|
||||
}
|
||||
let crossing = proper_cross(p(ab[0]), p(ab[1]), p(cd[0]), p(cd[1]));
|
||||
let overlap = shared_length(p(ab[0]), p(ab[1]), p(cd[0]), p(cd[1]));
|
||||
// Raw totals stay primary. Only physically common tips get
|
||||
// bounded PORT_STUB exemptions, never a whole compacted segment.
|
||||
shared += overlap;
|
||||
excluded_shared +=
|
||||
overlap.min(common_tips.len() as f64 * routing::PORT_STUB as f64);
|
||||
if let Some(at) = crossing
|
||||
&& unique.insert(((at.0 / EPS).round() as i64, (at.1 / EPS).round() as i64))
|
||||
{
|
||||
let near_tip = common_tips
|
||||
.iter()
|
||||
.any(|tip| length(sub(at, *tip)) <= routing::PORT_STUB as f64 + EPS);
|
||||
excluded_stub_crossings += usize::from(near_tip);
|
||||
crossings.push(json!({"edges":[a.id,b.id],"at":[at.0,at.1],"within_common_terminal_stub":near_tip}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut node_collisions = Vec::new();
|
||||
let mut label_nodes = Vec::new();
|
||||
let mut label_labels = Vec::new();
|
||||
let mut excluded_ancestor_labels = 0;
|
||||
let mut penetrations = Vec::new();
|
||||
let mut label_strokes = Vec::new();
|
||||
for e in &edges {
|
||||
let def = definitions[&e.id];
|
||||
for n in &view.nodes {
|
||||
if n.id == def.source || n.id == def.target {
|
||||
continue;
|
||||
}
|
||||
let mut obstacle = rect(n.position, n.size);
|
||||
let ancestor_header =
|
||||
ancestor(&view, n.id, def.source) || ancestor(&view, n.id, def.target);
|
||||
if ancestor_header {
|
||||
obstacle.3 = obstacle.1 + header_height(project, diagram, n);
|
||||
}
|
||||
let length: f64 = e
|
||||
.points
|
||||
.windows(2)
|
||||
.map(|w| inside_length(p(w[0]), p(w[1]), obstacle))
|
||||
.sum();
|
||||
if length > EPS {
|
||||
penetrations.push(json!({"edge":e.id,"node":n.id,"length":length,"ancestor_header_only":ancestor_header}));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (i, a) in view.nodes.iter().enumerate() {
|
||||
for b in view.nodes.iter().skip(i + 1) {
|
||||
let area = overlap(rect(a.position, a.size), rect(b.position, b.size));
|
||||
if area > 0. && !ancestor(&view, a.id, b.id) && !ancestor(&view, b.id, a.id) {
|
||||
node_collisions.push(json!({"nodes":[a.id,b.id],"intersection_area":area}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let labels: Vec<_> = edges.iter().filter(|e| !e.label.text.is_empty()).collect();
|
||||
for (i, e) in labels.iter().enumerate() {
|
||||
let lb = rect(e.label.bounds.position, e.label.bounds.size);
|
||||
let def = definitions[&e.id];
|
||||
for n in &view.nodes {
|
||||
let nb = rect(n.position, n.size);
|
||||
let area = overlap(lb, nb);
|
||||
if area == 0. {
|
||||
continue;
|
||||
}
|
||||
let body = (nb.0, nb.1 + header_height(project, diagram, n), nb.2, nb.3);
|
||||
if contains(body, lb)
|
||||
&& (ancestor(&view, n.id, def.source) || ancestor(&view, n.id, def.target))
|
||||
{
|
||||
excluded_ancestor_labels += 1;
|
||||
} else {
|
||||
label_nodes.push(json!({"edge":e.id,"node":n.id,"intersection_area":area}));
|
||||
}
|
||||
}
|
||||
for other in labels.iter().skip(i + 1) {
|
||||
let area = overlap(
|
||||
lb,
|
||||
rect(other.label.bounds.position, other.label.bounds.size),
|
||||
);
|
||||
if area > 0. {
|
||||
label_labels.push(json!({"edges":[e.id,other.id],"intersection_area":area}));
|
||||
}
|
||||
}
|
||||
for other in edges.iter().filter(|other| other.id != e.id) {
|
||||
let length: f64 = other
|
||||
.points
|
||||
.windows(2)
|
||||
.map(|w| inside_length(p(w[0]), p(w[1]), lb))
|
||||
.sum();
|
||||
if length > EPS {
|
||||
label_strokes
|
||||
.push(json!({"label_edge":e.id,"stroke_edge":other.id,"length":length}));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut total_length = 0.;
|
||||
let mut bends = 0;
|
||||
for e in &edges {
|
||||
total_length += e
|
||||
.points
|
||||
.windows(2)
|
||||
.map(|w| length(sub(p(w[1]), p(w[0]))))
|
||||
.sum::<f64>();
|
||||
bends += e
|
||||
.points
|
||||
.windows(3)
|
||||
.filter(|w| {
|
||||
let a = sub(p(w[1]), p(w[0]));
|
||||
let b = sub(p(w[2]), p(w[1]));
|
||||
length(a) > EPS && length(b) > EPS && (cross(a, b).abs() > EPS || dot(a, b) < 0.)
|
||||
})
|
||||
.count();
|
||||
}
|
||||
let warnings: Vec<_> = edges
|
||||
.iter()
|
||||
.flat_map(|e| {
|
||||
e.warnings
|
||||
.iter()
|
||||
.map(move |w| json!({"edge":e.id,"warning":format!("{w:?}")}))
|
||||
})
|
||||
.collect();
|
||||
json!({
|
||||
"nodes":view.nodes.len(),"edges":edges.len(),"ports":view.ports.len(),
|
||||
"proper_crossings":crossings.len(),"crossing_details":crossings,
|
||||
"point_contacts":contacts.len(),"point_contact_details":contacts,
|
||||
"common_physical_terminal_point_contacts":common_terminal_contacts,
|
||||
"pairwise_collinear_shared_length":shared,
|
||||
"excluded_common_terminal_shared_length":excluded_shared,"excluded_common_terminal_crossings":excluded_stub_crossings,
|
||||
"nonterminal_proper_crossings":crossings.len()-excluded_stub_crossings,
|
||||
"nonterminal_pairwise_shared_length":shared-excluded_shared,
|
||||
"unrelated_edge_node_penetrations":penetrations.len(),"edge_node_details":penetrations,
|
||||
"unrelated_label_stroke_intersections":label_strokes.len(),"label_stroke_details":label_strokes,
|
||||
"unrelated_node_collisions":node_collisions.len(),"node_collision_details":node_collisions,
|
||||
"label_node_collisions":label_nodes.len(),"label_node_details":label_nodes,
|
||||
"excluded_contained_endpoint_ancestor_label_pairs":excluded_ancestor_labels,
|
||||
"label_label_collisions":label_labels.len(),"label_label_details":label_labels,
|
||||
"warning_count":warnings.len(),"warnings":warnings,
|
||||
"drawing_bounds":{"position":routed.bounds.position,"size":routed.bounds.size},
|
||||
"drawing_area":routed.bounds.size.width as f64*routed.bounds.size.height as f64,
|
||||
"route_bends":bends,"route_length":total_length,
|
||||
"projected_nodes":view.nodes,"projected_ports":view.ports,
|
||||
"route_endpoints":edges.iter().map(|e|json!({"id":e.id,
|
||||
"source_owner":definitions[&e.id].source,"target_owner":definitions[&e.id].target,
|
||||
"source":e.points.first(),"target":e.points.last()})).collect::<Vec<_>>(),
|
||||
"routed_edges":edges.iter().map(|e|json!({"id":e.id,"points":e.points,"label":{
|
||||
"text":e.label.text,"position":e.label.bounds.position,"size":e.label.bounds.size}})).collect::<Vec<_>>()
|
||||
})
|
||||
}
|
||||
|
||||
struct Builder {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
next: u128,
|
||||
witness: Option<Project>,
|
||||
}
|
||||
impl Builder {
|
||||
fn new(name: &str) -> Self {
|
||||
let mut project = Project::empty();
|
||||
project.id = Id::from_u128(90_000);
|
||||
project.alias = "layout_probe".into();
|
||||
project.name = name.into();
|
||||
let old = project.ensure_diagram(Layer::Sa);
|
||||
let diagram = Id::from_u128(90_001);
|
||||
reidentify(&mut project, old, diagram).unwrap();
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
Self {
|
||||
project,
|
||||
diagram,
|
||||
next: 1,
|
||||
witness: None,
|
||||
}
|
||||
}
|
||||
fn stable(&mut self, old: Id) -> Id {
|
||||
let id = Id::from_u128(self.next);
|
||||
self.next += 1;
|
||||
reidentify(&mut self.project, old, id).unwrap();
|
||||
id
|
||||
}
|
||||
fn function(&mut self, name: &str, parent: Option<Id>, x: f32, y: f32) -> Id {
|
||||
let old = self
|
||||
.project
|
||||
.add_element(Kind::Function, Layer::Sa, name, parent)
|
||||
.unwrap();
|
||||
let id = self.stable(old);
|
||||
let old = self.project.place(self.diagram, id, Position { x, y });
|
||||
self.stable(old);
|
||||
id
|
||||
}
|
||||
fn exchange(&mut self, name: &str, a: Id, b: Id) -> Id {
|
||||
let old = self
|
||||
.project
|
||||
.relate(RelationKind::FunctionalExchange, name, a, b);
|
||||
let edge = self.stable(old);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = self.project.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
for (old, prefix) in [(source_port, "out"), (target_port, "in")] {
|
||||
let id = self.stable(old);
|
||||
self.project.elements.get_mut(&id).unwrap().alias =
|
||||
format!("{prefix}_{}", id.as_u128());
|
||||
}
|
||||
edge
|
||||
}
|
||||
fn node(&self, element: Id) -> Id {
|
||||
self.project.diagrams[&self.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == element)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
}
|
||||
fn fixtures() -> Vec<(&'static str, Builder)> {
|
||||
let mut all = Vec::new();
|
||||
for (name, count) in [("flat_chain_4", 4), ("chain_7_row_wrap", 7)] {
|
||||
let mut f = Builder::new(name);
|
||||
let mut nodes = Vec::new();
|
||||
for i in 0..count {
|
||||
nodes.push(f.function(&format!("Function {i}"), None, 40. + 320. * i as f32, 80.));
|
||||
}
|
||||
for i in 0..count - 1 {
|
||||
f.exchange(&format!("Flow {i}"), nodes[i], nodes[i + 1]);
|
||||
}
|
||||
all.push((name, f));
|
||||
}
|
||||
for (name, links) in [
|
||||
("diamond", vec![(0, 1), (0, 2), (1, 3), (2, 3)]),
|
||||
("crossed_bipartite", vec![(0, 3), (1, 2), (0, 2), (1, 3)]),
|
||||
] {
|
||||
let mut f = Builder::new(name);
|
||||
let mut nodes = Vec::new();
|
||||
let coordinates = if name == "diamond" {
|
||||
[(40., 240.), (400., 80.), (400., 400.), (760., 240.)]
|
||||
} else {
|
||||
[(40., 80.), (40., 400.), (620., 80.), (620., 400.)]
|
||||
};
|
||||
for (i, (x, y)) in coordinates.into_iter().enumerate() {
|
||||
nodes.push(f.function(&format!("Node {i}"), None, x, y));
|
||||
}
|
||||
for (i, (a, b)) in links.into_iter().enumerate() {
|
||||
f.exchange(&format!("Flow {i}"), nodes[a], nodes[b]);
|
||||
}
|
||||
all.push((name, f));
|
||||
}
|
||||
let mut f = Builder::new("nested_cross_boundary");
|
||||
let group = f.function("Parent function", None, 40., 40.);
|
||||
let a = f.function("Child send", Some(group), 100., 130.);
|
||||
let b = f.function("Child receive", Some(group), 100., 330.);
|
||||
let c = f.function("Outside", None, 900., 210.);
|
||||
f.exchange("Internal flow", a, b);
|
||||
f.exchange("Cross boundary", b, c);
|
||||
all.push(("nested_cross_boundary", f));
|
||||
for fixed in [false, true] {
|
||||
let name = if fixed {
|
||||
"fixed_ports"
|
||||
} else {
|
||||
"authored_detour_and_label"
|
||||
};
|
||||
let mut f = Builder::new(name);
|
||||
let a = f.function("Sender", None, 40., 80.);
|
||||
let b = f.function("Receiver", None, 640., 80.);
|
||||
let edge = f.exchange("Preserved information", a, b);
|
||||
if fixed {
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = f.project.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
for (element, port, side, offset) in [
|
||||
(a, source_port, PortAnchorSide::Left, 0.3),
|
||||
(b, target_port, PortAnchorSide::Right, 0.7),
|
||||
] {
|
||||
let node = f.node(element);
|
||||
f.project = prepare_command(
|
||||
&f.project,
|
||||
&Command::SetPortAnchor {
|
||||
diagram: f.diagram,
|
||||
node,
|
||||
anchor: PortAnchor { port, side, offset },
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
}
|
||||
} else {
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![
|
||||
Position { x: 400., y: -320. },
|
||||
Position { x: 1000., y: -320. },
|
||||
Position { x: 1000., y: 240. },
|
||||
],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.7,
|
||||
offset: Position { x: 100., y: -180. },
|
||||
}),
|
||||
});
|
||||
}
|
||||
all.push((name, f));
|
||||
}
|
||||
let mut f = Builder::new("two_containers_reversed_children");
|
||||
let left = f.function("Left group", None, 40., 40.);
|
||||
let right = f.function("Right group", None, 820., 40.);
|
||||
let la = f.function("Left A", Some(left), 100., 130.);
|
||||
let lb = f.function("Left B", Some(left), 100., 330.);
|
||||
let ra = f.function("Right A", Some(right), 880., 130.);
|
||||
let rb = f.function("Right B", Some(right), 880., 330.);
|
||||
f.exchange("Upper to lower", la, rb);
|
||||
f.exchange("Lower to upper", lb, ra);
|
||||
let mut witness = f.project.clone();
|
||||
for (element, y) in [(ra, 330.), (rb, 130.)] {
|
||||
let node = f.node(element);
|
||||
witness
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position
|
||||
.y = y;
|
||||
}
|
||||
f.witness = Some(witness);
|
||||
all.push(("two_containers_reversed_children", f));
|
||||
let mut f = Builder::new("long_ordinary_labels");
|
||||
let a = f.function("Input", None, 40., 100.);
|
||||
let b = f.function("Transform", None, 400., 100.);
|
||||
let c = f.function("Output", None, 760., 100.);
|
||||
f.exchange(
|
||||
"A very long first information exchange label exceeding the ordinary cutoff",
|
||||
a,
|
||||
b,
|
||||
);
|
||||
f.exchange(
|
||||
"A very long second information exchange label exceeding the ordinary cutoff",
|
||||
b,
|
||||
c,
|
||||
);
|
||||
f.exchange(
|
||||
"A very long bypass information exchange label exceeding the ordinary cutoff",
|
||||
a,
|
||||
c,
|
||||
);
|
||||
all.push(("long_ordinary_labels", f));
|
||||
all
|
||||
}
|
||||
|
||||
fn reset_effective_routes(project: &Project, diagram: Id) -> Project {
|
||||
let view = projection::project_view(project, diagram);
|
||||
let routed = routing::route_diagram(project, &project.diagrams[&diagram], &view);
|
||||
let mut reset = project.clone();
|
||||
for route in &mut reset.diagrams.get_mut(&diagram).unwrap().routes {
|
||||
if routed
|
||||
.edges
|
||||
.get(&route.edge)
|
||||
.is_some_and(|edge| !edge.custom_suspended)
|
||||
{
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
}
|
||||
}
|
||||
reset
|
||||
}
|
||||
|
||||
fn conservation(before: &Project, after: &Project, diagram: Id) -> Value {
|
||||
let mut semantic = after.clone();
|
||||
semantic.diagrams = before.diagrams.clone();
|
||||
let bd = &before.diagrams[&diagram];
|
||||
let ad = &after.diagrams[&diagram];
|
||||
let mut stable_view = ad.clone();
|
||||
stable_view.nodes = bd.nodes.clone();
|
||||
stable_view.routes = bd.routes.clone();
|
||||
stable_view.layout = bd.layout.clone();
|
||||
let mut other_views = after.diagrams.clone();
|
||||
other_views.insert(diagram, bd.clone());
|
||||
let node_metadata = bd.nodes.len() == ad.nodes.len()
|
||||
&& bd.nodes.iter().all(|(id, node)| {
|
||||
ad.nodes.get(id).is_some_and(|new| {
|
||||
let mut restored = new.clone();
|
||||
restored.position = node.position;
|
||||
restored.size = node.size;
|
||||
restored == *node
|
||||
})
|
||||
});
|
||||
json!({"semantic_records_exact":semantic==*before,"target_view_nongeometry_exact":stable_view==*bd,
|
||||
"other_views_exact":other_views==before.diagrams,"node_identity_visibility_anchors_exact":node_metadata,
|
||||
"authored_routes_exact":ad.routes==bd.routes})
|
||||
}
|
||||
|
||||
fn roundtrip(project: &Project) -> Value {
|
||||
let bytes = serde_json::to_vec(project).unwrap();
|
||||
let json_result = persistence::decode(&bytes);
|
||||
let source = language::format(project);
|
||||
let source_result = language::parse(&source);
|
||||
json!({"json_exact":json_result.as_ref().is_ok_and(|p|p==project),
|
||||
"json_error":json_result.err().map(|e|e.to_string()),
|
||||
"single_source_exact":source_result.as_ref().is_ok_and(|p|p==project),
|
||||
"source_error":source_result.err().map(|e|format!("{e:?}"))})
|
||||
}
|
||||
fn observe(name: &str, f: Builder) -> Value {
|
||||
let witness = f.witness;
|
||||
let before = f.project;
|
||||
let diagram = f.diagram;
|
||||
if let Err(error) = validate_candidate(&before) {
|
||||
return json!({"fixture":name,"fixture_error":error.to_string()});
|
||||
}
|
||||
let mut session = Session::new(before.clone());
|
||||
if let Err(error) = session.execute(Command::Layout(diagram)) {
|
||||
return json!({"fixture":name,"before":metrics(&before,diagram),"layout_error":error.to_string()});
|
||||
}
|
||||
let after = session.project().clone();
|
||||
let changed = after != before;
|
||||
let had_undo = session.can_undo();
|
||||
session.undo();
|
||||
let undo = session.project() == &before;
|
||||
let one_step = !session.can_undo();
|
||||
session.redo();
|
||||
let redo = session.project() == &after;
|
||||
let second = prepare_command(&after, &Command::Layout(diagram));
|
||||
let reset = reset_effective_routes(&after, diagram);
|
||||
let mut repeated = after.clone();
|
||||
let mut stability = Vec::new();
|
||||
for pass in 2..=10 {
|
||||
match prepare_command(&repeated, &Command::Layout(diagram)) {
|
||||
Ok(next) => {
|
||||
stability.push(json!({"pass":pass,"equal_previous":next.project==repeated,"equal_first":next.project==after}));
|
||||
repeated = next.project;
|
||||
}
|
||||
Err(error) => {
|
||||
stability.push(json!({"pass":pass,"error":error.to_string()}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let witness=witness.map(|p|json!({"metrics":metrics(&p,diagram),"project":p,
|
||||
"conservation":conservation(&before,&p,diagram),"validation_error":validate_candidate(&p).err().map(|e|e.to_string())}));
|
||||
let candidate = match layered::candidate(&before, diagram) {
|
||||
Err(error) => json!({"supported":false,"error":error.to_string()}),
|
||||
Ok(candidate) => {
|
||||
if let Err(error) = validate_candidate(&candidate) {
|
||||
json!({"supported":true,"validation_error":error.to_string(),"project":candidate})
|
||||
} else {
|
||||
let again = layered::candidate(&candidate, diagram);
|
||||
let duplicate = layered::candidate(&before, diagram);
|
||||
let mut repeated_candidate = candidate.clone();
|
||||
let mut candidate_stability = Vec::new();
|
||||
for pass in 2..=10 {
|
||||
match layered::candidate(&repeated_candidate, diagram) {
|
||||
Ok(next) => {
|
||||
candidate_stability.push(json!({"pass":pass,"equal_previous":next==repeated_candidate,"equal_first":next==candidate}));
|
||||
repeated_candidate = next;
|
||||
}
|
||||
Err(error) => {
|
||||
candidate_stability
|
||||
.push(json!({"pass":pass,"error":error.to_string()}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
json!({"supported":true,"metrics":metrics(&candidate,diagram),"project":candidate,
|
||||
"conservation":conservation(&before,&candidate,diagram),"roundtrip":roundtrip(&candidate),
|
||||
"ten_candidate_calls":candidate_stability,
|
||||
"repeat_same_input_exact":duplicate.as_ref().is_ok_and(|p|*p==candidate),
|
||||
"idempotent":again.as_ref().is_ok_and(|p|*p==candidate),
|
||||
"repeat_error":duplicate.err().map(|e|e.to_string()),"idempotence_error":again.err().map(|e|e.to_string())})
|
||||
}
|
||||
}
|
||||
};
|
||||
json!({"fixture":name,"diagram":diagram,"before":metrics(&before,diagram),"after":metrics(&after,diagram),
|
||||
"before_project":before,"after_project":after,"conservation":conservation(&before,&after,diagram),
|
||||
"reset_only_control":{"metrics":metrics(&reset,diagram),"project":reset,"conservation":conservation(&before,&reset,diagram),"roundtrip":roundtrip(&reset)},
|
||||
"manual_planar_order_witness":witness,"ten_layout_calls":stability,"experimental_candidate":candidate,
|
||||
"checks":{
|
||||
"changed":changed,"history_entry_matches_change":had_undo==changed,"undo_exact":undo,"one_step_undo":one_step,"redo_exact":redo,
|
||||
"second_layout_exact":second.as_ref().is_ok_and(|s|s.project==after),"second_layout_error":second.err().map(|e|e.to_string()),
|
||||
"before_roundtrip":roundtrip(&before),"after_roundtrip":roundtrip(&after)}})
|
||||
}
|
||||
fn main() {
|
||||
// Analytic checks validate the observer, not any layout's quality.
|
||||
let horizontal = [(0., 0.), (10., 0.)];
|
||||
assert_eq!(
|
||||
point_contacts(&horizontal, &[(5., -5.), (5., 5.)]),
|
||||
vec![((5., 0.), true)]
|
||||
);
|
||||
assert_eq!(
|
||||
point_contacts(&horizontal, &[(5., 0.), (5., 5.)]),
|
||||
vec![((5., 0.), false)]
|
||||
);
|
||||
let bend = [(5., -5.), (5., 0.), (8., 3.)];
|
||||
assert_eq!(point_contacts(&horizontal, &bend), vec![((5., 0.), false)]);
|
||||
assert_eq!(contact_role(&bend, (5., 0.)), "internal_vertex");
|
||||
assert_eq!(contact_role(&horizontal, (5., 0.)), "segment_interior");
|
||||
assert_eq!(
|
||||
point_contacts(&horizontal, &[(10., 0.), (10., 5.)]),
|
||||
vec![((10., 0.), false)]
|
||||
);
|
||||
assert_eq!(contact_role(&horizontal, (10., 0.)), "route_endpoint");
|
||||
assert_eq!(
|
||||
point_contacts(&horizontal, &[(10., 0.), (20., 0.)]),
|
||||
vec![((10., 0.), false)]
|
||||
);
|
||||
assert!(point_contacts(&horizontal, &[(4., 0.), (12., 0.)]).is_empty());
|
||||
assert_eq!(
|
||||
proper_cross((0., 0.), (10., 0.), (5., -5.), (5., 5.)),
|
||||
Some((5., 0.))
|
||||
);
|
||||
assert_eq!(
|
||||
proper_cross((0., 0.), (10., 0.), (10., 0.), (10., 5.)),
|
||||
None
|
||||
);
|
||||
assert!((shared_length((0., 0.), (10., 0.), (4., 0.), (12., 0.)) - 6.).abs() < EPS);
|
||||
assert_eq!(shared_length((0., 0.), (10., 0.), (4., 1.), (12., 1.)), 0.);
|
||||
assert_eq!(overlap((0., 0., 10., 10.), (10., 0., 20., 10.)), 0.);
|
||||
assert!((inside_length((0., 5.), (10., 5.), (2., 2., 8., 8.)) - 6.).abs() < 3. * EPS);
|
||||
assert_eq!(inside_length((0., 2.), (10., 2.), (2., 2., 8., 8.)), 0.);
|
||||
println!(
|
||||
"{}",
|
||||
json!({"probe":"auto_layout_probe_v2","reference_source":"715036e132677dd2bc2fb37ed316d93dc9987aec","metric_selfchecks":"passed", "quality_thresholds":null,
|
||||
"point_contact_definition":"Unexempted unique point per edge pair (EPS quantization), closed segments, proper-crossing flag ORed across segment witnesses; roles distinguish route endpoints/internal vertices/interiors. Positive collinear intervals are only in shared-length metric; a noncollinear witness at an overlap boundary is still a point contact. Common physical terminal requires equal owner and equal endpoint coordinate and is classified, never subtracted. Route endpoints emitted for exact comparison against the frozen prior run, not assumed invariant across node layout.",
|
||||
"exclusions":"Raw strict-interior crossings and pairwise shared length are primary; separately bound common physical endpoint exclusions to PORT_STUB. Ancestor node containment excluded. Labels wholly inside endpoint-ancestor BODY below header exempt; headers retained. Penetration excludes endpoint nodes and endpoint-ancestor bodies. Label/stroke excludes own edge. Core approximate label bounds, no glyph/port-label metrics."})
|
||||
);
|
||||
for (name, f) in fixtures() {
|
||||
println!("{}", observe(name, f));
|
||||
}
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
//! Isolated #32 experiment, not a production layout engine.
|
||||
//! Uses existing Layout sizing, three deterministic ordering passes, and one
|
||||
//! core-estimated-label spacing pass. Automatic elbows remain derived. Folded/omitted
|
||||
//! node topology is explicitly unsupported; filtered edges retain their metadata.
|
||||
use oa_core::{Command, Id, ModelError, Position, Project, Size, projection, routing};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const WORK_LIMIT: usize = 200_000;
|
||||
const NODE_LIMIT: usize = 256;
|
||||
const EDGE_LIMIT: usize = 1024;
|
||||
|
||||
fn spend(work: &mut usize) -> Result<(), ModelError> {
|
||||
*work += 1;
|
||||
if *work > WORK_LIMIT {
|
||||
return Err(ModelError("Layered probe work limit exceeded.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return a new Project; caller owns comparison, history and serialization.
|
||||
pub fn candidate(project: &Project, diagram: Id) -> Result<Project, ModelError> {
|
||||
let original = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| ModelError("Missing probe diagram.".into()))?;
|
||||
let view = projection::project_diagram(project, original);
|
||||
if view.nodes.len() > NODE_LIMIT || view.edges.len() > EDGE_LIMIT {
|
||||
return Err(ModelError("Layered probe graph limit exceeded.".into()));
|
||||
}
|
||||
if view.nodes.len() != original.nodes.len() || view.nodes.iter().any(|n| n.collapsed) {
|
||||
return Err(ModelError(
|
||||
"Layered probe does not support folded/omitted node topology.".into(),
|
||||
));
|
||||
}
|
||||
let routed = routing::route_diagram(project, original, &view);
|
||||
let eligible: BTreeSet<_> = view
|
||||
.edges
|
||||
.iter()
|
||||
.filter(|edge| {
|
||||
projection::edge_endpoint_placements(project, original, edge.id)
|
||||
== Some((edge.source, edge.target))
|
||||
&& routed
|
||||
.edges
|
||||
.get(&edge.id)
|
||||
.is_some_and(|route| !route.custom_suspended)
|
||||
})
|
||||
.map(|edge| edge.id)
|
||||
.collect();
|
||||
let mut result = oa_core::prepare_command(project, &Command::Layout(diagram))?.project;
|
||||
// Discard baseline route translation: final routes are reconstructed directly
|
||||
// from original geometry, avoiding two translations and preserving list order.
|
||||
result.diagrams.get_mut(&diagram).unwrap().routes = original.routes.clone();
|
||||
for route in &mut result.diagrams.get_mut(&diagram).unwrap().routes {
|
||||
if eligible.contains(&route.edge) {
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
}
|
||||
}
|
||||
let baseline = projection::project_view(&result, diagram);
|
||||
let labels = routing::route_diagram(&result, &result.diagrams[&diagram], &baseline);
|
||||
let mut gap_x = 64.0_f32;
|
||||
let mut gap_y = 32.0_f32;
|
||||
for edge in labels.edges.values() {
|
||||
gap_x = gap_x.max(edge.label.bounds.size.width + 24.0);
|
||||
gap_y = gap_y.max(edge.label.bounds.size.height + 24.0);
|
||||
if let Some(relation) = result.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, edge) {
|
||||
gap_x = gap_x.max(end.label.bounds.size.width + 24.0);
|
||||
gap_y = gap_y.max(end.label.bounds.size.height + 24.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !gap_x.is_finite() || !gap_y.is_finite() {
|
||||
return Err(ModelError("Nonfinite estimated label bound.".into()));
|
||||
}
|
||||
let parents: BTreeMap<_, _> = baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| (n.id, n.visual_parent))
|
||||
.collect();
|
||||
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||
for node in &baseline.nodes {
|
||||
children
|
||||
.entry(node.visual_parent)
|
||||
.or_default()
|
||||
.push(node.id);
|
||||
}
|
||||
let names: BTreeMap<_, _> = baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| (n.id, (result.elements[&n.element].alias.clone(), n.id)))
|
||||
.collect();
|
||||
for siblings in children.values_mut() {
|
||||
siblings.sort_by_key(|id| names[id].clone());
|
||||
}
|
||||
let minima: BTreeMap<_, _> = baseline.nodes.iter().map(|n| (n.id, n.size)).collect();
|
||||
let headers: BTreeMap<_, _> = baseline
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|n| {
|
||||
(
|
||||
n.id,
|
||||
if original.projection.state_machine().is_some() {
|
||||
projection::state_header_height(&result, n.element)
|
||||
} else {
|
||||
64.0
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut positions: BTreeMap<_, _> = baseline.nodes.iter().map(|n| (n.id, n.position)).collect();
|
||||
let mut sizes = minima.clone();
|
||||
let mut work = 0;
|
||||
let mut group_order: Vec<_> = children.keys().copied().collect();
|
||||
group_order.sort_by_key(|parent| {
|
||||
parent.map_or(0, |id| {
|
||||
baseline.nodes.iter().find(|n| n.id == id).unwrap().depth + 1
|
||||
})
|
||||
});
|
||||
let mut groups = BTreeMap::new();
|
||||
for pass in 0..3 {
|
||||
for parent in &group_order {
|
||||
let siblings = &children[parent];
|
||||
let mut arcs = BTreeSet::new();
|
||||
for edge in &baseline.edges {
|
||||
spend(&mut work)?;
|
||||
let a = child_under(edge.source, *parent, &parents, &mut work)?;
|
||||
let b = child_under(edge.target, *parent, &parents, &mut work)?;
|
||||
if edge.oriented
|
||||
&& let (Some(a), Some(b)) = (a, b)
|
||||
&& a != b
|
||||
{
|
||||
arcs.insert((a, b));
|
||||
}
|
||||
}
|
||||
let ranks = ranks(siblings, &arcs, &mut work)?;
|
||||
let mut layers: BTreeMap<usize, Vec<Id>> = BTreeMap::new();
|
||||
for id in siblings {
|
||||
layers.entry(ranks[id]).or_default().push(*id);
|
||||
}
|
||||
// Boundary neighbours participate even though they are not siblings.
|
||||
// Incoming preference anchors the downstream order to its upstream
|
||||
// counterpart instead of swapping both sides simultaneously.
|
||||
let mut scores = BTreeMap::new();
|
||||
for id in siblings {
|
||||
let mut incoming = Vec::new();
|
||||
for edge in &baseline.edges {
|
||||
spend(&mut work)?;
|
||||
let a = child_under(edge.source, *parent, &parents, &mut work)?;
|
||||
let b = child_under(edge.target, *parent, &parents, &mut work)?;
|
||||
if edge.oriented && b == Some(*id) && a != Some(*id) {
|
||||
incoming.push(positions[&edge.source].y + sizes[&edge.source].height / 2.0);
|
||||
}
|
||||
}
|
||||
// Do not also chase outgoing boundary neighbours: simultaneous
|
||||
// swaps in two connected containers can preserve the crossing.
|
||||
let values = &incoming;
|
||||
let score = if values.is_empty() {
|
||||
positions[id].y
|
||||
} else {
|
||||
values.iter().sum::<f32>() / values.len() as f32
|
||||
};
|
||||
scores.insert(*id, score);
|
||||
}
|
||||
for layer in layers.values_mut() {
|
||||
// First pass starts from canonical alias order; subsequent passes
|
||||
// retain a deterministic alias tie break, never hash iteration.
|
||||
if pass > 0 {
|
||||
layer.sort_by(|a, b| {
|
||||
scores[a]
|
||||
.total_cmp(&scores[b])
|
||||
.then_with(|| names[a].cmp(&names[b]))
|
||||
});
|
||||
}
|
||||
}
|
||||
groups.insert(*parent, layers.into_values().collect::<Vec<_>>());
|
||||
}
|
||||
// Recompute from frozen minima, never add spacing to last pass's size.
|
||||
sizes = minima.clone();
|
||||
measure(None, &groups, &headers, &mut sizes, gap_x, gap_y, &mut work)?;
|
||||
place(
|
||||
None,
|
||||
Position { x: 40.0, y: 40.0 },
|
||||
&groups,
|
||||
&headers,
|
||||
&sizes,
|
||||
&mut positions,
|
||||
gap_x,
|
||||
gap_y,
|
||||
&mut work,
|
||||
)?;
|
||||
}
|
||||
let d = result.diagrams.get_mut(&diagram).unwrap();
|
||||
for (id, node) in &mut d.nodes {
|
||||
node.position = positions[id];
|
||||
node.size = sizes[id];
|
||||
}
|
||||
let deltas: BTreeMap<_, _> = original
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(id, node)| {
|
||||
(
|
||||
*id,
|
||||
Position {
|
||||
x: positions[id].x - node.position.x,
|
||||
y: positions[id].y - node.position.y,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let translated: BTreeMap<_, _> = projection::translated_routes(project, original, &deltas)
|
||||
.into_iter()
|
||||
.map(|r| (r.edge, r))
|
||||
.collect();
|
||||
d.routes = original
|
||||
.routes
|
||||
.iter()
|
||||
.map(|route| {
|
||||
let mut route = translated.get(&route.edge).unwrap_or(route).clone();
|
||||
if eligible.contains(&route.edge) {
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
}
|
||||
route
|
||||
})
|
||||
.collect();
|
||||
if d.nodes.values().any(|n| {
|
||||
!n.position.x.is_finite()
|
||||
|| !n.position.y.is_finite()
|
||||
|| !n.size.width.is_finite()
|
||||
|| !n.size.height.is_finite()
|
||||
|| n.size.width <= 0.0
|
||||
|| n.size.height <= 0.0
|
||||
}) {
|
||||
return Err(ModelError("Invalid layered probe geometry.".into()));
|
||||
}
|
||||
oa_core::validate_candidate(&result)?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn child_under(
|
||||
mut id: Id,
|
||||
parent: Option<Id>,
|
||||
parents: &BTreeMap<Id, Option<Id>>,
|
||||
work: &mut usize,
|
||||
) -> Result<Option<Id>, ModelError> {
|
||||
for _ in 0..=parents.len() {
|
||||
spend(work)?;
|
||||
let Some(actual) = parents.get(&id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if *actual == parent {
|
||||
return Ok(Some(id));
|
||||
}
|
||||
let Some(next) = actual else { return Ok(None) };
|
||||
id = *next;
|
||||
}
|
||||
Err(ModelError("Layered probe containment cycle.".into()))
|
||||
}
|
||||
|
||||
fn ranks(
|
||||
ids: &[Id],
|
||||
arcs: &BTreeSet<(Id, Id)>,
|
||||
work: &mut usize,
|
||||
) -> Result<BTreeMap<Id, usize>, ModelError> {
|
||||
let mut remaining: BTreeSet<_> = ids.iter().copied().collect();
|
||||
let mut ranks: BTreeMap<_, usize> = BTreeMap::new();
|
||||
while !remaining.is_empty() {
|
||||
let mut ready = None;
|
||||
for id in ids.iter().filter(|id| remaining.contains(*id)) {
|
||||
let mut blocked = false;
|
||||
for (a, b) in arcs {
|
||||
spend(work)?;
|
||||
blocked |= b == id && remaining.contains(a);
|
||||
}
|
||||
if !blocked {
|
||||
ready = Some(*id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Explicit cycle fallback: break the first canonical remaining vertex's
|
||||
// incoming ranking constraints. Actual relation direction is unchanged.
|
||||
let id = ready.unwrap_or_else(|| *ids.iter().find(|id| remaining.contains(*id)).unwrap());
|
||||
let mut rank = 0;
|
||||
for (a, b) in arcs {
|
||||
spend(work)?;
|
||||
if *b == id
|
||||
&& let Some(previous) = ranks.get(a)
|
||||
{
|
||||
rank = rank.max(previous + 1);
|
||||
}
|
||||
}
|
||||
ranks.insert(id, rank);
|
||||
remaining.remove(&id);
|
||||
}
|
||||
Ok(ranks)
|
||||
}
|
||||
|
||||
type Groups = BTreeMap<Option<Id>, Vec<Vec<Id>>>;
|
||||
|
||||
fn measure(
|
||||
parent: Option<Id>,
|
||||
groups: &Groups,
|
||||
headers: &BTreeMap<Id, f32>,
|
||||
sizes: &mut BTreeMap<Id, Size>,
|
||||
gx: f32,
|
||||
gy: f32,
|
||||
work: &mut usize,
|
||||
) -> Result<Size, ModelError> {
|
||||
let Some(layers) = groups.get(&parent) else {
|
||||
return Ok(Size {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
});
|
||||
};
|
||||
let mut width = 0.0;
|
||||
let mut height = 0.0_f32;
|
||||
for (i, layer) in layers.iter().enumerate() {
|
||||
let mut lw = 0.0_f32;
|
||||
let mut lh = 0.0;
|
||||
for (j, id) in layer.iter().enumerate() {
|
||||
spend(work)?;
|
||||
let content = measure(Some(*id), groups, headers, sizes, gx, gy, work)?;
|
||||
let size = sizes.get_mut(id).unwrap();
|
||||
if groups.contains_key(&Some(*id)) {
|
||||
size.width = size.width.max(content.width + 48.0);
|
||||
size.height = size.height.max(content.height + headers[id] + 24.0);
|
||||
}
|
||||
lw = lw.max(size.width);
|
||||
lh += size.height + if j == 0 { 0.0 } else { gy };
|
||||
}
|
||||
width += lw + if i == 0 { 0.0 } else { gx };
|
||||
height = height.max(lh);
|
||||
}
|
||||
Ok(Size { width, height })
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn place(
|
||||
parent: Option<Id>,
|
||||
origin: Position,
|
||||
groups: &Groups,
|
||||
headers: &BTreeMap<Id, f32>,
|
||||
sizes: &BTreeMap<Id, Size>,
|
||||
positions: &mut BTreeMap<Id, Position>,
|
||||
gx: f32,
|
||||
gy: f32,
|
||||
work: &mut usize,
|
||||
) -> Result<(), ModelError> {
|
||||
let Some(layers) = groups.get(&parent) else {
|
||||
return Ok(());
|
||||
};
|
||||
let mut x = origin.x;
|
||||
for layer in layers {
|
||||
let mut y = origin.y;
|
||||
let mut width = 0.0_f32;
|
||||
for id in layer {
|
||||
spend(work)?;
|
||||
positions.insert(*id, Position { x, y });
|
||||
place(
|
||||
Some(*id),
|
||||
Position {
|
||||
x: x + 24.0,
|
||||
y: y + headers[id],
|
||||
},
|
||||
groups,
|
||||
headers,
|
||||
sizes,
|
||||
positions,
|
||||
gx,
|
||||
gy,
|
||||
work,
|
||||
)?;
|
||||
y += sizes[id].height + gy;
|
||||
width = width.max(sizes[id].width);
|
||||
}
|
||||
x += width + gx;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -340,6 +340,18 @@ pub(crate) fn diagnostic(source: &str, error: Error) -> Diagnostic {
|
||||
},
|
||||
}
|
||||
}
|
||||
pub(crate) fn diagnostic_with_issue(
|
||||
source: &str,
|
||||
error: Error,
|
||||
issue: Option<validation::Issue>,
|
||||
) -> Diagnostic {
|
||||
let mut result = diagnostic(source, error);
|
||||
if let Some(issue) = issue {
|
||||
result.object_id = issue.element;
|
||||
result.field = issue.field;
|
||||
}
|
||||
result
|
||||
}
|
||||
fn reserved(word: &str) -> bool {
|
||||
crate::reserved_alias(word)
|
||||
|| declaration_kind(word).is_some()
|
||||
@@ -622,6 +634,7 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
|
||||
if tokens.get(1).is_some_and(|t| t.text(source) == "2") {
|
||||
return v2_source::parse(source, tokens, limits);
|
||||
}
|
||||
let mut migration_issue = None;
|
||||
Parser {
|
||||
source,
|
||||
tokens,
|
||||
@@ -629,8 +642,8 @@ fn parse_document(source: &str, limits: ParseLimits) -> Result<ParsedDocument, V
|
||||
value_depth: 0,
|
||||
limits,
|
||||
}
|
||||
.document()
|
||||
.map_err(|e| vec![diagnostic(source, e)])
|
||||
.document(&mut migration_issue)
|
||||
.map_err(|e| vec![diagnostic_with_issue(source, e, migration_issue)])
|
||||
}
|
||||
fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
let mut offset = 0;
|
||||
@@ -1449,7 +1462,10 @@ impl Parser<'_> {
|
||||
},
|
||||
}
|
||||
}
|
||||
fn document(&mut self) -> Result<ParsedDocument, Error> {
|
||||
fn document(
|
||||
&mut self,
|
||||
migration_issue: &mut Option<validation::Issue>,
|
||||
) -> Result<ParsedDocument, Error> {
|
||||
let unit = self.unit(true, false)?;
|
||||
let header = unit.header.expect("entry header");
|
||||
let mut aliases = BTreeMap::from([(header.project.alias.clone(), header.project.id)]);
|
||||
@@ -1476,9 +1492,14 @@ impl Parser<'_> {
|
||||
.map(|(key, span)| ((d.id, key.clone()), span.start)),
|
||||
);
|
||||
}
|
||||
let decoded =
|
||||
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
|
||||
.map_err(|(_, error)| error)?;
|
||||
let decoded = resolve_declarations(
|
||||
header.project,
|
||||
&unit.declarations,
|
||||
&aliases,
|
||||
self.limits,
|
||||
migration_issue,
|
||||
)
|
||||
.map_err(|(_, error)| error)?;
|
||||
Ok(ParsedDocument {
|
||||
project: decoded.project,
|
||||
migration: decoded.migration,
|
||||
@@ -1682,6 +1703,7 @@ pub(crate) fn resolve_declarations(
|
||||
declarations: &[Declaration],
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
limits: ParseLimits,
|
||||
migration_issue: &mut Option<validation::Issue>,
|
||||
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
||||
let legacy = project.schema_version == 3;
|
||||
let mut legacy_elements = serde_json::Map::new();
|
||||
@@ -1989,6 +2011,38 @@ pub(crate) fn resolve_declarations(
|
||||
crate::persistence::migrate_v6(project, limits)
|
||||
})
|
||||
.map_err(|error| {
|
||||
// These frozen admission checks formerly ran in final validation.
|
||||
// Keep their diagnostic code and reference location after moving
|
||||
// them before schema normalization.
|
||||
if let crate::persistence::FileError::Model { issues, .. } = &error
|
||||
&& let Some(issue) = issues.first()
|
||||
&& let Some(code) = match issue.code.as_str() {
|
||||
"diagram_layer" => Some("diagram_layer"),
|
||||
"view_options" => Some("view_options"),
|
||||
_ => None,
|
||||
}
|
||||
{
|
||||
let index = issue
|
||||
.element
|
||||
.and_then(|id| declarations.iter().position(|d| d.id == id))
|
||||
.unwrap_or(usize::MAX);
|
||||
let offset = declarations.get(index).map_or(0, |d| {
|
||||
issue
|
||||
.field
|
||||
.as_ref()
|
||||
.and_then(|field| d.field_spans.get(field))
|
||||
.map_or(d.offset, |span| span.start)
|
||||
});
|
||||
*migration_issue = Some(issue.clone());
|
||||
return (
|
||||
index,
|
||||
Error {
|
||||
code,
|
||||
offset,
|
||||
message: issue.message.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
(
|
||||
usize::MAX,
|
||||
Error {
|
||||
|
||||
@@ -69,8 +69,8 @@ impl Project4 {
|
||||
}
|
||||
crate::persistence::check_pre6_relations(self.relations.values())?;
|
||||
crate::persistence::check_pre6_elements(self.elements.values())?;
|
||||
Ok(Project {
|
||||
schema_version: SCHEMA_VERSION,
|
||||
let mut project = Project {
|
||||
schema_version: self.schema_version,
|
||||
id: self.id,
|
||||
alias: self.alias,
|
||||
name: self.name,
|
||||
@@ -84,7 +84,11 @@ impl Project4 {
|
||||
.into_iter()
|
||||
.map(|(id, trace)| (id, trace.into()))
|
||||
.collect(),
|
||||
})
|
||||
};
|
||||
// Schema3's adapter also enters here, without calling `finish`.
|
||||
crate::persistence::check_pre7_diagram_admission(&project)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
Ok(project)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,6 +108,8 @@ pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
|
||||
}
|
||||
|
||||
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
// Source4 enters here directly rather than through `Project4`.
|
||||
crate::persistence::check_pre7_diagram_admission(&project)?;
|
||||
crate::persistence::check_pre6_relations(project.relations.values())?;
|
||||
crate::persistence::check_pre6_elements(project.elements.values())?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
|
||||
@@ -341,6 +341,50 @@ pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), Fil
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Frozen sources 3–6 admit opposite-layer placements and visibility references
|
||||
/// only in Traceability. Check before migration discards the advertised version;
|
||||
/// current ordinary projections may retain these records as latent presentation.
|
||||
pub(crate) fn check_pre7_diagram_admission(project: &Project) -> Result<(), FileError> {
|
||||
let mut issues = Vec::new();
|
||||
for diagram in project.diagrams.values() {
|
||||
let option_issues = if diagram.projection == ProjectionKind::Traceability {
|
||||
crate::view_options::validate_options_cross_layer(project, &diagram.view_options)
|
||||
} else {
|
||||
crate::view_options::validate_options(project, diagram.layer, &diagram.view_options)
|
||||
};
|
||||
for issue in option_issues {
|
||||
validation::error(
|
||||
&mut issues,
|
||||
diagram.id,
|
||||
"view_options",
|
||||
issue.field,
|
||||
issue.message,
|
||||
);
|
||||
}
|
||||
for node in diagram.nodes.values() {
|
||||
if !project.elements.get(&node.element).is_some_and(|element| {
|
||||
diagram.projection == ProjectionKind::Traceability
|
||||
|| element.layer() == diagram.layer
|
||||
}) {
|
||||
validation::error(
|
||||
&mut issues,
|
||||
node.id,
|
||||
"diagram_layer",
|
||||
"element",
|
||||
"Diagram nodes reference existing elements in the selected layer.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(issue) = issues.into_iter().next() {
|
||||
return Err(FileError::Model {
|
||||
message: issue.message.clone(),
|
||||
issues: vec![issue],
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn migrate_v5(
|
||||
mut project: Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
@@ -348,6 +392,7 @@ pub(crate) fn migrate_v5(
|
||||
if project.schema_version != 5 {
|
||||
return Err(FileError::Invalid("Expected schema_version 5.".into()));
|
||||
}
|
||||
check_pre7_diagram_admission(&project)?;
|
||||
check_pre6_relations(project.relations.values())?;
|
||||
check_pre6_elements(project.elements.values())?;
|
||||
for baseline in project
|
||||
@@ -420,6 +465,7 @@ pub(crate) fn migrate_v6(
|
||||
if project.schema_version != 6 {
|
||||
return Err(FileError::Invalid("Expected schema_version 6.".into()));
|
||||
}
|
||||
check_pre7_diagram_admission(&project)?;
|
||||
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
project.schema_version = SCHEMA_VERSION;
|
||||
|
||||
@@ -85,6 +85,7 @@ pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
|
||||
.filter(|n| {
|
||||
p.elements.get(&n.element).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& (d.projection == ProjectionKind::Traceability || e.layer() == d.layer)
|
||||
&& d.projection.includes(e.kind())
|
||||
&& d.projection
|
||||
.state_machine()
|
||||
|
||||
@@ -846,19 +846,15 @@ fn route_edge(
|
||||
.enumerate()
|
||||
{
|
||||
let incoming = points.iter().rev().find(|&&p| p != start).copied();
|
||||
// Only the final leg has a known continuation. Intermediate
|
||||
// authored pins must not inherit the target's terminal direction.
|
||||
let outgoing = (index == pins.len()).then_some(b.point);
|
||||
let path =
|
||||
fast_path(start, end, &space, &occupied, incoming, outgoing).or_else(|| {
|
||||
if !search {
|
||||
return None;
|
||||
}
|
||||
let (path, hit_limit) =
|
||||
grid_path(start, end, &space, &occupied, incoming, outgoing, budget);
|
||||
exhausted |= hit_limit;
|
||||
path
|
||||
});
|
||||
let path = fast_path(start, end, &space, &occupied, incoming).or_else(|| {
|
||||
if !search {
|
||||
return None;
|
||||
}
|
||||
let (path, hit_limit) =
|
||||
grid_path(start, end, &space, &occupied, incoming, budget);
|
||||
exhausted |= hit_limit;
|
||||
path
|
||||
});
|
||||
let Some(path) = path else {
|
||||
failed = true;
|
||||
break;
|
||||
@@ -1055,7 +1051,6 @@ fn fast_path(
|
||||
space: &RoutingSpace<'_>,
|
||||
previous: &[Segment],
|
||||
incoming: Option<Position>,
|
||||
outgoing: Option<Position>,
|
||||
) -> Option<Vec<Position>> {
|
||||
let obstacles = space.obstacles;
|
||||
if obstacles
|
||||
@@ -1075,9 +1070,6 @@ fn fast_path(
|
||||
incoming
|
||||
.zip(path.get(1))
|
||||
.map_or(0.0, |(before, &after)| turn_cost(before, start, after))
|
||||
+ outgoing
|
||||
.zip(path.len().checked_sub(2).and_then(|i| path.get(i)))
|
||||
.map_or(0.0, |(after, &before)| turn_cost(before, end, after))
|
||||
};
|
||||
let estimate = |path: &[Position]| base_cost(path) + join(path);
|
||||
candidates.sort_by(|a, b| estimate(a).total_cmp(&estimate(b)));
|
||||
@@ -1149,14 +1141,12 @@ impl Ord for QueueItem {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn grid_path(
|
||||
start: Position,
|
||||
end: Position,
|
||||
space: &RoutingSpace<'_>,
|
||||
previous: &[Segment],
|
||||
incoming: Option<Position>,
|
||||
outgoing: Option<Position>,
|
||||
budget: &mut usize,
|
||||
) -> (Option<Vec<Position>>, bool) {
|
||||
let obstacles = space.obstacles;
|
||||
@@ -1247,10 +1237,6 @@ fn grid_path(
|
||||
let cost = current.cost
|
||||
+ manhattan(a, b)
|
||||
+ before.map_or(0.0, |before| turn_cost(before, a, b))
|
||||
// Charge before queueing the target so competing arrival
|
||||
// directions are compared by their complete terminal cost.
|
||||
+ outgoing.filter(|_| next == last)
|
||||
.map_or(0.0, |after| turn_cost(a, b, after))
|
||||
+ crossing_cost(a, b, previous);
|
||||
if cost >= costs[state] {
|
||||
continue;
|
||||
@@ -1605,146 +1591,3 @@ impl BoundsBuilder {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod terminal_join_tests {
|
||||
use super::*;
|
||||
|
||||
fn complete_cost(
|
||||
path: &[Position],
|
||||
before: Position,
|
||||
after: Position,
|
||||
occupied: &[Segment],
|
||||
) -> f32 {
|
||||
let mut complete = vec![before];
|
||||
complete.extend_from_slice(path);
|
||||
complete.push(after);
|
||||
path_cost(&complete, occupied)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_join_prefers_equal_bend_witness_without_shared_trunk() {
|
||||
// Four orientations prevent an accidental horizontal/right-only fix.
|
||||
for orientation in 0..4 {
|
||||
let transform = |p: Position| match orientation {
|
||||
0 => p,
|
||||
1 => pos(-p.x, p.y),
|
||||
2 => pos(p.y, p.x),
|
||||
_ => pos(-p.y, -p.x),
|
||||
};
|
||||
let start = transform(pos(0.0, 0.0));
|
||||
let end = transform(pos(46.0, 180.0));
|
||||
let before = transform(pos(-18.0, 0.0));
|
||||
let after = transform(pos(64.0, 180.0));
|
||||
let occupied = vec![Segment {
|
||||
a: start,
|
||||
b: transform(pos(46.0, 0.0)),
|
||||
count: 1,
|
||||
}];
|
||||
let space = RoutingSpace::new(&[]);
|
||||
let witness = vec![start, transform(pos(0.0, 180.0)), end];
|
||||
// Length262 + two turns48 =310. The other complete L path has
|
||||
// the same bends/length but pays46*0.12=5.52 in shared-run cost.
|
||||
assert!((complete_cost(&witness, before, after, &occupied) - 310.0).abs() < EPSILON);
|
||||
let fast = fast_path(start, end, &space, &occupied, Some(before), Some(after)).unwrap();
|
||||
assert_eq!(fast, witness);
|
||||
assert!((complete_cost(&fast, before, after, &occupied) - 310.0).abs() < EPSILON);
|
||||
// Exercise grid directly; public routing's valid fast path would
|
||||
// otherwise hide a target-state ordering regression in grid search.
|
||||
let mut budget = VIEW_SEARCH_BUDGET;
|
||||
let (grid, exhausted) = grid_path(
|
||||
start,
|
||||
end,
|
||||
&space,
|
||||
&occupied,
|
||||
Some(before),
|
||||
Some(after),
|
||||
&mut budget,
|
||||
);
|
||||
assert!(!exhausted);
|
||||
let grid = grid.unwrap();
|
||||
assert_eq!(grid, witness);
|
||||
assert!((complete_cost(&grid, before, after, &occupied) - 310.0).abs() < EPSILON);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unspecified_continuation_keeps_intermediate_leg_choice() {
|
||||
let start = pos(0.0, 0.0);
|
||||
let pin = pos(46.0, 180.0);
|
||||
let before = pos(-18.0, 0.0);
|
||||
let occupied = vec![Segment {
|
||||
a: start,
|
||||
b: pos(46.0, 0.0),
|
||||
count: 1,
|
||||
}];
|
||||
let space = RoutingSpace::new(&[]);
|
||||
let witness = vec![start, pos(46.0, 0.0), pin];
|
||||
let fast = fast_path(start, pin, &space, &occupied, Some(before), None).unwrap();
|
||||
assert_eq!(fast, witness);
|
||||
// An intermediate authored pin has no known outgoing continuation:
|
||||
// length226 + one turn24 + shared5.52 =255.52 for this leg.
|
||||
assert!((path_cost(&fast, &occupied) - 255.52).abs() < EPSILON);
|
||||
let mut budget = VIEW_SEARCH_BUDGET;
|
||||
let (grid, exhausted) = grid_path(
|
||||
start,
|
||||
pin,
|
||||
&space,
|
||||
&occupied,
|
||||
Some(before),
|
||||
None,
|
||||
&mut budget,
|
||||
);
|
||||
assert!(!exhausted);
|
||||
assert_eq!(grid.unwrap(), witness);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn straight_and_degenerate_terminal_legs_remain_exact() {
|
||||
let space = RoutingSpace::new(&[]);
|
||||
let start = pos(0.0, 0.0);
|
||||
let end = pos(46.0, 0.0);
|
||||
for after in [pos(64.0, 0.0), pos(28.0, 0.0)] {
|
||||
let before = pos(-18.0, 0.0);
|
||||
let fast = fast_path(start, end, &space, &[], Some(before), Some(after)).unwrap();
|
||||
let expected = if after.x > end.x { 82.0 } else { 130.0 };
|
||||
assert!((complete_cost(&fast, before, after, &[]) - expected).abs() < EPSILON);
|
||||
let mut budget = VIEW_SEARCH_BUDGET;
|
||||
let (grid, exhausted) = grid_path(
|
||||
start,
|
||||
end,
|
||||
&space,
|
||||
&[],
|
||||
Some(before),
|
||||
Some(after),
|
||||
&mut budget,
|
||||
);
|
||||
assert!(!exhausted);
|
||||
assert_eq!(grid.unwrap(), fast);
|
||||
}
|
||||
assert_eq!(
|
||||
fast_path(
|
||||
start,
|
||||
start,
|
||||
&space,
|
||||
&[],
|
||||
Some(pos(-18.0, 0.0)),
|
||||
Some(pos(18.0, 0.0))
|
||||
)
|
||||
.unwrap(),
|
||||
vec![start]
|
||||
);
|
||||
let mut budget = VIEW_SEARCH_BUDGET;
|
||||
let (grid, exhausted) = grid_path(
|
||||
start,
|
||||
start,
|
||||
&space,
|
||||
&[],
|
||||
Some(pos(-18.0, 0.0)),
|
||||
Some(pos(18.0, 0.0)),
|
||||
&mut budget,
|
||||
);
|
||||
assert!(!exhausted);
|
||||
assert_eq!(grid.unwrap(), vec![start]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +589,17 @@ fn apply_command(
|
||||
.map(|d| d.id)
|
||||
.ok_or_else(missing)?;
|
||||
if source != *diagram {
|
||||
let destination = &p.diagrams[diagram];
|
||||
let element = p.diagrams[&source].nodes[node].element;
|
||||
if destination.projection != ProjectionKind::Traceability
|
||||
&& p.elements
|
||||
.get(&element)
|
||||
.is_none_or(|e| e.layer() != destination.layer)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Move the placement to a view in its layer or a traceability view.".into(),
|
||||
));
|
||||
}
|
||||
let placement = p
|
||||
.diagrams
|
||||
.get_mut(&source)
|
||||
|
||||
@@ -573,16 +573,26 @@ fn compile_once(
|
||||
span,
|
||||
}),
|
||||
};
|
||||
let decoded =
|
||||
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
|
||||
.map_err(|(index, error)| {
|
||||
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: language::diagnostic(&bundle.files[path], error),
|
||||
related: Vec::new(),
|
||||
}]
|
||||
})?;
|
||||
let mut migration_issue = None;
|
||||
let decoded = language::resolve_declarations(
|
||||
header.project,
|
||||
&declarations,
|
||||
&aliases,
|
||||
limits.model,
|
||||
&mut migration_issue,
|
||||
)
|
||||
.map_err(|(index, error)| {
|
||||
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: language::diagnostic_with_issue(
|
||||
&bundle.files[path],
|
||||
error,
|
||||
migration_issue,
|
||||
),
|
||||
related: Vec::new(),
|
||||
}]
|
||||
})?;
|
||||
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
|
||||
let errors: Vec<_> = diagnostics
|
||||
.iter()
|
||||
|
||||
@@ -620,11 +620,14 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
text(&mut out, d.id, "name", &d.name);
|
||||
text(&mut out, d.id, "description", &d.description);
|
||||
crate::scenario_layout::validate_diagram(p, d, &mut out);
|
||||
let option_issues = if d.projection == ProjectionKind::Traceability {
|
||||
crate::view_options::validate_options_cross_layer(p, &d.view_options)
|
||||
} else {
|
||||
crate::view_options::validate_options(p, d.layer, &d.view_options)
|
||||
};
|
||||
// Ordinary projections are reversible filters over the same authored view.
|
||||
// Retain opposite-layer visibility choices when leaving Traceability.
|
||||
let option_issues =
|
||||
if d.projection.state_machine().is_none() && d.projection.scenario().is_none() {
|
||||
crate::view_options::validate_options_cross_layer(p, &d.view_options)
|
||||
} else {
|
||||
crate::view_options::validate_options(p, d.layer, &d.view_options)
|
||||
};
|
||||
for issue in option_issues {
|
||||
error(&mut out, d.id, "view_options", issue.field, issue.message);
|
||||
}
|
||||
@@ -798,8 +801,10 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
);
|
||||
}
|
||||
match p.elements.get(&n.element) {
|
||||
Some(e) if d.projection == ProjectionKind::Traceability || e.layer() == d.layer => {
|
||||
}
|
||||
Some(e)
|
||||
if (d.projection.state_machine().is_none()
|
||||
&& d.projection.scenario().is_none())
|
||||
|| e.layer() == d.layer => {}
|
||||
_ => error(
|
||||
&mut out,
|
||||
n.id,
|
||||
|
||||
@@ -218,7 +218,9 @@ pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec
|
||||
validate_options_in(p, Some(layer), options)
|
||||
}
|
||||
|
||||
/// Traceability views admit references from either architecture layer.
|
||||
/// Ordinary saved views retain references from either architecture layer so
|
||||
/// projection changes preserve authored visibility choices. Projection decides
|
||||
/// which layer is visible; typed State/Scenario views use layer-scoped validation.
|
||||
pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec<OptionError> {
|
||||
validate_options_in(p, None, options)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Current latent presentation must not broaden frozen source/JSON admission.
|
||||
use oa_core::{view_options::*, *};
|
||||
|
||||
fn legacy_json(project: &Project, version: u32) -> Vec<u8> {
|
||||
let mut value = serde_json::to_value(project).unwrap();
|
||||
value["schema_version"] = version.into();
|
||||
for diagram in value["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
let fields = diagram.as_object_mut().unwrap();
|
||||
fields.remove("layout");
|
||||
if version < 6 {
|
||||
fields.remove("view_options");
|
||||
}
|
||||
}
|
||||
serde_json::to_vec(&value).unwrap()
|
||||
}
|
||||
|
||||
fn model_issue(error: persistence::FileError) -> validation::Issue {
|
||||
match error {
|
||||
persistence::FileError::Model { mut issues, .. } => issues.remove(0),
|
||||
other => panic!("Expected a located model issue, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_3_through_6_reject_cross_layer_nodes_but_keep_mixed_traceability() {
|
||||
let node_id = Id::parse_str("00000000-0000-4000-8000-000000000004").unwrap();
|
||||
for version in 3..=6 {
|
||||
let source = format!(
|
||||
"arcadia {version}; project compat \"Compatibility\" id \"00000000-0000-4000-8000-000000000001\" {{\n\
|
||||
sa_function work \"Counterpart\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n\
|
||||
diagram view \"OA concern\" id \"00000000-0000-4000-8000-000000000003\" {{ layer Oa; projection Traceability; }}\n\
|
||||
node occurrence id \"00000000-0000-4000-8000-000000000004\" {{ diagram @view; element @work; }}\n}}"
|
||||
);
|
||||
let mixed = language::parse(&source).unwrap();
|
||||
assert_eq!(
|
||||
persistence::decode(&legacy_json(&mixed, version)).unwrap(),
|
||||
mixed
|
||||
);
|
||||
|
||||
let ordinary = source.replace("projection Traceability", "projection Overview");
|
||||
let same_layer = ordinary.replace("layer Oa", "layer Sa");
|
||||
let ordinary_project = language::parse(&same_layer).unwrap();
|
||||
assert_eq!(
|
||||
persistence::decode(&legacy_json(&ordinary_project, version)).unwrap(),
|
||||
ordinary_project
|
||||
);
|
||||
let errors = language::parse(&ordinary).unwrap_err();
|
||||
// Source3 already reported migration errors with the offending location.
|
||||
let code = if version == 3 {
|
||||
"migration_error"
|
||||
} else {
|
||||
"diagram_layer"
|
||||
};
|
||||
assert!(
|
||||
errors.iter().any(|error| error.code == code
|
||||
&& error.line == 4
|
||||
&& error.message.contains("selected layer")),
|
||||
"{version}: {errors:?}"
|
||||
);
|
||||
|
||||
let mut latent = mixed.clone();
|
||||
let diagram = latent.diagrams.values_mut().next().unwrap();
|
||||
diagram.projection = ProjectionKind::Overview;
|
||||
let node = diagram.nodes.values().next().unwrap().id;
|
||||
assert_eq!(node, node_id);
|
||||
if version >= 4 {
|
||||
let diagnostic = errors
|
||||
.iter()
|
||||
.find(|error| error.code == "diagram_layer")
|
||||
.unwrap();
|
||||
assert_eq!(diagnostic.object_id, Some(node_id));
|
||||
assert_eq!(diagnostic.field.as_deref(), Some("element"));
|
||||
let bundle = source_project::SourceBundle {
|
||||
entry: "model.arcadia".into(),
|
||||
files: std::collections::BTreeMap::from([("model.arcadia".into(), ordinary)]),
|
||||
};
|
||||
let errors =
|
||||
source_project::compile_bundle(&bundle, source_project::ProjectLimits::default())
|
||||
.unwrap_err();
|
||||
let diagnostic = &errors
|
||||
.iter()
|
||||
.find(|error| error.diagnostic.code == "diagram_layer")
|
||||
.unwrap()
|
||||
.diagnostic;
|
||||
assert_eq!(diagnostic.object_id, Some(node_id));
|
||||
assert_eq!(diagnostic.field.as_deref(), Some("element"));
|
||||
}
|
||||
let issue = model_issue(persistence::decode(&legacy_json(&latent, version)).unwrap_err());
|
||||
assert_eq!(issue.code, "diagram_layer");
|
||||
assert_eq!(issue.element, Some(node));
|
||||
assert_eq!(issue.field.as_deref(), Some("element"));
|
||||
// The exact same record is valid under the current presentation contract.
|
||||
assert_eq!(language::parse(&language::format(&latent)).unwrap(), latent);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&latent).unwrap()).unwrap(),
|
||||
latent
|
||||
);
|
||||
let mut dangling = mixed;
|
||||
dangling
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.next()
|
||||
.unwrap()
|
||||
.element = Id::parse_str("00000000-0000-4000-8000-000000000005").unwrap();
|
||||
let issue = model_issue(persistence::decode(&legacy_json(&dangling, version)).unwrap_err());
|
||||
assert_eq!(issue.code, "diagram_layer");
|
||||
assert_eq!(issue.element, Some(node));
|
||||
assert_eq!(issue.field.as_deref(), Some("element"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_6_checks_every_layer_sensitive_saved_selector_before_upgrade() {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "A", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "B", None)
|
||||
.unwrap();
|
||||
let edge = project.relate(RelationKind::Interaction, "Interaction", a, b);
|
||||
let category = project
|
||||
.add_element(Kind::ExchangeCategory, Layer::Oa, "Work category", None)
|
||||
.unwrap();
|
||||
let participant_category = project
|
||||
.add_element(
|
||||
Kind::ExchangeCategory,
|
||||
Layer::Oa,
|
||||
"Participant category",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&participant_category)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Participant,
|
||||
exchanges: vec![],
|
||||
};
|
||||
let context = project
|
||||
.add_element(Kind::Process, Layer::Oa, "Process", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
for (field, options) in [
|
||||
(
|
||||
"hidden_edges",
|
||||
ViewOptions {
|
||||
hidden_edges: vec![edge],
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"work_categories",
|
||||
ViewOptions {
|
||||
work_categories: CategorySelection::Any(vec![category]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"participant_categories",
|
||||
ViewOptions {
|
||||
participant_categories: CategorySelection::Any(vec![participant_category]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
(
|
||||
"work_contexts",
|
||||
ViewOptions {
|
||||
work_contexts: ContextSelection::Any(vec![context]),
|
||||
..Default::default()
|
||||
},
|
||||
),
|
||||
] {
|
||||
project.diagrams.get_mut(&diagram).unwrap().view_options = options;
|
||||
let issue = model_issue(persistence::decode(&legacy_json(&project, 6)).unwrap_err());
|
||||
assert_eq!(issue.code, "view_options");
|
||||
assert_eq!(issue.element, Some(diagram));
|
||||
assert_eq!(issue.field.as_deref(), Some(field));
|
||||
let current_source = language::format(&project);
|
||||
assert_eq!(language::parse(¤t_source).unwrap(), project);
|
||||
let source6 = current_source.replacen("arcadia 7;", "arcadia 6;", 1);
|
||||
let errors = language::parse(&source6).unwrap_err();
|
||||
let diagnostic = errors
|
||||
.iter()
|
||||
.find(|error| error.code == "view_options")
|
||||
.unwrap();
|
||||
assert_eq!(diagnostic.object_id, Some(diagram));
|
||||
assert_eq!(diagnostic.field.as_deref(), Some(field));
|
||||
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
assert_eq!(
|
||||
persistence::decode(&legacy_json(&project, 6)).unwrap(),
|
||||
project
|
||||
);
|
||||
let source6 = language::format(&project).replacen("arcadia 7;", "arcadia 6;", 1);
|
||||
assert_eq!(language::parse(&source6).unwrap(), project);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Populated presentation survives mixed-layer projection switches.
|
||||
//! Complements projection_switching.rs without external fixture dependencies.
|
||||
use oa_core::{
|
||||
projection::*, routing::route_diagram, source_project::*, view_options::ViewIndex, *,
|
||||
};
|
||||
|
||||
struct Populated {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
groups: [Id; 2],
|
||||
children: [[Id; 2]; 2],
|
||||
edges: [Id; 2],
|
||||
ports: [Id; 2],
|
||||
trace: Id,
|
||||
}
|
||||
|
||||
fn xy(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn apply(project: &mut Project, command: Command) {
|
||||
*project = prepare_command(project, &command).unwrap().project;
|
||||
}
|
||||
|
||||
fn fixture(layer: Layer, collapsed: bool) -> Populated {
|
||||
let mut p = Project::empty();
|
||||
let oa_group = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational response", None)
|
||||
.unwrap();
|
||||
let oa_send = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request repair", Some(oa_group))
|
||||
.unwrap();
|
||||
let oa_receive = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Perform repair", Some(oa_group))
|
||||
.unwrap();
|
||||
let sa_group = p
|
||||
.add_element(Kind::Function, Layer::Sa, "System response", None)
|
||||
.unwrap();
|
||||
let sa_send = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Dispatch repair", Some(sa_group))
|
||||
.unwrap();
|
||||
let sa_receive = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Execute repair", Some(sa_group))
|
||||
.unwrap();
|
||||
let oa_edge = p.relate(
|
||||
RelationKind::Interaction,
|
||||
"Repair request",
|
||||
oa_send,
|
||||
oa_receive,
|
||||
);
|
||||
let sa_edge = p.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Repair dispatch",
|
||||
sa_send,
|
||||
sa_receive,
|
||||
);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = p.relations[&sa_edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let trace = Id::new_v4();
|
||||
let refinement = Trace {
|
||||
id: trace,
|
||||
alias: p.fresh_alias("dispatch_refinement", "trace", trace),
|
||||
name: "Dispatch refines repair request".into(),
|
||||
description: "Keep this cross-layer trace and its authored route.".into(),
|
||||
oa: oa_send,
|
||||
sa: sa_send,
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(oa_send).unwrap(),
|
||||
sa: p.properties(sa_send).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
};
|
||||
apply(&mut p, Command::CreateTrace(refinement));
|
||||
let d = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
|
||||
let oa_group_node = p.place(d, oa_group, xy(0., 0.));
|
||||
let oa_send_node = p.place(d, oa_send, xy(40., 120.));
|
||||
let oa_receive_node = p.place(d, oa_receive, xy(360., 120.));
|
||||
let sa_group_node = p.place(d, sa_group, xy(0., 500.));
|
||||
let sa_send_node = p.place(d, sa_send, xy(40., 620.));
|
||||
let sa_receive_node = p.place(d, sa_receive, xy(360., 620.));
|
||||
for (node, port, side, offset) in [
|
||||
(sa_send_node, source_port, PortAnchorSide::Bottom, 0.25),
|
||||
(sa_receive_node, target_port, PortAnchorSide::Top, 0.75),
|
||||
] {
|
||||
apply(
|
||||
&mut p,
|
||||
Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node,
|
||||
anchor: PortAnchor { port, side, offset },
|
||||
},
|
||||
);
|
||||
}
|
||||
for (edge, y, port_backed) in [
|
||||
(oa_edge, 260., false),
|
||||
(sa_edge, 860., true),
|
||||
(trace, 420., false),
|
||||
] {
|
||||
apply(
|
||||
&mut p,
|
||||
Command::SetEdgeRoute {
|
||||
diagram: d,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: (!port_backed).then_some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.3,
|
||||
}),
|
||||
target_attachment: (!port_backed).then_some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.7,
|
||||
}),
|
||||
waypoints: vec![xy(680., y), xy(740., y)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: xy(13., -27.),
|
||||
}),
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
for node in [oa_group_node, sa_group_node] {
|
||||
let mut placement = p.diagrams[&d].nodes[&node].clone();
|
||||
placement.collapsed = collapsed;
|
||||
placement.size = Size {
|
||||
width: 310.,
|
||||
height: 170.,
|
||||
};
|
||||
apply(
|
||||
&mut p,
|
||||
Command::EditNode {
|
||||
diagram: d,
|
||||
original: node,
|
||||
node: placement,
|
||||
},
|
||||
);
|
||||
}
|
||||
validate_candidate(&p).unwrap();
|
||||
Populated {
|
||||
project: p,
|
||||
diagram: d,
|
||||
groups: [oa_group_node, sa_group_node],
|
||||
children: [
|
||||
[oa_send_node, oa_receive_node],
|
||||
[sa_send_node, sa_receive_node],
|
||||
],
|
||||
edges: [oa_edge, sa_edge],
|
||||
ports: [source_port, target_port],
|
||||
trace,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_round_trips(p: &Project) {
|
||||
assert_eq!(language::parse(&language::format(p)).unwrap(), *p);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(p).unwrap()).unwrap(),
|
||||
*p
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(p), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*p
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_layer_only(p: &Project, d: Id, view: &ProjectedView) {
|
||||
let layer = p.diagrams[&d].layer;
|
||||
for node in &view.nodes {
|
||||
assert_eq!(p.elements[&node.element].layer(), layer);
|
||||
if let Some(parent) = node.visual_parent {
|
||||
let parent = view.nodes.iter().find(|n| n.id == parent).unwrap();
|
||||
assert_eq!(p.elements[&parent.element].layer(), layer);
|
||||
}
|
||||
}
|
||||
for port in &view.ports {
|
||||
assert_eq!(p.elements[&port.element].layer(), layer);
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
assert_eq!(p.elements[&owner.element].layer(), layer);
|
||||
}
|
||||
for edge in &view.edges {
|
||||
assert!(
|
||||
!edge.trace,
|
||||
"cross-layer trace leaked into ordinary projection"
|
||||
);
|
||||
assert_eq!(p.layer_of(edge.id), Some(layer));
|
||||
for endpoint in [edge.source, edge.target] {
|
||||
let owner = view.nodes.iter().find(|n| n.id == endpoint).unwrap();
|
||||
assert_eq!(p.elements[&owner.element].layer(), layer);
|
||||
}
|
||||
for port in [edge.source_port, edge.target_port].into_iter().flatten() {
|
||||
assert_eq!(p.elements[&port].layer(), layer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populated_mixed_projection_switch_conserves_authorship_and_restores_geometry() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for collapsed in [false, true] {
|
||||
let f = fixture(layer, collapsed);
|
||||
let baseline = &f.project;
|
||||
let d = f.diagram;
|
||||
let before_view = project_view(baseline, d);
|
||||
let before_routes = route_diagram(baseline, &baseline.diagrams[&d], &before_view);
|
||||
// Guard against a vacuous fixture: Traceability deliberately flattens parents.
|
||||
assert_eq!(before_view.nodes.len(), 6);
|
||||
assert!(before_view.nodes.iter().all(|n| n.visual_parent.is_none()));
|
||||
for id in [f.edges[0], f.edges[1], f.trace] {
|
||||
assert!(before_view.edges.iter().any(|e| e.id == id));
|
||||
let route = baseline.diagrams[&d]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == id)
|
||||
.unwrap();
|
||||
assert_eq!(route.waypoints.len(), 2);
|
||||
assert!(route.label.is_some());
|
||||
}
|
||||
for port in f.ports {
|
||||
assert!(before_view.ports.iter().any(|p| p.element == port));
|
||||
}
|
||||
assert_round_trips(baseline);
|
||||
for projection in [
|
||||
ProjectionKind::Overview,
|
||||
ProjectionKind::Decomposition,
|
||||
ProjectionKind::Allocation,
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
ProjectionKind::Capabilities,
|
||||
] {
|
||||
let mut expected = baseline.clone();
|
||||
expected.diagrams.get_mut(&d).unwrap().projection = projection;
|
||||
let mut session = Session::new(baseline.clone());
|
||||
session
|
||||
.execute(Command::EditDiagram(d, expected.diagrams[&d].clone()))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert_round_trips(session.project());
|
||||
let view = project_view(session.project(), d);
|
||||
assert_eq!(
|
||||
view,
|
||||
project_view_with_index(&expected, d, &ViewIndex::new(&expected))
|
||||
);
|
||||
assert_layer_only(session.project(), d, &view);
|
||||
if projection == ProjectionKind::Overview {
|
||||
let own = if layer == Layer::Oa { 0 } else { 1 };
|
||||
assert!(view.nodes.iter().any(|n| n.id == f.groups[own]));
|
||||
for child in f.children[own] {
|
||||
if collapsed {
|
||||
assert!(!view.nodes.iter().any(|n| n.id == child));
|
||||
} else {
|
||||
assert_eq!(
|
||||
view.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == child)
|
||||
.unwrap()
|
||||
.visual_parent,
|
||||
Some(f.groups[own])
|
||||
);
|
||||
}
|
||||
}
|
||||
if !collapsed {
|
||||
assert!(view.edges.iter().any(|e| e.id == f.edges[own]));
|
||||
if layer == Layer::Sa {
|
||||
assert_eq!(view.ports.len(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
session.undo();
|
||||
assert_eq!(session.project(), baseline);
|
||||
assert!(
|
||||
!session.can_undo(),
|
||||
"projection change must be one model history step"
|
||||
);
|
||||
assert_round_trips(session.project());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session
|
||||
.execute(Command::EditDiagram(d, baseline.diagrams[&d].clone()))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), baseline);
|
||||
let restored = project_view(session.project(), d);
|
||||
assert_eq!(restored, before_view);
|
||||
assert_eq!(
|
||||
route_diagram(
|
||||
session.project(),
|
||||
&session.project().diagrams[&d],
|
||||
&restored
|
||||
),
|
||||
before_routes
|
||||
);
|
||||
assert_round_trips(session.project());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
use oa_core::{source_project::*, view_options::*, *};
|
||||
|
||||
fn fixture(layer: Layer) -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let oa = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Assess alarm", None)
|
||||
.unwrap();
|
||||
let oa2 = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
|
||||
.unwrap();
|
||||
let sa = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Assess alarm", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::Interaction, "Alarm", oa, oa2);
|
||||
let category = p
|
||||
.add_element(Kind::ExchangeCategory, Layer::Oa, "Alarm data", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&category).unwrap().shared_mut().unwrap() = SharedBody::ExchangeCategory {
|
||||
family: ExchangeFamily::Work,
|
||||
exchanges: vec![edge],
|
||||
};
|
||||
let context = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Alarm handling", None)
|
||||
.unwrap();
|
||||
let d = p.ensure_diagram(layer);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
|
||||
for (i, element) in [oa, oa2, sa].into_iter().enumerate() {
|
||||
p.place(
|
||||
d,
|
||||
element,
|
||||
Position {
|
||||
x: 300. * i as f32,
|
||||
y: 40.,
|
||||
},
|
||||
);
|
||||
}
|
||||
p.diagrams.get_mut(&d).unwrap().view_options = ViewOptions {
|
||||
hidden_edges: vec![edge],
|
||||
work_categories: CategorySelection::Any(vec![category]),
|
||||
work_contexts: ContextSelection::Any(vec![context]),
|
||||
..Default::default()
|
||||
};
|
||||
validate_candidate(&p).unwrap();
|
||||
(p, d, oa, sa, category)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_switching_preserves_complete_model_history_and_all_serializations() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let (baseline, diagram, _, _, _) = fixture(layer);
|
||||
for projection in [
|
||||
ProjectionKind::Overview,
|
||||
ProjectionKind::Decomposition,
|
||||
ProjectionKind::Allocation,
|
||||
ProjectionKind::FunctionalDataflow,
|
||||
ProjectionKind::Capabilities,
|
||||
] {
|
||||
let mut expected = baseline.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().projection = projection;
|
||||
let mut session = Session::new(baseline.clone());
|
||||
session
|
||||
.execute(Command::EditDiagram(
|
||||
diagram,
|
||||
expected.diagrams[&diagram].clone(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
let view = projection::project_view(session.project(), diagram);
|
||||
assert!(
|
||||
view.nodes
|
||||
.iter()
|
||||
.all(|n| expected.elements[&n.element].layer() == layer)
|
||||
);
|
||||
assert_eq!(
|
||||
view,
|
||||
projection::project_view_with_index(&expected, diagram, &ViewIndex::new(&expected))
|
||||
);
|
||||
let source = language::format(&expected);
|
||||
assert_eq!(language::parse(&source).unwrap(), expected);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&expected).unwrap()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let bundle = format_bundle(&expected);
|
||||
assert_eq!(
|
||||
compile_bundle(&bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &baseline);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session
|
||||
.execute(Command::EditDiagram(
|
||||
diagram,
|
||||
baseline.diagrams[&diagram].clone(),
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &baseline);
|
||||
assert_eq!(
|
||||
projection::project_view(session.project(), diagram),
|
||||
projection::project_view(&baseline, diagram)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_switching_does_not_admit_missing_elements_or_invalid_filter_kinds() {
|
||||
let (mut p, d, oa, _, category) = fixture(Layer::Sa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
|
||||
validate_candidate(&p).unwrap();
|
||||
let valid = p.clone();
|
||||
let node = p.diagrams[&d]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == oa)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.element = Id::new_v4();
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
p = valid.clone();
|
||||
p.diagrams.get_mut(&d).unwrap().view_options.work_categories = CategorySelection::Any(vec![oa]);
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
p = valid;
|
||||
p.elements.remove(&category);
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_switching_keeps_typed_state_membership_strict() {
|
||||
let (mut p, d, _, _, _) = fixture(Layer::Sa);
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", Some(system))
|
||||
.unwrap();
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
assert!(
|
||||
validation::validate(&p)
|
||||
.iter()
|
||||
.any(|i| i.code == "state_diagram_member")
|
||||
);
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
}
|
||||
@@ -67,7 +67,18 @@ fn source6_traceability_default_keeps_both_layers_and_indexed_projection_matches
|
||||
.any(|e| e.id == interaction)
|
||||
);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Overview;
|
||||
assert!(validate_candidate(&p).is_err());
|
||||
validate_candidate(&p).unwrap();
|
||||
let ordinary = projection::project_view_with_index(&p, d, &index);
|
||||
assert!(
|
||||
ordinary
|
||||
.nodes
|
||||
.iter()
|
||||
.all(|n| p.elements[&n.element].layer() == Layer::Oa)
|
||||
);
|
||||
assert!(ordinary.edges.iter().any(|e| e.id == interaction));
|
||||
assert!(!ordinary.edges.iter().any(|e| e.id == fe));
|
||||
assert_eq!(p.diagrams[&d].nodes.len(), 4);
|
||||
assert_eq!(p.diagrams[&d].view_options.hidden_edges, vec![fe]);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Traceability;
|
||||
p.diagrams.get_mut(&d).unwrap().view_options.relation_kinds = RelationKinds::Only(vec![]);
|
||||
assert!(
|
||||
|
||||
@@ -4,8 +4,12 @@ pub(crate) mod architecture_authoring;
|
||||
mod architecture_authoring_tests;
|
||||
#[cfg(test)]
|
||||
mod behavior_removal_tests;
|
||||
#[cfg(test)]
|
||||
mod canvas_selection_tests;
|
||||
mod checkpoints;
|
||||
#[cfg(test)]
|
||||
mod connection_begin_regressions;
|
||||
#[cfg(test)]
|
||||
mod diagram_focus_tests;
|
||||
pub(crate) mod diagram_removal;
|
||||
#[cfg(test)]
|
||||
@@ -19,6 +23,8 @@ mod hosted;
|
||||
pub(crate) mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
#[cfg(test)]
|
||||
mod placement_relocation_regressions;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
@@ -381,6 +387,7 @@ pub enum Message {
|
||||
Behavior(crate::behavior::Event),
|
||||
Layer(Layer),
|
||||
Select(Id),
|
||||
CanvasSelect(crate::route_edit::Context, Id),
|
||||
EditProperties(Id),
|
||||
SelectNode(Id),
|
||||
DiagramSelection(multiselect::Event),
|
||||
@@ -947,6 +954,16 @@ impl App {
|
||||
return true;
|
||||
}
|
||||
let original = self.selected;
|
||||
let canvas_surface = self.architecture_surface();
|
||||
let placement_owner = self.draft.as_ref().and_then(|draft| match draft.kind {
|
||||
crate::forms::RecordKind::Node(owner) => self
|
||||
.project()
|
||||
.diagrams
|
||||
.get(&owner)
|
||||
.filter(|diagram| diagram.nodes.contains_key(&draft.original))
|
||||
.map(|diagram| diagram.id),
|
||||
_ => None,
|
||||
});
|
||||
let result = self
|
||||
.draft
|
||||
.as_ref()
|
||||
@@ -1001,13 +1018,16 @@ impl App {
|
||||
if let Some(layer) = self.project().layer_of(id) {
|
||||
self.layer = layer;
|
||||
}
|
||||
if let Some((diagram, layer, node)) = self
|
||||
let selected_surface = self
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.id == id || d.nodes.contains_key(&id))
|
||||
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)))
|
||||
{
|
||||
.map(|d| (d.id, d.layer, d.nodes.contains_key(&id).then_some(id)));
|
||||
let relocated_placement = placement_owner
|
||||
.zip(selected_surface.and_then(|(diagram, _, node)| node.map(|_| diagram)))
|
||||
.is_some_and(|(before, after)| before != after);
|
||||
if let Some((diagram, layer, node)) = selected_surface {
|
||||
self.layer = layer;
|
||||
if self.page == Page::Behavior
|
||||
&& let Some(machine) =
|
||||
@@ -1021,6 +1041,20 @@ impl App {
|
||||
self.selected_node = node;
|
||||
}
|
||||
}
|
||||
// Ordinary Properties edits retain their saved architecture view.
|
||||
// Follow an explicitly edited diagram identity/layer, never an
|
||||
// inferred replacement for an unrelated queued canvas click.
|
||||
// An accepted placement move explicitly chooses its new view.
|
||||
// Same-owner edits (including a new UUID) still retain inspection.
|
||||
if !relocated_placement && let Some((diagram, _, _)) = canvas_surface {
|
||||
let diagram = if diagram == original { id } else { diagram };
|
||||
if let Some(view) = self.project().diagrams.get(&diagram)
|
||||
&& view.projection.is_architecture()
|
||||
{
|
||||
self.layer = view.layer;
|
||||
self.active_diagram = Some(diagram);
|
||||
}
|
||||
}
|
||||
self.sync();
|
||||
self.status = "Properties applied".into();
|
||||
true
|
||||
@@ -1171,7 +1205,13 @@ impl App {
|
||||
if id != self.selected {
|
||||
self.selected_attachment = None;
|
||||
}
|
||||
if self.selected == id && !process_preview {
|
||||
if self.selected == id
|
||||
&& !process_preview
|
||||
&& self
|
||||
.project()
|
||||
.layer_of(id)
|
||||
.is_none_or(|layer| layer == self.layer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if !self.apply() {
|
||||
@@ -1217,6 +1257,59 @@ impl App {
|
||||
}
|
||||
self.refresh_draft();
|
||||
}
|
||||
/// The saved architecture surface, independent of the Inspector's object.
|
||||
fn architecture_surface(&self) -> Option<(Id, Layer, ProjectionKind)> {
|
||||
if self.page != Page::Diagram {
|
||||
return None;
|
||||
}
|
||||
let diagram = self.project().diagrams.get(&self.active_diagram?)?;
|
||||
diagram.projection.is_architecture().then_some((
|
||||
diagram.id,
|
||||
diagram.layer,
|
||||
diagram.projection,
|
||||
))
|
||||
}
|
||||
fn canvas_target_visible(&self, diagram: Id, id: Id) -> bool {
|
||||
let view = project_view(self.project(), diagram);
|
||||
view.nodes
|
||||
.iter()
|
||||
.any(|node| node.id == id || node.element == id)
|
||||
|| view.ports.iter().any(|port| port.element == id)
|
||||
|| view.edges.iter().any(|edge| edge.id == id)
|
||||
}
|
||||
/// Inspect a contextual target without following its model layer. An edit
|
||||
/// may invalidate a previously visible occurrence; accept the edit but
|
||||
/// refuse that old hit. Invisible Show-in-view targets remain intentional.
|
||||
fn inspect_canvas_target(&mut self, id: Id) -> bool {
|
||||
if self.selected == id {
|
||||
return true;
|
||||
}
|
||||
let surface = self.architecture_surface();
|
||||
let accepted_draft = self.draft_dirty();
|
||||
let was_visible = accepted_draft
|
||||
&& surface.is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id));
|
||||
if !self.project().contains(id) || !self.apply() {
|
||||
return false;
|
||||
}
|
||||
if !self.project().contains(id)
|
||||
|| (was_visible
|
||||
&& (self.architecture_surface() != surface
|
||||
|| !surface
|
||||
.is_some_and(|(diagram, _, _)| self.canvas_target_visible(diagram, id))))
|
||||
{
|
||||
if accepted_draft {
|
||||
self.status =
|
||||
"Properties applied. The view changed; select a visible target again.".into();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
self.selected = id;
|
||||
self.selected_node = None;
|
||||
self.selected_attachment = None;
|
||||
self.selected_bend = None;
|
||||
self.refresh_draft();
|
||||
true
|
||||
}
|
||||
fn reveal_explorer_selection(&mut self) {
|
||||
if let Some(group) = self.explorer_index.group(self.selected) {
|
||||
self.explorer_groups_collapsed.remove(&group);
|
||||
@@ -1363,6 +1456,11 @@ impl App {
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::CanvasSelect(captured, _) = &message
|
||||
&& (self.page != Page::Diagram || self.route_context() != Some(*captured))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
// Selection can change without advancing the captured canvas context.
|
||||
if let Message::Route(
|
||||
crate::route_edit::Event::Commit(captured, changed, ..)
|
||||
@@ -2118,6 +2216,7 @@ impl App {
|
||||
Message::Command(Command::Layout(diagram)) => self.saved_scenario_diagram(*diagram),
|
||||
Message::Projection(projection) => active || projection.scenario().is_some(),
|
||||
Message::SelectNode(_)
|
||||
| Message::CanvasSelect(..)
|
||||
| Message::PlaceSelected
|
||||
| Message::HideSelected
|
||||
| Message::ToggleCollapse
|
||||
@@ -2718,10 +2817,26 @@ impl App {
|
||||
e.kind() != Kind::Port && d.projection.includes(e.kind())
|
||||
})
|
||||
});
|
||||
if show_in_mixed_view {
|
||||
self.selected = menu.request.object;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
// Draw acts on the visible current view even when opened from
|
||||
// Explorer; explicit Select/Open actions still navigate.
|
||||
let draw_in_current_view = match &*action {
|
||||
Message::Connection(crate::connection_edit::Event::Begin(tool, source)) => {
|
||||
*source == menu.request.object
|
||||
&& self.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::can_begin(
|
||||
self.project(),
|
||||
diagram,
|
||||
*tool,
|
||||
*source,
|
||||
)
|
||||
})
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if show_in_mixed_view || draw_in_current_view || menu.request.canvas.is_some() {
|
||||
if !self.inspect_canvas_target(menu.request.object) {
|
||||
return Task::none();
|
||||
}
|
||||
} else {
|
||||
self.select(menu.request.object);
|
||||
}
|
||||
@@ -2733,7 +2848,15 @@ impl App {
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
let action = match *action {
|
||||
Message::EditProperties(id)
|
||||
if menu.request.canvas.is_some() && id == menu.request.object =>
|
||||
{
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Properties)
|
||||
}
|
||||
action => action,
|
||||
};
|
||||
return self.update(action);
|
||||
}
|
||||
Message::CreateContextElement(kind, child) => {
|
||||
self.create_kind = kind;
|
||||
@@ -2889,6 +3012,14 @@ impl App {
|
||||
self.status = "Property draft reverted".into();
|
||||
}
|
||||
Message::Select(id) => self.select(id),
|
||||
Message::CanvasSelect(context, id) => {
|
||||
let view = project_view(self.project(), context.diagram);
|
||||
if view.ports.iter().any(|port| port.element == id)
|
||||
|| view.edges.iter().any(|edge| edge.id == id)
|
||||
{
|
||||
self.inspect_canvas_target(id);
|
||||
}
|
||||
}
|
||||
Message::DiagramSelection(event) => self.multiselect_event(event),
|
||||
Message::EditProperties(id) => {
|
||||
self.select(id);
|
||||
@@ -2908,10 +3039,23 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
let original = self.selected;
|
||||
let surface = self.architecture_surface();
|
||||
let accepted_draft = self.draft_dirty();
|
||||
if !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
let id = if id == original { self.selected } else { id };
|
||||
if let Some((diagram, _, _)) = surface
|
||||
&& (self.architecture_surface() != surface
|
||||
|| !self.canvas_target_visible(diagram, id))
|
||||
{
|
||||
if accepted_draft {
|
||||
self.status =
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
.into();
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(d) = self
|
||||
.active_diagram
|
||||
.and_then(|d| self.project().diagrams.get(&d))
|
||||
@@ -3336,7 +3480,7 @@ impl App {
|
||||
self.workflow = Some(crate::workflow::Workflow::relationship_kind(
|
||||
self.project(),
|
||||
self.selected,
|
||||
self.layer,
|
||||
self.project().layer_of(self.selected).unwrap_or(self.layer),
|
||||
kind,
|
||||
incoming,
|
||||
));
|
||||
@@ -4362,13 +4506,18 @@ impl App {
|
||||
self.status = "Select an SA function or participant, then choose a typed port action in Properties.".into();
|
||||
return;
|
||||
}
|
||||
let creation_layer = if child {
|
||||
self.project().layer_of(self.selected).unwrap_or(self.layer)
|
||||
} else {
|
||||
self.layer
|
||||
};
|
||||
let allocated = child
|
||||
&& self.create_kind.is_work()
|
||||
&& self
|
||||
.project()
|
||||
.elements
|
||||
.get(&self.selected)
|
||||
.is_some_and(|e| e.layer() == self.layer && e.kind().is_performer());
|
||||
.is_some_and(|e| e.layer() == creation_layer && e.kind().is_performer());
|
||||
let parent = if child && !allocated {
|
||||
Some(self.selected)
|
||||
} else {
|
||||
@@ -4376,7 +4525,7 @@ impl App {
|
||||
};
|
||||
let Some(e) = self.project().make_element(
|
||||
self.create_kind,
|
||||
self.layer,
|
||||
creation_layer,
|
||||
&format!("New {}", self.create_kind.label().to_lowercase()),
|
||||
parent,
|
||||
) else {
|
||||
@@ -4405,16 +4554,16 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == self.layer && d.projection == preferred)
|
||||
.find(|d| d.layer == creation_layer && d.projection == preferred)
|
||||
.map(|d| d.id);
|
||||
if diagram_target.is_none() {
|
||||
let view = Id::new_v4();
|
||||
commands.push(Command::CreateDiagram(Diagram {
|
||||
id: view,
|
||||
alias: self.project().fresh_alias("engineering_view", "view", view),
|
||||
name: format!("{} {preferred}", self.layer),
|
||||
name: format!("{} {preferred}", creation_layer),
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
layer: creation_layer,
|
||||
projection: preferred,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
@@ -4547,7 +4696,13 @@ impl App {
|
||||
));
|
||||
}
|
||||
if self.execute(Command::Batch(commands)) {
|
||||
let changed_view = self.active_diagram != diagram_target;
|
||||
self.active_diagram = diagram_target;
|
||||
if changed_view
|
||||
&& let Some(view) = diagram_target.and_then(|id| self.project().diagrams.get(&id))
|
||||
{
|
||||
self.layer = view.layer;
|
||||
}
|
||||
self.selected = id;
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
self.refresh_draft();
|
||||
@@ -4561,7 +4716,7 @@ impl App {
|
||||
self.workflow = Some(crate::workflow::Workflow::relationship(
|
||||
self.project(),
|
||||
self.selected,
|
||||
self.layer,
|
||||
self.project().layer_of(self.selected).unwrap_or(self.layer),
|
||||
));
|
||||
}
|
||||
fn add_trace(&mut self) {
|
||||
@@ -4982,8 +5137,30 @@ impl App {
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
// Admission belongs to the caller: palette choice, contextual source, or
|
||||
// an already admitted session whose complete context is still current.
|
||||
fn arm_connection(
|
||||
&mut self,
|
||||
context: crate::route_edit::Context,
|
||||
tool: crate::connection_tool::Tool,
|
||||
) -> crate::route_edit::Context {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = crate::route_edit::Context {
|
||||
epoch: self.canvas_epoch,
|
||||
..context
|
||||
};
|
||||
self.connection = Some(crate::connection_edit::Session::new(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
context,
|
||||
tool,
|
||||
));
|
||||
self.selected_bend = None;
|
||||
self.status = tool.hint().into();
|
||||
context
|
||||
}
|
||||
fn edit_connection(&mut self, event: crate::connection_edit::Event) {
|
||||
use crate::connection_edit::{Event, Session};
|
||||
use crate::connection_edit::Event;
|
||||
let Some(context) = self.route_context() else {
|
||||
return;
|
||||
};
|
||||
@@ -4999,10 +5176,13 @@ impl App {
|
||||
}
|
||||
match event {
|
||||
Event::Begin(tool, source) => {
|
||||
self.edit_connection(Event::Choose(Some(tool)));
|
||||
if let Some(context) = self.route_context() {
|
||||
self.edit_connection(Event::Endpoint(context, source));
|
||||
if !crate::connection_edit::can_begin(self.project(), context.diagram, tool, source)
|
||||
{
|
||||
self.status = "Choose a visible compatible source in this diagram.".into();
|
||||
return;
|
||||
}
|
||||
let context = self.arm_connection(context, tool);
|
||||
self.edit_connection(Event::Endpoint(context, source));
|
||||
}
|
||||
Event::Choose(Some(tool)) => {
|
||||
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
||||
@@ -5016,16 +5196,7 @@ impl App {
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
let context = self.route_context().unwrap();
|
||||
self.connection = Some(Session::new(
|
||||
self.project(),
|
||||
&project_view(self.project(), context.diagram),
|
||||
context,
|
||||
tool,
|
||||
));
|
||||
self.selected_bend = None;
|
||||
self.status = tool.hint().into();
|
||||
self.arm_connection(context, tool);
|
||||
}
|
||||
Event::Endpoint(captured, id) => {
|
||||
if captured != context {
|
||||
@@ -5086,17 +5257,9 @@ impl App {
|
||||
&project_view(self.project(), context.diagram),
|
||||
id,
|
||||
);
|
||||
if session.tool == crate::connection_tool::Tool::Allocation
|
||||
&& self.project().diagrams[&context.diagram].projection
|
||||
== ProjectionKind::Traceability
|
||||
{
|
||||
// Mixed views can allocate either perspective. Choosing
|
||||
// a visible source must not navigate to its preferred view.
|
||||
self.selected = id;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
} else {
|
||||
self.select(id);
|
||||
if !self.inspect_canvas_target(id) {
|
||||
self.connection = Some(session);
|
||||
return;
|
||||
}
|
||||
self.status = if session.candidates.is_empty() {
|
||||
"No compatible targets are visible. Expand containers or show a compatible endpoint in this view; Esc cancels drawing.".into()
|
||||
@@ -5112,8 +5275,11 @@ impl App {
|
||||
Event::Restart(captured) => {
|
||||
if captured == context
|
||||
&& let Some(session) = &self.connection
|
||||
&& session.context == context
|
||||
{
|
||||
self.edit_connection(Event::Choose(Some(session.tool)));
|
||||
// A contextual mixed-layer tool need not be in this layer's
|
||||
// palette. Its unchanged session context retains admission.
|
||||
self.arm_connection(context, session.tool);
|
||||
}
|
||||
}
|
||||
Event::Choose(None) => {}
|
||||
|
||||
@@ -89,7 +89,10 @@ async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field
|
||||
);
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
Message::Select(_)
|
||||
| Message::CanvasSelect(..)
|
||||
| Message::SelectNode(_)
|
||||
| Message::MoveNode(..)
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selected);
|
||||
|
||||
@@ -237,7 +237,10 @@ fn mounted_dirty_property_press_does_not_select_apply_or_destroy_draft() {
|
||||
));
|
||||
assert!(!events.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
Message::Select(_)
|
||||
| Message::CanvasSelect(..)
|
||||
| Message::SelectNode(_)
|
||||
| Message::MoveNode(..)
|
||||
)));
|
||||
send(&mut f.app, events);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selection);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Compact neutral tool palette. Tool state never changes its allocated width.
|
||||
use super::*;
|
||||
use crate::connection_tool::Tool;
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Theme, mouse,
|
||||
widget::{
|
||||
@@ -11,7 +12,7 @@ use iced::{
|
||||
#[derive(Clone, Copy)]
|
||||
enum Icon {
|
||||
Element(Kind),
|
||||
Arrow,
|
||||
Connection(Tool),
|
||||
Existing,
|
||||
Select,
|
||||
Collapse(bool),
|
||||
@@ -76,10 +77,59 @@ impl canvas::Program<Message> for Icon {
|
||||
);
|
||||
}
|
||||
}
|
||||
Self::Arrow => {
|
||||
f.stroke(&line(&[(1., 8.), (14., 8.), (10., 4.)]), stroke);
|
||||
f.stroke(&line(&[(14., 8.), (10., 12.)]), stroke);
|
||||
}
|
||||
Self::Connection(connection) => match connection {
|
||||
// The OA and SA work-flow tools share a visual family; they
|
||||
// never coexist in a palette. Keep their small glyph legible.
|
||||
Tool::Interaction | Tool::FunctionalExchange => {
|
||||
for x in [1., 11.] {
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(
|
||||
Point::new(x, 5.),
|
||||
Size::new(4., 6.),
|
||||
1.5.into(),
|
||||
),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
f.stroke(&line(&[(5., 8.), (10., 8.)]), stroke);
|
||||
f.stroke(&line(&[(7.5, 5.5), (10., 8.), (7.5, 10.5)]), stroke);
|
||||
}
|
||||
Tool::CommunicationMean => {
|
||||
// Participant endpoints, without implying a fixed direction.
|
||||
for x in [1., 11.] {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(x, 4.), Size::new(4., 8.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
f.stroke(&line(&[(5., 8.), (11., 8.)]), stroke);
|
||||
}
|
||||
Tool::BoundaryExchange => {
|
||||
// Ports on facing participant boundaries. Standard exchanges
|
||||
// are unoriented, so the generic tool has no arrowhead.
|
||||
for x in [2., 14.] {
|
||||
f.stroke(&line(&[(x, 2.), (x, 14.)]), stroke);
|
||||
}
|
||||
for x in [1., 12.] {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(x, 6.), Size::new(3., 4.)),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
f.stroke(&line(&[(4., 8.), (12., 8.)]), stroke);
|
||||
}
|
||||
Tool::Allocation => {
|
||||
// Work enters a performer: allocation can appear as nesting
|
||||
// rather than a relationship line in allocated-work views.
|
||||
f.stroke(&line(&[(1., 2.), (1., 14.), (15., 14.), (15., 2.)]), stroke);
|
||||
f.stroke(
|
||||
&Path::rounded_rectangle(Point::new(5., 9.), Size::new(6., 3.), 1.into()),
|
||||
stroke,
|
||||
);
|
||||
f.stroke(&line(&[(8., 1.), (8., 7.)]), stroke);
|
||||
f.stroke(&line(&[(5., 4.), (8., 7.), (11., 4.)]), stroke);
|
||||
}
|
||||
},
|
||||
Self::Existing => {
|
||||
f.stroke(
|
||||
&Path::rectangle(Point::new(1., 1.), Size::new(10., 10.)),
|
||||
@@ -234,7 +284,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
}
|
||||
content = content.push(tool(
|
||||
app,
|
||||
Icon::Arrow,
|
||||
Icon::Connection(*connection),
|
||||
connection.label(),
|
||||
Message::Connection(crate::connection_edit::Event::Choose(Some(*connection))),
|
||||
app.connection
|
||||
|
||||
@@ -0,0 +1,904 @@
|
||||
//! Canvas inspection retains the displayed mixed-layer surface; navigation follows the model.
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
use crate::model_menu::{Event as MenuEvent, Request};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
process: Id,
|
||||
placement: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let process = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Operational process", None)
|
||||
.unwrap();
|
||||
let capability = p
|
||||
.add_element(Kind::Capability, Layer::Oa, "Operational capability", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Enables",
|
||||
process,
|
||||
capability,
|
||||
);
|
||||
p.ensure_diagram(Layer::Oa);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let placement = p.place(diagram, process, Position { x: 0., y: 0. });
|
||||
p.place(diagram, capability, Position { x: 500., y: 0. });
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(
|
||||
project_view(app.project(), diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.id == edge)
|
||||
);
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
process,
|
||||
placement,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_surface(f: &Fixture) {
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.layer, Layer::Sa);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_projection_switch_restores_all_authored_content_and_one_step_history() {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Allocation;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Projection(ProjectionKind::Allocation));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(project_view(f.app.project(), f.diagram).nodes.is_empty());
|
||||
assert_eq!(f.app.project().diagrams[&f.diagram].nodes.len(), 2);
|
||||
let switched = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &switched.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &switched.bundle);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Projection(ProjectionKind::Traceability));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert!(
|
||||
project_view(f.app.project(), f.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|e| e.id == f.edge)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_edge_and_group_inspection_preserve_surface_and_history() {
|
||||
for grouped in [false, true] {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
if grouped {
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Placement(f.placement),
|
||||
multiselect::Choice::Replace,
|
||||
)));
|
||||
}
|
||||
let message = if grouped {
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Edge(f.edge),
|
||||
multiselect::Choice::Add,
|
||||
))
|
||||
} else {
|
||||
Message::CanvasSelect(f.app.route_context().unwrap(), f.edge)
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Edge(f.edge))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets().len(),
|
||||
if grouped { 2 } else { 1 }
|
||||
);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke(f: &mut Fixture, object: Id, placement: Option<Id>, action: Message) {
|
||||
let mut request = Request::object(object, iced::Point::ORIGIN);
|
||||
request.canvas = f.app.route_context();
|
||||
request.placement = placement;
|
||||
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
|
||||
let token = f.app.model_menu.as_ref().unwrap().token;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(MenuEvent::Invoke(token, Box::new(action))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_context_properties_and_hide_keep_the_captured_surface() {
|
||||
let mut f = fixture();
|
||||
let edge = f.edge;
|
||||
invoke(&mut f, edge, None, Message::EditProperties(edge));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, edge);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let before = f.app.document.snapshot();
|
||||
let process = f.process;
|
||||
let placement = f.placement;
|
||||
invoke(&mut f, process, Some(placement), Message::HideSelected);
|
||||
assert_surface(&f);
|
||||
assert!(
|
||||
!f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&placement)
|
||||
);
|
||||
assert!(f.app.project().elements.contains_key(&process));
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_explicit_navigation_still_follows_the_target_layer() {
|
||||
let mut f = fixture();
|
||||
let process = f.process;
|
||||
let edge = f.edge;
|
||||
invoke(&mut f, edge, None, Message::Select(process));
|
||||
assert_eq!(f.app.selected, process);
|
||||
assert_eq!(f.app.layer, Layer::Oa);
|
||||
assert_ne!(f.app.active_diagram, Some(f.diagram));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_navigation_to_the_inspected_cross_layer_object_follows_its_layer() {
|
||||
let mut f = fixture();
|
||||
let _ = f.app.update(Message::CanvasSelect(
|
||||
f.app.route_context().unwrap(),
|
||||
f.edge,
|
||||
));
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Select(f.edge));
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.layer, Layer::Oa);
|
||||
assert_ne!(f.app.active_diagram, Some(f.diagram));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_invalid_draft_and_stale_packets_preserve_selection() {
|
||||
let mut f = fixture();
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::Field("/id".into(), "-".into()));
|
||||
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert_surface(&f);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Edge(f.edge),
|
||||
multiselect::Choice::Replace,
|
||||
)));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
let _ = f.app.update(Message::RevertProperties);
|
||||
f.app.canvas_epoch += 1;
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_layer_port_inspection_and_connection_start_retain_the_view() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let port = Id::new_v4();
|
||||
let element = make_port(
|
||||
&p,
|
||||
port,
|
||||
owner,
|
||||
"Output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(port, element);
|
||||
p.ensure_diagram(Layer::Sa);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let placement = p.place(diagram, owner, Position { x: 0., y: 0. });
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(
|
||||
project_view(app.project(), diagram)
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == port)
|
||||
);
|
||||
let _ = app.update(Message::CanvasSelect(app.route_context().unwrap(), port));
|
||||
assert_eq!(app.selected, port);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Port { port, placement })
|
||||
);
|
||||
// Reset inspection so the endpoint path cannot pass by selecting the same ID.
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
let context = app.route_context().unwrap();
|
||||
// Mixed canvases can retain a tool from either perspective.
|
||||
app.connection = Some(crate::connection_edit::Session::new(
|
||||
app.project(),
|
||||
&project_view(app.project(), diagram),
|
||||
context,
|
||||
crate::connection_tool::Tool::FunctionalExchange,
|
||||
));
|
||||
let _ = app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(context, port),
|
||||
));
|
||||
assert_eq!(app.selected, port);
|
||||
assert_eq!(app.layer, Layer::Oa);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(port));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
fn authoring_fixture(performer: bool) -> (Fixture, Id) {
|
||||
let mut f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let owner = project
|
||||
.add_element(
|
||||
if performer {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::Activity
|
||||
},
|
||||
Layer::Oa,
|
||||
"Operational owner",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational target", None)
|
||||
.unwrap();
|
||||
let placement = project.place(f.diagram, owner, Position { x: 0., y: 400. });
|
||||
project.place(f.diagram, target, Position { x: 500., y: 400. });
|
||||
f.app = App::with_project(project);
|
||||
f.app.page = Page::Diagram;
|
||||
f.app.layer = Layer::Sa;
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.selected = f.diagram;
|
||||
f.app.refresh_draft();
|
||||
f.app.reconcile_multiselect();
|
||||
f.process = owner;
|
||||
f.placement = placement;
|
||||
(f, target)
|
||||
}
|
||||
|
||||
fn assert_source_pair(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(
|
||||
app.document.bundle(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_relationship_actions_keep_operational_source_and_commit_one_step() {
|
||||
for context_action in [false, true] {
|
||||
let (mut f, target) = authoring_fixture(false);
|
||||
let before = f.app.document.snapshot();
|
||||
let owner = f.process;
|
||||
let placement = f.placement;
|
||||
if context_action {
|
||||
invoke(
|
||||
&mut f,
|
||||
owner,
|
||||
Some(placement),
|
||||
Message::AddRelationKind(RelationKind::Interaction, false),
|
||||
);
|
||||
} else {
|
||||
let _ = f.app.update(Message::SelectNode(placement));
|
||||
let _ = f.app.update(Message::AddRelation);
|
||||
}
|
||||
assert_surface(&f);
|
||||
let Some(crate::workflow::Workflow::Relation(relation)) = &f.app.workflow else {
|
||||
panic!("relationship form did not open: {}", f.app.status);
|
||||
};
|
||||
assert_eq!(
|
||||
relation.source.as_ref().map(|source| source.id),
|
||||
Some(owner)
|
||||
);
|
||||
assert_eq!(relation.kind, RelationKind::Interaction);
|
||||
let saved = f
|
||||
.app
|
||||
.workflow
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.recovery_snapshot()
|
||||
.unwrap();
|
||||
assert_eq!(saved["Relation"]["layer"], "Oa");
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Workflow(crate::workflow::Event::Target(Choice {
|
||||
id: target,
|
||||
label: "Operational target".into(),
|
||||
})));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert!(f.app.workflow.is_none(), "{}", f.app.status);
|
||||
let created: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1);
|
||||
assert_eq!(
|
||||
(created[0].kind(), created[0].source(), created[0].target()),
|
||||
(RelationKind::Interaction, owner, target)
|
||||
);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_add_child_command_uses_parent_layer_for_containment_and_allocation() {
|
||||
for performer in [false, true] {
|
||||
let (mut f, _) = authoring_fixture(performer);
|
||||
let before = f.app.document.snapshot();
|
||||
let owner = f.process;
|
||||
let placement = f.placement;
|
||||
invoke(
|
||||
&mut f,
|
||||
owner,
|
||||
Some(placement),
|
||||
Message::CreateContextElement(Kind::Activity, true),
|
||||
);
|
||||
assert_surface(&f);
|
||||
let created: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1, "{}", f.app.status);
|
||||
let child = created[0].id;
|
||||
assert_eq!(created[0].kind(), Kind::Activity);
|
||||
assert_eq!(created[0].layer(), Layer::Oa);
|
||||
assert_eq!(
|
||||
created[0].parent,
|
||||
if performer { None } else { Some(owner) }
|
||||
);
|
||||
let allocations: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|relation| !before.decoded.project.relations.contains_key(&relation.id))
|
||||
.collect();
|
||||
assert_eq!(allocations.len(), usize::from(performer));
|
||||
if performer {
|
||||
assert_eq!(
|
||||
(
|
||||
allocations[0].kind(),
|
||||
allocations[0].source(),
|
||||
allocations[0].target()
|
||||
),
|
||||
(RelationKind::Allocation, owner, child)
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.any(|node| node.element == child)
|
||||
);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_canvas_root_creation_still_uses_the_active_perspective() {
|
||||
let (mut f, _) = authoring_fixture(false);
|
||||
let _ = f.app.update(Message::SelectNode(f.placement));
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::CreateContextElement(Kind::Function, false));
|
||||
assert_surface(&f);
|
||||
let created: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| !before.decoded.project.elements.contains_key(&element.id))
|
||||
.collect();
|
||||
assert_eq!(created.len(), 1, "{}", f.app.status);
|
||||
assert_eq!(created[0].kind(), Kind::Function);
|
||||
assert_eq!(created[0].layer(), Layer::Sa);
|
||||
assert_eq!(created[0].parent, None);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn child_creation_navigates_to_a_parent_layer_preferred_view_only_when_needed() {
|
||||
for existing_preferred in [false, true] {
|
||||
let (mut f, _) = authoring_fixture(false);
|
||||
let mut project = f.app.project().clone();
|
||||
for diagram in project
|
||||
.diagrams
|
||||
.values_mut()
|
||||
.filter(|d| d.layer == Layer::Oa)
|
||||
{
|
||||
diagram.projection = if existing_preferred {
|
||||
ProjectionKind::Allocation
|
||||
} else {
|
||||
ProjectionKind::Overview
|
||||
};
|
||||
}
|
||||
project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Capabilities;
|
||||
f.app = App::with_project(project);
|
||||
let _ = f.app.update(Message::Select(f.process));
|
||||
let _ = f.app.update(Message::Diagram(Choice {
|
||||
id: f.diagram,
|
||||
label: "System capabilities".into(),
|
||||
}));
|
||||
assert_eq!(f.app.selected, f.process);
|
||||
assert_eq!(f.app.layer, Layer::Sa);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::CreateContextElement(Kind::Activity, true));
|
||||
assert_eq!(f.app.layer, Layer::Oa, "{}", f.app.status);
|
||||
let diagram = f.app.active_diagram.unwrap();
|
||||
assert_ne!(diagram, f.diagram);
|
||||
assert_eq!(f.app.project().diagrams[&diagram].layer, Layer::Oa);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&diagram].projection,
|
||||
ProjectionKind::Allocation
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams.len(),
|
||||
before.decoded.project.diagrams.len() + usize::from(!existing_preferred)
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().elements[&f.app.selected].parent,
|
||||
Some(f.process)
|
||||
);
|
||||
assert_source_pair(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
// Regression scope: a contextual click may accept a valid property draft, but
|
||||
// must not inherit the draft object's semantic-layer navigation. If acceptance
|
||||
// changes the originating diagram identity or hides the clicked target, retain
|
||||
// that one accepted edit and refuse the old click. Do not infer a new target or
|
||||
// reinterpret an old surface packet on a replacement diagram.
|
||||
fn draft_click_fixture() -> (Fixture, Id) {
|
||||
let mut f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let capability = project
|
||||
.add_element(Kind::Capability, Layer::Oa, "Second capability", None)
|
||||
.unwrap();
|
||||
let other_edge = project.relate(
|
||||
RelationKind::ChainInvolvement,
|
||||
"Also enables",
|
||||
f.process,
|
||||
capability,
|
||||
);
|
||||
project.place(f.diagram, capability, Position { x: 500., y: 300. });
|
||||
f.app = App::with_project(project);
|
||||
f.app.page = Page::Diagram;
|
||||
f.app.layer = Layer::Sa;
|
||||
f.app.active_diagram = Some(f.diagram);
|
||||
f.app.selected = f.diagram;
|
||||
f.app.refresh_draft();
|
||||
f.app.reconcile_multiselect();
|
||||
assert!(
|
||||
project_view(f.app.project(), f.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == other_edge)
|
||||
);
|
||||
(f, other_edge)
|
||||
}
|
||||
|
||||
fn expected_draft_commit(app: &App, command: Command) -> Document {
|
||||
let mut expected = Document::from_bundle(
|
||||
app.document.bundle().clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
expected.execute(command).unwrap();
|
||||
expected
|
||||
}
|
||||
|
||||
fn assert_accepted_draft(app: &App, expected: &Document, revision: u64) {
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
assert!(!app.draft_dirty());
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
assert_source_pair(app);
|
||||
}
|
||||
|
||||
fn assert_one_draft_history(
|
||||
app: &mut App,
|
||||
before: &oa_core::source_project::CompiledSourceProject,
|
||||
expected: &Document,
|
||||
) {
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"the click must not add a second edit"
|
||||
);
|
||||
assert!(app.document.can_redo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert!(!app.document.can_redo());
|
||||
}
|
||||
|
||||
fn pending_edge_name(f: &mut Fixture) {
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
assert_surface(f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/name".into(), "Reviewed enables".into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
assert!(f.app.draft.as_ref().unwrap().command().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_cross_layer_draft_then_canvas_edge_retains_surface_and_one_edit() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
// Capture after drafting, so this exercises successful draft application,
|
||||
// not refusal of a packet made stale by the property edit itself.
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, other_edge);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Edge(other_edge))
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_cross_layer_draft_then_node_click_retains_surface_and_one_edit() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
let _ = f.app.update(Message::SelectNode(f.placement));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.process);
|
||||
assert_eq!(f.app.selected_node, Some(f.placement));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Placement(f.placement))
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_reidentity_draft_commits_but_refuses_the_old_surface_click() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let replacement = Id::new_v4();
|
||||
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert!(!f.app.project().diagrams.contains_key(&context.diagram));
|
||||
assert!(
|
||||
f.app
|
||||
.active_diagram
|
||||
.is_some_and(|id| f.app.project().diagrams.contains_key(&id)),
|
||||
"never restore a removed diagram identity"
|
||||
);
|
||||
assert_ne!(f.app.selected, other_edge, "refuse the old surface click");
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_snapshot()
|
||||
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_draft_commits_but_refuses_the_now_hidden_canvas_target() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
|
||||
diagram.projection = ProjectionKind::Allocation;
|
||||
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
|
||||
let _ = f.app.update(Message::FieldShape(
|
||||
"/projection".into(),
|
||||
serde_json::to_value(ProjectionKind::Allocation).unwrap(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_surface(&f);
|
||||
assert!(
|
||||
!project_view(f.app.project(), f.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == other_edge)
|
||||
);
|
||||
assert_ne!(
|
||||
f.app.selected, other_edge,
|
||||
"do not inspect a no-longer-visible hit"
|
||||
);
|
||||
assert!(
|
||||
f.app
|
||||
.multiselect_snapshot()
|
||||
.is_none_or(|s| !s.targets().contains(&Target::Edge(other_edge)))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_cross_layer_draft_blocks_contextual_clicks_without_changing_history() {
|
||||
for click in 0..3 {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::CanvasSelect(context, f.edge));
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/id".into(), "not-a-uuid".into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let expected_error = f.app.draft.as_ref().unwrap().command().unwrap_err();
|
||||
let draft =
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
let message = match click {
|
||||
0 => Message::CanvasSelect(f.app.route_context().unwrap(), other_edge),
|
||||
1 => Message::SelectNode(f.placement),
|
||||
_ => Message::DiagramSelection(multiselect::Event::Choose(
|
||||
f.app.multiselect_context().unwrap(),
|
||||
Target::Edge(other_edge),
|
||||
multiselect::Choice::Add,
|
||||
)),
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
if click < 2 {
|
||||
assert_eq!(f.app.status, expected_error);
|
||||
} else {
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Finish or cancel the active edit before changing the diagram selection."
|
||||
);
|
||||
}
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.document.can_redo());
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selection.primary()
|
||||
);
|
||||
if click == 0 {
|
||||
// Pre-dispatch stale rejection applies no draft and must not emit
|
||||
// either success feedback or a new validation message.
|
||||
let stale = f.app.route_context().unwrap();
|
||||
f.app.canvas_epoch += 1;
|
||||
f.app.status = "Keep previous feedback".into();
|
||||
let _ = f.app.update(Message::CanvasSelect(stale, other_edge));
|
||||
assert_eq!(f.app.status, "Keep previous feedback");
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_select_after_valid_cross_layer_draft_still_navigates_and_commits_once() {
|
||||
let (mut f, other_edge) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
let _ = f.app.update(Message::Select(other_edge));
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.selected, other_edge);
|
||||
assert_eq!(f.app.layer, Layer::Oa);
|
||||
assert_ne!(f.app.active_diagram, Some(f.diagram));
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_apply_on_cross_layer_edge_retains_surface_and_one_edit() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let expected =
|
||||
expected_draft_commit(&f.app, Command::Rename(f.edge, "Reviewed enables".into()));
|
||||
pending_edge_name(&mut f);
|
||||
let _ = f.app.update(Message::Apply);
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.status, "Properties applied");
|
||||
assert_surface(&f);
|
||||
assert_eq!(f.app.selected, f.edge);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, f.edge);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
Some(&Target::Edge(f.edge))
|
||||
);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_apply_honors_an_intentional_active_diagram_layer_edit() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut diagram = f.app.project().diagrams[&f.diagram].clone();
|
||||
diagram.layer = Layer::Oa;
|
||||
let expected = expected_draft_commit(&f.app, Command::EditDiagram(f.diagram, diagram));
|
||||
let _ = f.app.update(Message::FieldShape(
|
||||
"/layer".into(),
|
||||
serde_json::to_value(Layer::Oa).unwrap(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let _ = f.app.update(Message::Apply);
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(
|
||||
f.app.layer,
|
||||
Layer::Oa,
|
||||
"honor the edited diagram's own layer"
|
||||
);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
assert_eq!(f.app.selected, f.diagram);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, f.diagram);
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_apply_follows_the_known_active_diagram_reidentity_once() {
|
||||
let (mut f, _) = draft_click_fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let replacement = Id::new_v4();
|
||||
let expected = expected_draft_commit(&f.app, Command::Reidentify(f.diagram, replacement));
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let _ = f.app.update(Message::Apply);
|
||||
assert_accepted_draft(&f.app, &expected, revision);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.layer, Layer::Sa);
|
||||
// This identity was explicitly authored in the diagram's own draft. No
|
||||
// queued click is being remapped or inferred from semantic similarity.
|
||||
assert_eq!(f.app.active_diagram, Some(replacement));
|
||||
assert_eq!(f.app.selected, replacement);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().original, replacement);
|
||||
assert!(!f.app.project().diagrams.contains_key(&f.diagram));
|
||||
assert_one_draft_history(&mut f.app, &before, &expected);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
// Test-only proposal pinned to peer 6520a89dc042dd433454f6cc63cb23b0e6df7911.
|
||||
// Independent App test module; real popup messages retain menu tokens.
|
||||
use super::*;
|
||||
use crate::model_menu::{Event as MenuEvent, Request};
|
||||
|
||||
struct BeginFixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
surface_layer: Layer,
|
||||
source: Id,
|
||||
target: Id,
|
||||
wrong_layer: Id,
|
||||
hidden: Id,
|
||||
tool: crate::connection_tool::Tool,
|
||||
}
|
||||
|
||||
fn begin_fixture(source_layer: Layer, allocation: bool, mixed: bool) -> BeginFixture {
|
||||
use crate::connection_tool::Tool;
|
||||
let mut p = Project::empty();
|
||||
let (work, performer, exchange) = match source_layer {
|
||||
Layer::Oa => (Kind::Activity, Kind::Entity, Tool::Interaction),
|
||||
Layer::Sa => (Kind::Function, Kind::System, Tool::FunctionalExchange),
|
||||
};
|
||||
let opposite = if source_layer == Layer::Oa {
|
||||
Layer::Sa
|
||||
} else {
|
||||
Layer::Oa
|
||||
};
|
||||
let surface_layer = if mixed { opposite } else { source_layer };
|
||||
let source = p
|
||||
.add_element(
|
||||
if allocation { performer } else { work },
|
||||
source_layer,
|
||||
"Source",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let target = p.add_element(work, source_layer, "Target", None).unwrap();
|
||||
let hidden = p
|
||||
.add_element(work, source_layer, "Hidden target", None)
|
||||
.unwrap();
|
||||
let other_work = if opposite == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
let wrong_layer = p
|
||||
.add_element(other_work, opposite, "Wrong layer target", None)
|
||||
.unwrap();
|
||||
p.ensure_diagram(source_layer);
|
||||
let diagram = p.ensure_diagram(surface_layer);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = if mixed {
|
||||
ProjectionKind::Traceability
|
||||
} else {
|
||||
ProjectionKind::Capabilities
|
||||
};
|
||||
p.place(diagram, source, Position { x: 0., y: 0. });
|
||||
p.place(diagram, target, Position { x: 400., y: 0. });
|
||||
if mixed {
|
||||
p.place(diagram, wrong_layer, Position { x: 800., y: 0. });
|
||||
}
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = surface_layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(!app.document.can_undo());
|
||||
BeginFixture {
|
||||
app,
|
||||
diagram,
|
||||
surface_layer,
|
||||
source,
|
||||
target,
|
||||
wrong_layer,
|
||||
hidden,
|
||||
tool: if allocation {
|
||||
Tool::Allocation
|
||||
} else {
|
||||
exchange
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_surface(f: &BeginFixture) {
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.layer, f.surface_layer);
|
||||
assert_eq!(f.app.active_diagram, Some(f.diagram));
|
||||
}
|
||||
|
||||
fn begin_popup_action(f: &mut BeginFixture) -> Option<Message> {
|
||||
begin_popup_action_from(f, true)
|
||||
}
|
||||
|
||||
fn begin_popup_action_from(f: &mut BeginFixture, canvas: bool) -> Option<Message> {
|
||||
let mut request = Request::object(f.source, iced::Point::ORIGIN);
|
||||
if canvas {
|
||||
request.canvas = f.app.route_context();
|
||||
request.placement = f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == f.source)
|
||||
.map(|node| node.id);
|
||||
}
|
||||
let _ = f.app.update(Message::Menu(MenuEvent::Open(request)));
|
||||
let popup = crate::model_menu::popup(&f.app).expect("real model popup");
|
||||
let expected = format!("Draw {}", f.tool.label().to_lowercase());
|
||||
for entry in popup.entries {
|
||||
if let crate::context_menu::Entry::Action { label, message, .. } = entry
|
||||
&& label == expected
|
||||
{
|
||||
if let Some(Message::Menu(MenuEvent::Invoke(token, _))) = &message {
|
||||
assert_eq!(*token, popup.token);
|
||||
} else {
|
||||
assert!(message.is_none(), "Draw must use the menu token envelope");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
}
|
||||
None // Omission is also a valid unavailable-action policy.
|
||||
}
|
||||
|
||||
fn begin_from_popup(f: &mut BeginFixture) -> Message {
|
||||
begin_from_popup_origin(f, true)
|
||||
}
|
||||
|
||||
fn begin_from_popup_origin(f: &mut BeginFixture, canvas: bool) -> Message {
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let action =
|
||||
begin_popup_action_from(f, canvas).expect("visible semantic source offers enabled Draw");
|
||||
let _ = f.app.update(action.clone());
|
||||
begin_surface(f);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
let session = f
|
||||
.app
|
||||
.connection
|
||||
.as_ref()
|
||||
.expect("menu Draw must arm its tool");
|
||||
assert_eq!(session.tool, f.tool);
|
||||
assert_eq!(session.source, Some(f.source));
|
||||
assert_eq!(session.context, f.app.route_context().unwrap());
|
||||
assert!(session.candidates.contains(&f.target));
|
||||
assert!(!session.candidates.contains(&f.wrong_layer));
|
||||
assert!(!session.candidates.contains(&f.hidden));
|
||||
action
|
||||
}
|
||||
|
||||
fn begin_complete_and_check_history(f: &mut BeginFixture) {
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let context = f.app.route_context().unwrap();
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(context, f.target),
|
||||
));
|
||||
begin_surface(f);
|
||||
assert!(f.app.connection.is_none());
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
let relations: Vec<_> = f
|
||||
.app
|
||||
.project()
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| !before.decoded.project.relations.contains_key(&r.id))
|
||||
.collect();
|
||||
assert_eq!(relations.len(), 1);
|
||||
let expected = match f.tool {
|
||||
crate::connection_tool::Tool::Interaction => RelationKind::Interaction,
|
||||
crate::connection_tool::Tool::FunctionalExchange => RelationKind::FunctionalExchange,
|
||||
crate::connection_tool::Tool::Allocation => RelationKind::Allocation,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
assert_eq!(relations[0].kind(), expected);
|
||||
assert_eq!(
|
||||
(relations[0].source(), relations[0].target()),
|
||||
(f.source, f.target)
|
||||
);
|
||||
// Random identities are accepted only for the precise command delta.
|
||||
// Everything else, including traces, metadata and view options, stays exact.
|
||||
let mut expected_project = before.decoded.project.clone();
|
||||
let created = relations[0];
|
||||
assert!(
|
||||
expected_project
|
||||
.relations
|
||||
.insert(created.id, created.clone())
|
||||
.is_none()
|
||||
);
|
||||
if let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
items,
|
||||
..
|
||||
} = &created.body
|
||||
{
|
||||
assert_eq!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
|
||||
assert!(items.is_empty());
|
||||
assert_ne!(source_port, target_port);
|
||||
let output = &f.app.project().elements[source_port];
|
||||
let input = &f.app.project().elements[target_port];
|
||||
assert_eq!(
|
||||
(output.kind(), output.layer(), output.parent),
|
||||
(Kind::Port, Layer::Sa, Some(f.source))
|
||||
);
|
||||
assert_eq!(
|
||||
(input.kind(), input.layer(), input.parent),
|
||||
(Kind::Port, Layer::Sa, Some(f.target))
|
||||
);
|
||||
assert!(
|
||||
matches!(port_contract(output), Some(PortContract::FunctionOutput { outgoing_items }) if outgoing_items.is_empty())
|
||||
);
|
||||
assert!(
|
||||
matches!(port_contract(input), Some(PortContract::FunctionInput { incoming_items }) if incoming_items.is_empty())
|
||||
);
|
||||
assert!(!before.decoded.project.elements.contains_key(source_port));
|
||||
assert!(!before.decoded.project.elements.contains_key(target_port));
|
||||
assert_eq!(output.id, *source_port);
|
||||
assert_eq!(input.id, *target_port);
|
||||
expected_project
|
||||
.elements
|
||||
.insert(*source_port, output.clone());
|
||||
expected_project
|
||||
.elements
|
||||
.insert(*target_port, input.clone());
|
||||
assert_eq!(
|
||||
f.app.project().elements.len(),
|
||||
before.decoded.project.elements.len() + 2
|
||||
);
|
||||
} else {
|
||||
assert_ne!(f.tool, crate::connection_tool::Tool::FunctionalExchange);
|
||||
assert_eq!(f.app.project().elements, before.decoded.project.elements);
|
||||
}
|
||||
assert_eq!(f.app.project(), &expected_project);
|
||||
assert_eq!(f.app.project().diagrams, before.decoded.project.diagrams);
|
||||
for (id, element) in &before.decoded.project.elements {
|
||||
assert_eq!(&f.app.project().elements[id], element);
|
||||
}
|
||||
begin_assert_source_pair(&f.app);
|
||||
let after = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!f.app.document.can_undo(),
|
||||
"creation is a single history step"
|
||||
);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &after.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
begin_assert_source_pair(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_exchange_uses_actual_menu_in_both_mixed_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, false, true);
|
||||
begin_from_popup(&mut f);
|
||||
begin_complete_and_check_history(&mut f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_cancel_restart_and_stale_menu_preserve_session_ownership() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, false, true);
|
||||
let original = f.app.document.snapshot();
|
||||
let stale_menu = begin_from_popup(&mut f);
|
||||
let first = f.app.route_context().unwrap();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Connection(crate::connection_edit::Event::Restart(
|
||||
first,
|
||||
)));
|
||||
begin_surface(&f);
|
||||
let restarted = f
|
||||
.app
|
||||
.connection
|
||||
.as_ref()
|
||||
.expect("restart retains contextual tool");
|
||||
assert_eq!(restarted.tool, f.tool);
|
||||
assert_eq!(restarted.source, None);
|
||||
assert!(restarted.candidates.contains(&f.source));
|
||||
let current = restarted.context;
|
||||
assert_ne!(current, first);
|
||||
let _ = f.app.update(stale_menu);
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(first, f.target),
|
||||
));
|
||||
assert_eq!(f.app.connection.as_ref().unwrap().context, current);
|
||||
assert_eq!(f.app.connection.as_ref().unwrap().source, None);
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(current, f.source),
|
||||
));
|
||||
assert_eq!(f.app.connection.as_ref().unwrap().source, Some(f.source));
|
||||
let _ = f.app.update(Message::Escape);
|
||||
let _ = f.app.update(Message::Connection(
|
||||
crate::connection_edit::Event::Endpoint(current, f.target),
|
||||
));
|
||||
assert!(f.app.connection.is_none());
|
||||
begin_surface(&f);
|
||||
assert_eq!(f.app.project(), &original.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &original.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_allocation_uses_actual_menu_in_both_mixed_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, true, true);
|
||||
begin_from_popup(&mut f);
|
||||
begin_complete_and_check_history(&mut f);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_allocation_is_not_enabled_when_projection_hides_work() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, true, false);
|
||||
let view = project_view(f.app.project(), f.diagram);
|
||||
assert!(view.nodes.iter().any(|n| n.element == f.source));
|
||||
assert!(!view.nodes.iter().any(|n| n.element == f.target));
|
||||
assert!(!f.tool.available_in(layer, ProjectionKind::Capabilities));
|
||||
let before = f.app.document.snapshot();
|
||||
assert!(
|
||||
begin_popup_action(&mut f).is_none(),
|
||||
"Draw allocation must be disabled or omitted when work is not projected"
|
||||
);
|
||||
let _ = f.app.update(Message::Menu(MenuEvent::Close));
|
||||
// Defensive handler refusal complements actual-menu enabledness above.
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Connection(crate::connection_edit::Event::Begin(
|
||||
f.tool, f.source,
|
||||
)));
|
||||
assert!(f.app.connection.is_none());
|
||||
begin_surface(&f);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn begin_assert_source_pair(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(
|
||||
app.document.bundle(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contextual_begin_explorer_draw_retains_visible_mixed_diagram_in_both_directions() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = begin_fixture(layer, false, true);
|
||||
// A real Explorer Request::object has no canvas/placement envelope,
|
||||
// but its generated Draw action still targets the visible current view.
|
||||
begin_from_popup_origin(&mut f, false);
|
||||
begin_complete_and_check_history(&mut f);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// Proposed tests against c7065db76ecca3faa733ccb5f09b87fd0a0958be.
|
||||
use super::*;
|
||||
|
||||
struct RelocationFixture {
|
||||
app: App,
|
||||
source: Id,
|
||||
target: Id,
|
||||
node: Id,
|
||||
other_node: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn relocation_fixture(source_layer: Layer, target_layer: Layer) -> RelocationFixture {
|
||||
let mut p = Project::empty();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Observed work", None)
|
||||
.unwrap();
|
||||
let other = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Other work", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::Interaction, "Observed exchange", work, other);
|
||||
let source = p.ensure_diagram(source_layer);
|
||||
p.diagrams.get_mut(&source).unwrap().projection = ProjectionKind::Traceability;
|
||||
let node = p.place(source, work, Position { x: 20., y: 20. });
|
||||
let other_node = p.place(source, other, Position { x: 500., y: 20. });
|
||||
let mut destination = p.diagrams[&source].clone();
|
||||
destination.id = Id::new_v4();
|
||||
destination.alias = "relocation_destination".into();
|
||||
destination.name = "Relocation destination".into();
|
||||
destination.layer = target_layer;
|
||||
destination.nodes.clear();
|
||||
let target = destination.id;
|
||||
p.diagrams.insert(target, destination);
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = source_layer;
|
||||
app.active_diagram = Some(source);
|
||||
app.selected = node;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert!(
|
||||
matches!(app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Node(d) if d == source)
|
||||
);
|
||||
assert!(!app.document.can_undo());
|
||||
RelocationFixture {
|
||||
app,
|
||||
source,
|
||||
target,
|
||||
node,
|
||||
other_node,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn relocation_field(app: &mut App, path: &str, value: impl ToString) {
|
||||
let _ = app.update(Message::Field(path.into(), value.to_string()));
|
||||
}
|
||||
|
||||
fn relocation_source_pair(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(
|
||||
app.document.bundle(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
fn relocation_selection(app: &App, diagram: Id, placement: Id) {
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.active_diagram, Some(diagram));
|
||||
assert_eq!(app.layer, app.project().diagrams[&diagram].layer);
|
||||
assert_eq!(app.selected, placement);
|
||||
assert_eq!(app.selected_node, Some(placement));
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.original, placement);
|
||||
assert!(matches!(draft.kind, crate::forms::RecordKind::Node(d) if d == diagram));
|
||||
}
|
||||
|
||||
fn relocation_expected(app: &App, command: Command) -> Document {
|
||||
let mut expected = Document::from_bundle(
|
||||
app.document.bundle().clone(),
|
||||
oa_core::source_project::ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
expected.execute(command).unwrap();
|
||||
expected
|
||||
}
|
||||
|
||||
fn relocation_history(
|
||||
app: &mut App,
|
||||
before: &oa_core::source_project::CompiledSourceProject,
|
||||
expected: &Document,
|
||||
) {
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert!(app.document.can_undo());
|
||||
assert!(!app.document.can_redo());
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(app.document.can_redo());
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.project(), expected.project());
|
||||
assert_eq!(app.document.bundle(), expected.bundle());
|
||||
assert!(!app.document.can_redo());
|
||||
relocation_source_pair(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_placement_relocation_follows_actual_owner_with_optional_new_identity() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for reidentify in [false, true] {
|
||||
let mut f = relocation_fixture(Layer::Oa, layer);
|
||||
let before = f.app.document.snapshot();
|
||||
let before_project = before.decoded.project.clone();
|
||||
let revision = f.app.document.revision();
|
||||
let id = if reidentify { Id::new_v4() } else { f.node };
|
||||
let mut expected = before_project.clone();
|
||||
let mut placement = expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.node)
|
||||
.unwrap();
|
||||
placement.id = id;
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.target)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(id, placement);
|
||||
let expected_document = relocation_expected(
|
||||
&f.app,
|
||||
Command::Batch(vec![
|
||||
Command::EditNode {
|
||||
diagram: f.source,
|
||||
original: f.node,
|
||||
node: expected.diagrams[&f.target].nodes[&id].clone(),
|
||||
},
|
||||
Command::RelocateNode {
|
||||
node: id,
|
||||
diagram: f.target,
|
||||
},
|
||||
]),
|
||||
);
|
||||
assert_eq!(expected_document.project(), &expected);
|
||||
relocation_field(&mut f.app, "/diagram", f.target);
|
||||
if reidentify {
|
||||
relocation_field(&mut f.app, "/id", id);
|
||||
}
|
||||
assert!(f.app.apply(), "{}", f.app.status);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
relocation_selection(&f.app, f.target, id);
|
||||
relocation_source_pair(&f.app);
|
||||
assert_eq!(f.app.document.bundle(), expected_document.bundle());
|
||||
relocation_history(&mut f.app, &before, &expected_document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_owner_placement_position_alias_and_identity_keep_mixed_surface() {
|
||||
for different_current_view in [false, true] {
|
||||
for field in ["/position/x", "/alias", "/id"] {
|
||||
let mut f = relocation_fixture(Layer::Sa, Layer::Oa);
|
||||
// Ordinary Select plus the diagram picker can retain an independent
|
||||
// placement Inspector while showing a different saved view. This is
|
||||
// an App-handler workflow, not a claim of native menu execution.
|
||||
let _ = f.app.update(Message::Select(f.app.project().id));
|
||||
let _ = f.app.update(Message::Select(f.node));
|
||||
let current = if different_current_view {
|
||||
f.target
|
||||
} else {
|
||||
f.source
|
||||
};
|
||||
if different_current_view {
|
||||
let _ = f.app.update(Message::Diagram(Choice {
|
||||
id: current,
|
||||
label: "Other current view".into(),
|
||||
}));
|
||||
}
|
||||
assert_eq!(f.app.selected, f.node);
|
||||
assert_eq!(f.app.active_diagram, Some(current));
|
||||
assert!(
|
||||
matches!(f.app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Node(d) if d == f.source)
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let mut placement = expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.node)
|
||||
.unwrap();
|
||||
let value = match field {
|
||||
"/position/x" => {
|
||||
placement.position.x = 123.;
|
||||
"123".to_owned()
|
||||
}
|
||||
"/alias" => {
|
||||
placement.alias = "renamed_placement".into();
|
||||
placement.alias.clone()
|
||||
}
|
||||
_ => {
|
||||
placement.id = Id::new_v4();
|
||||
placement.id.to_string()
|
||||
}
|
||||
};
|
||||
let id = placement.id;
|
||||
let expected_document = relocation_expected(
|
||||
&f.app,
|
||||
Command::EditNode {
|
||||
diagram: f.source,
|
||||
original: f.node,
|
||||
node: placement.clone(),
|
||||
},
|
||||
);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(id, placement);
|
||||
assert_eq!(expected_document.project(), &expected);
|
||||
relocation_field(&mut f.app, field, value);
|
||||
assert!(f.app.apply(), "{}", f.app.status);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), expected_document.bundle());
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
assert_eq!(f.app.page, Page::Diagram);
|
||||
assert_eq!(f.app.active_diagram, Some(current));
|
||||
assert_eq!(f.app.layer, f.app.project().diagrams[¤t].layer);
|
||||
assert_eq!(f.app.selected, id);
|
||||
assert_eq!(
|
||||
f.app.selected_node,
|
||||
if different_current_view {
|
||||
None
|
||||
} else {
|
||||
Some(id)
|
||||
}
|
||||
);
|
||||
let draft = f.app.draft.as_ref().unwrap();
|
||||
assert_eq!(draft.original, id);
|
||||
assert!(matches!(draft.kind, crate::forms::RecordKind::Node(d) if d == f.source));
|
||||
relocation_source_pair(&f.app);
|
||||
relocation_history(&mut f.app, &before, &expected_document);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_relocation_refuses_queued_old_canvas_target_with_feedback() {
|
||||
for edge_hit in [false, true] {
|
||||
let mut f = relocation_fixture(Layer::Oa, Layer::Sa);
|
||||
let before = f.app.document.snapshot();
|
||||
let expected_document = relocation_expected(
|
||||
&f.app,
|
||||
Command::Batch(vec![
|
||||
Command::EditNode {
|
||||
diagram: f.source,
|
||||
original: f.node,
|
||||
node: f.app.project().diagrams[&f.source].nodes[&f.node].clone(),
|
||||
},
|
||||
Command::RelocateNode {
|
||||
node: f.node,
|
||||
diagram: f.target,
|
||||
},
|
||||
]),
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let placement = expected
|
||||
.diagrams
|
||||
.get_mut(&f.source)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.node)
|
||||
.unwrap();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.target)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.insert(f.node, placement);
|
||||
let revision = f.app.document.revision();
|
||||
let captured = f.app.route_context().unwrap();
|
||||
relocation_field(&mut f.app, "/diagram", f.target);
|
||||
let message = if edge_hit {
|
||||
Message::CanvasSelect(captured, f.edge)
|
||||
} else {
|
||||
Message::SelectNode(f.other_node)
|
||||
};
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
relocation_selection(&f.app, f.target, f.node);
|
||||
assert_eq!(
|
||||
f.app.status,
|
||||
"Properties applied. The view changed; select a visible target again."
|
||||
);
|
||||
assert!(
|
||||
f.app.multiselect_snapshot().is_none_or(|snapshot| {
|
||||
!snapshot.targets().iter().any(|target| {
|
||||
matches!(target, crate::diagram_usage::Target::Placement(id) if *id == f.other_node)
|
||||
|| matches!(target, crate::diagram_usage::Target::Edge(id) if *id == f.edge)
|
||||
})
|
||||
}),
|
||||
"the rejected old hit must not enter the destination selection"
|
||||
);
|
||||
assert_eq!(expected_document.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), expected_document.bundle());
|
||||
relocation_source_pair(&f.app);
|
||||
relocation_history(&mut f.app, &before, &expected_document);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_placement_destination_preserves_owner_model_source_and_draft() {
|
||||
let mut f = relocation_fixture(Layer::Oa, Layer::Sa);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
relocation_field(&mut f.app, "/diagram", Id::new_v4());
|
||||
let draft = serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
assert!(!f.app.apply());
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert!(!f.app.document.can_undo());
|
||||
relocation_selection(&f.app, f.source, f.node);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,24 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
/// Contextual drawing follows the visible semantic source, not the palette layer.
|
||||
/// The caller separately validates the menu/context lifetime and pending drafts.
|
||||
pub fn can_begin(project: &Project, diagram: Id, tool: Tool, source: Id) -> bool {
|
||||
let Some(diagram) = project.diagrams.get(&diagram) else {
|
||||
return false;
|
||||
};
|
||||
let Some(element) = project.elements.get(&source) else {
|
||||
return false;
|
||||
};
|
||||
diagram.projection.is_architecture()
|
||||
&& (element.layer() == diagram.layer
|
||||
|| diagram.projection == oa_core::ProjectionKind::Traceability)
|
||||
&& Tool::for_layer(element.layer()).contains(&tool)
|
||||
&& tool.can_start(project, source)
|
||||
&& (tool != Tool::Allocation || tool.available_in(element.layer(), diagram.projection))
|
||||
&& visible(&oa_core::projection::project_view(project, diagram.id)).any(|id| id == source)
|
||||
}
|
||||
|
||||
pub fn visible(view: &ProjectedView) -> impl Iterator<Item = Id> + '_ {
|
||||
view.nodes
|
||||
.iter()
|
||||
|
||||
@@ -993,7 +993,10 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
world,
|
||||
));
|
||||
}
|
||||
return Some(Action::publish(Message::Select(port.element)).and_capture());
|
||||
return Some(
|
||||
Action::publish(Message::CanvasSelect(self.context, port.element))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if let Some(id) = self.hit(view, p) {
|
||||
let position = self
|
||||
@@ -1010,7 +1013,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
.hit_label(&scene.routed, p)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, p))
|
||||
{
|
||||
Some(Action::publish(Message::Select(id)).and_capture())
|
||||
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
|
||||
} else if let Some((id, segment)) = self.hit_edge(&scene.routed, p) {
|
||||
if id == self.selected
|
||||
&& self.editable
|
||||
@@ -1026,7 +1029,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
self.begin_route(state, world, Handle::Bend(index), route);
|
||||
Some(Action::capture())
|
||||
} else {
|
||||
Some(Action::publish(Message::Select(id)).and_capture())
|
||||
Some(Action::publish(Message::CanvasSelect(self.context, id)).and_capture())
|
||||
}
|
||||
} else {
|
||||
state.pan_start = Some((mouse::Button::Left, p));
|
||||
@@ -2441,7 +2444,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(selected,Some(Message::Select(id)) if id==port.element));
|
||||
assert!(matches!(selected,Some(Message::CanvasSelect(_, id)) if id==port.element));
|
||||
let request = canvas.context_request(
|
||||
&state,
|
||||
click,
|
||||
@@ -3054,7 +3057,7 @@ mod tests {
|
||||
let mut state = State::default();
|
||||
assert_eq!(canvas.hit_association_label(&routed, at), Some(edge));
|
||||
assert!(
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::Select(id)) if id==edge)
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),at),Some(Message::CanvasSelect(_, id)) if id==edge)
|
||||
);
|
||||
assert!(
|
||||
state.route_drag.is_none(),
|
||||
@@ -3104,7 +3107,7 @@ mod tests {
|
||||
let center = scene.routed.edges[&edge].label.center;
|
||||
let screen = point(center) + canvas.pan;
|
||||
assert!(
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::Select(id)) if id==edge)
|
||||
matches!(pointer(&canvas,&mut state,mouse::Event::ButtonPressed(mouse::Button::Left),screen),Some(Message::CanvasSelect(_, id)) if id==edge)
|
||||
);
|
||||
canvas.selected = edge;
|
||||
pointer(
|
||||
|
||||
@@ -179,7 +179,7 @@ fn coalesced_port_drag_commits_once_through_app_and_source_history() {
|
||||
zoom,
|
||||
);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
|
||||
assert!(matches!(&messages[0],Message::CanvasSelect(_, id)if *id==output));
|
||||
assert!(
|
||||
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
|
||||
);
|
||||
@@ -251,7 +251,7 @@ fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
|
||||
b,
|
||||
);
|
||||
let picked = match &press {
|
||||
Message::Select(id) => Some(*id),
|
||||
Message::CanvasSelect(_, id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let release_debug = format!("{release:?}");
|
||||
@@ -300,7 +300,7 @@ fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
|
||||
.filter_map(|e| event(&c, &mut state, e, b))
|
||||
.collect();
|
||||
assert!(
|
||||
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
|
||||
matches!(messages.first(),Some(Message::CanvasSelect(_, id)) if *id==output),
|
||||
"Stationary source was lost between batches: {messages:?}"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -165,7 +165,7 @@ fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(press, Some(Message::Select(id)) if id == port.element));
|
||||
assert!(matches!(press, Some(Message::CanvasSelect(_, id)) if id == port.element));
|
||||
assert_eq!(state.port_drag.as_ref().unwrap().anchor.port, port.element);
|
||||
let menu = canvas.context_request(&state, at, bounds);
|
||||
assert_eq!(menu.object, port.element);
|
||||
|
||||
@@ -375,7 +375,7 @@ mod tests {
|
||||
messages.extend(message);
|
||||
}
|
||||
}
|
||||
assert!(matches!(messages.first(), Some(Message::Select(id)) if *id == port));
|
||||
assert!(matches!(messages.first(), Some(Message::CanvasSelect(_, id)) if *id == port));
|
||||
assert!(
|
||||
matches!(messages.last(), Some(Message::PortAnchor(crate::port_edit::Event::Commit(_, _, anchor))) if anchor.port == port && anchor.side == PortAnchorSide::Top)
|
||||
);
|
||||
|
||||
@@ -322,6 +322,8 @@ fn article(topic: Topic) -> Article {
|
||||
"Show a correspondence between OA and SA",
|
||||
&[
|
||||
"Choose OA ↔ SA traceability in View content. Changing this setting does not create counterparts or add their placements, so an existing single-layer diagram can initially look unchanged.",
|
||||
"Switching back to Architecture or another single-layer projection shows only that view's layer. The other layer's placements, routing and saved visibility remain stored; return to OA ↔ SA traceability to show them again, subject to the view's filters. Selecting a displayed card, port or line and opening its Properties keeps the current view and perspective. Applying ordinary Properties edits keeps that saved architecture view open. Changing a placement's Diagram explicitly moves it and follows its destination. Changes you make to the view's own perspective or identity still take effect. If the app asks you to select again, choose a visible target.",
|
||||
"In a mixed traceability view, use Draw from a visible source's canvas menu or the Model explorer sidebar on the Architecture page to connect from that source's OA or SA layer without leaving the view. Choose a highlighted compatible visible target; Escape cancels. The palette offers tools for the view's perspective. Opening an endpoint or selecting a record in the Model explorer sidebar follows that record's perspective. Contextual relationship forms keep the view while opening; applying the reviewed relationship can navigate to its result.",
|
||||
"Use Review layer traces to inspect named Operational and System endpoints and the meaning of their link. This opens the model's layer-trace list with no search restriction; traces between relationships are reviewed here rather than drawn between element cards.",
|
||||
"In the chosen architecture view, use Add existing elements. The chooser offers compatible elements from both layers. Select an existing endpoint and click its position in the canvas; repeat for the other end if needed. This adds a placement, not a new semantic element or trace. For port correspondences, place the ports' owners.",
|
||||
"The view shows correspondence lines alongside ordinary relationships. The shown-line count reflects the current projection and visibility settings, not trace coverage. If a line is hidden, use View visibility and inspect Saved view visibility; placing an endpoint does not remove explicit hides or filters.",
|
||||
|
||||
@@ -557,10 +557,7 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
&format!("Draw {}", tool.label().to_lowercase()),
|
||||
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
|
||||
app.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::visible(&projection::project_view(
|
||||
p, diagram,
|
||||
))
|
||||
.any(|element| element == id)
|
||||
crate::connection_edit::can_begin(p, diagram, *tool, id)
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -372,3 +372,150 @@ async fn source6_http_explicit_communication_ports_preview_apply_and_restart_pre
|
||||
assert_pair(&reopened);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mixed_traceability_projection_roundtrip_preserves_dormant_http_state() {
|
||||
let f = fixture();
|
||||
let mut model = compile_bundle(&f.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project;
|
||||
let view = model.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.layer = Layer::Sa;
|
||||
view.projection = ProjectionKind::Traceability;
|
||||
// The OA placements and category are intentionally opposite the view's layer.
|
||||
view.view_options.hidden_edges = vec![f.mean];
|
||||
for (index, element) in f.sa.into_iter().enumerate() {
|
||||
model.place(
|
||||
f.diagram,
|
||||
element,
|
||||
Position {
|
||||
x: index as f32 * 400.,
|
||||
y: 300.,
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut bundle = f.bundle.clone();
|
||||
bundle.files.insert(
|
||||
bundle.entry.clone(),
|
||||
format!(
|
||||
"// Authored source-6 engineering rationale\n{}",
|
||||
oa_core::language::format(&model).replacen(
|
||||
"{\n",
|
||||
"{\n include \"notes.arcadia\";\n",
|
||||
1
|
||||
)
|
||||
),
|
||||
);
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let original = import(&server, &bundle).await;
|
||||
assert_pair(&original);
|
||||
let resource = original.resource_id;
|
||||
let mixed = oa_core::projection::project_view(&original.model, f.diagram);
|
||||
assert!(
|
||||
f.oa.iter()
|
||||
.chain(&f.sa)
|
||||
.all(|id| mixed.nodes.iter().any(|n| n.element == *id))
|
||||
);
|
||||
|
||||
let mut ordinary = original.model.diagrams[&f.diagram].clone();
|
||||
ordinary.projection = ProjectionKind::Allocation;
|
||||
let response = transact(
|
||||
&server,
|
||||
&original,
|
||||
vec![Command::EditDiagram(f.diagram, ordinary.clone())],
|
||||
)
|
||||
.await;
|
||||
let status = response.status();
|
||||
let etag = response
|
||||
.headers()
|
||||
.get("etag")
|
||||
.map(|v| v.to_str().unwrap().to_owned());
|
||||
let result: Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{result}");
|
||||
assert_eq!(result["changed"], true);
|
||||
assert_eq!(result["dry_run"], false);
|
||||
assert_eq!(result["revision"], original.revision + 1);
|
||||
let changed = server.get(resource).await;
|
||||
assert_eq!(etag.as_deref(), Some(changed.etag().as_str()));
|
||||
let mut expected = original.model.clone();
|
||||
expected.diagrams.insert(f.diagram, ordinary.clone());
|
||||
assert_eq!(
|
||||
changed.model, expected,
|
||||
"only the projection changes; dormant state is retained"
|
||||
);
|
||||
assert_pair(&changed);
|
||||
assert_eq!(
|
||||
changed.source_project.files["notes.arcadia"],
|
||||
bundle.files["notes.arcadia"]
|
||||
);
|
||||
assert!(
|
||||
changed.source_project.files[&bundle.entry]
|
||||
.starts_with("// Authored source-6 engineering rationale\n")
|
||||
);
|
||||
let projected = oa_core::projection::project_view(&changed.model, f.diagram);
|
||||
assert!(projected.nodes.iter().all(|n| !f.oa.contains(&n.element)));
|
||||
assert!(
|
||||
f.sa.iter()
|
||||
.all(|id| projected.nodes.iter().any(|n| n.element == *id))
|
||||
);
|
||||
|
||||
// A dormant option still must name an existing category. Rejection must not
|
||||
// advance the ETag or rewrite either side of the persisted model/source pair.
|
||||
let disk = directory.path().join(format!("{resource}.json"));
|
||||
let accepted_bytes = std::fs::read(&disk).unwrap();
|
||||
ordinary.view_options.participant_categories = CategorySelection::Any(vec![Id::new_v4()]);
|
||||
let rejected = transact(
|
||||
&server,
|
||||
&changed,
|
||||
vec![Command::EditDiagram(f.diagram, ordinary)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let retained = server.get(resource).await;
|
||||
assert_eq!(retained.etag(), changed.etag());
|
||||
assert_eq!(
|
||||
serde_json::to_value(&retained).unwrap(),
|
||||
serde_json::to_value(&changed).unwrap()
|
||||
);
|
||||
assert_eq!(std::fs::read(&disk).unwrap(), accepted_bytes);
|
||||
server.stop().await;
|
||||
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(
|
||||
serde_json::to_value(&reopened).unwrap(),
|
||||
serde_json::to_value(&changed).unwrap()
|
||||
);
|
||||
assert_pair(&reopened);
|
||||
let response = transact(
|
||||
&server,
|
||||
&reopened,
|
||||
vec![Command::EditDiagram(
|
||||
f.diagram,
|
||||
original.model.diagrams[&f.diagram].clone(),
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
let status = response.status();
|
||||
let result: Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{result}");
|
||||
assert_eq!(result["changed"], true);
|
||||
assert_eq!(result["revision"], changed.revision + 1);
|
||||
let restored = server.get(resource).await;
|
||||
assert_eq!(restored.model, original.model);
|
||||
assert_pair(&restored);
|
||||
assert_eq!(
|
||||
restored.source_project.files["notes.arcadia"],
|
||||
bundle.files["notes.arcadia"]
|
||||
);
|
||||
let projected = oa_core::projection::project_view(&restored.model, f.diagram);
|
||||
assert!(
|
||||
f.oa.iter()
|
||||
.chain(&f.sa)
|
||||
.all(|id| projected.nodes.iter().any(|n| n.element == *id))
|
||||
);
|
||||
assert!(!projected.edges.iter().any(|edge| edge.id == f.mean));
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ schemas["ViewOptions"] = obj({
|
||||
"coverage": {**ref("Coverage"), "default": "All"},
|
||||
"work_contexts": {**ref("ContextSelection"), "default": "All"},
|
||||
"exchange_labels": {**ref("ExchangeLabels"), "default": "Name"}}, [])
|
||||
schemas["ViewOptions"]["description"] = "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. Traceability permits both-layer hidden/category/context references; ordinary projections require the diagram layer. Filtering preserves unfiltered automatic port positions."
|
||||
schemas["ViewOptions"]["description"] = "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. String projections preserve both-layer hidden/category/context references and latent placements; only Traceability renders both layers. Typed StateMachine/Scenario views retain layer-scoped admission. Filtering preserves unfiltered automatic port positions."
|
||||
schemas["Diagram"]["properties"]["view_options"] = {**ref("ViewOptions"), "default": {}}
|
||||
|
||||
def upsert_variant(variants, name, value):
|
||||
|
||||
@@ -570,8 +570,15 @@ retain their identities and contracts; migration does not silently replace them.
|
||||
``coverage`` and ``work_contexts``, and ``Name`` for ``exchange_labels``.
|
||||
``{"Only":[]}`` hides all relation and trace lines; ``{"Any":[]}`` preserves
|
||||
an explicit empty category/context selection. Category members union within
|
||||
each family, and independent filters intersect. ``Traceability`` permits
|
||||
references from both layers; ordinary projections require the diagram's layer.
|
||||
each family, and independent filters intersect. String projections permit saved
|
||||
references and placements from both layers. Only ``Traceability`` renders both;
|
||||
other string projections filter to the diagram's declared layer. ``EditDiagram``
|
||||
may change between these projections without deleting latent placements, routes
|
||||
or visibility choices. Returning to Traceability restores their applicability.
|
||||
Typed StateMachine/Scenario views retain their strict membership and layer checks.
|
||||
Missing objects, wrong reference kinds/families and duplicate selections remain
|
||||
invalid. Frozen versions 1–6 keep their prior layer admission before migration;
|
||||
older version-7 implementations may reject these newly accepted ordinary views.
|
||||
These presentation choices preserve semantic records, routes, placements and
|
||||
the automatic port positions derived from unfiltered eligible exchanges.
|
||||
|
||||
|
||||
@@ -409,8 +409,12 @@ filters rather than widening them to All.
|
||||
|
||||
Traceability views retain their established two-layer projection: default options
|
||||
preserve eligible OA and SA lines. Their hidden-edge, category and context
|
||||
references may therefore select either layer. All ordinary projections require
|
||||
these references in the declared diagram layer. A filter never hides the other
|
||||
references may therefore select either layer. Ordinary string projections retain
|
||||
these references from either layer as saved presentation choices; they still
|
||||
render only their declared layer. Switching projections MUST preserve those
|
||||
choices, including explicit empty selections. Existing-object, reference-kind,
|
||||
family and uniqueness constraints still apply. Typed StateMachine and Scenario
|
||||
views retain layer-scoped reference admission. A filter never hides the other
|
||||
layer merely because the Traceability diagram has an OA or SA layer label.
|
||||
|
||||
Versions 1–5 reject the new relation, category constructor, diagram field (even
|
||||
@@ -1073,6 +1077,20 @@ capabilities with their involvement, exploitation, include, extend and
|
||||
generalization links. Decomposition and Overview retain general containment;
|
||||
Overview also exposes all relationships. These string projections hide
|
||||
inapplicable placements without deleting them or changing semantic records.
|
||||
This includes opposite-layer placements retained after leaving Traceability.
|
||||
Changing between string projections changes only the projection value; placement
|
||||
identities, positions, sizes, collapsed state, port anchors, routes and visibility
|
||||
choices remain authored. Returning to Traceability restores their applicability.
|
||||
Ordinary views MUST NOT render opposite-layer nodes, parents, ports or lines.
|
||||
This current-version admission correction does not broaden frozen source/JSON
|
||||
versions 1–6: their layer-scoped placement and visibility checks remain required
|
||||
before migration. Earlier version-7 implementations can reject ordinary views
|
||||
containing these retained opposite-layer records.
|
||||
|
||||
The explicit RelocateNode operation moves a placement to a different diagram
|
||||
only when its element belongs to the destination layer or the destination is
|
||||
Traceability. Retaining an existing hidden placement through a projection change
|
||||
does not authorize a relocation that immediately hides it in another layer.
|
||||
|
||||
A StateMachine projection MUST reference an existing StateMachine in the
|
||||
diagram's layer. Its placements MUST refer to that machine, its Regions, or
|
||||
|
||||
@@ -12490,7 +12490,7 @@
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": false,
|
||||
"description": "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. Traceability permits both-layer hidden/category/context references; ordinary projections require the diagram layer. Filtering preserves unfiltered automatic port positions."
|
||||
"description": "Presentation only. Independent predicates intersect; category members union within a family. Only([]) hides all relations and traces. Any([]) remains an explicit empty selection. String projections preserve both-layer hidden/category/context references and latent placements; only Traceability renders both layers. Typed StateMachine/Scenario views retain layer-scoped admission. Filtering preserves unfiltered automatic port positions."
|
||||
},
|
||||
"CommunicationPortChoice": {
|
||||
"oneOf": [
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
# Architectural and product decisions
|
||||
|
||||
## Canvas inspection, contextual drawing and explicit relocation — 2026-10-02
|
||||
|
||||
An ordinary Properties Apply retains the current Architecture diagram, layer and
|
||||
projection. An accepted edit to the active Diagram itself uses its resulting
|
||||
identity, layer and projection. A queued canvas target is revalidated against that retained surface
|
||||
after Apply; an obsolete target is refused with feedback instead of navigating
|
||||
or selecting hidden content. Explicit Select/Open still follows the requested
|
||||
model target. An accepted change to a placement's actual owning diagram is also
|
||||
explicit navigation: it follows the committed destination and resulting UUID.
|
||||
Changing only geometry, alias or UUID in the same owning diagram retains the
|
||||
inspection surface. Invalid or missing ownership never grants this exception.
|
||||
|
||||
Contextual Draw uses the invoked menu target as its semantic source and the current
|
||||
diagram as its editing surface. Shared admission checks the diagram projection,
|
||||
visibility, family and semantic endpoint roles before arming. Restart clears the
|
||||
pending source while retaining an admitted tool. Explicit model navigation and
|
||||
post-review creation navigation retain their separate behavior. This avoids
|
||||
silently conflating an opposite-layer inspection with a request to change views.
|
||||
|
||||
The source repairs at c706 and 6018, the failed c706 workspace attempt, the
|
||||
full-model/source invariants and corrected Windows acceptance are attributed in
|
||||
[the navigation checkpoint](navigation-validation-20261002.md). The simpler
|
||||
blanket surface restoration was rejected when the unchanged relocation test
|
||||
showed that it hid successful explicit moves. This is bounded Architecture
|
||||
acceptance; diagram label readability and the complete OA/SA goal remain open.
|
||||
|
||||
## Private source composition decision — 2026-09-23
|
||||
|
||||
Adopt the opaque validated original-span rename batch as the next private
|
||||
|
||||
@@ -1,3 +1,24 @@
|
||||
# Navigation contribution checkpoint — 2026-10-02 UTC
|
||||
|
||||
Verified production source **6018ae30b9296b9f6b37d7fc038175080d15fafb** preserves
|
||||
ordinary opposite-layer Properties inspection and contextual drawing while
|
||||
following accepted explicit placement relocation. Windows full workspace tests
|
||||
pass **2,236 / 0 failures / 8 existing ignores**; formatting, strict workspace
|
||||
all-targets Clippy and matching desktop/server/CLI builds pass. Native creation,
|
||||
projection/history, corrected relocation and saved-source reopen evidence retain
|
||||
their actual c706/6018 attribution and independent reviews.
|
||||
[Source, commands, portable evidence and limits](navigation-validation-20261002.md)
|
||||
are authoritative. The earlier c706 workspace failure remains recorded.
|
||||
|
||||
This is a contribution checkpoint, not the complete OA/SA application or an
|
||||
integrated Help/Save release. Label ellipsis/readability and broader usability
|
||||
remain open. **Linux/WSL is still excluded by the user's explicit pause.**
|
||||
[Issue48](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/48)
|
||||
coordinates the subsequent verified union with delivered Save/Help; ownership,
|
||||
live status and release decisions belong there. Existing released versions are
|
||||
unchanged. Earlier integrated and contribution checkpoints follow with their
|
||||
original source and acceptance boundaries.
|
||||
|
||||
# Integrated prerelease source checkpoint — 2026-09-24 UTC
|
||||
|
||||
This branch assembles peer contributions for the Windows prerelease in issue37.
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
# Automatic layout investigation
|
||||
|
||||
Issue [32](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/32)
|
||||
owns scope, ownership, milestones and integration status. This is an experiment
|
||||
record above released `715036e132677dd2bc2fb37ed316d93dc9987aec`, not an accepted
|
||||
replacement layout contract or a completed fix. User-owned requirements remain
|
||||
in `PROJECT_GOAL.md` and the issue.
|
||||
|
||||
The [first executed experiment](experiments/auto-layout-20260925/results-01.md)
|
||||
records nine Windows fixtures at `e7deea70`: hierarchy/label improvements,
|
||||
repeat stability, and two shared-connector regressions. The candidate remains
|
||||
experimental; successful execution is not acceptance of a replacement command.
|
||||
|
||||
The [target-join experiment](experiments/auto-layout-20260925/results-j.md)
|
||||
isolates a routing score correction with fixed Projects/nodes/ports/endpoints.
|
||||
Its 185 regression checks pass, but an original-scene overlap regression and
|
||||
additional bipartite T contacts prevent a general readability acceptance.
|
||||
|
||||
## Question and existing evidence
|
||||
|
||||
Can explicit Auto-layout produce a readable arrangement that accounts for
|
||||
connections and their labels, including removal of stale bends, while preserving
|
||||
semantic endpoints, containment/allocation, source parity and reversible editing?
|
||||
|
||||
The current `session::layout` sizes the containment forest, sorts roots using
|
||||
directed edges and alias tie breaks, places four roots per row, and translates
|
||||
authored route points only when both actual endpoints move equally. Its authored
|
||||
route preservation is deliberate and tested in `tests/release_routing.rs` and
|
||||
`tests/state_diagrams.rs`; the user's requested automatic bend replacement needs
|
||||
an explicit amendment, not a silently weakened test.
|
||||
|
||||
The [source map](experiments/auto-layout-20260925/source-evaluator-map.md)
|
||||
identifies the actual callers and preserved input/source/API contracts. The
|
||||
[primary-source research](experiments/auto-layout-20260925/research.md) compares
|
||||
Sirius/Capella workflows, ELK and reuse of the existing Rust router. The
|
||||
[candidate critique](experiments/auto-layout-20260925/candidate-critique.md)
|
||||
records unresolved design gaps before any production choice.
|
||||
|
||||
The existing `routing` module already supplies bounded deterministic orthogonal
|
||||
search, node/header obstacles, fixed semantic port endpoints, crossing/shared-run
|
||||
costs and automatic label collision search. Automatic elbows are derived, not
|
||||
persisted. The investigation should reuse this capability unless experiments
|
||||
show a concrete reason to replace it. Process topology rendering is a different
|
||||
path; its reported overlapping routes do not alone prove an Auto-layout defect.
|
||||
|
||||
## Candidate decisions, not yet accepted
|
||||
|
||||
Compare these materially different choices against visible route/label quality,
|
||||
semantic conservation, predictable user control, runtime/dependency cost,
|
||||
determinism, model size and maintainability:
|
||||
|
||||
1. Retain node arrangement and only reset effective visible route/label constraints.
|
||||
This is a useful control, but cannot by itself optimize poor node ordering.
|
||||
2. Use a hierarchy-aware layered node arrangement and the existing router, with
|
||||
label-aware spacing and bounded crossing-reduction sweeps. Explicit Auto-layout
|
||||
can replace effective visible pins/label offsets; ordinary drag keeps its
|
||||
existing preservation rules. This is a candidate, not a chosen implementation.
|
||||
3. Adopt a mature external layout engine, accounting for ports, labels, nested
|
||||
containers, runtime packaging and coordinate conversion. Evidence must justify
|
||||
the integration/dependency cost over reusing the current router.
|
||||
|
||||
Open contract questions include hidden/folded routes, authored attachment and port
|
||||
anchors, undirected and cyclic graphs, nested cross-boundary edges, repeated
|
||||
layout stability, and the observable treatment of exhausted search/poor results.
|
||||
No option may change semantic endpoints, ownership, allocation, expressions,
|
||||
relation kind or source identity to improve a drawing. Other views must remain
|
||||
unchanged, and one Undo on clean input must recover the complete pre-layout
|
||||
project. Existing application behavior applies an unrelated pending property
|
||||
draft first; that separate Apply is not silently folded into Layout's history.
|
||||
|
||||
The initial experiment retains the current command as the baseline and compares
|
||||
reset-only with a layered prototype isolated under `examples/auto_layout_probe`.
|
||||
Neither prototype changes production behavior. Effective reset eligibility must
|
||||
come from the pre-layout projection and actual endpoint identities; merely being
|
||||
drawn does not make a promoted route's dormant metadata eligible. Explicit label
|
||||
reset means both fraction and offset, while semantic text and endpoint/port
|
||||
attachments remain fixed. Production adoption remains conditional on the
|
||||
critic's hierarchy, stability, direction, measurement and global-work checks.
|
||||
|
||||
The first [probe recipe](experiments/auto-layout-20260925/probe-notes.md) covers
|
||||
nine small SA FunctionalDataflow fixtures. Its [independent source review](
|
||||
experiments/auto-layout-20260925/probe-review.md) found no blocker to that finite
|
||||
diagnostic run and lists important evaluator limitations. After that review,
|
||||
the parent inspected the added ten-call prototype stability loop and formatted
|
||||
the examples; the loop reports every differing/error result without a quality
|
||||
pass. No production file was changed. The experiment intentionally does not
|
||||
establish folded/filtered, State, Allocation, source-bundle or native acceptance.
|
||||
|
||||
## Evaluator before implementation
|
||||
|
||||
Use independently specified small graphs and real project views. Separate proper
|
||||
crossings, collinear shared runs, obstacle collisions, label/card collisions,
|
||||
label/label collisions, routing warnings, bends, length and occupied area. A low
|
||||
crossing count alone can hide coincident edges or enormous drawings. Exempt only
|
||||
explicitly documented intentional containment and common endpoint geometry;
|
||||
never discard a failure because it is inconvenient to the proposed algorithm.
|
||||
|
||||
Compare the current command and the reset-only control before choosing a design.
|
||||
Include a chain longer than the current four-column row, diamond/bipartite cases,
|
||||
nested cross-boundary exchanges, stale authored detours/labels, fixed ports,
|
||||
cycles/self-loops and disconnected components. Check deterministic repeat calls,
|
||||
idempotence, model/source round trips and complete Undo/Redo. A probe is evidence
|
||||
about its named fixtures, not universal graph quality or native usability.
|
||||
|
||||
Actual representative Windows workflows, including meaningful window/zoom sizes,
|
||||
selection and saved reopen, follow an implemented candidate. Linux remains
|
||||
excluded by the current user instruction. The separately published State label
|
||||
candidate in issue36 is not silently treated as integrated into this base.
|
||||
@@ -0,0 +1,11 @@
|
||||
# Connection tool icons
|
||||
|
||||
Branch candidate for [#41](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/41), based on published Windows preview source `715036e132677dd2bc2fb37ed316d93dc9987aec`. This is not integrated or native acceptance.
|
||||
|
||||
The reported collapsed palette repeats one arrow for three different tools. Extend the existing Rust/Iced canvas glyphs at their actual 16×16 size: work endpoints plus an arrow for Interaction/Functional exchange, neutral participant endpoints for Communication means, facing component ports for Boundary exchange, and work entering a performer for Allocation. The first pair never coexist in one perspective. Unoriented generic tools must not imply a fixed direction.
|
||||
|
||||
Letter badges were rejected as language-dependent and cramped. Different arrow/dash styles risk confusing tool identity with relation direction. Native endpoint/container shapes fit the existing icon system without font or asset dependencies. Preserve button dimensions, explicit labels/tooltips, ordering, messages, availability and selection rules.
|
||||
|
||||
Independent source critique found no blocker and confirmed the tool semantics and Iced API use. Actual-size legibility remains unverified, particularly the exchange arrow, port details and possible download/import interpretation of Allocation. Verify enabled, disabled, hovered and selected palettes in both perspectives, expansion/collapse, tool activation, representative relation creation and cancellation. Existing keyboard/accessibility behavior must be checked, not inferred from hover labels. Reuse semantic connection tests; geometry hashes alone are not an identification or usability evaluator.
|
||||
|
||||
Linux remains excluded by the existing user pause. Live ownership and integration status belong to the linked issue.
|
||||
@@ -1,38 +0,0 @@
|
||||
# #32 candidate critique — 2026-09-25
|
||||
|
||||
Read-only adversarial review of `harness/auto-layout-investigation-20260925.md` in the715036e-based isolated checkout, against the source/evaluator map. This critiques candidate choices; no implementation, execution or acceptance is claimed.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Use the existing explicit **Command::Layout / Auto-layout / Arrange elements** operation for the new automatic behavior. The user specifically asks that operation to replace bends and consider labels/intersections. A second command/UI choice would preserve the obsolete behavior as a competing default without demonstrated need. Deliberately amend its documented contract and focused preservation tests, retain exact Undo, and retain existing ordinary drag/Arrange-next-drag contracts. A new command becomes warranted only if concrete compatibility/user workflows require both automatic policies, not merely because a test currently asserts old behavior.
|
||||
|
||||
Prefer comparing candidate1 reset-only against candidate2 layered siblings plus the existing router. No external engine is justified yet. The existing router already creates derived elbows, so reset does not require writing every solver corner as a persisted hard pin. Keep generated corners derived initially; the saved node placement plus remaining explicit attachments determines their repeatable reconstruction.
|
||||
|
||||
Recommended explicit Layout route contract: identify eligible visible, non-promoted edges from the **pre-layout projection**, remove their stored waypoints and label override (fraction and offset together), retain source/target attachments and node port anchors. Retain unrelated/hidden/promoted route records with existing equal-delta endpoint-owner translation. Do not call broad Reset routing if that also clears attachments. Preserve stable route-list order and remove an emptied record only under an explicit canonical no-metadata rule. Layout-generated record churn is not progress.
|
||||
|
||||
## Blocking design gaps before accepting candidate2
|
||||
|
||||
1. **Eligibility must be frozen once.** Visible edges, semantic endpoint-owner placements and promoted endpoints are different sets. Compute the reset set before node movement using projection visibility plus actual owner identity, not “every stored edge” or endpoints merely present in `d.nodes`. A filtered/hidden edge can retain a valid route; a folded edge may be drawn from an ancestor while its own route is suspended. Recomputing eligibility mid-layout risks erasing metadata when visibility/promotion changes. Small experiment: parallel visible/filtered edges, one folded child endpoint, and one both-endpoints-fold-to-same-anchor edge, all with distinctive pins/labels/attachments. Require only declared reset members to change, and retained pins to translate only through the existing owner-delta rule. Unfold/unfilter must reveal the retained custom route.
|
||||
|
||||
2. **Two forests are needed conceptually, not two model hierarchies.** The authored projection-parent forest includes folded descendants; the rendered topology may omit them. Reordering only rendered nodes can leave hidden children outside resized parents or leave future expand geometry broken. Conversely, expanding all hidden content for outer layout wastes space and ignores the actual view. Decide visible sibling arrangement versus hidden subtree placement/reservation explicitly. Lift each rendered inter-subtree edge to the relevant direct children at its lowest common ancestor; do not flatten every edge to roots (loses nested ordering) or count the same edge at every depth as independent demand. Small experiment: nested A/B children with crossed exchanges, folded A, external sibling C and a State entry/exit boundary. Check containment/header/port-footer rules before and after expand, with semantic parents/allocations exact.
|
||||
|
||||
3. **Idempotence will fail if spacing compounds current sizes.** Current measure uses each authored `node.size` as a lower bound. If label clearance expands a container and the next call adds the same margin again, repeated Arrange grows indefinitely. Label spacing based on previous routed bounds can similarly oscillate. Decide whether current sizes are immutable minima, or may shrink; the latter exceeds today's behavior and needs justification. Compute desired padding from content minima with `max`, not repeated addition to an already padded size. Small experiment: ten consecutive calls on nested long-label/parallel-edge fixtures, comparing full Projects after first vs second and sizes after tenth; repeat after Save/reparse. A two-call-only test can miss alternating period-two layouts.
|
||||
|
||||
4. **Cycle/direction policy is not settled by “stable sweeps”.** Some projected edges are undirected; direction may be semantic or merely an endpoint representation. Use directed topology where justified, deterministic SCC treatment for cycles, and explicit undirected ordering inputs. Do not arbitrarily make a Capability/Traceability edge's serialized orientation a mandatory rank. Freeze alias/identity tie rules; crossing sweeps require a stable acceptance tie-break to avoid ping-pong. Small experiment: directed diamond+return, reversed undirected endpoint records, self-loop, parallel/reversed edges and disconnected isolates. Permute input records; independently require identical output for identical IDs and stable quality under UUID changes (not necessarily identical coordinates under reidentification).
|
||||
|
||||
5. **Label reset and label-aware spacing need different evidence.** Clearing authored overrides is a reasonable explicit-auto interpretation but must be announced, since issue32 says “take into account” labels, not explicitly erase them. Retain semantic text/typed references; only the target view's override changes. A stale far-away label is a concrete reset fixture; an intentionally constrained attachment/label case tests whether residual collisions are honestly reported. Base715 label widths are scalar estimates, and State title/multiline geometry is known inaccurate without36. Do not tune spacing to claim accurate measured-State acceptance on715. First isolate ordinary/association labels; use conservative declared bounds for experiments, then rerun State B/W/U once36 integrates. Font-free core/API parity forbids quietly using desktop-only font results inside Command::Layout.
|
||||
|
||||
6. **Bounded sweeps do not alone bound runtime.** Each complete route evaluation invokes previous-edge interactions, node obstacles and label comparisons; repeated per-sibling trial routing can multiply this cost by nodes×sweeps. Lifting topology naively can multiply edges×depth. Define global deterministic work budgets for candidate ordering/scoring, not just a fixed iteration count, and retain the best valid candidate on exhaustion. Route only a bounded number of whole candidates; cheap crossing surrogates can rank candidates but final acceptance must inspect actual geometry. Small experiment: a fixed family increasing sibling count/edge density/nesting, recording candidate count, comparison/work counters and wall time; include no-edge/isolate cases so they do not pay quadratic routing costs. No hard latency threshold should be fabricated from one machine.
|
||||
|
||||
## Input/source/API contract checks
|
||||
|
||||
- Clean whole-view Layout should create one reversible model/source transaction. `app.rs` applies a pending property draft before executing Layout, potentially as a separate transaction; do not accidentally promise one Undo reverses both an unrelated draft Apply and Layout. Use a clean-input one-step Undo oracle and a separate valid/invalid pending-draft admission test. Keep diagram UUID remapping, stale selection handling and disabled/pending form policy.
|
||||
- `prepare_command`/Document/source bundle/REST dry-run must produce the same Project for the same inputs without GUI fonts, caches or selection. A frontend-only rearrangement would fail this. Roundtrip the stored record representation, check unrelated source files/comments remain exact, and recover the exact old source/model with Undo—not merely visually similar positions.
|
||||
- Keep Allocation Arrange-next-drag, ordinary group translation, semantic ports, reconnect, Hide/show, collapse flags and other diagrams unchanged. A route reset belongs only to explicit Layout. A failed candidate must not clear history or leave a partially reset diagram.
|
||||
- Native validation later needs visible controls, Fit/export agreement, actual label containment and route readability, plus Save/reopen/Undo. Do not extend the implementation merely to expose a new State Auto-layout control before a demonstrated workflow need; shared State initialization still needs regression coverage.
|
||||
|
||||
## Smallest discriminating experiment set
|
||||
|
||||
Freeze six hand-built cases before implementation: (A) stale dogleg+far label with fixed attachments, (B) two-container reversed sibling connections with a known zero-crossing witness, (C) nested folded/filtered route-conservation sentinel, (D) cycle/self-loop/parallel/undirected variants, (E) long ordinary/association labels with feasible compact clearance, (F) dense bounded stress. Compare existing Layout, reset-only, and a layered prototype on the same independent vector: invalid overlap/penetration, label collisions, proper crossings, positive collinear overlap, turns, path length/stretch, area and routing warnings. Include ten-call idempotence, exact other-view/semantic conservation, source roundtrip and Undo/Redo.
|
||||
|
||||
Candidate2 earns selection only if it materially improves at least B/E over reset-only without regressing the hard invariants or bounded workload. A tie favors the smaller candidate. A nonplanar or contradictory-fixed-port fixture may retain crossings; zero-crossing-by-overlap, giant detours, hidden edges or clipped labels is not a solution. Failure on a named fixture should revise the exact layer/policy implicated, not trigger an automatic wholesale engine dependency.
|
||||
@@ -1,56 +0,0 @@
|
||||
"""Compare fixed Project/node/port/endpoint geometry and report routing changes.
|
||||
|
||||
Usage: python compare_probe.py OLD_JSONL NEW_JSONL NEW_OUTPUT_JSON
|
||||
No layout-quality pass is inferred from equal inputs or successful execution.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
old_path, new_path, out = map(Path, sys.argv[1:])
|
||||
def read(path):
|
||||
rows = [json.loads(line) for line in path.read_text(encoding='utf-8').splitlines()]
|
||||
return {row['fixture']: row for row in rows if 'fixture' in row}
|
||||
old, new = read(old_path), read(new_path)
|
||||
assert old.keys() == new.keys()
|
||||
def variants(row):
|
||||
yield 'before', row['before_project'], row['before']
|
||||
yield 'baseline', row['after_project'], row['after']
|
||||
for name in ['reset_only_control', 'experimental_candidate', 'manual_planar_order_witness']:
|
||||
if row[name] is not None:
|
||||
yield name, row[name]['project'], row[name]['metrics']
|
||||
def endpoints(metrics):
|
||||
return {e['id']: [e['points'][0], e['points'][-1]] for e in metrics['routed_edges']}
|
||||
keys = ['proper_crossings', 'pairwise_collinear_shared_length',
|
||||
'nonterminal_pairwise_shared_length', 'unrelated_node_collisions',
|
||||
'unrelated_edge_node_penetrations', 'label_node_collisions',
|
||||
'label_label_collisions', 'unrelated_label_stroke_intersections',
|
||||
'warning_count', 'route_bends', 'route_length', 'drawing_area']
|
||||
results = []
|
||||
for name in old:
|
||||
lhs, rhs = list(variants(old[name])), list(variants(new[name]))
|
||||
assert len(lhs) == len(rhs)
|
||||
for (variant, op, om), (other, np, nm) in zip(lhs, rhs):
|
||||
assert variant == other
|
||||
fixed = {'project':op == np, 'nodes':om['projected_nodes'] == nm['projected_nodes'],
|
||||
'ports':om['projected_ports'] == nm['projected_ports'],
|
||||
'route_endpoints':endpoints(om) == endpoints(nm)}
|
||||
results.append({'fixture':name, 'variant':variant, 'fixed_geometry':fixed,
|
||||
'metrics':{key:{'old':om[key], 'new':nm[key]} for key in keys},
|
||||
'new_point_contacts':nm.get('point_contacts'),
|
||||
'new_point_contact_details':nm.get('point_contact_details')})
|
||||
summary = {'old_sha256':hashlib.sha256(old_path.read_bytes()).hexdigest(),
|
||||
'new_sha256':hashlib.sha256(new_path.read_bytes()).hexdigest(),
|
||||
'all_projects_nodes_ports_endpoints_exact':all(all(r['fixed_geometry'].values()) for r in results),
|
||||
'scope':'Fixed-geometry comparison; quality vector retained, no aggregate quality pass.',
|
||||
'results':results}
|
||||
with out.open('x',encoding='utf-8') as f:
|
||||
json.dump(summary,f,indent=2)
|
||||
f.write('\n')
|
||||
print(json.dumps({k:v for k,v in summary.items() if k != 'results'}))
|
||||
for row in results:
|
||||
if row['variant'] == 'experimental_candidate':
|
||||
print(json.dumps({'fixture':row['fixture'], 'fixed_geometry':row['fixed_geometry'],
|
||||
'changed_metrics':{k:v for k,v in row['metrics'].items() if v['old'] != v['new']},
|
||||
'point_contacts':row['new_point_contacts']}))
|
||||
@@ -1,47 +0,0 @@
|
||||
# #32 bounded automatic-layout probe
|
||||
|
||||
Prepared `crates/oa-core/examples/auto_layout_probe.rs` in `.local/auto-layout-20260925` against reference source `715036e132677dd2bc2fb37ed316d93dc9987aec`. No production edits, compiler, Cargo, GUI or Gitea operation by this evaluator. Rustfmt parsed/formatted only the owned main example (`skip_children=true`). The separately owned `auto_layout_probe/layered.rs` is imported through its agreed `candidate(&Project, Id) -> Result<Project, ModelError>` interface; the evaluator does not use candidate internals to define fixtures, geometry metrics or expected conservation.
|
||||
|
||||
## Execution and output
|
||||
|
||||
From the worktree, in the parent's configured exclusive Windows toolchain environment:
|
||||
|
||||
```powershell
|
||||
cargo run --locked --offline -p oa-core --example auto_layout_probe > auto-layout-probe.jsonl
|
||||
```
|
||||
|
||||
The parent has `.local/run-auto-layout-check-20260925.py` for pinned receipt-based execution. Prefer that for recorded evidence; its source hash/commit must identify the actual build. The example's `reference_source` metadata is the investigation baseline, not a runtime attestation of checkout identity. Stdout is JSON Lines: one metadata/self-check record and nine fixture records. No output files, network calls or UI operations are performed by the example. Errors/unsupported candidates are explicit fields, not quality passes. There is no aggregate score or threshold; a zero metric is not a readability or hard-safety certification.
|
||||
|
||||
Each fixture emits the original and current `Command::Layout` Projects and real projected/routed geometry. It also emits the same observations for a **reset-only control applied after existing Layout**, and for the separately implemented experimental candidate invoked on the original Project. The control clears only waypoints and label placement on visible routed edges whose geometry is not suspended/promoted, retaining endpoint attachments, hidden/promoted records and empty route records. The candidate does not determine this control. Ten successive existing Layout calls and ten successive candidate calls are each compared with their preceding and first result, stopping with an explicit error if needed. Candidate identical-input repeat and one further application are also compared separately. Reset eligibility is measured after baseline Layout; the candidate freezes it before Layout. These all-visible fixtures do not change visibility, so broader eligibility-equivalence claims are deferred.
|
||||
|
||||
## Declared fixtures
|
||||
|
||||
1. Four-node flat chain, initially left to right.
|
||||
2. Seven-node chain, initially left to right, exposing four-column row wrapping if present.
|
||||
3. Diamond with branch/join semantics and declared two-level positions.
|
||||
4. Crossed bipartite ordering, including both straight and crossed pairings.
|
||||
5. Parent Function with two child Functions, internal exchange and exchange to an outside Function.
|
||||
6. Two-node authored high detour with a far label offset; reset-only isolates stale constraint cost.
|
||||
7. Two-node exchange with explicit source-Left0.3 and target-Right0.7 port anchors.
|
||||
8. Two parent Functions, each with two children, and reversed inter-parent pairings. A separate predeclared witness swaps only right-child y positions, aligning each source with its target. Its real routing is measured; no zero-crossing result is assumed.
|
||||
9. Three Functions with long ordinary exchange names, including a bypass; the actual existing summary/name excerpt bounds are observed.
|
||||
|
||||
Project, diagram, Function, placement, relation and automatically created port UUIDs are remapped to stable IDs in creation order. Port aliases are reset explicitly. Names and other aliases are unique per fixture, avoiding random-ID tie dependence. The fixture Projects in output allow independent repetition and offline visualization; projected node rectangles/ports and per-edge routed point sequences/label rectangles are also included.
|
||||
|
||||
## Metric definitions and exclusions
|
||||
|
||||
- **Proper crossings:** unique intersection locations per distinct edge pair, interior to both straight segments. Locations deduplicate at0.0001 world units. Segment endpoints, bend contacts, T junctions and collinear overlaps are excluded from this count. Raw crossings remain the primary count. A separate nonterminal count subtracts only contacts within `PORT_STUB` of a physically equal endpoint with the same projected owner, where both tested segments are terminal. Entire compacted first/last segments are never exempt.
|
||||
- **Shared runs:** summed pairwise collinear overlap length, reported separately from crossings. Raw length is primary. Common exact endpoint terminal overlap gets a separately reported exemption bounded by `PORT_STUB` per common endpoint, capped by the overlap. Three overlapping edges contribute three pairwise overlaps; retraced segments may contribute multiplicity. This is not union length.
|
||||
- **Unrelated node collisions:** positive rectangle intersection area for distinct nodes; ancestor/descendant pairs are exempt from this count. No inferred intent is attached to a reported collision.
|
||||
- **Label/node collisions:** positive intersection of real routed label bounds with projected node rectangles, including endpoint nodes. A label wholly inside an endpoint ancestor's *body below the header* is exempt; overlap with its header remains reported. Ordinary header height is64; dedicated State height uses the core helper. Border crossings are retained. These conservative boxes are not glyph measurements.
|
||||
- **Edge/node penetration:** per edge/node pair, positive clipped segment length in unrelated node interiors. Endpoint-owner nodes are exempt; endpoint ancestors contribute only their header obstacles, so intentional passage through container bodies/boundaries is not counted. This does not detect re-entry into an edge's own endpoint node.
|
||||
- **Label/stroke intersection:** positive clipped length of another edge's stroke inside a label rectangle, one record per label-edge/stroke-edge pair. Own-edge strokes are excluded; an intentional anchor/label association is not declared a defect. Stroke thickness, arrowheads and antialiasing are unmodeled.
|
||||
- **Other observations:** actual routing warnings and details; routed total length; direction-changing bends including reversal; total drawing bounding-box area (including labels/nodes); real node/port/edge geometry. Area is scale-sensitive and is not minimized at the expense of crossings or readability.
|
||||
|
||||
Tiny analytic assertions check proper crossing versus endpoint contact, collinear overlap versus separated lines, rectangle contact, and line/interior clipping. They will run with the example, but have not been executed by this author. Geometry tolerance is0.0001 world units, while proper-intersection parameter/parallel checks use1e-9.
|
||||
|
||||
## Conservation and limits
|
||||
|
||||
The current command is exercised through `Session`, with exact one-step Undo/Redo and history/no-op checks. Full JSON decode and formatted single-source parse round trips are compared to the complete Project before/after; these do not prove original multi-file comments/byte preservation. Semantic records, target-view nongeometry fields, other views, per-node identity/visibility/port anchors and authored routes are reported separately. Reset/candidate route equality may intentionally be false and must be assessed against their stated reset policy, not silently treated as semantic loss.
|
||||
|
||||
Folded/filter/hidden-route preservation is implemented in the reset eligibility check but not independently falsified by these nine fixtures. Dedicated State #36 native typography, dense stress, cycles, disconnected large sets, non-planar crossing lower bounds, self-crossings, self-loops, full stroke geometry and interactive cognitive load are absent. The candidate may explicitly reject unsupported cases. No algorithm can promise crossing-free output for arbitrary non-planar graphs; the paired-container witness only establishes a concrete alternative ordering to examine. Use vector observations and the output geometry to diagnose mechanisms before selecting a candidate; this is a bounded experiment, not a production layout-quality framework or native acceptance.
|
||||
@@ -1,27 +0,0 @@
|
||||
# #32 first diagnostic probe review
|
||||
|
||||
**No blocking finding for compiling/running the current finite experiment.** This is a source/evaluator-validity review, not production acceptance or a claim the candidate improves layout. Nine small deterministic FunctionalDataflow fixtures support a bounded comparison; later scope gaps should not delay this run. No compiler, GUI, helper, source edit or Gitea operation performed.
|
||||
|
||||
Reviewed snapshot SHA256: auto_layout_probe.rs `72d850fdd9aad65788004949380c28cba48a0c2be3dc28f3aef3900c7f8724c7`; auto_layout_probe/layered.rs `bda9a59968b4b0e284e8e1393eb86c90cdf0d856d5f9830ae3c6fc0043df839a`. The evaluator worker is separately finalizing candidate repetition reporting; later edits require a scoped diff check, not relabeling this reviewed snapshot.
|
||||
|
||||
## Evaluator validity
|
||||
|
||||
Raw strict-interior segment crossings and pairwise collinear shared length are primary outputs, with crossing coordinates deduplicated per edge pair. Endpoint sharing is not exempted wholesale: secondary figures subtract only bounded common physical tip lengths. Node/header, label/node, label/label and unrelated stroke/label measurements remain separate. Analytic primitive checks cover a proper crossing, endpoint touch, collinear overlap, disjoint parallel segments, rectangle touch and inside-length boundary. Complete before/after models and routed points are emitted, permitting independent examination beyond scalar totals. No quality threshold or automatic overall-pass field promotes a poor drawing to acceptance.
|
||||
|
||||
Fixture IDs are normalized before use; semantic port IDs/aliases are also stabilized. The manual reversed-child witness is separately authored and measured, not generated from candidate output. It must still be read as a witness with its reported validation/geometry, not an asserted universal optimum. Standard parse/JSON roundtrip and old-Layout history checks are meaningful within their named scope.
|
||||
|
||||
## Actionable limitations to retain when interpreting this run
|
||||
|
||||
1. **Reset control eligibility:** reset_effective_routes receives the already arranged Project, whereas layered candidate freezes its eligible set before Layout. For these all-visible, uncollapsed fixtures, Layout does not change membership/promotion, so this does not invalidate the current comparison. Freeze the original eligible IDs for future folded/filtered cases; do not generalize current equivalence.
|
||||
2. **Conservation granularity:** semantic/other-view/node-metadata checks are independent and useful. target_view_nongeometry_exact restores all routes and layout before comparing; authored_routes_exact will intentionally become false. Those fields do not separately prove attachment preservation, invisible-route retention or a no-change layout field. Add targeted comparisons before making those claims on future fixtures. The current prototype source copies original route records and only clears eligible waypoints/labels, then translates retained routes, so no observed source-level violation was found here.
|
||||
3. **History scope:** undo_exact/one_step_undo/redo_exact exercise existing Command::Layout through Session only. The layered candidate is a pure Project transformer, not an integrated command and has no tested operation/history lifecycle. Its repeat-same-input and second-call comparison are useful; ten-call candidate stability was pending in this snapshot. Do not describe baseline ten_layout_calls as candidate stability.
|
||||
4. **Metric coverage:** strict proper crossings deliberately exclude T contacts and intersections at segment bends; pairwise shared length counts segment-pair traversal multiplicity, not geometric union length. No candidate should be selected solely by one scalar if it shifts crossings into uncounted touches. Endpoint-owner interiors are excluded from unrelated-edge penetration, and label/stroke excludes its own edge. These are declared definitions, not proof of complete route validity. Association-end labels, actual glyph ink, State multiline measured bounds and port labels are not covered by current primary label metrics. Current nine fixtures contain functional exchanges, so these omissions do not corrupt their narrow comparison.
|
||||
5. **Fixture coverage:** all current fixture families are small SA FunctionalDataflow cases. Other-view exactness is vacuous where only one view exists; folding, filtering, omitted-node, State, Allocation, Scenario, undirected cycles, dense scaling, UI admission and multi-file source conservation are not established. Preserve these gaps explicitly rather than broadening the initial run unnecessarily.
|
||||
|
||||
## Prototype source risks the experiment should expose
|
||||
|
||||
The candidate preserves semantic records and route-list order, retains attachments/port anchors, rejects folded/omitted topology explicitly, and uses stable alias/ID order, deterministic cycle fallback, a graph limit and counted ordering work. Derived corners are not persisted as hard pins. No product file is replaced.
|
||||
|
||||
Three passes use frozen per-call minima, but those minima come from baseline Layout using the input's existing sizes; cross-call size ratcheting remains possible and should appear in repeated full-Project comparison. Global maximum label width controls every sibling gap, so one long label may balloon unrelated layout area. Incoming-neighbor ordering can improve the reversed-container fixture without proving general crossing reduction; undirected edges do not constrain ranks/order. The work budget excludes existing projection/routing work and several collections, so it bounds selected prototype loops, not total CPU complexity. With current small fixtures these are diagnostic candidate weaknesses, not reasons to prevent the run.
|
||||
|
||||
Interpret a failed invariant, growing repeated layout, worse collisions or huge area as evidence against this prototype. Preserve errors and unsupported statuses; do not use successful process exit as layout-quality acceptance. Decide further work from the produced geometry and independent metric vector. Production integration, complete command/source/API/history behavior, native readability and broader issue32 acceptance remain separate.
|
||||
@@ -1,75 +0,0 @@
|
||||
"""Render recorded probe geometry for inspection; this is not a desktop screenshot.
|
||||
|
||||
Usage: python render_probe.py PROBE_STDOUT_JSONL NEW_OUTPUT_DIRECTORY
|
||||
Uses one scale across every result for each fixture; input geometry is unchanged.
|
||||
"""
|
||||
from pathlib import Path
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
|
||||
def geometries(value, name=""):
|
||||
if not isinstance(value, dict):
|
||||
return
|
||||
if "routed_edges" in value and "projected_nodes" in value:
|
||||
yield name, value
|
||||
return
|
||||
for key, child in value.items():
|
||||
yield from geometries(child, f"{name}.{key}".strip("."))
|
||||
|
||||
|
||||
source, output = map(Path, sys.argv[1:])
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
font = ImageFont.truetype("C:/Windows/Fonts/arial.ttf", 13)
|
||||
files = []
|
||||
for line in source.read_text(encoding="utf-8").splitlines():
|
||||
item = json.loads(line)
|
||||
fixture = item.get("fixture")
|
||||
if not fixture:
|
||||
continue
|
||||
variants = list(geometries(item))
|
||||
if not variants:
|
||||
continue
|
||||
largest_width = max(g["drawing_bounds"]["size"]["width"] for _, g in variants)
|
||||
largest_height = max(g["drawing_bounds"]["size"]["height"] for _, g in variants)
|
||||
scale = min(1.0, 1140 / max(1, largest_width), 690 / max(1, largest_height))
|
||||
for name, geometry in variants:
|
||||
canvas = Image.new("RGB", (1200, 800), "white")
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
origin = geometry["drawing_bounds"]["position"]
|
||||
|
||||
def point(position):
|
||||
return (30 + (position["x"] - origin["x"]) * scale,
|
||||
70 + (position["y"] - origin["y"]) * scale)
|
||||
|
||||
def rectangle(position, size):
|
||||
x, y = point(position)
|
||||
return x, y, x + size["width"] * scale, y + size["height"] * scale
|
||||
|
||||
draw.text((20, 12), f"{fixture} / {name} | geometry only, not native UI", font=font, fill="black")
|
||||
draw.text((20, 32), f"Fixed fixture scale {scale:.4f}; text below identifies bounds, not glyph measurements", font=font, fill="black")
|
||||
for node in sorted(geometry["projected_nodes"], key=lambda n: (n["depth"], n["id"])):
|
||||
box = rectangle(node["position"], node["size"])
|
||||
draw.rectangle(box, fill="#eef2f6", outline="#687787", width=1)
|
||||
draw.text((box[0] + 3, box[1] + 3), node["id"][-5:], font=font, fill="#334155")
|
||||
for edge in geometry["routed_edges"]:
|
||||
points = [point(p) for p in edge["points"]]
|
||||
if len(points) > 1:
|
||||
draw.line(points, fill="#245c88", width=2)
|
||||
label = edge["label"]
|
||||
if label["text"]:
|
||||
box = rectangle(label["position"], label["size"])
|
||||
draw.rectangle(box, outline="#ad5715", width=1)
|
||||
target = output / f"{fixture}-{name.replace('.', '-')}.png"
|
||||
canvas.save(target)
|
||||
files.append({"name": target.name, "bytes": target.stat().st_size,
|
||||
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(), "scale": scale})
|
||||
(output / "manifest.json").write_text(json.dumps({
|
||||
"input_sha256": hashlib.sha256(source.read_bytes()).hexdigest(),
|
||||
"renderer_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
"scope": "Offline schematic of recorded core geometry; no native or font-rendering acceptance.",
|
||||
"images": files}, indent=2) + "\n", encoding="utf-8")
|
||||
print(f"Rendered {len(files)} geometry records without modifying the probe output.")
|
||||
@@ -1,66 +0,0 @@
|
||||
# #32 automatic arrangement: bounded research
|
||||
|
||||
Prepared 2026-09-25; local research, not an accepted design or implementation review. No compiler, GUI, helper, or remote mutation was run. Primary sources were read online. Local source spot-check: clean `.local/auto-layout-20260925`, commit `715036e132677dd2bc2fb37ed316d93dc9987aec`. Parent owns current implementation mapping and contract selection.
|
||||
|
||||
The user needs Arrange to generate/remove bends, reduce connection intersections, and account for connection labels, while retaining meaningful nested containment/deployment. A useful solution therefore needs coordinated node ordering, space allocation, routing, and label placement. Merely sorting boxes or clearing existing waypoints cannot establish success.
|
||||
|
||||
## Verified precedents and their limits
|
||||
|
||||
**Capella workflow.** Capella 7 release notes document Layout Children, temporary movement of pinned elements, and renaming Arrange All/Selection to Layout All/Selection. This supports making operation scope and preservation of manual organization explicit. It does **not** establish that Capella uses ELK as its default engine. [Official Capella 7 notes](https://github.com/eclipse-capella/capella/wiki/Release-Notes-7.0.0).
|
||||
|
||||
**Sirius workflow.** Its user guide describes avoiding overlap, fitting containers around contents, attempting fewer edge crossings, and preserving pinned locations. Layout Children operates at the immediate level. A selected container can have its contents arranged while its location remains fixed. The guide also describes simplifying an unpinned edge between pinned endpoints. However, its pinning section and later toolbar section disagree about edge pinning support; do not copy that ambiguity into ARCADIA's contract. [Sirius user guide](https://eclipse.dev/sirius/doc/user/diagrams/Diagrams.html). Sirius documents configurable default/composite and ordered-tree layouts, with ELK as an additional integration described as experimental. Availability of that integration is not evidence of a particular Capella installation's behavior. [Sirius layout configuration](https://eclipse.dev/sirius/doc/specifier/diagrams/Diagrams.html).
|
||||
|
||||
**ELK capabilities.** ELK Layered explicitly supports orthogonal routing, ports, labels, compound graphs, and cross-hierarchy edges with suitable configuration. [Algorithm reference](https://eclipse.dev/elk/reference/algorithms/org-eclipse-elk-layered.html). `INCLUDE_CHILDREN` allows a joint run through descendants; the root default otherwise separates children. Independent child layout is therefore not equivalent to globally considering connections across nested containers. [Hierarchy handling](https://eclipse.dev/elk/reference/options/org-eclipse-elk-hierarchyHandling.html). Port constraints distinguish free placement, fixed side/order/ratio, and fixed position. [Port constraints](https://eclipse.dev/elk/reference/options/org-eclipse-elk-portConstraints.html).
|
||||
|
||||
The authors describe a Sugiyama-family layered algorithm, constrained barycenter crossing minimization, size/port-aware coordinate assignment, and separate support for compound graphs and disconnected components. These are heuristics with configurable priorities, not a guarantee of globally minimal crossings. [ELK authors, GD 2024](https://drops.dagstuhl.de/storage/00lipics/lipics-vol320-gd2024/LIPIcs.GD.2024.56/LIPIcs.GD.2024.56.pdf).
|
||||
|
||||
**Routing is a distinct problem.** Wybrow, Marriott, and Stuckey describe orthogonal visibility graphs, A* route search, and ordering/nudging of shared segments. Their bend/length optimality statement concerns individual routes under their model, not joint optimal placement of all nodes, labels, and connectors. [Orthogonal Connector Routing, GD 2009](https://people.eng.unimelb.edu.au/pstuckey/papers/gd09.pdf). libavoid exposes penalties for crossings, segments, cluster crossings and shared paths, plus endpoint direction constraints. It supplies routing rather than a complete node arrangement solution. [libavoid API](https://www.adaptagrams.org/documentation/namespaceAvoid.html).
|
||||
|
||||
## Materially different approaches
|
||||
|
||||
| Candidate | Practical proposal for this project | Main advantage | Main risk / decisive falsifier |
|
||||
| --- | --- | --- | --- |
|
||||
| Native Rust layered arrangement + existing router | Build a bounded, deterministic placement pipeline over the projected hierarchy; reserve measured label space and improve routes after placement. | Best match to Rust preference, existing model/history, and deployment; reuse current geometry. | Nested cross-boundary routes and label congestion make a supposedly small implementation grow. Reject if the finite compound fixtures require ad hoc per-diagram exceptions or cannot reduce known avoidable crossings. |
|
||||
| ELK Layered through elkjs | Translate one immutable projected graph to ELK JSON, run a pinned bundled JS engine off the UI thread, validate results, apply a presentation transaction. | Mature joint treatment of ports, labels, layering and hierarchy. | Adds runtime/distribution/adapter cost. Reject if exact port semantics, nested coordinates, measured label boxes, bounded cancellation, or Windows/Linux packaging cannot be demonstrated. |
|
||||
| Preserve nodes; improve routes and label locations | Use existing Rust routing plus bounded rerouting/order search, or investigate libavoid through FFI. | Least disruption to the user's spatial organization; useful separate Reroute command. | Cannot untangle crossings imposed by node/port ordering. Insufficient as the sole Arrange implementation if an order swap solves a fixture it cannot improve. |
|
||||
|
||||
elkjs is the official JavaScript form of ELK's layout algorithms, consumes/returns graph geometry, and supports worker execution. Using the distributed JavaScript does not require a Java runtime. A Rust embedding still needs an explicitly selected JS runtime or packaged process; compatibility, footprint, cancellation and licensing must be checked in a bounded spike. It is not a Rust library or a ready Iced integration. [elkjs repository and API](https://github.com/kieler/elkjs).
|
||||
|
||||
Graphviz is a possible comparison baseline, not my preferred default: its current official `splines=ortho` documentation explicitly says this routing does not handle ports or, for dot, edge labels. That limitation directly intersects this task. A separate router/label solver could repair the integration, but removes much of its simplicity. [Graphviz splines](https://graphviz.org/docs/attrs/splines/). Force/stress layout is another possible overview style, but I would not spend the first experiment on it: engineering flow direction, nesting and endpoint constraints still need substantial machinery afterward. This is a fit judgment, not a claim those algorithms cannot be extended.
|
||||
|
||||
## Proposed minimum contract, independent of engine
|
||||
|
||||
1. **Operate on diagram presentation.** Preserve semantic identities, relation direction, containment/deployment, port identity and connectivity, node/edge membership, and label text. Layout cycle breaking must never reverse the actual model relation. Projected parentage, not an independently guessed semantic tree, defines nesting. Hidden/collapsed content must have a declared policy; do not silently expose it.
|
||||
2. **Define scope before implementation.** Initial whole-diagram Arrange is simpler than arbitrary selection. If selection is included, decide whether a container means its immediate children or its full subtree, whether it can grow, and how boundary-crossing edges update. Outside nodes remain fixed. Do not infer support for selection from a whole-graph solver.
|
||||
3. **Define replaceable geometry.** Explicit Arrange may replace the in-scope route waypoints, creating useful bends and removing redundant ones. Decide independently whether authored endpoint attachments and authored label placement are preserved or reset. Recommended initial rule: preserve semantic/fixed attachments; authorize replacement of bend geometry; treat preserved label boxes as constraints. Do not retain stale absolute label offsets accidentally after wholesale rearrangement. Parent must settle this user-visible policy.
|
||||
4. **Measure before arranging.** Supply visible node dimensions, headers, port/row areas, label rectangles and clearance in world units. Use the same prepared text and measurement as painting, including multiline labels and fallback glyphs. Feed label occupancy into spacing/routing, then validate the final rendered geometry. Fitting labels only after arranging boxes can recreate collisions.
|
||||
5. **Atomic application.** Return a candidate presentation delta, validate it, then create one Undo entry. Failure/cancellation leaves the model unchanged. Undo restores complete prior geometry; Redo and save/reopen reproduce the result. A stale asynchronous result must not overwrite edits made since its input snapshot.
|
||||
6. **Quality, not an impossible universal promise.** Require no invalid geometry, prohibited node/header penetration, or lost constraints. Minimize avoidable crossings, label collisions and shared-path ambiguity before compactness and bend count. Preserve a satisfactory baseline when candidates are worse. Hard constraints may make a zero-collision drawing impossible; report the residual rather than claiming success from a cost scalar.
|
||||
|
||||
The local spot-check explains one important seam: `crates/oa-core/src/session.rs::layout` already obtains `projection::parents`, recursively sizes nested nodes and rejects Scenario geometry. `routing.rs` explicitly distinguishes persisted authored pins from bounded derived routes. Generated bends need not all become persisted manual pins: persist the chosen presentation change according to a declared regeneration policy. The #36 successor's measured State labels must be reconciled when integrating; this base predates it. No claim about uninspected callers or final API follows from this spot-check.
|
||||
|
||||
## Native Rust candidate: finite algorithm experiment
|
||||
|
||||
This is a proposal, not an implementation prescription. Build a temporary layout graph with stable IDs, measured boxes, containment, real port constraints and typed edges. Determine feedback edges for cycles without modifying semantics; assign layers; insert temporary long-edge/label placeholders; perform a fixed number of alternating barycenter/median sweeps with adjacent swaps accepted only when crossing cost improves; assign non-overlapping coordinates. Lay out children with explicit boundary connection ordering and reserve parent headers/port areas. Bottom-up subtree packing alone is a baseline, not proof of cross-hierarchy quality.
|
||||
|
||||
Route orthogonally around the resulting obstacles, with penalties for bends, crossings and excessive shared paths. Place measured labels in reserved corridors; if conflicts remain, permit a bounded spacing/rerouting pass. Remove duplicate and collinear intermediate vertices. Keep separate metrics for edge crossings, shared-path length, label collisions, bends, area and displacement so a shorter route cannot disguise a newly unreadable diagram. Stable tie-breaking, bounded passes and retaining the best valid candidate limit oscillation and cost. Compare this candidate against the same finite fixtures processed by elkjs before investing in a general framework.
|
||||
|
||||
## Falsifiers and finite milestones
|
||||
|
||||
**First: freeze the evaluator and geometry policy.** Six small fixtures are enough to expose foundational errors: (a) a two-layer order swap with a known crossing-free solution; (b) a diamond plus cycle/self-loop; (c) a two-level OA containment graph with internal and cross-container connections; (d) SA deployment nesting with fixed semantic ports and high-degree fan-out; (e) long/multiline labels in a tight corridor; (f) disconnected components, collapsed content and an already tidy layout. Include one route needing added bends and one needing removed bends. If scope excludes any fixture feature, state that before scoring.
|
||||
|
||||
Count proper interior intersections between distinct routed edges separately from collinear overlaps. Exclude common endpoint contact, but do not exempt edges merely because they share a source: an intersection elsewhere still counts. Count label-label and label-unrelated-edge/node intersections; intentional own-edge label contact and container background containment need specific exemptions. Check allowed container boundary passages rather than counting every containment-border crossing as an error. Record scores on final routes, not only straight-line layer proxies.
|
||||
|
||||
**Second: choose using measured results.** For the known solvable fixtures, demand the known crossing/collision target, semantic equality, correct port attachment and legal containment. Test deterministic repetition, a second Arrange reaching a stable result, and small perturbations not causing unbounded growth. Include invalid inputs/work-budget exhaustion; preserve the input on failure. Set an interactive-time budget against a declared representative model size and hardware before timing, rather than inventing a universal threshold here.
|
||||
|
||||
**Third: verify the real workflow.** Parent performs Arrange, inspects routes and labels at normal reading zoom, Undo, Redo, save and fresh reopen on OA and SA models. Check that connections remain traceable and nesting still conveys the intended relationship; aggregate crossing counts alone do not establish readability. No such execution or acceptance is claimed by this research.
|
||||
|
||||
Recommended next decision: settle reset/preservation of bends, endpoint attachments and label anchors, then compare the native layered candidate and elkjs on the same compound, port-and-label fixtures. Route-only improvement is a useful fallback capability but should not silently redefine the user's Arrange request.
|
||||
|
||||
## Parent's current code findings: implication for the first experiment
|
||||
|
||||
Parent reports that the existing router already has deterministic bounded orthogonal search, obstacle exclusion, crossing/shared-run penalties and automatic label collision sampling; current Arrange uses a four-per-row root placement after topological/alias ordering. These are parent-provided inspection findings, not independent runtime verification here. They materially favor **reuse of existing routing plus improved node arrangement** as the first candidate, rather than reimplementing routing or immediately adopting another engine. Feed its final-route metrics back into a bounded choice among placement candidates; label sampling alone still cannot create missing corridor space.
|
||||
|
||||
Parent's proposed policy—preserve semantic ports, authored port anchors and endpoint attachment preferences; reset only visible effective bends/label overrides during explicit full Auto-layout; retain hidden/promoted metadata; leave automatic elbows derived—is a coherent candidate. Its key falsifier is visibility scope: a folded/proxy edge must not cause deletion of dormant metadata belonging to a hidden original endpoint/route. Define “visible effective” by exact original edge ID plus unsuspended geometry, not just membership in the rendered edge list. Test folded then expanded diagrams, ancestor-promoted endpoints, partially visible authored routes and a visible label whose anchor was inherited. If retained dormant metadata becomes effective on expansion, the restored manual shape is expected under this policy and should not be advertised as newly automatically arranged. Explicit full Auto-layout can reasonably reset visible label overrides because its stated purpose authorizes rearrangement, but ordinary redraw, drag and routing must not acquire that reset behavior.
|
||||
|
||||
Compare reuse against elkjs only after scoring this bounded candidate. A successful existing-router experiment removes much of the justification for another runtime; persistent compound/port-order failures would supply concrete reasons to reopen engine adoption. The newly observed untracked `harness/auto-layout-investigation-20260925.md` in the worktree is parent work; this research did not modify it or product source.
|
||||
@@ -1,51 +0,0 @@
|
||||
# #32 residual routes after terminal-join candidate J
|
||||
|
||||
Read-only diagnosis of run03 `command/output.log`, `.local/auto-layout-j-comparison-20260925.json`, the geometry-only crossed-bipartite PNG, and current `routing.rs` / `projection/port_layout.rs`. No edits, builds or native GUI execution. J is an experimental cost correction, not an accepted readability improvement for every scene.
|
||||
|
||||
## The remaining187 units are not coincident distinct ports
|
||||
|
||||
The four functional exchanges deliberately reuse four semantic ports:
|
||||
|
||||
| Flow | Source port | Target port |
|
||||
| --- | --- | --- |
|
||||
| 0 (`009`) | `010` | `014` |
|
||||
| 1 (`00c`) | `013` | `011` |
|
||||
| 2 (`00f`) | `010` | `011` |
|
||||
| 3 (`012`) | `013` | `014` |
|
||||
|
||||
Projected source ports are `(264,106)` and `(264,286)`; target ports `(346,106)` and `(346,286)`. Each port is a single real semantic identity serving two edges. There are no authored port anchors in this fixture. The route endpoints and ports were unchanged by J. Splitting a common port into several displayed ports or changing relation port IDs would change the contract/semantics and is not a routing remedy.
|
||||
|
||||
Of187 pairwise shared units,72 are four legitimate common-port18-unit terminal stubs. The remaining115 are four avoidable interior overlaps:
|
||||
|
||||
- Flow0/Flow2: `y=106, x=282..328`, length46.
|
||||
- Flow1/Flow2: `y=106, x=305..328`, length23.
|
||||
- Flow1/Flow3: `y=286, x=282..305`, length23.
|
||||
- Flow0/Flow1: `y=106, x=305..328`, length23. These routes have different endpoints, so this is not a common-port exemption.
|
||||
|
||||
These sum115. The two diagonals now have separate vertical channels (`x=328` and `x=305`); horizontal tails still merge different routes. The geometry PNG confirms that a user sees shared horizontal lines with branch contacts, not four independent readable connections. It is not native/glyph evidence.
|
||||
|
||||
`port_layout::layout` chooses automatic sides from peer centers, semantic fallback and clear exits; explicit anchors override the resulting side/offset. It sorts multiple automatic ports on a side by peer-axis coordinate and ID, then allocates distinct positions around fixed anchors. Here each node has only one relevant port, so improving distinct-port separation would not address this fixture. Moving a common automatic port could alter geometry but still leaves all its incident edges sharing the same physical endpoint. Fixed anchors must remain exact.
|
||||
|
||||
## J trades one objective against another
|
||||
|
||||
J improves layered diamond overlap82→36 (nonterminal0) and layered bipartite344→187 (nonterminal115). But the original unarranged bipartite regresses730→872 shared units (nonterminal658→800), while bends5→4 and length2076→2064. The current original routes use a320-unit top horizontal trunk and a160-unit second-diagonal tail; later straight edges overlap those trunks. This is consistent with a length/bend-biased objective, not evidence that J universally improves routing.
|
||||
|
||||
`crossing_cost` charges only0.12 per shared unit, capped80 per prior segment; a turn costs24. J now compares more complete turn costs, so it can rationally prefer a cheaper route with more overlap. The router evaluates edges in stable-ID order and does not jointly revise earlier paths. Terminal fairness alone cannot correct an objective that undervalues ambiguous shared strokes. Public/ordinary scenes must remain in the comparison; do not judge only Arrange-generated geometry.
|
||||
|
||||
## Next bounded experiment: penalty versus available lanes
|
||||
|
||||
Recommend a tiny factorial comparison with node positions, ports, anchors and edge order frozen: J; J+P; J+L; J+P+L. Keep the original unarranged bipartite as a named regression sentinel alongside layered diamond/bipartite and the existing corpus. This separates missing candidate geometry from cost preference; no placement change initially.
|
||||
|
||||
**P:** use one predeclared experimental overlap coefficient, e.g.3.0 instead of0.12, retaining the80 per-segment cap and proper-crossing24 initially. This is a falsifier, not a proposed final default. A12-unit dogleg around a straight shared span can cost24 additional length plus96 for four full-path turns. The cheap existing coefficient cannot compensate for that; even3.0 with the80 cap may still reject it. Record this failure explicitly rather than repeatedly tuning. A second experiment removing/changing the cap would require separate justification; the cap and segment subdivision make the objective unlike a simple global shared-length weight.
|
||||
|
||||
**L:** offer a bounded number of parallel candidate coordinates at `CLEARANCE=12` on either side of occupied horizontal/vertical segments, subject to current obstacle clearance and fixed terminal stubs. These are derived path candidates, never authored waypoints. Put them in fast-path candidate space and, if needed, the grid space consistently, with a deterministic global cap. The current obstacle-boundary/midpoint coordinate set does not systematically offer these close parallel lanes. More space between boxes alone cannot supply the missing candidate coordinate or change its cost preference.
|
||||
|
||||
A smaller staging of this comparison is to run P first and inspect actual chosen alternatives, then add L only if the recorded candidates lack the desired lane. Do not silently combine penalty, cap, edge ordering and node spacing changes. If P merely creates crossings/long exterior detours, that disproves it as a standalone improvement. If L offers legal lanes but they are never selected, that isolates the objective. The result may justify a different explicit priority or bounded shared-segment nudge, but not before this evidence.
|
||||
|
||||
**Alignment control:** an additional fixed24-unit inter-layer gap or stagger is lower priority. It changes the diagram and automatic peer positions, complicates attribution, and can lengthen shared trunks. Alignment may be useful for a particular placement heuristic, but it is not a direct explanation of identical horizontal tails between the same common ports. Port-placement changes are outside the identified dependency scope.
|
||||
|
||||
## Acceptance constraints for the experiment
|
||||
|
||||
Keep exact semantic graph, port identities, attachments, authored anchors and generated-versus-authored route distinction. Report raw overlap, legitimate terminal overlap, nonterminal overlap, proper crossings AND branch/bend contacts, labels, obstacles, length, turns, warnings and workload. Current proper-crossing zero excludes contacts at bends; trading coincident strokes for distinct, obvious crossings can improve traceability even if that one metric rises. Conversely, tiny offsets that appear merged at normal zoom are not success.
|
||||
|
||||
Use shared-port fan-out/fan-in and distinct-port controls, fixed-anchor controls, narrow corridor, authored-pin and opposite-direction routes. Compare original, reset-only and layered scenes, plus a fixed edge-ID permutation to expose priority sensitivity. Parent should inspect actual route tracing at normal zoom before adopting any weight/lanes policy. No quality acceptance or port-placement defect is asserted here.
|
||||
@@ -1,106 +0,0 @@
|
||||
# First automatic-layout experiment
|
||||
|
||||
## Decision
|
||||
|
||||
Retain the hierarchy/label-spacing prototype for further experiments; do not
|
||||
adopt it as production Auto-layout. It improves several named fixtures beyond
|
||||
resetting old bends, but regresses shared connector length in two fixtures.
|
||||
Shorter routes and zero proper crossings do not establish readable connections.
|
||||
The next experiment should isolate route selection at fixed node positions,
|
||||
before adding more node spacing or replacing the existing routing engine.
|
||||
|
||||
## Method and receipt
|
||||
|
||||
Tested source `e7deea7039bcf31675cffbb462cfeb19d62a7e20`, based on released
|
||||
`715036e132677dd2bc2fb37ed316d93dc9987aec`. No production file changed in that
|
||||
commit. Windows x86_64-pc-windows-gnullvm, Rust 1.98.1, LLVM mingw, one Cargo job,
|
||||
incremental compilation disabled; Linux excluded. Command:
|
||||
|
||||
```text
|
||||
cargo run --locked --offline -p oa-core --example auto_layout_probe
|
||||
```
|
||||
|
||||
The recorded runner completed at 2026-09-25T00:42:06Z, exit 0, unchanged inputs,
|
||||
no recorded failure. stderr contains only compile/run progress. This means the
|
||||
diagnostic process completed, not that layout quality passed. The JSONL has one
|
||||
metadata record and nine fixture records, with full input/result Projects and
|
||||
core route geometry. Its SHA256 is
|
||||
`21786ae5cb668f60e9252dc884668ee5e98289443d0b3723a9ea69bf21d47fd1`.
|
||||
|
||||
[Evidence archive](run-01/probe-e7deea70.zip), 900995 bytes, SHA256
|
||||
`c52f82d4782cd6a7f8cbe67498c8b2371e99204a65976aa8bcbbd0a86f835069`,
|
||||
contains original receipts, source inventories, output, runners and 37 offline
|
||||
geometry PNGs. [Manifest](run-01/manifest.json) binds every member and the
|
||||
post-run executable identity. All archive members were byte-compared to the
|
||||
local originals. Original absolute paths describe this machine; reproduce using
|
||||
the source commit and command above, with a suitable Windows Rust toolchain.
|
||||
|
||||
## Observations
|
||||
|
||||
Values below compare existing Layout with the layered prototype. Reset-only
|
||||
matches existing Layout except for the authored-detour fixture, where it already
|
||||
reduces length to 240, bends to zero and area to 90816. Shared length is pairwise
|
||||
segment overlap, not the union of occupied space; area uses recorded bounds.
|
||||
|
||||
| Fixture | Shared length | Label/node collisions | Route length | Bends | Area |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Four-node chain | 0 → 0 | 0 → 0 | 720 → 246 | 0 → 0 | 213312 → 150744 |
|
||||
| Seven-node chain | 0 → 0 | 0 → 0 | 2560 → 492 | 8 → 0 | 523584 → 271920 |
|
||||
| Diamond | 36 → 82 | 0 → 0 | 2200 → 688 | 8 → 4 | 300576 → 260832 |
|
||||
| Crossed bipartite | 276 → 344 | 0 → 0 | 3284 → 688 | 12 → 4 | 300576 → 165360 |
|
||||
| Nested cross-boundary | 0 → 0 | 1 → 0 | 1076 → 364 | 6 → 2 | 284096 → 366528 |
|
||||
| Authored detour/label | 0 → 0 | 0 → 0 | 2348 → 187 | 6 → 0 | 628040 → 83820 |
|
||||
| Fixed ports | 0 → 0 | 0 → 0 | 916 → 863 | 4 → 4 | 125976 → 116754 |
|
||||
| Reversed children in two containers | 148 → 0 | 1 → 0 | 872 → 372 | 4 → 0 | 288512 → 272800 |
|
||||
| Long ordinary labels | 36 → 36 | 2 → 0 | 1340 → 1436 | 4 → 4 | 200448 → 208800 |
|
||||
|
||||
Proper interior crossings, unrelated node overlaps, unrelated edge/node
|
||||
penetrations, label/label collisions, unrelated label/stroke intersections and
|
||||
route warnings are zero in the baseline, reset and candidate outputs. This is
|
||||
not proof of crossing elimination: the crossing definition excludes bend and
|
||||
T contacts, and overlapping strokes can make endpoint pairing ambiguous.
|
||||
|
||||
The parent inspected offline geometry for the reversed-container baseline and
|
||||
candidate, crossed-bipartite candidate and long-label candidate. Reordering the
|
||||
right container's children separates the two routes; the bipartite result has
|
||||
a visually ambiguous shared central trunk. The long-label boxes occupy the
|
||||
available corridors. These images render core boxes/lines, not actual label
|
||||
glyphs or the desktop application, and therefore are not native UI acceptance.
|
||||
|
||||
Every candidate reports exact same-input determinism, idempotence and equality
|
||||
to its first result for calls 2–10. Reported semantic, node identity/metadata and
|
||||
nongeometry conservation hold. JSON and canonical single-source parse/format
|
||||
round trips hold for all nine before/baseline/reset/candidate Projects. The
|
||||
authored-detour route changes intentionally. These assertions do not separately
|
||||
establish hidden-route or attachment lifecycle policy. Other-view conservation
|
||||
is vacuous in these single-view fixtures.
|
||||
|
||||
Existing Layout's Session operation passes one-step Undo/Redo and history
|
||||
checks. The pure candidate function has no integrated command/history evidence.
|
||||
The [source review](probe-review.md) defines remaining metric and fixture gaps.
|
||||
Folded/filtered topology, State/Allocation diagrams, cycles/undirected graphs,
|
||||
large models, multifile sources, application/API admission and native editing
|
||||
remain outside this run. Issue36's multiline State candidate is not in this base.
|
||||
|
||||
An [independent results audit](results-audit-01.md) checked receipt bindings,
|
||||
complete Projects, all 37 image hashes and eight offline images. It independently
|
||||
recomputed both shared-route regressions and confirmed the scoped conclusions.
|
||||
|
||||
## Implications
|
||||
|
||||
Resetting obsolete bends is useful but insufficient. Hierarchy ordering and
|
||||
label clearance have distinct value, with measurable area/length tradeoffs.
|
||||
Increasing a global gap cannot be assumed to fix shared-route ambiguity.
|
||||
Inspect the existing router's candidate scoring and compare complete endpoint
|
||||
join costs, overlap penalties and available lane candidates using the same
|
||||
geometry. Preserve the full quality vector and endpoint/obstacle invariants;
|
||||
reject a change that merely converts overlap into uncounted contacts.
|
||||
|
||||
The [routing inspection](routing-experiment-plan.md) identified a concrete
|
||||
asymmetry: a locally selected route leg scores the source join but omits the
|
||||
target-stub join until after choosing that leg. Test that small correction first,
|
||||
at unchanged node positions, before the auditor's proposed alignment/spacing
|
||||
alternatives. This is a falsifiable source-based hypothesis, not a conclusion
|
||||
that it explains all shared routes. A separate committed candidate will preserve
|
||||
this first control; no overlap weight or node placement should change in that
|
||||
comparison.
|
||||
@@ -1,56 +0,0 @@
|
||||
# #32 first experiment — independent results audit
|
||||
|
||||
Source e7deea7039bcf31675cffbb462cfeb19d62a7e20, production baseline715036e132677dd2bc2fb37ed316d93dc9987aec. Command: `cargo run --locked --offline -p oa-core --example auto_layout_probe`; Windows x86_64-pc-windows-gnullvm/dev. Recorded exit0, failure=null, source inputs unchanged. Independently checked receipt-bound log hashes and equal before/after source inventories. No rerun, compiler, GUI or model helper executed.
|
||||
|
||||
**Conclusion: useful evidence for continued experimentation, not a production winner.** All nine finite fixtures completed; candidate conservation/repeat/roundtrip checks pass. Two shared-run regressions are real, while nested/reversed-container/long-label improvements are also supported. There are no predefined quality thresholds, so exit0 means the probe ran, not that layout quality passed.
|
||||
|
||||
## Verified results
|
||||
|
||||
Exactly one metadata row plus nine fixture rows. Every candidate is supported and valid as recorded, repeats exactly from identical input, equals its second-call result, and remains equal to its first result through calls2–10. Existing Layout likewise has passing repeat/history checks. Every before/after/reset/candidate canonical JSON and single-source roundtrip is recorded exact. Candidate semantic/node-identity/visibility/port-anchor/nongeometry booleans are true on all nine. I independently compared complete output Projects after restoring only diagrams, and checked target layout fields and route attachment fields against input: no difference. Most attachment lists are empty, so this is not broad attachment coverage. Other-view checks are vacuous for single-view fixtures.
|
||||
|
||||
The authored_detour_and_label candidate intentionally has authored_routes_exact=false because its three pins and label override are removed. That is the experimental reset policy, not an unexplained conservation failure. Reset-only already removes the detour; layered must earn its additional complexity elsewhere. Actual Undo/Redo was executed only for existing Command::Layout. Layered is a pure transformer with no command/UI integration or history evidence.
|
||||
|
||||
Values below compare **baseline Layout plus reset-only control → layered candidate**, not the original hand-placed input:
|
||||
|
||||
| Fixture | Shared length | Label/node pairs | Bends | Route length | Drawing area |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| flat_chain_4 | 0 → 0 | 0 → 0 | 0 → 0 | 720 → 246 | 213312 → 150744 |
|
||||
| chain_7_row_wrap | 0 → 0 | 0 → 0 | 8 → 0 | 2560 → 492 | 523584 → 271920 |
|
||||
| diamond | 36 → 82 | 0 → 0 | 8 → 4 | 2200 → 688 | 300576 → 260832 |
|
||||
| crossed_bipartite | 276 → 344 | 0 → 0 | 12 → 4 | 3284 → 688 | 300576 → 165360 |
|
||||
| nested_cross_boundary | 0 → 0 | 1 → 0 | 6 → 2 | 1076 → 364 | 284096 → 366528 |
|
||||
| authored_detour_and_label | 0 → 0 | 0 → 0 | 0 → 0 | 240 → 187 | 90816 → 83820 |
|
||||
| fixed_ports | 0 → 0 | 0 → 0 | 4 → 4 | 916 → 863 | 125976 → 116754 |
|
||||
| two_containers_reversed_children | 148 → 0 | 1 → 0 | 4 → 0 | 872 → 372 | 288512 → 272800 |
|
||||
| long_ordinary_labels | 36 → 36 | 2 → 0 | 4 → 4 | 1340 → 1436 | 200448 → 208800 |
|
||||
|
||||
All these control/candidate outputs report zero strict proper crossings, unrelated-node collisions, unrelated edge/node penetrations, label/label pairs, unrelated label/stroke pairs and routing warnings. Therefore this run provides **no discriminating evidence of proper-crossing reduction**. Zero warnings is not proof of unambiguous independent routes.
|
||||
|
||||
## Independently checked regressions and improvements
|
||||
|
||||
- **diamond:** shared length36→82. Control has two18-unit common terminal runs. Candidate retains those and adds one46-unit horizontal shared run for edge pair0009/000c. Nonterminal shared length0→46; this is not just a terminal-exemption artifact. Bends8→4 and length2200→688 improve, but they partially come with merged routing.
|
||||
- **crossed_bipartite:** shared276→344, nonterminal204→272. Control has four18-unit common-tip runs plus204 horizontal. Candidate has those same72 terminal units, two additional46 horizontal overlaps, and a180-unit vertical overlap between distinct edge pairs0009/000c. Independent axis-interval sums match the emitted values exactly. A more compact drawing and fewer turns do not erase this distinguishability regression.
|
||||
- **nested_cross_boundary:** reported label/node collision1→0 and route length1076→364; area284096→366528 increases about29%. The geometry PNG shows the parent widened to place its children horizontally. This is a real tradeoff, not universal improvement.
|
||||
- **two_containers_reversed_children:** shared148→0, label/node1→0, bends4→0, length872→372 and area288512→272800. The hand-authored alternate child order exists independently; the candidate's straight separated connections support a meaningful ordering improvement over reset-only.
|
||||
- **long_ordinary_labels:** label/node2→0, shared36 unchanged, but length1340→1436 and area200448→208800 grow. This is supported clearance-for-space tradeoff using approximate core rectangles, not native glyph evidence.
|
||||
|
||||
Viewed eight offline PNGs: baseline/candidate diamond, baseline/candidate crossed_bipartite, baseline/candidate nested_cross_boundary, candidate two_containers_reversed_children and candidate long_ordinary_labels. They corroborate route sharing and spacing changes. All37 image identities and their manifest binding to the original output log were checked. Images are schematic rectangles/polylines with fixed scale per fixture, not application renders or text measurements.
|
||||
|
||||
## Method limits versus actual failures
|
||||
|
||||
Proper-crossing definition excludes T contacts and bend intersections; shared length counts pairwise traversal multiplicity. I recomputed the two reported regressions independently from axis-aligned intervals, so those particular increases are actual geometry outcomes, not metric bugs. An unconditional dominance claim fails on those results, but the experiment has not predeclared a total preference ordering across compactness and sharing. Retain the whole metric vector.
|
||||
|
||||
Reset eligibility is post-baseline-Layout versus pre-layout in the candidate; equivalent for current unchanged-visibility fixtures only. No folded/filter/omitted-node/State/Allocation/undirected-cycle/dense scaling/UI admission/multi-file source lifecycle acceptance follows. The source work budget is partial and runtime complexity was not established here. No native GUI, universal label-ink correctness, candidate command Undo, or issue32 completion is claimed. Base715 lacks the separate36 measured State label follow-up.
|
||||
|
||||
## Next falsifiable experiment
|
||||
|
||||
Keep these nine frozen inputs and add one explicit orthogonal proper-crossing witness plus a non-incident overlap/touch counterexample to verify the evaluator distinguishes them. Then isolate diamond/bipartite routing interaction with a tiny predeclared set of node-placement alternatives (current top alignment, centered rank, deterministic stagger, conservative wider gap), keeping the router unchanged. Record actual routed metrics for every alternative; do not silently choose whichever plot looks best or optimize only bend count.
|
||||
|
||||
Hypothesis: a bounded alignment/spacing change can remove the candidate's excess nonterminal sharing without surrendering reversed-container and long-label gains. Falsifier: no alternative reaches control-or-better shared length while retaining no new obstacle/label collisions and predeclared reasonable area/length bounds. Use controls' reported geometry as a fixed comparator and preserve every alternative; if it fails, investigate router channel/parallel-edge handling rather than repeatedly widening all spacing or introducing a whole external engine. This next probe remains diagnostic; attachment/hidden-route sentinels and real command/history/native validation follow before production selection.
|
||||
|
||||
Evidence SHA256:
|
||||
- 01-probe/command/result.json: `119179803e8c26b960ca4280e0429bb537a6f9ff5666267e49bfab989f48dc73`
|
||||
- 01-probe/command/output.log: `21786ae5cb668f60e9252dc884668ee5e98289443d0b3723a9ea69bf21d47fd1`
|
||||
- 01-probe/command/inputs-before.json: `2e56c60055651f59c58228fb3cdfbd4bfc6dea10236f333ae1824af9a928ecfe`
|
||||
- 01-probe/command/inputs-after.json: `2e56c60055651f59c58228fb3cdfbd4bfc6dea10236f333ae1824af9a928ecfe`
|
||||
- geometry/manifest.json: `022fa0f307f9fb92fccbbc139637c62f40d1dcc3a4ea555a35c6b08d82bdb3b5`
|
||||
@@ -1,92 +0,0 @@
|
||||
# Target-join scoring experiment
|
||||
|
||||
**Keep experimental.** Accounting for the target turn during route-leg selection
|
||||
confirms the suspected source/target scoring asymmetry, but does not establish
|
||||
uniform readability improvement. The original bipartite scene becomes more
|
||||
overlapped. Do not integrate this branch as a completed Arrange fix.
|
||||
|
||||
Production routing/test source is `497c1ad8b39788148c5f03bc8542d6d8e28dbc01`.
|
||||
Successor `08656b18bf8934cf264469cf68fc6dea97c15cbb` only resolves three strict
|
||||
Clippy findings in the diagnostic observer. Probe runs at both commits emitted
|
||||
the same 371192 bytes, SHA256
|
||||
`f9ac7c1320b1cf026259bfcaf13a39e968ae317463712d8dc793375d9a4a541f`.
|
||||
The [independent source review](terminal-join-source-review.md) and
|
||||
[independent results audit](terminal-join-results-audit.md) retain the scoped
|
||||
claims and limitations. The latter predates the completed contact audit below.
|
||||
|
||||
The [evidence archive](run-j/terminal-join-08656b18.zip) contains original runs
|
||||
02–06, including the failed Clippy run, source inventories, full Projects and
|
||||
geometry, comparison JSON, independent contact audit/script, runners and 37
|
||||
offline geometry images. It is 3178939 bytes, SHA256
|
||||
`bd2146237353af05b8667dc7bd728007143a2f1b24ad9f8728fdbc50ffb3368e`.
|
||||
[Manifest](run-j/manifest.json) records every member; every archived byte was
|
||||
compared with its original. Paths in original receipts identify the builder's
|
||||
machine, not a dependency on a peer's filesystem.
|
||||
|
||||
## Checks and comparison
|
||||
|
||||
Windows Rust 1.98.1 gnullvm, locked/offline, one job, incremental disabled;
|
||||
Linux excluded. All receipts close with unchanged inputs and no runner failure.
|
||||
|
||||
```text
|
||||
cargo test --locked --offline -p oa-core --lib --test release_routing --test routing_geometry --test routing_labels --test state_diagrams --test group_movement
|
||||
cargo run --locked --offline -p oa-core --example auto_layout_probe
|
||||
cargo clippy --locked --offline -p oa-core --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
Tests: 185 passed, zero failed/ignored (132 library, 7 group movement, 13 release
|
||||
routing, 18 routing geometry, 2 routing labels, 13 State). Three new private tests
|
||||
exercise fast/grid terminal scoring in four orientations, absent continuation,
|
||||
straight/reversal and zero-length legs. Existing authored-pin/attachment/history
|
||||
checks pass. Initial Clippy rejected two manual-range expressions and one nested
|
||||
if in the observer; after that observer-only cleanup, strict core/all-target
|
||||
Clippy passes and the rerun output is byte-identical. No whole-workspace/native
|
||||
acceptance is implied.
|
||||
|
||||
Across all 37 before/baseline/reset/layered/manual-witness variants, complete
|
||||
Projects, projected nodes, projected ports and exact route endpoints are
|
||||
unchanged versus the first experiment. This isolates routing/label placement;
|
||||
it is stronger than merely reusing fixture input. All nine candidate invariants,
|
||||
round trips, deterministic repeats and calls 2–10 remain exact. The candidate
|
||||
still has no integrated command Undo/Redo evidence.
|
||||
|
||||
- Layered diamond: shared length 82→36, nonterminal 46→0; all other original
|
||||
metrics unchanged. Remaining 36 units are common terminal stubs.
|
||||
- Layered bipartite: shared length 344→187, nonterminal 272→115; routed drawing
|
||||
area 165360→175960. Labels, bends, length and other recorded metrics unchanged.
|
||||
Seven other layered metric vectors are unchanged.
|
||||
- **Original bipartite regression:** sharing 730→872, nonterminal 658→800;
|
||||
bends 5→4 and length 2076→2064. The scoring change can favor fewer bends over
|
||||
distinguishable routes. This is not a monotonic quality improvement.
|
||||
- Original diamond sharing 136→36; original two-container sharing 200→54;
|
||||
existing Layout/reset-only two-container sharing 148→18. The independent
|
||||
audit lists the complete changed vector; 30 of 37 variants are unchanged.
|
||||
|
||||
The separate Python contact audit uses orthogonal interval reasoning to
|
||||
reconstruct BOTH old and new contacts. Primitive witnesses pass; all 37 new
|
||||
contact/proper counts match Rust, and all 37 old proper counts match v1. Layered
|
||||
diamond contacts decrease 3→2. Layered bipartite contacts increase 8→9: eight bend
|
||||
contacts become six bend plus three T contacts. Proper crossings remain zero.
|
||||
Thus decreased coincident length partly trades into branch contacts. Details,
|
||||
edge IDs, coordinates and definitions are in the archive's
|
||||
`support/auto-layout-contact-audit-result-20260925.json`; script is adjacent.
|
||||
There is no inferred aggregate quality pass.
|
||||
|
||||
The parent viewed diamond/bipartite offline images: diamond branches separate,
|
||||
but bipartite horizontal tails remain visually joined despite separate vertical
|
||||
channels. These are schematics of core bounds, not native application or glyph
|
||||
evidence. The [residual diagnosis](residual-routing.md) confirms four genuine
|
||||
shared semantic ports; distinct-port placement is not the defect. Of 187 shared
|
||||
units, 72 are bounded common-port stubs and 115 are avoidable interior overlap.
|
||||
|
||||
## Next discriminating comparison
|
||||
|
||||
Retain this J result as a frozen control. Test one predeclared overlap coefficient
|
||||
3.0 instead of 0.12, with the existing per-segment cap 80, crossing cost 24,
|
||||
node/port geometry and edge ordering unchanged. This P variant is an isolated
|
||||
experiment, not a proposed default or a release-ready source change. Preserve
|
||||
the original bipartite regression as a sentinel and the entire quality vector.
|
||||
If stronger penalties cannot choose useful existing alternatives, inspect
|
||||
bounded parallel lane candidates separately; do not silently combine scoring,
|
||||
spacing, caps and ordering changes. A candidate that shifts overlap into hard-to-
|
||||
follow contacts, label collisions or large detours has not solved the user task.
|
||||
@@ -1,53 +0,0 @@
|
||||
# #32 next routing experiment: distinguish shared terminals from ambiguous trunks
|
||||
|
||||
Local source/receipt diagnosis only; no builds, formatters, GUI or product edits. Input: completed `e7deea70` probe `.local/auto-layout-checks-20260925/01-probe/command/output.log`; implementation read from `.local/auto-layout-20260925/crates/oa-core/src/routing.rs`. The probe uses **core estimated label bounds**, not renderer glyph measurement. No new candidate module is justified until the required private routing hook is agreed.
|
||||
|
||||
## Exact observed geometry
|
||||
|
||||
The layered diamond has 82 units of pairwise shared length: 36 classified as common terminal stubs, 46 nonterminal. Edges ending `009` and `00c` share the physical output `(264,106)`, its 18-unit stub to `(282,106)`, then an avoidable extra horizontal run to `(328,106)`. Edge `00c` then turns down to `(328,286)` and enters `(346,286)`. A candidate branching immediately at `(282,106)`, going to `(282,286)`, then right to the target would separate that extra 46-unit trunk while preserving the port and stub. This is a geometric proposal, not a verified collision-free final result; the same obstacle and label checks must evaluate it.
|
||||
|
||||
The layered crossed-bipartite case has 344 shared units: 72 common-terminal, 272 nonterminal. Its opposite diagonal routes both use `x=328, y=106..286`, an exact 180-unit coincident vertical run. Top and bottom horizontal portions add 92 nonterminal shared units. The source stubs are at `x=282`; target stubs at `x=328`. Proper crossings are reported as zero, but these coincident routes do not provide unambiguous independent traces. Do not treat the zero as a solved crossing problem.
|
||||
|
||||
## Source evidence: a local scoring asymmetry precedes penalty tuning
|
||||
|
||||
`crossing_cost` charges `min(shared_length * 0.12, 80)` per previously occupied segment, multiplied by segment count. A proper interior crossing costs24. `BEND_COST` is24. `RoutingSpace::new` uses obstacle boundary coordinates; fast-path candidates add the start/end midpoint, and grid coordinates add start/end. Neither systematically adds lanes offset from previously occupied trunks.
|
||||
|
||||
More importantly, `fast_path(start, end, space, previous, incoming)` includes the turn from the preceding source-side segment, but has no outgoing direction parameter. `route_edge` chooses this local path and only later appends the final target attachment point `b.point`; complete `path_cost` is then compared across endpoint choices, not across the discarded alternative paths for that same endpoint pair. With fixed semantic ports there may be no alternate endpoint choice to recover the discarded route. `grid_path` is only a fallback when `fast_path` returns None, not a better-cost search after a valid fast path.
|
||||
|
||||
For the diamond, horizontal-then-vertical has one internal bend and no incoming bend; its 46-unit shared portion costs5.52. Vertical-then-horizontal has one internal bend plus an incoming bend, costing24 extra locally. But appending the target segment adds the missing final bend to horizontal-then-vertical. Complete paths have equal bend counts; the local comparison prefers the shared route because it omitted one terminal join. The 180-unit bipartite overlap costs21.6, also below one turn. This explains a plausible bias, not proof that correcting it resolves every route.
|
||||
|
||||
## Minimal discriminating comparison (proposal to parent)
|
||||
|
||||
Freeze layered node positions, labels' text, attachments, port identities/anchors, edge order and work budgets. Compare a small fixed sequence, not an open parameter search:
|
||||
|
||||
1. **J: symmetric terminal-join cost.** Supply an optional outgoing point/direction to fast-path scoring. For the final segment to `b.stub`, use `b.point`; include `turn_cost(penultimate, end, outgoing)` both in actual candidate score and admissible pruning estimates where applicable. Intermediate authored pins have no predetermined outgoing direction; do not invent one. If grid routing supports this experiment too, target state selection must account for the terminal turn before returning the best result. Preserve baseline behavior for absent outgoing context. This isolates the suspected bias without stronger penalties or enlarged diagrams.
|
||||
2. **JP: J plus stronger shared-run penalty.** One declared test coefficient, e.g.1.0 per unit with the existing80 cap, compared with0.12. Keep the proper crossing cost24 unchanged so the comparison exposes whether penalizing overlap merely substitutes crossings. Do not choose this variant solely by shared length. Preserve all hard endpoint constraints.
|
||||
3. **JPL: JP plus bounded lane candidates.** For occupied horizontal/vertical trunks, offer parallel offsets of `CLEARANCE` on either side where applicable; validate obstacle clearance and fixed terminal stubs. Use a strict deterministic global cap on added coordinates/candidates and reuse existing work exhaustion handling. This tests whether absent geometric alternatives, rather than insufficient penalty, is the blocker. No persisted waypoint authoring and no unconstrained port movement.
|
||||
4. **Spacing control.** Only if J/JP fail, compare the unchanged router with one fixed additional inter-layer gap (e.g.24 world units). This is a control, not the assumed remedy: larger gaps may lengthen shared trunks and enlarge diagrams while retaining identical topological ambiguity. Do not mix spacing and router changes in the first comparison.
|
||||
|
||||
The first J variant is the smallest informative next experiment. If it removes diamond's extra trunk while retaining invariants, stop and inspect results before implementing JP/JPL. A clean shared terminal fan-out need not eliminate its unavoidable initial18-unit stub. Conversely, sharing a node identity does not exempt arbitrary later overlap, and different physical ports on the same node must not be collapsed into one endpoint exemption.
|
||||
|
||||
## Required hook and ownership boundary
|
||||
|
||||
The candidate module can call public `route_diagram`, but cannot change private `crossing_cost`, `fast_path`, `grid_path` or candidate generation. Returning a changed Project with temporary authored waypoints would test a different policy and would violate the generated-elbows-remain-derived experiment. Copying the entire router into an example introduces unnecessary divergence.
|
||||
|
||||
Therefore propose a parent-owned isolated routing experiment hook, with no default production behavior change, or a separate disposable source variant controlled by the existing runner. Evaluator needs an optional routing provider returning `RoutedView` for a fixed Project, rather than only `candidate -> Project`. It must reuse that provider for every metric, capture, and repeat check and label the variant explicitly. Until parent approves that hook, no module/interface changes were made. The current original/reset/layered comparisons remain reproducible.
|
||||
|
||||
## Falsifiers and risks
|
||||
|
||||
Evaluate both raw and terminal-excluded shared length, proper crossings, T/bend contacts, route lengths/turns, unrelated-node/header penetration, label collisions, warnings and runtime/work counts. A positive overlap moved onto a bend is not a successful reduction in intersections. The crossed K2,2 fixture with fixed port ordering may require a genuine visible crossing or an outer detour; do not demand zero overlap AND zero crossings without a fixed-attachment feasible witness. Separate forced terminal contacts from independently traceable route interiors.
|
||||
|
||||
Keep diamond, crossed-bipartite, chain and long-label fixtures; add a fixed shared-output fan-out, two distinct ports on one node, opposite-direction connections, and a narrow obstacle corridor. Verify exact endpoint coordinates and metadata; preserve ordinary authored pins and preview/settled agreement if the routing change advances beyond this isolated experiment. Stable-ID order can privilege earlier routes, so rerun the same graph under a fixed edge-ID permutation and disclose sensitivity instead of relabeling edges to improve the score.
|
||||
|
||||
Labels are placed after paths settle; lane changes can worsen label collisions even when node positions stay fixed. Parent's eventual GUI inspection should trace each diagonal from its source label/port through the corridor to its intended target at normal zoom. No geometry metric alone establishes that user outcome. This note selects a falsifiable experiment, not an accepted product fix.
|
||||
|
||||
## Exact J patch plan, held for parent's separate candidate pin
|
||||
|
||||
No edits to `routing.rs` are authorized yet. After parent publishes the first experiment evidence, the narrowly proposed change is:
|
||||
|
||||
- Add `outgoing: Option<Position>` beside `incoming` in private `fast_path` and `grid_path` only. At `route_edge`'s loop over authored pins followed by `b.stub`, pass `Some(b.point)` only when `index == pins.len()`; pass `None` for every intermediate waypoint. The preceding settled path still supplies `incoming` exactly as now. No public API or metadata change.
|
||||
- In `fast_path`, extend its shared `join(path)` closure with the terminal turn from `path[path.len()-2]` through `end` to `outgoing`, when two path points exist. Both `estimate(path)` and actual scoring already call `join`; change both consistently through that closure. Keep the two-bend lower-bound early exit: Manhattan distance plus two bend costs remains a lower bound for two-bend alternatives because terminal penalties are nonnegative. Keep final whole-path `path_cost` unchanged; local scoring does not persist a cost, so it does not double-charge the returned path.
|
||||
- In `grid_path`, add the terminal turn **during relaxation when `next == last`**, using `turn_cost(a, b, outgoing)`. Include it in `costs[state]` and the queue priority before the target state can be popped. Do not merely add a penalty after returning the first reached target: that would fail to compare alternative arrival directions. The existing Manhattan heuristic remains admissible because the new terminal penalty is nonnegative. The initial incoming-turn logic remains unchanged.
|
||||
- `start == end` has no locally selectable segment. Keep the degenerate result, and rely on whole-path scoring for the junction between preceding/following points. Do not fabricate a direction from an absent penultimate point. Waypoint positions/order remain exact; this patch does not claim globally optimal look-ahead across several hard-pin legs.
|
||||
|
||||
Focused tests before comparing full fixtures: (1) fast-path diamond geometry with one occupied horizontal trunk, fixed start/target stubs, asserting the equal-complete-bend alternative branches earlier; (2) directly exercise grid routing with two clear arrivals where adding the target join changes the preferred total, compare complete cost to an enumerated feasible alternative; (3) mirrored/rotated terminal geometry, including straight continuation and reversal costs; (4) outgoing=None preserves established intermediate-waypoint behavior; (5) full route with one/multiple authored pins retains exact pins and terminal attachments, plus start=end leg; (6) preview versus settled geometry remains consistent under the same baseline/budget. Use private unit tests for fast/grid so a passing public route cannot accidentally avoid the grid test. Then rerun the unchanged probe corpus and report the complete quality vector, especially any newly visible crossings. No expected overall improvement is asserted before execution.
|
||||
@@ -1 +0,0 @@
|
||||
* -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
@@ -1,217 +0,0 @@
|
||||
{
|
||||
"source_commit": "e7deea7039bcf31675cffbb462cfeb19d62a7e20",
|
||||
"baseline_commit": "715036e132677dd2bc2fb37ed316d93dc9987aec",
|
||||
"scope": "Nine small SA FunctionalDataflow diagnostic fixtures; Windows core geometry, not native UI or production acceptance.",
|
||||
"binary_after_completed_run": {
|
||||
"path": "target/debug/examples/auto_layout_probe.exe",
|
||||
"bytes": 24039424,
|
||||
"sha256": "a95736170f58d25c60a16f2683b961aad8b31f97e4357c3e0d2f7e81d21176bd"
|
||||
},
|
||||
"members": {
|
||||
"run/command/changes.patch": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"run/command/context.json": {
|
||||
"bytes": 3488,
|
||||
"sha256": "4dd4e602c2b33fd229a7241e1a4d6a2de399df197b5613af4b3126bfd357c476"
|
||||
},
|
||||
"run/command/inputs-after.json": {
|
||||
"bytes": 1722092,
|
||||
"sha256": "2e56c60055651f59c58228fb3cdfbd4bfc6dea10236f333ae1824af9a928ecfe"
|
||||
},
|
||||
"run/command/inputs-before.json": {
|
||||
"bytes": 1722092,
|
||||
"sha256": "2e56c60055651f59c58228fb3cdfbd4bfc6dea10236f333ae1824af9a928ecfe"
|
||||
},
|
||||
"run/command/output.log": {
|
||||
"bytes": 331975,
|
||||
"sha256": "21786ae5cb668f60e9252dc884668ee5e98289443d0b3723a9ea69bf21d47fd1"
|
||||
},
|
||||
"run/command/result.json": {
|
||||
"bytes": 4124,
|
||||
"sha256": "119179803e8c26b960ca4280e0429bb537a6f9ff5666267e49bfab989f48dc73"
|
||||
},
|
||||
"run/command/stderr.log": {
|
||||
"bytes": 279,
|
||||
"sha256": "492e4346b5daf9d9eed2aa96c89c5104b9d8d7f830074b1346c9f5aaf053d662"
|
||||
},
|
||||
"run/environment.json": {
|
||||
"bytes": 9463,
|
||||
"sha256": "14e53dd6359601bdd9be3ad24c527fca96310d9eb4ffa91965f71841f8df2f82"
|
||||
},
|
||||
"run/orchestration.log": {
|
||||
"bytes": 165,
|
||||
"sha256": "e4bf56f792c8e8fc9288fc08ef5f0159941b8026e435a427722b59ccc4e34831"
|
||||
},
|
||||
"geometry/authored_detour_and_label-after.png": {
|
||||
"bytes": 12492,
|
||||
"sha256": "44e30b94ef11b176155ab3df90fe5b52788c07442a42a355d3458fa904c147bd"
|
||||
},
|
||||
"geometry/authored_detour_and_label-before.png": {
|
||||
"bytes": 12468,
|
||||
"sha256": "097588fae4fd6253c25ab4b60be1ad685ead3a3012b206f382cec0be1341e700"
|
||||
},
|
||||
"geometry/authored_detour_and_label-experimental_candidate-metrics.png": {
|
||||
"bytes": 13162,
|
||||
"sha256": "82a07de168cda7dacdad2bd532dae408172466b2776acf8cfaabef6711c0539b"
|
||||
},
|
||||
"geometry/authored_detour_and_label-reset_only_control-metrics.png": {
|
||||
"bytes": 12777,
|
||||
"sha256": "e676edc49ec6ebb2fe29e976c36cba589f76330e7f9eb27b8835415a2ed5d225"
|
||||
},
|
||||
"geometry/chain_7_row_wrap-after.png": {
|
||||
"bytes": 14271,
|
||||
"sha256": "a020c1535e3d6e48cb5e6d7ae8403a256767c68ce26876fd773f3b626c61e365"
|
||||
},
|
||||
"geometry/chain_7_row_wrap-before.png": {
|
||||
"bytes": 13771,
|
||||
"sha256": "0b282cbd29eb3e29fa7d3bb9c3c9746caa14c77372b3191d78fa1a60743d0b11"
|
||||
},
|
||||
"geometry/chain_7_row_wrap-experimental_candidate-metrics.png": {
|
||||
"bytes": 14396,
|
||||
"sha256": "3ca042155d918083c5f4135ae059205b29b8ee5944819a6040a22eeb572f5f04"
|
||||
},
|
||||
"geometry/chain_7_row_wrap-reset_only_control-metrics.png": {
|
||||
"bytes": 14549,
|
||||
"sha256": "8828f289159deb0c2bedd130495197639775cd8edeb561f0de5501e2980d1e1d"
|
||||
},
|
||||
"geometry/crossed_bipartite-after.png": {
|
||||
"bytes": 13144,
|
||||
"sha256": "54a1b5e983bc9b6f619e67c13173d0ff5000e30f080aaf6c0a9064f59036f4b7"
|
||||
},
|
||||
"geometry/crossed_bipartite-before.png": {
|
||||
"bytes": 13413,
|
||||
"sha256": "8622726dfdfc01e93fcd2d9d22c6d082e5a169b9dcd59f788e876954431214fd"
|
||||
},
|
||||
"geometry/crossed_bipartite-experimental_candidate-metrics.png": {
|
||||
"bytes": 13848,
|
||||
"sha256": "de238d7e904a050b93cc63926f9b4b4723d4b98e6741971fc98f81d771a8a52e"
|
||||
},
|
||||
"geometry/crossed_bipartite-reset_only_control-metrics.png": {
|
||||
"bytes": 13349,
|
||||
"sha256": "0b2a8c49368258b23c15a8e06bf9b881f0e53fed8b5fd7d6f216a09837634abc"
|
||||
},
|
||||
"geometry/diamond-after.png": {
|
||||
"bytes": 12556,
|
||||
"sha256": "6afcff1e5c10758e5eab3b803ec858d0af3da953be4af93df75257544a7252b2"
|
||||
},
|
||||
"geometry/diamond-before.png": {
|
||||
"bytes": 13255,
|
||||
"sha256": "30d514155ec0e88a68e62089cd50b7747924a81095c957aa67b1bf5a70ac18f8"
|
||||
},
|
||||
"geometry/diamond-experimental_candidate-metrics.png": {
|
||||
"bytes": 13581,
|
||||
"sha256": "14dc1304822e74f8c7e0146a02af69ea39dc90d0d655a75b97383644c04b8900"
|
||||
},
|
||||
"geometry/diamond-reset_only_control-metrics.png": {
|
||||
"bytes": 12982,
|
||||
"sha256": "48414d7a859c774f2cef51d0fc1512b1c9fe1540c540f96fac7f9531583d8731"
|
||||
},
|
||||
"geometry/fixed_ports-after.png": {
|
||||
"bytes": 11889,
|
||||
"sha256": "574cb0249c3bcc3140b9d61906332295a8462e32fbcd6b9526d8b05e202e38a0"
|
||||
},
|
||||
"geometry/fixed_ports-before.png": {
|
||||
"bytes": 12013,
|
||||
"sha256": "d07ef2105aa63f3aaacb19e9a7ca4960239b01f05f734baa6270657a465b4ab0"
|
||||
},
|
||||
"geometry/fixed_ports-experimental_candidate-metrics.png": {
|
||||
"bytes": 12394,
|
||||
"sha256": "d7fdcd7d4a1a014dd34243400dcd74a7596388fc5747853e5a6629535ca3083e"
|
||||
},
|
||||
"geometry/fixed_ports-reset_only_control-metrics.png": {
|
||||
"bytes": 12231,
|
||||
"sha256": "237e181db92baf49c24554ec4325137b014413603b62cb08dee63e6e68fa3e8d"
|
||||
},
|
||||
"geometry/flat_chain_4-after.png": {
|
||||
"bytes": 12582,
|
||||
"sha256": "7326cd482a61bea4cb787770449253a55f41e102794c8fc6a8500d9244c20cc7"
|
||||
},
|
||||
"geometry/flat_chain_4-before.png": {
|
||||
"bytes": 12723,
|
||||
"sha256": "b086e2b23d4b68cb43366725a26fbb39fdc2e87d18cd7109c9b3ec950ab665ba"
|
||||
},
|
||||
"geometry/flat_chain_4-experimental_candidate-metrics.png": {
|
||||
"bytes": 13405,
|
||||
"sha256": "3bb352dd5066a3b2cc5bb298c9f47a02416186421df4841b96c944e523d11104"
|
||||
},
|
||||
"geometry/flat_chain_4-reset_only_control-metrics.png": {
|
||||
"bytes": 12918,
|
||||
"sha256": "eba3eede6771a1ebfc7b773b48b599f5ca49613ad7444ab26acfea7d20dde515"
|
||||
},
|
||||
"geometry/long_ordinary_labels-after.png": {
|
||||
"bytes": 12643,
|
||||
"sha256": "f4f0c360e9ce44d631dd1d9f7c202954be943fdbede6288edbaddf9b890fcaec"
|
||||
},
|
||||
"geometry/long_ordinary_labels-before.png": {
|
||||
"bytes": 12661,
|
||||
"sha256": "3c17c1195337cda7cd4141a15428b66cac1475421600b6ecffc3db57dbfd9f94"
|
||||
},
|
||||
"geometry/long_ordinary_labels-experimental_candidate-metrics.png": {
|
||||
"bytes": 13235,
|
||||
"sha256": "d2931b576901fec68b23afd1a54d4c301eda4a75ba30184344749e0add277bc0"
|
||||
},
|
||||
"geometry/long_ordinary_labels-reset_only_control-metrics.png": {
|
||||
"bytes": 12969,
|
||||
"sha256": "68aacc7196a4995ff8a884a15ec91a31bd386bdd2d232fd2c27696a7f83cf791"
|
||||
},
|
||||
"geometry/manifest.json": {
|
||||
"bytes": 8099,
|
||||
"sha256": "022fa0f307f9fb92fccbbc139637c62f40d1dcc3a4ea555a35c6b08d82bdb3b5"
|
||||
},
|
||||
"geometry/nested_cross_boundary-after.png": {
|
||||
"bytes": 13344,
|
||||
"sha256": "51db6785ab89f5d92c61288604ae3c76aee467e291738b54466db5704c0eec8b"
|
||||
},
|
||||
"geometry/nested_cross_boundary-before.png": {
|
||||
"bytes": 13547,
|
||||
"sha256": "3b3963f5ef5a42106a006f1825ae5d85b722aa3bf163452ba986ef3369b57cc2"
|
||||
},
|
||||
"geometry/nested_cross_boundary-experimental_candidate-metrics.png": {
|
||||
"bytes": 13535,
|
||||
"sha256": "0ba365785753e748dc7c86c3f1eb5767f9ccb87c0ae0e18085e6a9d32b79f60d"
|
||||
},
|
||||
"geometry/nested_cross_boundary-reset_only_control-metrics.png": {
|
||||
"bytes": 13549,
|
||||
"sha256": "fbe21b57616459ecdbbd083dccd73bd120f5b7ad59a8ad80ea38cbbf9c3f49c1"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-after.png": {
|
||||
"bytes": 13950,
|
||||
"sha256": "1536fa9da0fa89a64b12e7faf6d0dab1c3fa0177517e39fd1adaeaee3f2214fe"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-before.png": {
|
||||
"bytes": 14076,
|
||||
"sha256": "2f3a2d55d30ff206d9daf9b8f84ba0e960332a02275289c66ab2e104da2e890e"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-experimental_candidate-metrics.png": {
|
||||
"bytes": 14500,
|
||||
"sha256": "8a0744cf04d305a0431468395b3676604e03991e577f9f9d2f45e8497b1ef9ca"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-manual_planar_order_witness-metrics.png": {
|
||||
"bytes": 14531,
|
||||
"sha256": "60623d358d9cb930c2c2c4995c9286c653e597c411b8879b77ac099fe9b0406b"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-reset_only_control-metrics.png": {
|
||||
"bytes": 14126,
|
||||
"sha256": "2483bf404953ce9b3da38f2adca10c4e02e34638e2999acae7beecb5fe936a83"
|
||||
},
|
||||
"tooling/run-auto-layout-check-20260925.py": {
|
||||
"bytes": 2771,
|
||||
"sha256": "d652c4df47f13aefc723bc54362d7b61308b8983c8d39ae7721c012519358ea2"
|
||||
},
|
||||
"tooling/run.py": {
|
||||
"bytes": 13814,
|
||||
"sha256": "698ed7182726c77fa4a1c85fc6dd08187ed20689941f57e749e3f0bc845f3252"
|
||||
},
|
||||
"tooling/auto-layout-format-precommit-20260925.json": {
|
||||
"bytes": 364,
|
||||
"sha256": "29c93825b415e04204d3fe23a85eef0798560bbb5a1a169689326602f65a9f35"
|
||||
}
|
||||
},
|
||||
"archive": {
|
||||
"name": "probe-e7deea70.zip",
|
||||
"bytes": 900995,
|
||||
"sha256": "c52f82d4782cd6a7f8cbe67498c8b2371e99204a65976aa8bcbbd0a86f835069"
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
* -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
@@ -1,398 +0,0 @@
|
||||
{
|
||||
"scope": "Target-join candidate experiment, not production or native acceptance",
|
||||
"runs": [
|
||||
{
|
||||
"run": "02-terminal-join-tests",
|
||||
"source_commit": "497c1ad8b39788148c5f03bc8542d6d8e28dbc01",
|
||||
"exit_code": 0
|
||||
},
|
||||
{
|
||||
"run": "03-terminal-join-probe",
|
||||
"source_commit": "497c1ad8b39788148c5f03bc8542d6d8e28dbc01",
|
||||
"exit_code": 0
|
||||
},
|
||||
{
|
||||
"run": "04-terminal-join-clippy",
|
||||
"source_commit": "497c1ad8b39788148c5f03bc8542d6d8e28dbc01",
|
||||
"exit_code": 101
|
||||
},
|
||||
{
|
||||
"run": "05-terminal-join-clippy",
|
||||
"source_commit": "08656b18bf8934cf264469cf68fc6dea97c15cbb",
|
||||
"exit_code": 0
|
||||
},
|
||||
{
|
||||
"run": "06-terminal-join-probe",
|
||||
"source_commit": "08656b18bf8934cf264469cf68fc6dea97c15cbb",
|
||||
"exit_code": 0
|
||||
}
|
||||
],
|
||||
"observer_lint_cleanup_output_byte_identical": true,
|
||||
"post_run_probe_binary": {
|
||||
"bytes": 24156160,
|
||||
"sha256": "3541618adc25d12ca136941e973f506cc11bc556840193023d4295c27be758a5"
|
||||
},
|
||||
"members": {
|
||||
"02-terminal-join-tests/command/changes.patch": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"02-terminal-join-tests/command/context.json": {
|
||||
"bytes": 3658,
|
||||
"sha256": "6d8d90920415df092df40afb4031fdbd12d2bc87a0daebea692b9f9288804783"
|
||||
},
|
||||
"02-terminal-join-tests/command/inputs-after.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "bca5713f2201da1c52334e2bd941f76ea59efda5a3036bbb9b9ddd6ec5667606"
|
||||
},
|
||||
"02-terminal-join-tests/command/inputs-before.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "bca5713f2201da1c52334e2bd941f76ea59efda5a3036bbb9b9ddd6ec5667606"
|
||||
},
|
||||
"02-terminal-join-tests/command/output.log": {
|
||||
"bytes": 18709,
|
||||
"sha256": "f024aaba0fcb680d02d49b5afde398453cf5183a08b561ff96e4c9a42cf70a17"
|
||||
},
|
||||
"02-terminal-join-tests/command/result.json": {
|
||||
"bytes": 4321,
|
||||
"sha256": "4b56d6ac30cc87ba7c7757906a3d0258e056178528b56c77312bd9675ce579f3"
|
||||
},
|
||||
"02-terminal-join-tests/command/stderr.log": {
|
||||
"bytes": 990,
|
||||
"sha256": "73f8f63d3339affe4bf6b47408a2ed020aca62606dc14faa03be3f094550eccf"
|
||||
},
|
||||
"02-terminal-join-tests/environment.json": {
|
||||
"bytes": 9463,
|
||||
"sha256": "14e53dd6359601bdd9be3ad24c527fca96310d9eb4ffa91965f71841f8df2f82"
|
||||
},
|
||||
"02-terminal-join-tests/orchestration.log": {
|
||||
"bytes": 179,
|
||||
"sha256": "3c673d3e29089ce517cd6026946443f9ae8fcba2fccb384835fffe2e8e19b58d"
|
||||
},
|
||||
"03-terminal-join-probe/command/changes.patch": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"03-terminal-join-probe/command/context.json": {
|
||||
"bytes": 3502,
|
||||
"sha256": "380e3e8fae97e1ee75417c1efc3ee64bdca1958aa61caa1766fba86a6c7e52d3"
|
||||
},
|
||||
"03-terminal-join-probe/command/inputs-after.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "bca5713f2201da1c52334e2bd941f76ea59efda5a3036bbb9b9ddd6ec5667606"
|
||||
},
|
||||
"03-terminal-join-probe/command/inputs-before.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "bca5713f2201da1c52334e2bd941f76ea59efda5a3036bbb9b9ddd6ec5667606"
|
||||
},
|
||||
"03-terminal-join-probe/command/output.log": {
|
||||
"bytes": 371192,
|
||||
"sha256": "f9ac7c1320b1cf026259bfcaf13a39e968ae317463712d8dc793375d9a4a541f"
|
||||
},
|
||||
"03-terminal-join-probe/command/result.json": {
|
||||
"bytes": 4166,
|
||||
"sha256": "57a1f866b2cb67880decd26becf3ac2e7715e38bbd26d3089c47dd62294afa4f"
|
||||
},
|
||||
"03-terminal-join-probe/command/stderr.log": {
|
||||
"bytes": 278,
|
||||
"sha256": "686a3d705aa21b73652e7589b1fa44094bbbc05f3118b2886161e0f815c8a334"
|
||||
},
|
||||
"03-terminal-join-probe/environment.json": {
|
||||
"bytes": 9463,
|
||||
"sha256": "14e53dd6359601bdd9be3ad24c527fca96310d9eb4ffa91965f71841f8df2f82"
|
||||
},
|
||||
"03-terminal-join-probe/orchestration.log": {
|
||||
"bytes": 179,
|
||||
"sha256": "bf0516c97a7f3003b29a549f463458f3dc5516b1f41af024e9cf7f6062b9280b"
|
||||
},
|
||||
"04-terminal-join-clippy/command/changes.patch": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"04-terminal-join-clippy/command/context.json": {
|
||||
"bytes": 3521,
|
||||
"sha256": "9f20479acb398ab0dc3c35708ddaef39e4457fcc22f910e13342163718d61fc0"
|
||||
},
|
||||
"04-terminal-join-clippy/command/inputs-after.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "bca5713f2201da1c52334e2bd941f76ea59efda5a3036bbb9b9ddd6ec5667606"
|
||||
},
|
||||
"04-terminal-join-clippy/command/inputs-before.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "bca5713f2201da1c52334e2bd941f76ea59efda5a3036bbb9b9ddd6ec5667606"
|
||||
},
|
||||
"04-terminal-join-clippy/command/output.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"04-terminal-join-clippy/command/result.json": {
|
||||
"bytes": 4185,
|
||||
"sha256": "fe9e21a9e203fa0ecfeed957041faa22180287a2ecd158f8a7e77b881ccffc1b"
|
||||
},
|
||||
"04-terminal-join-clippy/command/stderr.log": {
|
||||
"bytes": 2728,
|
||||
"sha256": "e375843af8e5bb8e921e78b7e7fca844787d6785710506f5e2c5c8b6788beb59"
|
||||
},
|
||||
"04-terminal-join-clippy/environment.json": {
|
||||
"bytes": 9463,
|
||||
"sha256": "14e53dd6359601bdd9be3ad24c527fca96310d9eb4ffa91965f71841f8df2f82"
|
||||
},
|
||||
"04-terminal-join-clippy/orchestration.log": {
|
||||
"bytes": 182,
|
||||
"sha256": "949915299dd53b9c59b5116da6f163973cc339d5ae3eb3b1a73151065f68bd6e"
|
||||
},
|
||||
"05-terminal-join-clippy/command/changes.patch": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"05-terminal-join-clippy/command/context.json": {
|
||||
"bytes": 3521,
|
||||
"sha256": "15bd08c0a37dc3e01a0d046b6cd1341aa11bfc858b8da92ad20512e8401e8815"
|
||||
},
|
||||
"05-terminal-join-clippy/command/inputs-after.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "721dfe221e70bc2fee3ffd53061d6ad154c01e843c0c8e325d5c70c756aa809c"
|
||||
},
|
||||
"05-terminal-join-clippy/command/inputs-before.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "721dfe221e70bc2fee3ffd53061d6ad154c01e843c0c8e325d5c70c756aa809c"
|
||||
},
|
||||
"05-terminal-join-clippy/command/output.log": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"05-terminal-join-clippy/command/result.json": {
|
||||
"bytes": 4182,
|
||||
"sha256": "6970eff6a4d9741e27acdb2ef2bfceefcd122c1590741edeb7a112f4f5c357d4"
|
||||
},
|
||||
"05-terminal-join-clippy/command/stderr.log": {
|
||||
"bytes": 177,
|
||||
"sha256": "5be2ed4d5b119d9b42e2b1e874e3c9e2d6b27cc0610c7d2300760f51a3dccdbe"
|
||||
},
|
||||
"05-terminal-join-clippy/environment.json": {
|
||||
"bytes": 9463,
|
||||
"sha256": "14e53dd6359601bdd9be3ad24c527fca96310d9eb4ffa91965f71841f8df2f82"
|
||||
},
|
||||
"05-terminal-join-clippy/orchestration.log": {
|
||||
"bytes": 180,
|
||||
"sha256": "dc27f63c6f1e1ea3c1e6ff6dac85cf49d5927f6784ee768c3c12b1e8ec62848f"
|
||||
},
|
||||
"06-terminal-join-probe/command/changes.patch": {
|
||||
"bytes": 0,
|
||||
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
},
|
||||
"06-terminal-join-probe/command/context.json": {
|
||||
"bytes": 3502,
|
||||
"sha256": "5500c31ead4f64132a628646a9863f587e59ef8281a74a70fcff9a038c283200"
|
||||
},
|
||||
"06-terminal-join-probe/command/inputs-after.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "721dfe221e70bc2fee3ffd53061d6ad154c01e843c0c8e325d5c70c756aa809c"
|
||||
},
|
||||
"06-terminal-join-probe/command/inputs-before.json": {
|
||||
"bytes": 1725008,
|
||||
"sha256": "721dfe221e70bc2fee3ffd53061d6ad154c01e843c0c8e325d5c70c756aa809c"
|
||||
},
|
||||
"06-terminal-join-probe/command/output.log": {
|
||||
"bytes": 371192,
|
||||
"sha256": "f9ac7c1320b1cf026259bfcaf13a39e968ae317463712d8dc793375d9a4a541f"
|
||||
},
|
||||
"06-terminal-join-probe/command/result.json": {
|
||||
"bytes": 4166,
|
||||
"sha256": "bd3b20390a03ed7c637586119e12e0e55949303f9b17f6e018f231b1a9095833"
|
||||
},
|
||||
"06-terminal-join-probe/command/stderr.log": {
|
||||
"bytes": 278,
|
||||
"sha256": "8a49d76b8cf56f31f6877cb6baf9d9f82d5abfbc1bfcc2ae73b8d3ff209c482c"
|
||||
},
|
||||
"06-terminal-join-probe/environment.json": {
|
||||
"bytes": 9463,
|
||||
"sha256": "14e53dd6359601bdd9be3ad24c527fca96310d9eb4ffa91965f71841f8df2f82"
|
||||
},
|
||||
"06-terminal-join-probe/orchestration.log": {
|
||||
"bytes": 179,
|
||||
"sha256": "e85db49cd4d73dbff0b45f92cfb96016f743db3e64878ceeee078515e7fe324d"
|
||||
},
|
||||
"geometry/authored_detour_and_label-after.png": {
|
||||
"bytes": 12492,
|
||||
"sha256": "44e30b94ef11b176155ab3df90fe5b52788c07442a42a355d3458fa904c147bd"
|
||||
},
|
||||
"geometry/authored_detour_and_label-before.png": {
|
||||
"bytes": 12468,
|
||||
"sha256": "097588fae4fd6253c25ab4b60be1ad685ead3a3012b206f382cec0be1341e700"
|
||||
},
|
||||
"geometry/authored_detour_and_label-experimental_candidate-metrics.png": {
|
||||
"bytes": 13162,
|
||||
"sha256": "82a07de168cda7dacdad2bd532dae408172466b2776acf8cfaabef6711c0539b"
|
||||
},
|
||||
"geometry/authored_detour_and_label-reset_only_control-metrics.png": {
|
||||
"bytes": 12777,
|
||||
"sha256": "e676edc49ec6ebb2fe29e976c36cba589f76330e7f9eb27b8835415a2ed5d225"
|
||||
},
|
||||
"geometry/chain_7_row_wrap-after.png": {
|
||||
"bytes": 14271,
|
||||
"sha256": "a020c1535e3d6e48cb5e6d7ae8403a256767c68ce26876fd773f3b626c61e365"
|
||||
},
|
||||
"geometry/chain_7_row_wrap-before.png": {
|
||||
"bytes": 13771,
|
||||
"sha256": "0b282cbd29eb3e29fa7d3bb9c3c9746caa14c77372b3191d78fa1a60743d0b11"
|
||||
},
|
||||
"geometry/chain_7_row_wrap-experimental_candidate-metrics.png": {
|
||||
"bytes": 14396,
|
||||
"sha256": "3ca042155d918083c5f4135ae059205b29b8ee5944819a6040a22eeb572f5f04"
|
||||
},
|
||||
"geometry/chain_7_row_wrap-reset_only_control-metrics.png": {
|
||||
"bytes": 14549,
|
||||
"sha256": "8828f289159deb0c2bedd130495197639775cd8edeb561f0de5501e2980d1e1d"
|
||||
},
|
||||
"geometry/crossed_bipartite-after.png": {
|
||||
"bytes": 13144,
|
||||
"sha256": "54a1b5e983bc9b6f619e67c13173d0ff5000e30f080aaf6c0a9064f59036f4b7"
|
||||
},
|
||||
"geometry/crossed_bipartite-before.png": {
|
||||
"bytes": 13406,
|
||||
"sha256": "aa3ba4f8ab67f6cd63d1c31fe309786e139db78ec6a36a0b00e8a854b590fe0c"
|
||||
},
|
||||
"geometry/crossed_bipartite-experimental_candidate-metrics.png": {
|
||||
"bytes": 13855,
|
||||
"sha256": "e3cc0a326a2c8b577e40101c5f67ea9c3182bca6d3f0498cdc1f25a64df57b90"
|
||||
},
|
||||
"geometry/crossed_bipartite-reset_only_control-metrics.png": {
|
||||
"bytes": 13349,
|
||||
"sha256": "0b2a8c49368258b23c15a8e06bf9b881f0e53fed8b5fd7d6f216a09837634abc"
|
||||
},
|
||||
"geometry/diamond-after.png": {
|
||||
"bytes": 12556,
|
||||
"sha256": "6afcff1e5c10758e5eab3b803ec858d0af3da953be4af93df75257544a7252b2"
|
||||
},
|
||||
"geometry/diamond-before.png": {
|
||||
"bytes": 13228,
|
||||
"sha256": "38e341a1ed51d4b4a94513f34c3d48b05c72177dcc3792dc660cb780fd7f98c9"
|
||||
},
|
||||
"geometry/diamond-experimental_candidate-metrics.png": {
|
||||
"bytes": 13555,
|
||||
"sha256": "a61d178e2cd3fcff8fc4ed3c42e5ab71b042d26d776f59fe5eaadb3ef8481e5e"
|
||||
},
|
||||
"geometry/diamond-reset_only_control-metrics.png": {
|
||||
"bytes": 12982,
|
||||
"sha256": "48414d7a859c774f2cef51d0fc1512b1c9fe1540c540f96fac7f9531583d8731"
|
||||
},
|
||||
"geometry/fixed_ports-after.png": {
|
||||
"bytes": 11889,
|
||||
"sha256": "574cb0249c3bcc3140b9d61906332295a8462e32fbcd6b9526d8b05e202e38a0"
|
||||
},
|
||||
"geometry/fixed_ports-before.png": {
|
||||
"bytes": 12013,
|
||||
"sha256": "d07ef2105aa63f3aaacb19e9a7ca4960239b01f05f734baa6270657a465b4ab0"
|
||||
},
|
||||
"geometry/fixed_ports-experimental_candidate-metrics.png": {
|
||||
"bytes": 12394,
|
||||
"sha256": "d7fdcd7d4a1a014dd34243400dcd74a7596388fc5747853e5a6629535ca3083e"
|
||||
},
|
||||
"geometry/fixed_ports-reset_only_control-metrics.png": {
|
||||
"bytes": 12231,
|
||||
"sha256": "237e181db92baf49c24554ec4325137b014413603b62cb08dee63e6e68fa3e8d"
|
||||
},
|
||||
"geometry/flat_chain_4-after.png": {
|
||||
"bytes": 12582,
|
||||
"sha256": "7326cd482a61bea4cb787770449253a55f41e102794c8fc6a8500d9244c20cc7"
|
||||
},
|
||||
"geometry/flat_chain_4-before.png": {
|
||||
"bytes": 12723,
|
||||
"sha256": "b086e2b23d4b68cb43366725a26fbb39fdc2e87d18cd7109c9b3ec950ab665ba"
|
||||
},
|
||||
"geometry/flat_chain_4-experimental_candidate-metrics.png": {
|
||||
"bytes": 13405,
|
||||
"sha256": "3bb352dd5066a3b2cc5bb298c9f47a02416186421df4841b96c944e523d11104"
|
||||
},
|
||||
"geometry/flat_chain_4-reset_only_control-metrics.png": {
|
||||
"bytes": 12918,
|
||||
"sha256": "eba3eede6771a1ebfc7b773b48b599f5ca49613ad7444ab26acfea7d20dde515"
|
||||
},
|
||||
"geometry/long_ordinary_labels-after.png": {
|
||||
"bytes": 12643,
|
||||
"sha256": "f4f0c360e9ce44d631dd1d9f7c202954be943fdbede6288edbaddf9b890fcaec"
|
||||
},
|
||||
"geometry/long_ordinary_labels-before.png": {
|
||||
"bytes": 12661,
|
||||
"sha256": "3c17c1195337cda7cd4141a15428b66cac1475421600b6ecffc3db57dbfd9f94"
|
||||
},
|
||||
"geometry/long_ordinary_labels-experimental_candidate-metrics.png": {
|
||||
"bytes": 13235,
|
||||
"sha256": "d2931b576901fec68b23afd1a54d4c301eda4a75ba30184344749e0add277bc0"
|
||||
},
|
||||
"geometry/long_ordinary_labels-reset_only_control-metrics.png": {
|
||||
"bytes": 12969,
|
||||
"sha256": "68aacc7196a4995ff8a884a15ec91a31bd386bdd2d232fd2c27696a7f83cf791"
|
||||
},
|
||||
"geometry/manifest.json": {
|
||||
"bytes": 8099,
|
||||
"sha256": "0102449f730651a92233c4ae959e95e0a58036fe2b1c309224558c496241c741"
|
||||
},
|
||||
"geometry/nested_cross_boundary-after.png": {
|
||||
"bytes": 13344,
|
||||
"sha256": "51db6785ab89f5d92c61288604ae3c76aee467e291738b54466db5704c0eec8b"
|
||||
},
|
||||
"geometry/nested_cross_boundary-before.png": {
|
||||
"bytes": 13547,
|
||||
"sha256": "3b3963f5ef5a42106a006f1825ae5d85b722aa3bf163452ba986ef3369b57cc2"
|
||||
},
|
||||
"geometry/nested_cross_boundary-experimental_candidate-metrics.png": {
|
||||
"bytes": 13535,
|
||||
"sha256": "0ba365785753e748dc7c86c3f1eb5767f9ccb87c0ae0e18085e6a9d32b79f60d"
|
||||
},
|
||||
"geometry/nested_cross_boundary-reset_only_control-metrics.png": {
|
||||
"bytes": 13549,
|
||||
"sha256": "fbe21b57616459ecdbbd083dccd73bd120f5b7ad59a8ad80ea38cbbf9c3f49c1"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-after.png": {
|
||||
"bytes": 13955,
|
||||
"sha256": "1722ff21eaadd831e00b2176d61f8fa65b083d2fd5b633a196a3335d48ca125e"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-before.png": {
|
||||
"bytes": 14080,
|
||||
"sha256": "78e765c579dd317e26177d0cd05cccc61519638e9198fdd0d344d9b54a95c1ba"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-experimental_candidate-metrics.png": {
|
||||
"bytes": 14500,
|
||||
"sha256": "8a0744cf04d305a0431468395b3676604e03991e577f9f9d2f45e8497b1ef9ca"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-manual_planar_order_witness-metrics.png": {
|
||||
"bytes": 14531,
|
||||
"sha256": "60623d358d9cb930c2c2c4995c9286c653e597c411b8879b77ac099fe9b0406b"
|
||||
},
|
||||
"geometry/two_containers_reversed_children-reset_only_control-metrics.png": {
|
||||
"bytes": 14134,
|
||||
"sha256": "ce6bc93dc38fe5b25552c8ebc5e778da175df53992e6a70ded8377fd99372587"
|
||||
},
|
||||
"support/auto-layout-j-comparison-20260925.json": {
|
||||
"bytes": 76669,
|
||||
"sha256": "3d5435686c2d23e97f08340693015ef66d1127726c9b5b5212678f43b3ad6b76"
|
||||
},
|
||||
"support/auto-layout-contact-audit-result-20260925.json": {
|
||||
"bytes": 104481,
|
||||
"sha256": "ec94203f42782d87c0459c251ab226567818e2715bc46a3e9ee0c25a6c15d620"
|
||||
},
|
||||
"support/auto-layout-contact-audit-20260925.py": {
|
||||
"bytes": 9838,
|
||||
"sha256": "398b9677d2ef2333514dd9c1ba5bc2f020c9927f395ed33bf11ac59b68139f46"
|
||||
},
|
||||
"support/auto-layout-format-j-20260925.json": {
|
||||
"bytes": 203,
|
||||
"sha256": "5bd0e4b979aedf21bd38e4c41a1521dafffb78c3cccb61cd12dad56d4f4699e9"
|
||||
},
|
||||
"support/run-auto-layout-check-20260925.py": {
|
||||
"bytes": 2771,
|
||||
"sha256": "d652c4df47f13aefc723bc54362d7b61308b8983c8d39ae7721c012519358ea2"
|
||||
},
|
||||
"support/run.py": {
|
||||
"bytes": 13814,
|
||||
"sha256": "698ed7182726c77fa4a1c85fc6dd08187ed20689941f57e749e3f0bc845f3252"
|
||||
}
|
||||
},
|
||||
"archive": {
|
||||
"name": "terminal-join-08656b18.zip",
|
||||
"bytes": 3178939,
|
||||
"sha256": "bd2146237353af05b8667dc7bd728007143a2f1b24ad9f8728fdbc50ffb3368e"
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
# #32 Auto-layout source and evaluator map
|
||||
|
||||
Read-only inspection of `.local/auto-layout-20260925`, base715036e132677dd2bc2fb37ed316d93dc9987aec, on2026-09-25. Issue32 explicitly asks automatic creation/removal of bends, attention to connection intersections and labels. No source changes, compiler, GUI, model helper or Gitea writes were performed. No geometric outcomes below are claimed as measured results.
|
||||
|
||||
## Actual surfaces and shared execution
|
||||
|
||||
- `crates/oa-desktop/src/views.rs:443`: saved Architecture diagram toolbar **Auto-layout** sends `Message::Command(Command::Layout(id))`. `model_menu.rs:417`: background **Arrange elements** sends the same command. The generic chooser excludes Scenario, not State, but normal State navigation routes to its dedicated workspace. `behavior/state_menu.rs` returns early for the dedicated State menu, and `state_view.rs` has no equivalent Auto-layout button. State view creation still calls shared Layout (`behavior/state_edit.rs:361–410`); behavior creation batches Layout too (`behavior/creation.rs:477–504`). Distinguish exposed UI from callable core behavior.
|
||||
- `crates/oa-core/src/model.rs:1431`: saved projection kinds are Overview, Decomposition, Allocation, Traceability, FunctionalDataflow, Capabilities, StateMachine and Scenario. `session.rs:947` accepts the first seven; Scenario explicitly refuses and requires its separate geometry operation. Process topology and Scenario lane/message arrangement are separate behavior canvases, not this generic command.
|
||||
- Allocation **Arrange next drag / Alt+A** is a different manual translation workflow (`allocation_drag/arrange.rs`, `app/allocation_drag.rs`, `diagram/work_drag.rs`). Do not silently turn it into automatic graph layout or change its allocation/group/refusal/Undo contracts merely because its label contains Arrange.
|
||||
- `app.rs:3559` applies pending property edits and remaps a changed diagram identity before executing Layout; preserve that admission/identity behavior (`tests.rs::placement_identity_edit_then_drag_and_diagram_identity_then_arrange`). `document.rs:322` prepares/validates a candidate, updates the source bundle with identity provenance, then commits one model/source history entry. Core Session execute at`session.rs:275` likewise records one changed candidate; Undo/Redo swap exact Projects and no-op candidates create no history.
|
||||
- REST transactions at`crates/oa-server/src/routes.rs:1011–1041` accept serialized Command batches including Layout under expected-revision admission, with dry-run and atomic PreparedProject/store commit. Core behavior therefore cannot rely on desktop-only fonts/state. `docs/v2-interface.md` lists Layout convenience commands. CLI exposes check/lint/stabilize/rename-alias, not a direct layout verb. Text/source may store explicit node/route records; parsing/loading them must not implicitly auto-arrange.
|
||||
|
||||
## Current algorithm and why the issue is real
|
||||
|
||||
`session.rs::layout` measures a projection-parent forest, vertically stacks children below headers, and puts roots into rows of four. Roots use an exchange-aware topological-like ordering with alias/ID tie breaks and deterministic cycle fallback. Child order is placement-ID order. It changes node positions/sizes, computes per-node deltas and calls `projection::translated_routes`; it neither scores routed geometry/labels nor generates or removes stored waypoints.
|
||||
|
||||
`projection.rs:81` supplies actual visual parents: nearest placed semantic ancestor; Allocation derives performer grouping from allocation summaries; Traceability is flat; State filters the machine. Ports have owner placements, not independent node placements (`edge_endpoint_placements`). Measurement reserves minimum sizes, State action/internal rows and port footer (`session.rs:967–1006`). Folded descendants still belong to the authored forest; projected visibility and stored layout are distinct.
|
||||
|
||||
The router is **not unaware of crossings or labels**: `routing.rs:457` routes stable edge IDs with a bounded search and previous-edge costs; `crossing_cost:990` penalizes proper crossings and positive collinear overlap separately. All paths settle before automatic label placement; `label_geometry`/`label_collisions:1370–1552` consider node obstacles, earlier labels and all visible strokes. But routing remains greedy, has finite search budgets, and authored label placement bypasses avoidance. Auto-layout never feeds these costs back into node order/spacing. Existing pins constrain paths even after node arrangement, except folded/promoted routes temporarily suspend their custom geometry. Thus a node-only Layout plus pinned greedy routing cannot promise the joint improvement requested by32.
|
||||
|
||||
Base715 uses scalar-estimated label rectangles. The separate #36 measured State multiline layout is not present in this base; do not assume its geometry or acceptance has already integrated. Ordinary labels, association-end labels/stereotypes and State labels also differ in presentation. Parent should coordinate routing-file overlap with #36 before integration, not silently reimplement its measurement policy.
|
||||
|
||||
## Contracts requiring an intentional change
|
||||
|
||||
1. `harness/release-routing-contract.md:143–146` explicitly says Layout preserves pins, translating only equally moved endpoint owners, with Reset routing separate. `crates/oa-desktop/src/guide.rs:360` repeats **Auto-layout preserves pins**. These are directly incompatible with the user's requested automatic bend removal/generation and must be revised for the explicit whole-view operation, with Undo restoring prior authorship.
|
||||
2. `crates/oa-core/tests/release_routing.rs::layout_preserves_authored_route_count_order_labels_and_undo` fixes route list count/order, two pins on each route and exact labels. Replace only the superseded Layout assertions with the new declared output contract plus stronger geometry checks; retain unrelated-diagram exactness and exact Undo. Do not remove all route-preservation tests.
|
||||
3. `state_diagrams.rs::layout_reserves_internal_rows_and_state_routes_retain_hard_pins` combines two things: the **pre-Layout** router must traverse authored pins without mutation; the post-Layout assertions reserve State rows and source roundtrip. Preserve those valid assertions, and rename/split the test if Layout no longer retains pins. Its current body does not actually assert post-Layout pin preservation.
|
||||
4. Decide explicitly whether generated bends remain derived route corners or become persisted editable waypoints. The user asks visible automatic routing improvement, not necessarily a new semantic field. `routing.rs` documents only authored pins in the model; blindly storing every solver corner would turn generated geometry into hard constraints and can cause second-run drift. Whichever representation is chosen needs first/repeat Arrange and manual-after-Arrange tests.
|
||||
5. Define scope for invisible/suspended routes, authored label offsets and explicit attachments. User32 authorizes changing bends; it does not automatically authorize deleting manual semantic-port metadata, every hidden route, or authored label placement. A narrow baseline is reset/rebuild bends for eligible visible edges while retaining attachments, labels and unrelated/hidden records; measure whether that suffices. If an authored label constraint defeats label clearance, report it or make its intentional reset policy explicit instead of pretending the layout is collision-free.
|
||||
|
||||
## Invariants to retain
|
||||
|
||||
Complete elements/relations/traces/types/names/text/IDs/containment/allocations/occurrences remain exact; only the selected diagram's declared presentation fields may differ. Other diagrams, node identities, collapsed flags, view options, unplaced elements and manual port side/fraction metadata remain exact. Automatic port geometry may change with node geometry, but semantic port identity/contract and explicit anchors do not. Existing tests `port_anchors::separate_views_resize_layout_and_hidden_owners_keep_manual_metadata`, `workflows` nested-parent containment, and desktop port/identity tests are relevant sentinels.
|
||||
|
||||
Keep move-one-end pins-fixed and move-both-owners equal-delta translation in `projection::translated_routes`, normal drag/reflow/group previews, reconnect, folding suspension/resumption, Hide/show and Reset routing unchanged unless separately justified. Keep finite geometry and existing point-count/attachment/fraction validation (`validation.rs:650–720`), deterministic bounded failure/fallback, source/JSON roundtrips, dry-run nonmutation, rejected-operation no-history, and exact one-step Undo/Redo. A Layout improvement must not trigger new allocation/reconnect semantics.
|
||||
|
||||
## Independent fixtures and falsifiers to freeze before candidate comparison
|
||||
|
||||
Use hand-declared topology, IDs and geometry; record the old candidate's metrics once, then compare materially different candidates against the same evaluator. Do not use a candidate's own cost function as the acceptance oracle.
|
||||
|
||||
| Fixture | Independent observable requirement |
|
||||
| --- | --- |
|
||||
| Two connected OA activities / SA functions with redundant dogleg and one blocking third card | Explicit Arrange removes obsolete turns; unobstructed edge straightens, blocked edge gains necessary orthogonal turns; semantic endpoints exact, no nonincident-node penetration. Include authored source/target attachments. |
|
||||
| Two containers, each with two children, reversed child-order connections | A planar hand-constructed witness has zero proper crossings; require zero on this bounded case, child containment and preserved port contract. Detect current ID-ordered child stacking weakness. |
|
||||
| Four-node diamond plus return edge, self-loop, parallel and reverse-direction edges | No false cycle/deadlock; meaningful separation of parallel paths; self-loop remains visible; distinguish shared endpoint fans from mid-path overlap. Test swapped UUIDs and stable semantic aliases. |
|
||||
| Five-plus roots and disconnected component | Exposes current every-four-roots wrap and ordering discontinuity. No overlapping cards, reasonable route/area bounds and deterministic repeated result. |
|
||||
| OA/SA nested Allocation with ports, uniform subtree, folded ancestor and hidden route | Child interiors below reserved headers/footer, anchors on owner boundary, no fabricated ports, no semantic reassignment; hidden-route policy explicit; other view exact. |
|
||||
| Traceability mixed OA/SA with duplicate placement sentinels elsewhere | Flat visual graph stays flat, valid edge identities and other diagram exact. |
|
||||
| State machine with composite State, region, entry/exit boundary, internal/action rows and self-transition | State-specific legal boundary crossings, row reservations and no overwritten semantic state; shared Layout initialization remains valid even without a State Auto-layout button. |
|
||||
| Long ordinary/association labels, adjacent parallel labels, and #36 B/W/U State cases once integrated | Independently measure label/node, label/label and unrelated stroke/label intersections using actual displayed geometry; native ink/readability check separate from advance-box arithmetic. |
|
||||
| Dense/nonplanar or contradictory-anchor stress case | Finite time/work and explicit residual warnings/metrics; no claim all crossings can disappear. Preserve exact Undo and input on refusal. |
|
||||
|
||||
## Metrics and acceptance discipline
|
||||
|
||||
Report a vector, not a single opaque beauty score: (1) hard invalid geometry/illegal obstacle penetration and sibling overlap area; (2) label-node/label-label intersection area and other-edge strokes entering labels; (3) distinct proper edge-pair crossings; (4) positive-length collinear shared segments; (5) bend count after duplicate/collinear simplification, path length/stretch and total view bounds. Define endpoint/ancestor exemptions before running: parent-child containment is intentional, a shared semantic endpoint is not a crossing, owner leads and legal State boundary crossings are not nonincident obstacle violations. Deduplicate segment-pair crossing events at the same physical point; count edge pairs separately. Count near-parallel clearance as a separate readability observation, not zero-width intersection.
|
||||
|
||||
Use independent rectangle/segment math and full projected/routed outputs; export SVG and canvas must agree with those outputs. Include authored and derived labels, including association ends. A zero-crossing result obtained by coincident overlapping paths, gigantic detours, clipping labels or hiding edges fails the other metrics. Set fixture-specific route/area ceilings from a feasible manually declared witness rather than arbitrary post-hoc thresholds. Compare a second Arrange for idempotence, input-order permutation for determinism, pure camera changes for model invariance, then source Save/reopen and exact Undo/Redo.
|
||||
|
||||
Unavoidable cases: nonplanar graphs cannot all be planar with distinct nonoverlapping edges; fixed attachment orders, nested boundaries, coincident semantic endpoints and required self-loops can constrain clearance. Existing authored labels can intentionally occlude nodes. Report constrained residuals honestly and avoid turning ordinary automatic layout into deletion of semantic edges or impossible universal no-crossing guarantees.
|
||||
|
||||
The first useful milestone is a finite fixture/evaluator plus an explicit bend/label/hidden-route policy, then a prototype compared against current Layout. No need to expand into unrelated Process/Scenario geometry or redesign manual Arrange drag for this issue.
|
||||
@@ -1,39 +0,0 @@
|
||||
# J terminal-join actual-results independent audit
|
||||
|
||||
Result: the finite experiment supports the terminal-cost asymmetry diagnosis, with a real ordinary-scene overlap regression retained below. It does not establish universally improved routing, complete readability, or #32 acceptance. Static audit only: no compilation, probe rerun, GUI, source edit or publication by reviewer.
|
||||
|
||||
## Evidence and checks
|
||||
|
||||
Tested pin `497c1ad8b39788148c5f03bc8542d6d8e28dbc01`, compared against first probe `e7deea7039bcf31675cffbb462cfeb19d62a7e20`. Run03 command `cargo run --locked --offline -p oa-core --example auto_layout_probe`, Windows x86_64-pc-windows-gnullvm/dev: receipt exit0, failure null, inputs unchanged. Independently verified receipt log hashes. Test02 at the same pin also exit0/unchanged: six summaries132+7+13+18+2+13 =185 passed, zero failed/ignored.
|
||||
|
||||
Independently parsed both raw JSONL outputs, rather than relying only on comparison-helper booleans: all37 variant Projects, complete projected nodes/ports, and per-edge first/last coordinates match exactly. Thus changed area is routed/label bounds, not changed node placement. Independently recomputed every raw pairwise axis-aligned collinear interval total in both runs (74 variant observations); all agree with emitted totals.
|
||||
|
||||
All nine candidate rows report supported, identical repeated input, idempotence, passes2–10 equal first and previous, exact JSON/canonical-single-source roundtrips, no associated errors. Conservation remains true except the intentional authored-detour route reset (`authored_routes_exact=false`). Exact old/new Projects additionally confirm J changed none of these model records. Existing Command::Layout history/Undo/Redo checks pass in all nine rows; the layered candidate remains a pure Project transformation with no candidate-command history evidence. No native GUI evidence.
|
||||
|
||||
## Complete changed quality-vector rows
|
||||
|
||||
| Fixture / variant | Raw sharing old→J | Nonterminal sharing old→J | Other changed fields |
|
||||
|---|---:|---:|---|
|
||||
| diamond / before |136→36|100→0|none|
|
||||
| diamond / layered candidate |82→36|46→0|none|
|
||||
| crossed_bipartite / before |730→872|658→800|bends5→4; length2076→2064|
|
||||
| crossed_bipartite / layered candidate |344→187|272→115|drawing area165360→175960|
|
||||
| two_containers_reversed_children / before |200→54|200→54|none|
|
||||
| two_containers_reversed_children / baseline |148→18|148→18|none|
|
||||
| two_containers_reversed_children / reset control |148→18|148→18|none|
|
||||
|
||||
These are all changes in the comparison's pre-existing12-field quality vector; the remaining30 rows are unchanged. All baseline/reset rows outside the two-container case are unchanged. Seven of nine layered candidate vectors remain unchanged. Raw strict proper crossings remain zero throughout these comparisons; no demonstrated proper-crossing reduction follows.
|
||||
|
||||
The crossed-bipartite BEFORE regression is substantive, not a metric artifact: independent interval sums confirm142 additional shared units. Fewer bends and12 shorter length do not erase that readability tradeoff. Layered bipartite improves157 shared units but retains115 nonterminal units and grows routed drawing area10600 (~6.41%). Viewed its actual offline PNG: distinct vertical channels remain joined to shared horizontal routes. This is not an unambiguous-trace acceptance result. Diamond's remaining36 units are the separately classified common terminal stubs.
|
||||
|
||||
The new point-contact metric has no v1 field to compare directly: do not interpret its mere introduction as newly created contacts. Current layered diamond records2 contacts and bipartite9; the independent contact worker's retrospective audit is pending at this review. Counts classify contacts and do not alone establish readability. Approximate core label bounds, missing renderer/port-label measurements, finite fixture families, axis-only grid state, sequential edge ordering and no global hard-pin optimization remain limits.
|
||||
|
||||
## Binding and recommendation
|
||||
|
||||
- Old raw output SHA256 `21786ae5cb668f60e9252dc884668ee5e98289443d0b3723a9ea69bf21d47fd1`.
|
||||
- J raw output SHA256 `f9ac7c1320b1cf026259bfcaf13a39e968ae317463712d8dc793375d9a4a541f`.
|
||||
- Run03 result.json SHA256 `57a1f866b2cb67880decd26becf3ac2e7715e38bbd26d3089c47dd62294afa4f`.
|
||||
- Comparison JSON SHA256 `3d5435686c2d23e97f08340693015ef66d1127726c9b5b5212678f43b3ad6b76`.
|
||||
- Geometry manifest SHA256 `0102449f730651a92233c4ae959e95e0a58036fe2b1c309224558c496241c741`; all37 image byte lengths/SHA256 verified. These are offline geometry schematics, not native captures.
|
||||
|
||||
Recommendation: retain J as a successfully isolated scoring correction candidate, with explicit non-monotonic quality results. Before further penalty tuning, use the independently reconstructed contacts and the crossed-bipartite BEFORE regression as falsifiers for any stronger penalty/lane experiment. Keep exact Projects/nodes/ports/endpoints fixed and retain the entire vector, including labels and area. Do not advance directly to broad production quality acceptance based on the layered diamond improvement.
|
||||
@@ -1,25 +0,0 @@
|
||||
# Terminal-join experiment independent source review
|
||||
|
||||
Read-only review of the local candidate based on e7deea7039bcf31675cffbb462cfeb19d62a7e20; no compiler, probe, GUI or helper executed by reviewer. Reviewed routing.rs SHA256 43c2e983a132fd1c687706f5dc6b27867403aae2aa253cdc25195141d24759a5 and the accompanying v2 probe diff. Parent owns formatting, final source pin and execution receipts.
|
||||
|
||||
Recommendation: proceed with the bounded J experiment. No blocking defect found in the changed scoring or contact observer. This is source review, not test success or production acceptance.
|
||||
|
||||
- route_edge supplies the known target continuation only to the final leg, preserving intermediate hard-pin context. The final whole-path score remains unchanged; local selection does not persist or double-charge its score.
|
||||
- fast_path applies the same incoming/outgoing join to both candidate estimates and actual costs. Its Manhattan + two-turn lower bound remains safe for genuine two-bend alternatives because joins/occupied-segment costs are nonnegative. Compacted zero/one-bend alternatives are already represented by the initial L candidates.
|
||||
- grid_path charges the terminal turn before inserting target states into the queue. Manhattan remains an admissible and consistent lower bound for these nonnegative transition additions. Adding a charge after returning the first target would have been incorrect; this implementation avoids that trap.
|
||||
- Zero-length spans retain the existing result and do not invent an arrival vector. turn_cost explicitly returns zero for a zero-length adjacent leg. Whole-route behavior around repeated pins remains covered by existing integration checks rather than a new optimization claim.
|
||||
- New private tests exercise both fast and grid directly, four reflected/rotated diamond witnesses with independently stated complete cost310, absent outgoing context, straight continuation/reversal and degenerate spans. Full route pins, attachment metadata and preview/settled agreement still depend on the parent-planned existing suites; no passing result is inferred from their presence.
|
||||
|
||||
Important limits: grid states remain two axes, not four signed arrival directions, although turn_cost distinguishes reversals. This pre-existing state abstraction is not proof of globally optimal reversal-aware A*. The patch correctly incorporates target cost before arrival-state merging, but does not fix all possible intermediate state dominance issues. Fast routing is still preferred over grid whenever feasible; authored pins are solved leg by leg without global lookahead. Budget/obstacle fallback behavior is unchanged. These are not blockers to this finite asymmetry experiment, but must remain outside its claimed guarantee.
|
||||
|
||||
The probe contact metric is additive: closed point intersections deduplicate by edge pair and EPS-quantized coordinate, OR proper-crossing witnesses, distinguish endpoint/vertex/interior roles, and classify common terminals by both owner and physical coordinate without subtracting them. Positive collinear intervals remain in the separate shared-length metric; a noncollinear witness at their boundary can still count as a point contact. The analytic witnesses include proper, T, bend, endpoint, collinear-touch and overlap cases. These definitions are suitable for the finite orthogonal fixtures, not an exact general floating-point geometry theorem.
|
||||
|
||||
Execution interpretation: compare projected_nodes, projected_ports and exact emitted endpoints against the frozen first run before calling this a fixed-node experiment. The layered prototype itself reroutes while deriving spacing, so identical fixture input alone does not establish identical output placement. Preserve the full metric vector: replacing coincidence with point contacts/crossings or worsening labels must stay visible. No generic node/label layout or native traceability acceptance follows from this experiment.
|
||||
|
||||
## Formatting recheck and source identities
|
||||
|
||||
Original reviewed routing SHA256: `43c2e983a132fd1c687706f5dc6b27867403aae2aa253cdc25195141d24759a5` (measured by reviewer). Original evaluator SHA256: `b7e10e6d346bac93486b075229d8b5b4b74acba05b4bc5778cc60b89a7a6f4d3` (worker/parent-provided identity; reviewer inspected its actual diff).
|
||||
|
||||
Current formatted routing SHA256: `f482f6ef79ee0ef2c615a0ef7fff0d5ad0c56dd080efa8e374b7c29ab9961507`. Current formatted evaluator SHA256: `2cc8d3cb04f8a48bef02008e7b9a0d78aa254009ee85ef7d546df1d04f97ebca`. Both measured by reviewer. Reread full scoped `git diff --ignore-all-space` after formatting: scoring expressions, conditionals, witness coordinates/assertions and observer definitions agree with the pre-format reviewed output. Observed differences are wrapping/indentation and optional trailing commas in routing tests. There is no retained original-byte snapshot for a mechanical whitespace-only comparison; this is an independent source re-review, not a claimed byte-level proof.
|
||||
|
||||
Exact fixed-geometry comparators: per fixture and variant compare full `projected_nodes`, full `projected_ports`, and per-edge first/last coordinates in `routed_edges` with the frozen prior output. Compare endpoint owners to projected edge definitions, and preserve source/target attachment and semantic port identity/anchor metadata in the full Projects. New `route_endpoints` makes the current identity/coordinate association explicit; derive the prior coordinates from prior `routed_edges`, since v1 did not emit that new field. Inspect route/label changes only after those placement/attachment checks, and retain any mismatches as results rather than silently excluding fixtures.
|
||||
@@ -0,0 +1,714 @@
|
||||
"""Navigation evidence transport. Integrity is not product acceptance.
|
||||
|
||||
Commands: freeze TEMPLATE SOURCE_LOCAL NEW_FROZEN_CONFIG;
|
||||
build FROZEN_CONFIG SOURCE_LOCAL FRESH_PACKET; verify PACKET;
|
||||
selftest FRESH_DIRECTORY. No extraction, subprocesses, or native execution.
|
||||
"""
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import stat
|
||||
import sys
|
||||
import zipfile
|
||||
import datetime
|
||||
import copy
|
||||
|
||||
MAX_FILE = 128 * 1024 * 1024
|
||||
MAX_TOTAL = 1024 * 1024 * 1024
|
||||
EXCLUDED = {"bin", "profile", "target", ".git", "navigation-oracle-runtime"}
|
||||
BLOCKED_SUFFIXES = {'.exe', '.dll', '.pdb', '.lib', '.obj', '.pfx', '.pem', '.key'}
|
||||
|
||||
|
||||
def need(ok, message):
|
||||
if not ok:
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def pairs(items):
|
||||
out = {}
|
||||
for key, value in items:
|
||||
need(key not in out, "duplicate JSON key: " + key)
|
||||
out[key] = value
|
||||
return out
|
||||
|
||||
|
||||
def decode(data):
|
||||
def reject_constant(value):
|
||||
raise ValueError('nonfinite JSON constant: ' + value)
|
||||
return json.loads(data, object_pairs_hook=pairs, parse_constant=reject_constant)
|
||||
|
||||
|
||||
def digest(data):
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def safe(name):
|
||||
need(isinstance(name, str) and name and "\\" not in name and ":" not in name,
|
||||
"unsafe path")
|
||||
p = PurePosixPath(name)
|
||||
need(bool(p.parts) and not p.is_absolute() and str(p) == name, "noncanonical path: " + name)
|
||||
need(all(x not in (".", "..") and not x.endswith((".", " ")) for x in p.parts),
|
||||
"unsafe component: " + name)
|
||||
need(not any(re.fullmatch(r"(?i)(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?", x)
|
||||
for x in p.parts), "reserved component")
|
||||
need(not any(ord(c) < 32 or c in '<>"|?*' for c in name), "nonportable path")
|
||||
return name
|
||||
|
||||
|
||||
def permitted(name):
|
||||
safe(name)
|
||||
parts = PurePosixPath(name).parts
|
||||
need(not any(x.casefold() in EXCLUDED | {'.ssh', '.codex', '.config', 'credentials', 'auth'}
|
||||
or x.casefold().startswith('.env') for x in parts), 'excluded/private input')
|
||||
need(PurePosixPath(name).suffix.casefold() not in BLOCKED_SUFFIXES
|
||||
and PurePosixPath(name).name.casefold() != 'push-navigation.py', 'binary/private input')
|
||||
|
||||
|
||||
def hash_string(value):
|
||||
need(isinstance(value, str) and re.fullmatch('[0-9a-f]{64}', value), 'invalid hash')
|
||||
return value
|
||||
|
||||
|
||||
def timestamp(value):
|
||||
need(isinstance(value, str) and value, 'missing timestamp')
|
||||
parsed = datetime.datetime.fromisoformat(value)
|
||||
need(parsed.tzinfo is not None, 'timestamp needs timezone')
|
||||
return parsed
|
||||
|
||||
|
||||
def inventory(value):
|
||||
need(isinstance(value, dict) and value, 'missing identity inventory')
|
||||
folded = set()
|
||||
for name, sha in value.items():
|
||||
safe(name)
|
||||
need(name.casefold() not in folded, 'identity inventory case collision')
|
||||
folded.add(name.casefold())
|
||||
hash_string(sha)
|
||||
|
||||
|
||||
def validate_config(config, frozen=False):
|
||||
need(config.get('schema') == 'navigation-evidence-config-v2', 'config schema')
|
||||
need(config.get('exclusions') == sorted(EXCLUDED), 'exclusion policy mismatch')
|
||||
includes = config['includes']
|
||||
need(isinstance(includes, list) and includes, 'empty includes')
|
||||
names = []
|
||||
for item in includes:
|
||||
name = safe(item['path'])
|
||||
permitted(name)
|
||||
need(item['kind'] in ('file', 'tree') and isinstance(item['category'], str)
|
||||
and item['category'], 'invalid include')
|
||||
folded = name.casefold()
|
||||
need(not any(folded == old or folded.startswith(old + '/') or old.startswith(folded + '/')
|
||||
for old in names), 'overlapping include roots')
|
||||
names.append(folded)
|
||||
for key in ('gates', 'native_cases', 'oracle_runs'):
|
||||
values = [x['name'] if isinstance(x, dict) else x for x in config[key]]
|
||||
need(len(values) == len(set(values)), 'duplicate case')
|
||||
for name in values:
|
||||
need(re.fullmatch('[a-z0-9-]+', name), 'invalid case name')
|
||||
if frozen:
|
||||
need(config.get('frozen_entries'), 'freeze and review exact file inventory first')
|
||||
|
||||
|
||||
def coverage(config, entries):
|
||||
seen = set()
|
||||
for entry in entries:
|
||||
name = entry['path']
|
||||
permitted(name)
|
||||
need(name.casefold() not in seen, 'duplicate logical path')
|
||||
seen.add(name.casefold())
|
||||
owners = [i for i in config['includes'] if name == i['path'] or
|
||||
(i['kind'] == 'tree' and name.startswith(i['path'] + '/'))]
|
||||
need(len(owners) == 1 and entry['category'] == owners[0]['category'], 'outside whitelist/category')
|
||||
for item in config['includes']:
|
||||
need(any(e['path'] == item['path'] or (item['kind'] == 'tree' and
|
||||
e['path'].startswith(item['path'] + '/')) for e in entries), 'missing required include')
|
||||
for name in config['documentation']:
|
||||
need(safe(name).casefold() in seen, 'missing documentation')
|
||||
|
||||
|
||||
def regular(path):
|
||||
# Also rejects Windows junctions, including ancestors outside the selected root.
|
||||
for item in [path, *path.parents]:
|
||||
s = item.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "link/reparse: " + str(item))
|
||||
need(path.is_file(), "not a regular file: " + str(path))
|
||||
need(path.stat().st_size <= MAX_FILE, "file size cap")
|
||||
|
||||
|
||||
def read(path):
|
||||
regular(path)
|
||||
data = path.read_bytes()
|
||||
need(len(data) <= MAX_FILE, "file grew over cap")
|
||||
return data
|
||||
|
||||
|
||||
def walk_checked(base, exclude=False):
|
||||
# Check before descending, including junctions; os.walk never gets an
|
||||
# unchecked reparse directory to recurse into.
|
||||
for item in [base, *base.parents]:
|
||||
s = item.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "link/reparse ancestor")
|
||||
for current, dirs, files in os.walk(base, followlinks=False):
|
||||
if exclude:
|
||||
dirs[:] = [x for x in dirs if x.casefold() not in EXCLUDED]
|
||||
for name in dirs + files:
|
||||
path = Path(current) / name
|
||||
s = path.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "link/reparse tree")
|
||||
for name in files:
|
||||
yield Path(current) / name
|
||||
|
||||
|
||||
def selected(source, config):
|
||||
found = {}
|
||||
for item in config["includes"]:
|
||||
name = safe(item["path"])
|
||||
base = source / name
|
||||
need(base.exists(), "required input missing: " + name)
|
||||
candidates = [base] if item["kind"] == "file" else sorted(walk_checked(base, exclude=True))
|
||||
need(item["kind"] in ("file", "tree"), "unknown include kind")
|
||||
for file in candidates:
|
||||
relative = file.relative_to(source).as_posix()
|
||||
if any(x.casefold() in EXCLUDED for x in PurePosixPath(relative).parts):
|
||||
continue
|
||||
# Check directories too, before skipping them; never traverse a junction.
|
||||
s = file.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "link/reparse input")
|
||||
if file.is_dir():
|
||||
continue
|
||||
safe(relative)
|
||||
need(relative.casefold() not in found, "overlapping/case-colliding include")
|
||||
regular(file)
|
||||
found[relative.casefold()] = (relative, item["category"], file)
|
||||
return sorted(found.values())
|
||||
|
||||
|
||||
def check_zip(data, source_inputs):
|
||||
need(isinstance(source_inputs, dict) and source_inputs, "missing source inventory")
|
||||
expected = {}
|
||||
for name, sha in source_inputs.items():
|
||||
safe(name)
|
||||
need(name.casefold() not in expected and re.fullmatch("[0-9a-f]{64}", sha),
|
||||
"invalid source inventory")
|
||||
expected[name.casefold()] = (name, sha)
|
||||
seen = set()
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
||||
members = archive.infolist()
|
||||
need(len(members) <= 10000, "ZIP member cap")
|
||||
need(sum(x.file_size for x in members) <= 512 * 1024 * 1024, "ZIP total cap")
|
||||
for member in members:
|
||||
name = safe(member.filename)
|
||||
need(not member.is_dir() and not member.flag_bits & 1, "ZIP directory/encryption")
|
||||
mode = member.external_attr >> 16
|
||||
need(stat.S_IFMT(mode) in (0, stat.S_IFREG), "ZIP nonregular member")
|
||||
need(member.file_size <= 64 * 1024 * 1024, "ZIP member cap")
|
||||
key = name.casefold()
|
||||
need(key not in seen and key in expected and expected[key][0] == name,
|
||||
"ZIP path inventory mismatch")
|
||||
seen.add(key)
|
||||
content = archive.read(member) # zipfile checks CRC; never extracts.
|
||||
need(len(content) == member.file_size and digest(content) == expected[key][1],
|
||||
"ZIP content mismatch")
|
||||
need(seen == set(expected), "ZIP missing inputs")
|
||||
|
||||
|
||||
def streams(record, prefix, get, suffix='.'):
|
||||
for stream in ('stdout', 'stderr'):
|
||||
size = record[stream + '_bytes']
|
||||
need(type(size) is int and size >= 0, 'invalid stream size')
|
||||
path = prefix + suffix + stream if suffix == '.' else prefix + stream + '.log'
|
||||
need(len(get(path)) == size, 'stream size mismatch: ' + path)
|
||||
|
||||
|
||||
def step_sequence(result, planned, get, prefix, stop_on_failure, require_finished=True):
|
||||
steps = result.get('steps')
|
||||
need(isinstance(steps, list) and 0 < len(steps) <= len(planned), 'invalid step count')
|
||||
need(len({s['label'] for s in planned}) == len(planned), 'duplicate plan label')
|
||||
for actual, expected in zip(steps, planned):
|
||||
need(actual['label'] == expected['label'] and actual['command'] == expected['command']
|
||||
and actual.get('expectation', 'exit0') == expected.get('expectation', 'exit0'),
|
||||
'step differs from plan')
|
||||
need(type(actual.get('exit_code')) is int, 'missing step exit')
|
||||
start = actual.get('started', actual.get('start'))
|
||||
need(timestamp(actual['finished']) >= timestamp(start), 'step timestamp order')
|
||||
streams(actual, prefix + safe(actual['label']), get)
|
||||
failed = [i for i,s in enumerate(steps) if s['exit_code'] != 0]
|
||||
if stop_on_failure and failed:
|
||||
need(failed == [len(steps)-1], 'not first-failure stopped prefix')
|
||||
return 'stopped-at-first-failure'
|
||||
need(len(steps) == len(planned), 'gate still open: incomplete successful prefix')
|
||||
if require_finished:
|
||||
need(timestamp(result.get('finished')) >= timestamp(steps[-1]['finished']), 'missing final closure')
|
||||
return 'completed-with-failures' if failed else 'completed-successfully'
|
||||
|
||||
|
||||
def same_fields(before, after, keys):
|
||||
for key in keys:
|
||||
need(key in before and key in after and before[key] is not None and
|
||||
before[key] == after[key], 'identity mismatch/missing: ' + key)
|
||||
|
||||
|
||||
def receipts(config, get, paths):
|
||||
matrix = []
|
||||
for gate in config["gates"]:
|
||||
prefix = "navigation-gates/" + gate["name"] + "/"
|
||||
before = decode(get(prefix + "initial.json"))
|
||||
raw_result = get(prefix + 'result.json')
|
||||
result = decode(raw_result)
|
||||
same_fields(before, result, ('source', 'source_inputs', 'started', 'environment'))
|
||||
need(isinstance(result['source'], str) and re.fullmatch('[0-9a-f]{40}', result['source']), 'source commit')
|
||||
inventory(result['source_inputs'])
|
||||
need(result.get("inputs_unchanged") is True, "gate source conservation missing")
|
||||
if gate['name'] == 'attempt01':
|
||||
need(digest(raw_result) == 'de7c4a7361a4c4991f783c16656af2ac38c49edaaf4d473f26462f3580264486',
|
||||
'historical attempt01 changed')
|
||||
planned = [{'label':a, 'command':b} for a,b in (
|
||||
('fmt','cargo fmt --all -- --check'),
|
||||
('workspace-tests','cargo test --workspace --locked -- --test-threads=1'),
|
||||
('clippy','cargo clippy --workspace --locked --all-targets -- -D warnings'),
|
||||
('native-builds','cargo build --workspace --locked --bins'))]
|
||||
outcome = step_sequence(result, planned, get, prefix, False, False)
|
||||
need(outcome == 'completed-successfully' and gate['zip'] is False and
|
||||
gate.get('missing_zip_reason'), 'historical attempt boundary')
|
||||
outcome = 'historically-closed-no-final-timestamp-no-input-ZIP'
|
||||
else:
|
||||
same_fields(before, result, ('phase', 'plan_sha256'))
|
||||
need(result['phase'] == gate['name'], 'gate phase mismatch')
|
||||
plan_bytes = get(gate['plan'])
|
||||
need(digest(plan_bytes) == result['plan_sha256'], 'gate plan mismatch')
|
||||
plan = decode(plan_bytes)
|
||||
need(plan['head'] == result['source'], 'gate plan source mismatch')
|
||||
outcome = step_sequence(result, plan['steps'], get, prefix, plan.get('stop_on_failure', True))
|
||||
need(gate['zip'] is True, 'unapproved missing source ZIP')
|
||||
if gate["zip"]:
|
||||
check_zip(get(prefix + "inputs.zip"), result["source_inputs"])
|
||||
matrix.append({"kind": "gate", "name": gate["name"], "source": result["source"],
|
||||
"receipt": prefix + "result.json", 'closure':outcome,
|
||||
"exits": [x["exit_code"] for x in result['steps']],
|
||||
"acceptance": "not inferred"})
|
||||
for name in config["native_cases"]:
|
||||
prefix = "navigation-native/" + safe(name) + "/"
|
||||
before = decode(get(prefix + "started.json"))
|
||||
result = decode(get(prefix + "finished.json"))
|
||||
same_fields(before, result, ('case','binary_source','binary_pins','pid','start','argv',
|
||||
'cwd','path','local_app_data'))
|
||||
need(result['case'] == name and type(result['pid']) is int and result['pid'] > 0
|
||||
and isinstance(result['argv'],list) and len(result['argv']) == 1, 'native launch identity')
|
||||
inventory(result['binary_pins'])
|
||||
need(set(result['binary_pins']) == {'arcadia-studio.exe','libunwind.dll'}, 'native binary pins')
|
||||
need(re.fullmatch('[0-9a-f]{40}', result['binary_source']), 'native source identity')
|
||||
if name == 'baseline01':
|
||||
same_fields(before,result,('fixture_sha256','release_tag'))
|
||||
hash_string(result['fixture_sha256'])
|
||||
need(result.get('fixture_unchanged') is True,'legacy fixture changed')
|
||||
else:
|
||||
same_fields(before,result,('original_fixtures',))
|
||||
inventory(result['original_fixtures'])
|
||||
need(result.get('original_fixtures_unchanged') is True,'native fixtures changed')
|
||||
need(result.get('binaries_unchanged') is True,'native binaries changed')
|
||||
need(type(result.get('exit_code')) is int and
|
||||
timestamp(result.get('finished')) >= timestamp(result['start']), 'native still open')
|
||||
streams(result,prefix,get,suffix='native')
|
||||
get(prefix + "observations.md")
|
||||
matrix.append({"kind": "native", "name": name, "source": result.get("binary_source"),
|
||||
"receipt": prefix + "finished.json", "binary_pins": result.get("binary_pins"),
|
||||
"exit": result["exit_code"], "acceptance": "not inferred"})
|
||||
for name in config["oracle_runs"]:
|
||||
prefix = "navigation-oracle-runs/" + safe(name) + "/"
|
||||
before = decode(get(prefix+'initial.json'))
|
||||
raw = get(prefix+'result.json')
|
||||
result = decode(raw)
|
||||
same_fields(before,result,('case','head','started','environment','qa_root','source_inputs'))
|
||||
need(result['case']==name,'oracle case mismatch')
|
||||
need(re.fullmatch('[0-9a-f]{40}',result['head']),'oracle source identity')
|
||||
inventory(result['source_inputs'])
|
||||
need(result.get('inputs_unchanged_except_initial_isolated_lock') is True,'oracle input drift')
|
||||
target = 'C:/Users/User/Documents/Git/ARCADIA-Studio/.local/target-gnullvm'
|
||||
planned = [
|
||||
{'label':'tests','command':f'cargo test --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir {target} -- --nocapture --test-threads=1'},
|
||||
{'label':'build','command':f'cargo build --locked --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir {target}'}]
|
||||
outcome = step_sequence(result,planned,get,prefix,True)
|
||||
final_lock = hash_string(result['final_utility_lock_sha256'])
|
||||
need(all(s['utility_lock_sha256']==final_lock for s in result['steps']), 'oracle lock drift')
|
||||
if name == 'attempt01':
|
||||
need(digest(raw)=='6523dd082eb02ad6f93130320fdfac4bcd3e320e61534a8ae1a54977537d17e7'
|
||||
and outcome=='stopped-at-first-failure','unknown historical oracle failure')
|
||||
else:
|
||||
need(outcome=='completed-successfully','oracle build not complete')
|
||||
hash_string(result['executable']['sha256'])
|
||||
need(type(result['executable']['bytes']) is int and result['executable']['bytes']>0,'oracle executable size')
|
||||
need(digest(get('navigation-oracle-qa-'+name+'/checks.jsonl'))==result['qa_checks_sha256'], 'oracle QA pin')
|
||||
matrix.append({'kind':'oracle-run','name':name,'source':result['head'],'closure':outcome,
|
||||
'receipt':prefix+'result.json','exits':[s['exit_code'] for s in result['steps']],
|
||||
'acceptance':'not inferred'})
|
||||
command_root = 'navigation-oracle-commands/'
|
||||
commands = sorted({p[len(command_root):].split('/')[0] for p in paths if p.startswith(command_root)})
|
||||
if not commands:
|
||||
return matrix
|
||||
build_path = 'navigation-oracle-runs/attempt02/result.json'
|
||||
build_raw = get(build_path)
|
||||
build = decode(build_raw)
|
||||
for name in commands:
|
||||
prefix = command_root+name+'/'
|
||||
safe(name)
|
||||
if name == 'draw-fixture-runtime':
|
||||
need({p for p in paths if p.startswith(prefix)} == {prefix+'failure.json'}, 'recorder failure files changed')
|
||||
failure_raw = get(prefix+'failure.json')
|
||||
failure = decode(failure_raw)
|
||||
need(digest(failure_raw)=='ecfa343366a1e156f9f20e9522cdc89f98352975b9100d3b7b2e6140fb4bafe3'
|
||||
and failure['oracle_started'] is False, 'unknown recorder failure')
|
||||
matrix.append({'kind':'oracle-command','name':name,'closure':'recorder-failed-before-launch',
|
||||
'receipt':prefix+'failure.json','acceptance':'none'})
|
||||
continue
|
||||
raw = get(prefix+'receipt.json')
|
||||
result = decode(raw)
|
||||
need(result['label']==name and type(result['exit_code']) is int and
|
||||
timestamp(result['finished'])>=timestamp(result['started']), 'open/misidentified oracle command')
|
||||
argv = result['argv']
|
||||
need(isinstance(argv,list) and all(isinstance(x,str) and x for x in argv) and
|
||||
len(argv)>=3 and argv[1] in ('fixture','snapshot','compare'), 'oracle argv')
|
||||
need(result['exe_unchanged'] is True and result['exe_sha256']==build['executable']['sha256']
|
||||
and result['build_receipt_sha256']==digest(build_raw), 'oracle build/executable binding')
|
||||
need(re.fullmatch('[0-9a-f]{40}',result['head']), 'oracle source identity')
|
||||
for stream in ('stdout','stderr'):
|
||||
size=result[stream+'_bytes']
|
||||
need(type(size) is int and size>=0 and len(get(prefix+stream))==size,'oracle stream size')
|
||||
if name == 'draw-fixture':
|
||||
need(digest(raw)=='89620b6726a510795a02bd173e0540c48610dbff0135915b0ce3ad81a6868818'
|
||||
and result['exit_code']==3221225781,'unknown runtime failure')
|
||||
outcome='runtime-load-failure-no-admission'
|
||||
else:
|
||||
need(result['exit_code']==0 and get(prefix+'stderr')==b'', 'oracle command failed: review/classify first')
|
||||
need(result['runtime_sha256']==config['oracle_runtime_sha256'] and
|
||||
result['path']=='C:\\Windows\\System32;C:\\Windows', 'oracle runtime environment')
|
||||
out=decode(get(prefix+'stdout'))
|
||||
if argv[1]=='snapshot':
|
||||
need(len(argv)==4 and out['status']=='captured', 'snapshot receipt status')
|
||||
dest=logical_argument(argv[3],config)
|
||||
snapshot=decode(get(dest+'/snapshot.json'))
|
||||
need(out['entry']==snapshot['entry'] and out['files']==len(snapshot['files']) and
|
||||
out['source_bytes']==sum(len(v) for v in snapshot['files'].values()) and
|
||||
out['engineering_diagnostics']==snapshot['diagnostics'], 'snapshot metadata binding')
|
||||
need(decode(get(dest+'/project.json'))==snapshot['project'], 'snapshot exported Project mismatch')
|
||||
elif argv[1]=='compare':
|
||||
need(len(argv) in (4,7) and out['pass'] is True and out['source_contract_pass'] is True
|
||||
and out['expected_entire_project_equal'] is True and
|
||||
out['raw_entry_and_path_set_equal'] is True,'compare status')
|
||||
for arg in argv[2:4]: get(logical_argument(arg,config)+'/snapshot.json')
|
||||
if len(argv)==4:
|
||||
need(out['permitted_projection'] is None and out['raw_source_bytes_equal'] is True,'exact compare scope')
|
||||
else:
|
||||
need(argv[4]=='--projection' and out['permitted_projection']=={'diagram':argv[5],'projection':argv[6]}
|
||||
and out['projection_declaration_diff']['outside_projection_value_exact'] is True,'projection compare scope')
|
||||
else:
|
||||
need(len(argv)==3 and out.get('status')=='fixture_written','fixture status')
|
||||
dest=logical_argument(argv[2],config)
|
||||
need(logical_argument(out['entry'],config)==dest+'/project.arcadia' and
|
||||
logical_argument(out['project_json'],config)==dest+'/project.json','fixture destination')
|
||||
get(dest+'/project.arcadia'); get(dest+'/project.json')
|
||||
outcome='recorded-success-metadata-checked-not-rerun'
|
||||
matrix.append({'kind':'oracle-command','name':name,'closure':outcome,'exit':result['exit_code'],
|
||||
'receipt':prefix+'receipt.json','acceptance':'not inferred'})
|
||||
return matrix
|
||||
|
||||
|
||||
def logical_argument(value, config):
|
||||
# Historical argv is data. This strips only a declared original root; never resolves/extracts it.
|
||||
value=value.replace('\\','/')
|
||||
for prefix in (config['original_source_local'].rstrip('/')+'/', '.local/'):
|
||||
if value.startswith(prefix):
|
||||
return safe(value[len(prefix):])
|
||||
raise ValueError('unknown historical source argument: '+value)
|
||||
|
||||
|
||||
def write_new(path, data):
|
||||
with path.open("xb") as file:
|
||||
file.write(data)
|
||||
|
||||
|
||||
def fresh_directory(path):
|
||||
for item in [path.parent, *path.parent.parents]:
|
||||
s = item.lstat()
|
||||
need(stat.S_ISDIR(s.st_mode) and not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s, "st_file_attributes", 0) & 0x400, "output parent link/reparse")
|
||||
path.mkdir(parents=False, exist_ok=False)
|
||||
|
||||
|
||||
def collect(source, config):
|
||||
entries, blobs = [], {}
|
||||
selection = selected(source, config)
|
||||
total = 0
|
||||
for name, category, file in selection:
|
||||
permitted(name)
|
||||
data = read(file)
|
||||
total += len(data)
|
||||
need(total <= MAX_TOTAL, 'logical packet size cap')
|
||||
sha = digest(data)
|
||||
entries.append({"path": name, "category": category, "size": len(data), "sha256": sha})
|
||||
blobs[sha] = data
|
||||
coverage(config, entries)
|
||||
return selection, entries, blobs
|
||||
|
||||
|
||||
def stable(source, config, selection, entries):
|
||||
need([(a,b) for a,b,_ in selected(source,config)] == [(a,b) for a,b,_ in selection],
|
||||
"input file set changed during collection")
|
||||
for entry in entries:
|
||||
need(digest(read(source / entry["path"])) == entry["sha256"], "input changed during collection")
|
||||
|
||||
|
||||
def freeze(config_path, source, frozen_path):
|
||||
# Creates only a candidate inventory. Human review/pinning must precede build.
|
||||
source=Path(source).absolute()
|
||||
config_bytes=read(Path(config_path).absolute())
|
||||
config=decode(config_bytes)
|
||||
validate_config(config)
|
||||
need('frozen_entries' not in config, 'already frozen')
|
||||
selection,entries,blobs=collect(source,config)
|
||||
index = {x["path"]: blobs[x["sha256"]] for x in entries}
|
||||
receipts(config,index.__getitem__,set(index))
|
||||
stable(source,config,selection,entries)
|
||||
config['frozen_entries']=entries
|
||||
config['template_sha256']=digest(config_bytes)
|
||||
config['inventory_review']='REQUIRED: inspect logical file list and content before build/publication'
|
||||
path=Path(frozen_path).absolute()
|
||||
for item in [path.parent,*path.parent.parents]:
|
||||
s=item.lstat()
|
||||
need(stat.S_ISDIR(s.st_mode) and not stat.S_ISLNK(s.st_mode) and
|
||||
not getattr(s,'st_file_attributes',0)&0x400,'frozen output parent link/reparse')
|
||||
write_new(path,(json.dumps(config,indent=2)+'\n').encode())
|
||||
return {'frozen_config_sha256':digest(read(path)),'logical_files':len(entries),
|
||||
'review_required':True,'acceptance':'not inferred'}
|
||||
|
||||
|
||||
def build(config_path, source, output):
|
||||
source, output = Path(source).absolute(), Path(output).absolute()
|
||||
config_bytes = read(Path(config_path).absolute())
|
||||
config = decode(config_bytes)
|
||||
validate_config(config,frozen=True)
|
||||
selection,entries,blobs=collect(source,config)
|
||||
need(entries==config['frozen_entries'],'source differs from frozen inventory')
|
||||
index = {x["path"]: blobs[x["sha256"]] for x in entries}
|
||||
matrix = receipts(config, index.__getitem__,set(index))
|
||||
verifier = read(Path(__file__).absolute())
|
||||
need(index.get('navigation-evidence.py')==verifier,'running helper differs from frozen helper')
|
||||
fresh_directory(output)
|
||||
(output / "objects").mkdir()
|
||||
for sha, data in blobs.items():
|
||||
write_new(output / "objects" / sha, data)
|
||||
write_new(output / "navigation-evidence.py", verifier)
|
||||
write_new(output / "config.json", config_bytes)
|
||||
stable(source,config,selection,entries)
|
||||
manifest = {"schema": "navigation-evidence-v2", "entries": entries, "matrix": matrix,
|
||||
"config_sha256": digest(config_bytes), "verifier_sha256": digest(verifier),
|
||||
"documentation": config["documentation"], "acceptance": "not inferred"}
|
||||
write_new(output / "manifest.json", (json.dumps(manifest, indent=2) + "\n").encode())
|
||||
report = verify(output)
|
||||
# Read-only attribute is accidental-write protection, not tamper proofing.
|
||||
for file in walk_checked(output):
|
||||
if file.is_file():
|
||||
file.chmod(stat.S_IREAD)
|
||||
return report
|
||||
|
||||
|
||||
def verify(output):
|
||||
output = Path(output).absolute()
|
||||
manifest = decode(read(output / "manifest.json"))
|
||||
need(manifest["schema"] == "navigation-evidence-v2", "manifest schema")
|
||||
config_bytes = read(output / "config.json")
|
||||
need(digest(config_bytes) == manifest["config_sha256"], "config hash")
|
||||
need(digest(read(output / "navigation-evidence.py")) == manifest["verifier_sha256"], "verifier hash")
|
||||
config = decode(config_bytes)
|
||||
validate_config(config,frozen=True)
|
||||
need(manifest['entries']==config['frozen_entries'],'manifest differs from frozen whitelist')
|
||||
need(manifest['documentation']==config['documentation'] and manifest['acceptance']=='not inferred','manifest scope')
|
||||
coverage(config,manifest['entries'])
|
||||
names, objects, index = set(), set(), {}
|
||||
total=0
|
||||
for entry in manifest["entries"]:
|
||||
name = safe(entry["path"])
|
||||
need(name.casefold() not in names, "duplicate manifest path")
|
||||
need(not any(x.casefold() in EXCLUDED for x in PurePosixPath(name).parts), "excluded input")
|
||||
names.add(name.casefold())
|
||||
sha = entry["sha256"]
|
||||
need(isinstance(sha,str) and re.fullmatch("[0-9a-f]{64}", sha), "invalid digest")
|
||||
need(type(entry["size"]) is int and 0 <= entry["size"] <= MAX_FILE, "invalid size")
|
||||
total+=entry['size']
|
||||
need(total<=MAX_TOTAL,'logical size cap')
|
||||
data = read(output / "objects" / sha)
|
||||
need(len(data) == entry["size"] and digest(data) == sha, "object mismatch")
|
||||
index[name] = data
|
||||
objects.add("objects/" + sha)
|
||||
need(index.get('navigation-evidence.py')==read(output/'navigation-evidence.py'),'selfpin object mismatch')
|
||||
expected = objects | {"manifest.json", "config.json", "navigation-evidence.py"}
|
||||
actual = set()
|
||||
for file in walk_checked(output):
|
||||
s = file.lstat()
|
||||
need(not stat.S_ISLNK(s.st_mode) and not getattr(s,"st_file_attributes",0) & 0x400,
|
||||
"packet link/reparse")
|
||||
if file.is_file():
|
||||
actual.add(file.relative_to(output).as_posix())
|
||||
need(actual == expected, "extra or missing packet file")
|
||||
need(receipts(config, index.__getitem__,set(index)) == manifest["matrix"], "matrix mismatch")
|
||||
return {"integrity": "PASS", "logical_files": len(names), "objects": len(objects),
|
||||
"semantic_acceptance": "not evaluated", "manifest_sha256": digest(read(output / "manifest.json"))}
|
||||
|
||||
|
||||
def selftest(output):
|
||||
root = Path(output).absolute()
|
||||
fresh_directory(root)
|
||||
tests = []
|
||||
def reject(label, fn):
|
||||
try:
|
||||
fn()
|
||||
except (ValueError, FileNotFoundError, FileExistsError, KeyError, zipfile.BadZipFile):
|
||||
tests.append(label)
|
||||
return
|
||||
raise ValueError('falsifier unexpectedly accepted: '+label)
|
||||
source=root/'source'; source.mkdir()
|
||||
script=read(Path(__file__).absolute())
|
||||
for name,data in [('a.txt',b'evidence'),('b.txt',b'evidence'),('navigation-evidence.py',script)]:
|
||||
write_new(source/name,data)
|
||||
config={'schema':'navigation-evidence-config-v2','includes':[
|
||||
{'path':name,'category':'test','kind':'file'} for name in ('a.txt','b.txt','navigation-evidence.py')],
|
||||
'gates':[],'native_cases':[],'oracle_runs':[],'documentation':[],
|
||||
'exclusions':sorted(EXCLUDED),'original_source_local':'C:/example/.local',
|
||||
'oracle_runtime_sha256':'0'*64}
|
||||
template=root/'template.json'; write_new(template,json.dumps(config).encode())
|
||||
frozen=root/'frozen.json'; freeze(template,source,frozen)
|
||||
packet=root/'valid'; good=build(frozen,source,packet)
|
||||
need(good['logical_files']==3 and good['objects']==2,'dedup roundtrip')
|
||||
tests.append('valid-freeze-build-verify-dedup')
|
||||
reject('existing output refused',lambda:build(frozen,source,packet))
|
||||
reject('existing freeze refused',lambda:freeze(template,source,frozen))
|
||||
original=decode(read(packet/'manifest.json'))
|
||||
original_config=decode(read(packet/'config.json'))
|
||||
# Each falsifier gets its own create-new packet; valid packet stays read-only.
|
||||
for mutation in ('alter','delete','duplicate','traversal','extra','extra-listed','duplicate-json',
|
||||
'selfpin','omitted-required','unwhitelisted','category'):
|
||||
p=root/mutation; p.mkdir(); (p/'objects').mkdir()
|
||||
m=copy.deepcopy(original); cfg=copy.deepcopy(original_config)
|
||||
if mutation=='duplicate': m['entries'].append(dict(m['entries'][0]))
|
||||
if mutation=='traversal': m['entries'][0]['path']='../a.txt'
|
||||
if mutation=='extra-listed': m['entries'].append({'path':'absent.txt','category':'test','size':3,'sha256':'a'*64})
|
||||
if mutation=='omitted-required':
|
||||
m['entries']=[e for e in m['entries'] if e['path']!='a.txt']; cfg['frozen_entries']=copy.deepcopy(m['entries'])
|
||||
if mutation=='unwhitelisted':
|
||||
e=dict(m['entries'][0]); e['path']='unauthorized.txt'; m['entries'].append(e); cfg['frozen_entries']=copy.deepcopy(m['entries'])
|
||||
if mutation=='category':
|
||||
m['entries'][0]['category']='other'; cfg['frozen_entries']=copy.deepcopy(m['entries'])
|
||||
if mutation=='selfpin':
|
||||
m['verifier_sha256']=digest(b'changed')
|
||||
cb=json.dumps(cfg).encode(); m['config_sha256']=digest(cb)
|
||||
raw=json.dumps(m).encode()
|
||||
if mutation=='duplicate-json': raw=raw.replace(b'"schema":',b'"schema":"bad", "schema":',1)
|
||||
write_new(p/'manifest.json',raw); write_new(p/'config.json',cb)
|
||||
write_new(p/'navigation-evidence.py',b'changed' if mutation=='selfpin' else script)
|
||||
for obj in (packet/'objects').iterdir():
|
||||
if mutation=='delete' and obj.name==digest(b'evidence'): continue
|
||||
write_new(p/'objects'/obj.name,b'changed' if mutation=='alter' and obj.name==digest(b'evidence') else read(obj))
|
||||
if mutation=='extra': write_new(p/'extra.txt',b'extra')
|
||||
reject(mutation,lambda:verify(p))
|
||||
planned=[{'label':'one','command':'first'},{'label':'two','command':'second'}]
|
||||
closed={'started':'2026-01-01T00:00:00+00:00','finished':'2026-01-01T00:00:01+00:00',
|
||||
'exit_code':0,'stdout_bytes':0,'stderr_bytes':0}
|
||||
step=dict(closed,label='one',command='first')
|
||||
partial={'steps':[step]}
|
||||
reject('successful prefix still open',lambda:step_sequence(partial,planned,lambda _:b'','',True))
|
||||
complete={'steps':[step,dict(closed,label='two',command='second')]}
|
||||
reject('successful missing final timestamp',lambda:step_sequence(complete,planned,lambda _:b'','',True))
|
||||
complete['finished']=closed['finished']
|
||||
need(step_sequence(complete,planned,lambda _:b'','',True)=='completed-successfully','valid closure')
|
||||
failed={'steps':[dict(step,exit_code=101)]}
|
||||
need(step_sequence(failed,planned,lambda _:b'','',True)=='stopped-at-first-failure','failed prefix classification')
|
||||
tests.append('complete success and stopped failure distinguished')
|
||||
reject('stream size mismatch',lambda:step_sequence(complete,planned,lambda _:b'x','',True))
|
||||
reject('native/oracle identity mismatch',lambda:same_fields({'pid':1},{'pid':2},['pid']))
|
||||
reject('missing identity on both sides',lambda:same_fields({}, {}, ['binary_source']))
|
||||
reject('duplicate receipt/config JSON',lambda:decode(b'{"steps":[],"steps":[]}'))
|
||||
for constant in (b'NaN', b'Infinity', b'-Infinity'):
|
||||
reject('nonfinite JSON '+constant.decode(),lambda value=constant:decode(b'{"size":'+value+b'}'))
|
||||
reject('dot root path',lambda:safe('.'))
|
||||
native={'case':'baseline01','binary_source':'1'*40,'binary_pins':{'arcadia-studio.exe':'2'*64,'libunwind.dll':'3'*64},
|
||||
'pid':7,'start':closed['started'],'argv':['C:/private/bin/arcadia-studio.exe'],
|
||||
'cwd':'C:/private/working','path':'C:/Windows','local_app_data':'C:/private/profile',
|
||||
'fixture_sha256':'4'*64,'release_tag':'historical-test'}
|
||||
done=dict(native,finished=closed['finished'],exit_code=0,stdout_bytes=0,stderr_bytes=0,
|
||||
binaries_unchanged=True,fixture_unchanged=True)
|
||||
nc=dict(config,native_cases=['baseline01'])
|
||||
np='navigation-native/baseline01/'
|
||||
def native_records(result):
|
||||
data={np+'started.json':json.dumps(native).encode(),np+'finished.json':json.dumps(result).encode(),
|
||||
np+'stdout.log':b'',np+'stderr.log':b'',np+'observations.md':b'bounded selftest'}
|
||||
return receipts(nc,data.__getitem__,set(data))
|
||||
need(native_records(done)[0]['exit']==0,'legacy native fixture valid')
|
||||
for key,value in [('pid',8),('fixture_sha256','5'*64),('binaries_unchanged',False),('finished',None),('stdout_bytes',1)]:
|
||||
reject('native '+key,lambda k=key,v=value:native_records(dict(done,**{k:v})))
|
||||
# A command receipt must bind the included build object and its runtime/status.
|
||||
bp='navigation-oracle-runs/attempt02/result.json'; cp='navigation-oracle-commands/example/'
|
||||
bb=json.dumps({'executable':{'sha256':'6'*64}}).encode()
|
||||
oc=dict(config,oracle_runtime_sha256='7'*64)
|
||||
command={'label':'example','argv':['C:/oracle.exe','compare','.local/a','.local/b'],
|
||||
'started':closed['started'],'finished':closed['finished'],'exit_code':0,
|
||||
'exe_unchanged':True,'exe_sha256':'6'*64,'build_receipt_sha256':digest(bb),
|
||||
'head':'8'*40,'runtime_sha256':'7'*64,'path':'C:\\Windows\\System32;C:\\Windows',
|
||||
'stderr_bytes':0}
|
||||
command_output=json.dumps({'pass':True,'source_contract_pass':True,'expected_entire_project_equal':True,
|
||||
'raw_entry_and_path_set_equal':True,'permitted_projection':None,'raw_source_bytes_equal':True}).encode()
|
||||
command['stdout_bytes']=len(command_output)
|
||||
def command_records(result):
|
||||
data={bp:bb,cp+'receipt.json':json.dumps(result).encode(),cp+'stdout':command_output,cp+'stderr':b'',
|
||||
'a/snapshot.json':b'{}','b/snapshot.json':b'{}'}
|
||||
return receipts(oc,data.__getitem__,set(data))
|
||||
need(command_records(command)[0]['exit']==0,'command metadata valid')
|
||||
for key,value in [('build_receipt_sha256','9'*64),('runtime_sha256','9'*64),('exit_code',2),
|
||||
('finished',None),('stdout_bytes',0),('exe_unchanged',False)]:
|
||||
reject('oracle command '+key,lambda k=key,v=value:command_records(dict(command,**{k:v})))
|
||||
for name in ('../x','C:/x','a\\b','AUX.txt','a/../b','profile/x','auth/settings.json','x.exe'):
|
||||
reject('unsafe/private '+name,lambda n=name:permitted(n))
|
||||
for mutation in ('wrong-content','missing','extra','case-collision','traversal','symlink'):
|
||||
buf=io.BytesIO()
|
||||
with zipfile.ZipFile(buf,'w') as z:
|
||||
if mutation!='missing': z.writestr('a.txt',b'bad' if mutation=='wrong-content' else b'ok')
|
||||
if mutation=='extra': z.writestr('extra.txt',b'ok')
|
||||
if mutation=='case-collision': z.writestr('A.txt',b'ok')
|
||||
if mutation=='traversal': z.writestr('../x',b'ok')
|
||||
if mutation=='symlink':
|
||||
info=zipfile.ZipInfo('link'); info.create_system=3; info.external_attr=(stat.S_IFLNK|0o777)<<16
|
||||
z.writestr(info,b'a.txt')
|
||||
reject('ZIP '+mutation,lambda b=buf.getvalue():check_zip(b,{'a.txt':digest(b'ok')}))
|
||||
crc=io.BytesIO()
|
||||
with zipfile.ZipFile(crc,'w',compression=zipfile.ZIP_STORED) as z:
|
||||
z.writestr('a.txt',b'crc-evidence')
|
||||
damaged=bytearray(crc.getvalue()); at=damaged.index(b'crc-evidence'); damaged[at]^=1
|
||||
reject('ZIP CRC corruption',lambda:check_zip(bytes(damaged),{'a.txt':digest(b'crc-evidence')}))
|
||||
# Real filesystem link test when the platform permits unprivileged link creation.
|
||||
skipped=[]
|
||||
link=root/'linked-source'
|
||||
try:
|
||||
link.symlink_to(source,target_is_directory=True)
|
||||
except (OSError,NotImplementedError) as error:
|
||||
skipped.append('filesystem reparse/symlink creation unavailable: '+type(error).__name__)
|
||||
else:
|
||||
reject('filesystem link ancestor',lambda:read(link/'a.txt'))
|
||||
reject('output link ancestor',lambda:fresh_directory(link/'new'))
|
||||
# Changed source after freeze must fail before packet creation.
|
||||
(source/'a.txt').write_bytes(b'changed')
|
||||
reject('source changed since freeze',lambda:build(frozen,source,root/'changed-source'))
|
||||
report = {"selftest":"PASS", "cases":tests,'skipped':skipped,"scope":"transport integrity only; skipped controls are not passes"}
|
||||
write_new(root / "result.json", (json.dumps(report,indent=2)+"\n").encode())
|
||||
return report
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mode, *args = sys.argv[1:]
|
||||
need(mode in ("freeze", "build", "verify", "selftest"), "unknown mode")
|
||||
print(json.dumps({"freeze":freeze,"build":build,"verify":verify,"selftest":selftest}[mode](*args),indent=2))
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\peer01\\bin\\arcadia-studio.exe",
|
||||
"id": 25822524,
|
||||
"title": "Restore safe water pressure — release 1 walkthrough — Arcadia Studio"
|
||||
},
|
||||
"accessibility": {
|
||||
"focused_element": "0 window Restore safe water pressure — release 1 walkthrough — Arcadia Studio Secondary Actions: Raise",
|
||||
"tree": "Window: \"Restore safe water pressure — release 1 walkthrough — Arcadia Studio\", App: arcadia-studio.exe.\n\t0 window Restore safe water pressure — release 1 walkthrough — Arcadia Studio Secondary Actions: Raise\n\t\t1 title bar (settable, string) Value: Restore safe water pressure — release 1 walkthrough — Arcadia Studio\n\t\t\t2 menu bar System ID: MenuBar\n\t\t\t\t3 menu item System\n\t\t\t4 button Minimize\n\t\t\t5 button Maximize\n\t\t\t6 button Close\n\nThe focused UI element is 0 window Restore safe water pressure — release 1 walkthrough — Arcadia Studio Secondary Actions: Raise."
|
||||
},
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "04-before-oa-edge-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "f267facc804d62ea065ae970b7315cc0a5e580dbf726a291626a354f923adf54"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,12 @@
|
||||
sa_function sa_target "SA execute repair" id "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3" {
|
||||
}
|
||||
|
||||
sa_port functional_exchange_sa_dispatch_repair_sa_execute_repair_input "Functional exchange: SA dispatch repair → SA execute repair input" id "0a6f4b4b-911c-531d-82d5-172bdc8b0564" {
|
||||
contract {
|
||||
FunctionInput {
|
||||
incoming_items [];
|
||||
};
|
||||
};
|
||||
parent @sa_target;
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 131 KiB |
@@ -0,0 +1,60 @@
|
||||
sa_scenario sa_conversation "Repair request and measured reply" id "730b27ca-b039-5a3b-8dc6-03418f9061b3" {
|
||||
context @sa_restore;
|
||||
postcondition "The controller has received the measured pressure after repair";
|
||||
precondition "A pressure alarm has been assessed";
|
||||
steps [@sa_call, @sa_reply];
|
||||
viewpoint BoundaryInterface;
|
||||
}
|
||||
|
||||
sa_occurrence sa_call "Request valve repair" id "8bab4783-a098-5984-9e36-493957caf26c" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @sa_contact;
|
||||
message_kind Call;
|
||||
receiver @sa_responder;
|
||||
sender @sa_caller;
|
||||
values [{
|
||||
expression "420.0";
|
||||
field @sa_pressure_parameter;
|
||||
}];
|
||||
};
|
||||
};
|
||||
parent @sa_conversation;
|
||||
}
|
||||
|
||||
sa_lifeline sa_caller "Controller" id "98c61788-2fee-51f4-8cf9-de48e8c70e9e" {
|
||||
parent @sa_conversation;
|
||||
represented @sa_control;
|
||||
}
|
||||
|
||||
sa_occurrence sa_reply "Return pressure after repair" id "4e1c0a56-ce80-5f13-a93a-6956de54880c" {
|
||||
body {
|
||||
Exchange {
|
||||
exchange @sa_contact;
|
||||
message_kind Reply;
|
||||
receiver @sa_caller;
|
||||
reply_to @sa_call;
|
||||
sender @sa_responder;
|
||||
values [{
|
||||
expression "320.0";
|
||||
field @sa_pressure_parameter;
|
||||
}];
|
||||
};
|
||||
};
|
||||
parent @sa_conversation;
|
||||
}
|
||||
|
||||
sa_lifeline sa_responder "Technician" id "6249b54a-e396-5338-82b9-89266c55670d" {
|
||||
parent @sa_conversation;
|
||||
represented @sa_technician;
|
||||
}
|
||||
|
||||
artifact repair_test_evidence "Repair conversation acceptance notes" id "f5922bf7-05d8-590f-86e9-1d09f37f4fa6" {
|
||||
description "A local teaching artifact, not proof of a real valve's safety.";
|
||||
fragment "acceptance";
|
||||
owner @sa_conversation;
|
||||
relation Verifies;
|
||||
revision "lesson-1";
|
||||
uri "evidence/repair-check.md";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\draw01\\bin\\arcadia-studio.exe",
|
||||
"id": 43582746,
|
||||
"title": "Cross-layer native Draw verification — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "04-sa-draw-armed-oa-retained-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "1aea20c087a78156ab476ce8269edd295a906a86cd45476147f8483363cab690"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 132 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\draw01\\bin\\arcadia-studio.exe",
|
||||
"id": 43582746,
|
||||
"title": "Cross-layer native Draw verification — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "05-wrong-layer-target-refused-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "a73be6efe78cc071da3c4ee352a11023689cbb8ddf0632121be40dd920d9e574"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
oa_activity oa_source "OA request repair" id "e4a68a7d-6949-5c17-9263-c03c03c21af2" {
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"case": "relocation01-reopen",
|
||||
"binary_source": "6018ae30b9296b9f6b37d7fc038175080d15fafb",
|
||||
"start": "2026-10-02T06:55:10.845877+00:00",
|
||||
"binary_pins": {
|
||||
"arcadia-studio.exe": "f86b29555d1e512538ff3f7ee75619b0286add5aa2d60cdc724e8c27cc931165",
|
||||
"libunwind.dll": "f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4"
|
||||
},
|
||||
"original_fixtures": {
|
||||
"eight-card.json": "9db1015369f450d5810c96a01fddb8a009d57f6aafbda5182703f15ded23d9c8",
|
||||
"draw/oracle.json": "b3eb669d353bf9f3490faf50cdf65b9f46d77719de993557c56ea159d4696ae0",
|
||||
"draw/project.arcadia": "2a657d69cf30487364672cd45d0a0fa01a78d64140cc58eeee47e61ea4a112d4",
|
||||
"draw/project.json": "520e5645bcf87380a50be08cb9ef758b85a19414e58ce3bd61926c09767a7963",
|
||||
"draw/views/diagram_mixed_oa.arcadia": "d481e137236c62b13afebae3b14d6eedb61c27b7f6566cb3f6dc325d4aaea927",
|
||||
"draw/views/diagram_mixed_sa.arcadia": "b5c326eafea071ed0f058b8a792271c2bb83c87eab6b7718a51a9cd3782d449d",
|
||||
"draw/oa/activities/activity_oa_source.arcadia": "4d8b0971cc64a3ae13f7bf4b0c79a5ee978facb8ee660c2690c473b0b935ba5f",
|
||||
"draw/oa/activities/activity_oa_target.arcadia": "afef0fbac3fb7fbe42936a0c213e72541b31a471af8257a9462bdafb1f39c8ab",
|
||||
"draw/sa/functions/function_sa_source.arcadia": "536de40f46f64c745af0a41a8dfa70309782e90617be69dd37610cb4e9730ed2",
|
||||
"draw/sa/functions/function_sa_target.arcadia": "cb6d97f9b3c112f9b50bb9bb018ce0e6e34d51c8ae53afd6d6493367f371f1fa",
|
||||
"relocation-fixture.json": "282a3ef1206000c1bd8d32c55bde950831806096fbfcec7c324e182a49d7df4c"
|
||||
},
|
||||
"argv": [
|
||||
"C:\\ar-nav-01a0d717\\.local\\navigation-native\\relocation01-reopen\\bin\\arcadia-studio.exe"
|
||||
],
|
||||
"cwd": "C:\\ar-nav-01a0d717\\.local\\navigation-native\\relocation01-reopen\\working",
|
||||
"path": "C:\\Windows\\System32;C:\\Windows",
|
||||
"local_app_data": "C:\\ar-nav-01a0d717\\.local\\navigation-native\\relocation01-reopen\\profile",
|
||||
"pid": 6776
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"label": "draw01-dataflow",
|
||||
"argv": [
|
||||
"C:\\ar-nav-01a0d717\\.local\\navigation-oracle-runtime\\navigation-oracle.exe",
|
||||
"snapshot",
|
||||
"C:/ar-nav-01a0d717/.local/navigation-native/draw01/working/draw-save/project.arcadia",
|
||||
"C:/ar-nav-01a0d717/.local/navigation-native/draw01/dataflow"
|
||||
],
|
||||
"started": "2026-10-02T06:31:09.966755+00:00",
|
||||
"exe_sha256": "bd2db2e177d9d21f44b45e5efa89071e00155d31509672bf5aea6a57915e09a6",
|
||||
"runtime_sha256": "f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4",
|
||||
"path": "C:\\Windows\\System32;C:\\Windows",
|
||||
"head": "c7065db76ecca3faa733ccb5f09b87fd0a0958be",
|
||||
"build_receipt_sha256": "1ed50fe9c42ff4a67084ed70ce934404f5549ca68bc54e6d2f1436e66ea8d22f",
|
||||
"exit_code": 0,
|
||||
"finished": "2026-10-02T06:31:10.897201+00:00",
|
||||
"stdout_bytes": 7336,
|
||||
"stderr_bytes": 0,
|
||||
"exe_unchanged": true
|
||||
}
|
||||
|
After Width: | Height: | Size: 117 KiB |
@@ -0,0 +1,17 @@
|
||||
oa_exchange_category oa_contact_category "Field contact" id "19bd3a3a-4d0c-56d5-bf50-a6b43fd81771" {
|
||||
exchanges [@oa_contact];
|
||||
family "Participant";
|
||||
}
|
||||
|
||||
oa_event oa_high_pressure "Pressure alarm detected" id "65d6b8d6-271a-5c84-8b42-abbe310c955e" {
|
||||
body {
|
||||
Change {
|
||||
expression "measured_pressure > operating_limit";
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
oa_exchange_category oa_repair_traffic "Repair work exchanges" id "9349b353-53f2-56f8-b571-a05b09335cdf" {
|
||||
exchanges [@oa_repair_request, @oa_repair_report];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"head": "6018ae30b9296b9f6b37d7fc038175080d15fafb",
|
||||
"steps": [
|
||||
{"label":"native-bins","command":"cargo build --workspace --bins --locked"}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 124 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 148 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\fixed01-reopen\\bin\\arcadia-studio.exe",
|
||||
"id": 17957882,
|
||||
"title": "Restore safe water pressure — release 1 walkthrough — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "05-reopen-saved-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "ab1fde5b53c66d227235610ab3aed1069ab86a9d70e4b10e18148d6275e5f925"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"schema_version": 7,
|
||||
"id": "01324fcc-9456-5341-8fd0-2c261bddf186",
|
||||
"alias": "navigation_draw",
|
||||
"name": "Cross-layer native Draw verification",
|
||||
"description": "Synthetic native acceptance fixture; four complete typed work elements, two saved mixed-layer views, initially no relations.",
|
||||
"elements": {
|
||||
"0a6f4b4b-911c-531d-82d5-172bdc8b0564": {
|
||||
"id": "0a6f4b4b-911c-531d-82d5-172bdc8b0564",
|
||||
"alias": "functional_exchange_sa_dispatch_repair_sa_execute_repair_input",
|
||||
"name": "Functional exchange: SA dispatch repair → SA execute repair input",
|
||||
"description": "",
|
||||
"parent": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionInput": {
|
||||
"incoming_items": []
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"7957e536-d4b4-50c2-8271-ef829952353f": {
|
||||
"id": "7957e536-d4b4-50c2-8271-ef829952353f",
|
||||
"alias": "functional_exchange_sa_dispatch_repair_sa_execute_repair_output",
|
||||
"name": "Functional exchange: SA dispatch repair → SA execute repair output",
|
||||
"description": "",
|
||||
"parent": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionOutput": {
|
||||
"outgoing_items": []
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"7b9d5c34-1c45-5048-a3a0-77e5784a5a69": {
|
||||
"id": "7b9d5c34-1c45-5048-a3a0-77e5784a5a69",
|
||||
"alias": "oa_target",
|
||||
"name": "OA perform repair",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Oa": "Activity"
|
||||
}
|
||||
},
|
||||
"c5e2f9d6-ba03-50b6-9860-b2dd717c02b3": {
|
||||
"id": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"alias": "sa_target",
|
||||
"name": "SA execute repair",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
},
|
||||
"e4a68a7d-6949-5c17-9263-c03c03c21af2": {
|
||||
"id": "e4a68a7d-6949-5c17-9263-c03c03c21af2",
|
||||
"alias": "oa_source",
|
||||
"name": "OA request repair",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Oa": "Activity"
|
||||
}
|
||||
},
|
||||
"fa0825f4-ae33-5eb3-9222-bf3a6465e72e": {
|
||||
"id": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"alias": "sa_source",
|
||||
"name": "SA dispatch repair",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"50e20de1-197f-4126-90da-81c6e4c93409": {
|
||||
"id": "50e20de1-197f-4126-90da-81c6e4c93409",
|
||||
"alias": "functional_exchange_sa_dispatch_repair_sa_execute_repair",
|
||||
"name": "Functional exchange: SA dispatch repair → SA execute repair",
|
||||
"description": "",
|
||||
"body": {
|
||||
"FunctionalExchange": {
|
||||
"source": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"target": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"source_port": "7957e536-d4b4-50c2-8271-ef829952353f",
|
||||
"target_port": "0a6f4b4b-911c-531d-82d5-172bdc8b0564",
|
||||
"items": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"a425a1af-e16f-4e25-9fb7-9f063dbe2fda": {
|
||||
"id": "a425a1af-e16f-4e25-9fb7-9f063dbe2fda",
|
||||
"alias": "interaction_oa_request_repair_oa_perform_repair",
|
||||
"name": "Interaction: OA request repair → OA perform repair",
|
||||
"description": "",
|
||||
"body": {
|
||||
"Interaction": {
|
||||
"source": "e4a68a7d-6949-5c17-9263-c03c03c21af2",
|
||||
"target": "7b9d5c34-1c45-5048-a3a0-77e5784a5a69",
|
||||
"items": []
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagrams": {
|
||||
"a60ffb09-36c2-5abc-bec5-6eab563c8cb8": {
|
||||
"id": "a60ffb09-36c2-5abc-bec5-6eab563c8cb8",
|
||||
"alias": "mixed_oa",
|
||||
"name": "OA mixed Draw canvas",
|
||||
"description": "",
|
||||
"layer": "Oa",
|
||||
"projection": "Traceability",
|
||||
"view_options": {
|
||||
"hidden_edges": [],
|
||||
"relation_kinds": "All",
|
||||
"work_categories": "All",
|
||||
"participant_categories": "All",
|
||||
"coverage": "All",
|
||||
"work_contexts": "All",
|
||||
"exchange_labels": "Name"
|
||||
},
|
||||
"nodes": {
|
||||
"1f2bb997-b4af-5910-b91b-1948764cff4f": {
|
||||
"id": "1f2bb997-b4af-5910-b91b-1948764cff4f",
|
||||
"alias": "mixed_oa_oa_target",
|
||||
"element": "7b9d5c34-1c45-5048-a3a0-77e5784a5a69",
|
||||
"position": {
|
||||
"x": 520.0,
|
||||
"y": 60.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"371bfc51-edcc-5770-aad1-11b84772d2c8": {
|
||||
"id": "371bfc51-edcc-5770-aad1-11b84772d2c8",
|
||||
"alias": "mixed_oa_oa_source",
|
||||
"element": "e4a68a7d-6949-5c17-9263-c03c03c21af2",
|
||||
"position": {
|
||||
"x": 60.0,
|
||||
"y": 60.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"752822e9-1f95-5e9c-b1d3-58c325d875fa": {
|
||||
"id": "752822e9-1f95-5e9c-b1d3-58c325d875fa",
|
||||
"alias": "mixed_oa_sa_source",
|
||||
"element": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"position": {
|
||||
"x": 60.0,
|
||||
"y": 300.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"eb2ce67e-c283-5689-8780-b54e1b06d7cc": {
|
||||
"id": "eb2ce67e-c283-5689-8780-b54e1b06d7cc",
|
||||
"alias": "mixed_oa_sa_target",
|
||||
"element": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"position": {
|
||||
"x": 520.0,
|
||||
"y": 300.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
"routes": []
|
||||
},
|
||||
"aa64b55c-9bbb-5444-bda4-c9be0c66242a": {
|
||||
"id": "aa64b55c-9bbb-5444-bda4-c9be0c66242a",
|
||||
"alias": "mixed_sa",
|
||||
"name": "SA mixed Draw canvas",
|
||||
"description": "",
|
||||
"layer": "Sa",
|
||||
"projection": "Traceability",
|
||||
"view_options": {
|
||||
"hidden_edges": [],
|
||||
"relation_kinds": "All",
|
||||
"work_categories": "All",
|
||||
"participant_categories": "All",
|
||||
"coverage": "All",
|
||||
"work_contexts": "All",
|
||||
"exchange_labels": "Name"
|
||||
},
|
||||
"nodes": {
|
||||
"44169450-37f5-59fd-b9ab-32482c943f99": {
|
||||
"id": "44169450-37f5-59fd-b9ab-32482c943f99",
|
||||
"alias": "mixed_sa_sa_target",
|
||||
"element": "c5e2f9d6-ba03-50b6-9860-b2dd717c02b3",
|
||||
"position": {
|
||||
"x": 520.0,
|
||||
"y": 300.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"a0a18bd6-891a-56a4-a46d-d7b7d9911fc9": {
|
||||
"id": "a0a18bd6-891a-56a4-a46d-d7b7d9911fc9",
|
||||
"alias": "mixed_sa_oa_target",
|
||||
"element": "7b9d5c34-1c45-5048-a3a0-77e5784a5a69",
|
||||
"position": {
|
||||
"x": 520.0,
|
||||
"y": 60.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"aca2f538-69bf-561f-9eff-433febee5234": {
|
||||
"id": "aca2f538-69bf-561f-9eff-433febee5234",
|
||||
"alias": "mixed_sa_sa_source",
|
||||
"element": "fa0825f4-ae33-5eb3-9222-bf3a6465e72e",
|
||||
"position": {
|
||||
"x": 60.0,
|
||||
"y": 300.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
},
|
||||
"f5e4244f-e1c4-59ec-9109-be1e851386c1": {
|
||||
"id": "f5e4244f-e1c4-59ec-9109-be1e851386c1",
|
||||
"alias": "mixed_sa_oa_source",
|
||||
"element": "e4a68a7d-6949-5c17-9263-c03c03c21af2",
|
||||
"position": {
|
||||
"x": 60.0,
|
||||
"y": 60.0
|
||||
},
|
||||
"size": {
|
||||
"width": 224.0,
|
||||
"height": 94.0
|
||||
},
|
||||
"collapsed": false
|
||||
}
|
||||
},
|
||||
"routes": []
|
||||
}
|
||||
},
|
||||
"artifact_links": {},
|
||||
"traces": {}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"recorded_at": "2026-10-02T07:10:23.939491+00:00",
|
||||
"command": [
|
||||
"C:/Users/User/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/python.exe",
|
||||
".local/navigation-evidence.py",
|
||||
"selftest",
|
||||
".local/navigation-evidence-selftest-attempt01"
|
||||
],
|
||||
"working_directory": "C:/ar-nav-01a0d717",
|
||||
"tool_exit_code": 0,
|
||||
"helper_sha256": "3b2c6c59c895dd2e40b5dc408e68fe7c3760447c62a7f98413db60ea18ec7825",
|
||||
"result_sha256": "4ef9a80a3de9bc60e21804f2d2ed51d77a78474c5e23f855b06ea5ce25e7cebb",
|
||||
"result_reported_cases": 52,
|
||||
"skipped": [
|
||||
"filesystem reparse/symlink creation unavailable: OSError"
|
||||
],
|
||||
"scope": "Parent executed after independent candidate review. Recorded now from returned tool exit and retained actual result; no fabricated command start/end timestamps. Transport only."
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
# Independent native subset evidence review
|
||||
|
||||
2026-10-02 UTC; reviewer diagram_reading (Astra). Root C:/ar-nav-01a0d717. Read-only review of fixed01, fixed01-reopen and draw01 notes, process receipts, original JPEGs/state manifests, and selected saved-data oracle receipts. No GUI control, Cargo, source/model or Gitea changes; only this note was written. This is evidence review of the existing private c706 run, not a new native run or release acceptance.
|
||||
|
||||
## Finding requiring a wording correction
|
||||
|
||||
The draw01 observation that the OA Interaction label became fully visible at 96% overstates the display. Capture16 shows its label clipped at the top canvas boundary at 113%. Captures28,31,33 show its label box back inside the viewport at 96%, but the text is still ellipsized as Interaction: OA request repair .... The functional-exchange label is also ellipsized in 09 and 33. Say that refitting resolved viewport clipping while full label text remained truncated. This matters to the still-open small-diagram readability requirement; successful creation does not establish readable labels.
|
||||
|
||||
The expanded palette also visibly clips both right-side card contents at 113% in captures25–27. At 96% after perspective navigation, capture28 contains all four cards within the narrower canvas. The screenshots support this width/camera limitation; they cannot prove that authored placements were unchanged. The final saved-data comparator supplies the separate conservation evidence.
|
||||
|
||||
No other material contradiction found in the requested bounded subset. Do not broaden these results to source acceptance: the notes explicitly identify c706's failed workspace placement-relocation regression. This review does not rerun or overturn that failure or accept a later repair.
|
||||
|
||||
## Directly inspected original captures
|
||||
|
||||
All images below were opened individually through view_image at their original JPEG paths; no regenerated diagrams or crops were used.
|
||||
|
||||
- draw01: 04,05,06,07,08,09,16,21,22,23,25,26,27,28,31,33,34,35 (all numbered -0.jpg).
|
||||
- fixed01: 06-oa-edge-inspected, 12-valid-apply-retains-mixed, 17-valid-draft-click-oa-process, 22-allocation-switch-succeeds.
|
||||
- fixed01-reopen: 01-reopened-source-1 (owned warning), 04-reopened-allocation-0, 06-reverse-traceability-0, 09-explicit-open-endpoint-0.
|
||||
|
||||
Draw04 visibly retains Operational and OA mixed Draw canvas while the Inspector identifies SA dispatch repair / sa_source. The source is green and the compatible SA execute card is outlined blue; OA cards are not candidate-highlighted. Draw05 retains that view and shows Choose a highlighted compatible endpoint after the pointer is on the OA card, with a temporary dashed preview. It supports refusal feedback, not a semantic mutation claim. Draw06 shows both SA functions as possible sources and the source-selection hint, consistent with a blank click resetting source choice while keeping the tool. Draw07 shows Select/move and Drawing cancelled; 08 shows Nothing to undo. A screenshot of that status cannot independently count history entries.
|
||||
|
||||
Draw09 shows the Functional exchange between the two SA cards, triangle port glyphs, matching source/target names in Properties, created feedback, and the same OA canvas. Draw16 shows System and SA mixed Draw canvas while Properties identifies an Interaction from OA request repair to OA perform repair (UUID a425a1af-e16f-4e25-9fb7-9f063dbe2fda). The green OA line and existing lower SA line are visible. Exact automatic port identities, contracts, record counts and unchanged records belong to the separately reviewed saved-Project evaluator, not visual inference.
|
||||
|
||||
Draw21 has the Model explorer tab active and its source context menu has no Draw entry. Draw22 has Architecture active with the same sidebar object menu and includes Draw functional exchange. Draw23 confirms the latter starts from sa_source on the retained SA canvas, with the target/port highlighted. Documentation should say Architecture sidebar, not generically Model explorer. This review confirms the visible distinction, not that a later documentation change is already shipped.
|
||||
|
||||
Draw25 shows expanded SA Functional exchange enabled, Boundary exchange and Allocation greyed. Draw26 leaves Select/move active and displays the Boundary exchange compatible-source tooltip. Draw27 activates Functional exchange, adds its source-selection hint and blue compatible SA outlines. Draw28 shows the OA labels/glyphs; Interaction, Communication means and Allocation are visually distinct at this scale, but this is not evidence that an unaided person understands each icon. Draw33/34 show compact OA controls and the disabled Communication means guidance. Draw35 shows the compact Interaction selected and only OA cards highlighted as sources. SA compact selected glyph state is visible in contextual/sidebar capture23; that is not an independent direct click test of the compact SA glyph. The notes distinguish that from the separately clicked expanded control.
|
||||
|
||||
Draw31 shows Functional dataflow, just the two OA cards and their line, and the reduced palette (Activity, Interaction, Add existing; no participant/Communication/Allocation controls). Hidden SA records cannot be inferred as conserved from this image. The recorded projection comparator is the relevant saved-data evidence.
|
||||
|
||||
Fixed01 captures06/12 keep System and the named mixed Traceability view while the OA Chain involvement alias oa_assessment_delivery is inspected/renamed, with UUID 628cb4fc-fe63-5f6b-b364-6268f26b5740. Capture17 shows the OA process selected, Properties applied feedback, and the same canvas. Capture22 shows six SA cards in allocated nesting and Inspector projection Allocation; OA process/capability are absent from the canvas. Fixed01-reopen04 repeats that allocation rendering; 06 restores eight-card traceability. Reopen09 intentionally navigates to Operational and selects oa_assessment_procedure after the explicit endpoint action. This complements inspection retention rather than contradicting it.
|
||||
|
||||
The owned warning JPEG confirms Windows rejected the forward-slash filename before model admission. The detailed Return/UIA recovery sequence is recorded in the observer's note; a still JPEG alone does not independently prove each failed input call. The reviewed draw04 state JSON has accessibility:null, consistent with the tool's limited semantic exposure. Neither that fact nor pointer success establishes screen-reader support, complete Tab navigation, focus semantics, or keyboard-only authoring. Escape/Undo/Redo are bounded exercised keyboard paths; pan hints visible in the UI do not establish that keyboard pan was tested.
|
||||
|
||||
## Process, logs, saved-state evidence and limits
|
||||
|
||||
All three started/finished pairs identify c7065db76ecca3faa733ccb5f09b87fd0a0958be and GUI a9d8b2fe2eecb66e17cd16ab2efe6c270e70588d0282fe2ac5d30e50f827b8d8 with DLL f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4, system-only PATH and separate profile paths. Finished receipts record exit0, unchanged copied binaries/original fixtures; actual stdout.log and stderr.log files are zero bytes for each case. PIDs are fixed01 9668, reopen 9384, draw01 8736. Fresh-process attribution comes from these process records and action notes, not similar screenshots. There is no fresh-process Draw reopen claim.
|
||||
|
||||
Read the fixed01 preparation/inspection/name-undo/implicit-undo/projection-undo/projection-redo exact compare receipts, fixed01-reopen/reverse conservation, draw01 final and dataflow comparison receipts. All inspected command receipts have exit0 and empty actual stderr; comparison outputs pass. Exact comparisons report complete expected Project and raw source-byte equality. Projection comparisons report only the nominated view change and compiler-indexed token replacement, with exact outside-token bytes. The name/implicit saved-delta reports identify the one relation and expected name; their differing evaluator attribution is correctly disclosed in observations.md. The draw delta reports pin helper 6ac2c230... and expressly limit source coverage to inventories. The earlier independent helper review remains .local/native-draw-evaluator-review.md.
|
||||
|
||||
These are existing receipts, not executions performed by this reviewer. Screenshots alone do not establish complete Project conservation, invisible source bytes, one-step transaction history, no unsaved RAM changes, or fresh-process persistence. Counts and warning counts likewise do not establish those facts. Do not infer performance from this run: notes disclose concurrent compiler activity. Synthetic fixtures are useful bounded cases, not recovered user models or comprehensive engineering projects.
|
||||
|
||||
## Evidence pins
|
||||
|
||||
SHA-256 of the reviewed observation/process files:
|
||||
|
||||
| Case | observations.md | started.json | finished.json |
|
||||
| --- | --- | --- | --- |
|
||||
| fixed01 | 7c75d9e04772c35d5084243216caee2781fcebdb24d569c5652de70d554f2403 | f202e806fc935cc43bbf5f53bd64b8cae2627f84785e679c39d4b8b1de63114a | 45c59211f3ce4c78b2dabd93f34b31dec8abbfee0c49a6e553c4a1d3610cf23c |
|
||||
| fixed01-reopen | d6d5724bdbdb02b917b435b7668888886420b31dd8af5ef2d6746c37ca5f9596 | b386c60c93e02883e7b32ec679596f454d1a4f4e23fd799200928b694f4c7bb6 | fdd86e93bd8740245fd0d9e7d0bbe6981b1e7b98b82cdbf8ea2923a13c22bae7 |
|
||||
| draw01 | 52b21b0f80a39808949c7afa531b934528a4069521d04b2e24303fa00ca6a57e | f17a7aa2cc3bc1719e718a40099f38b0b3a8c5c5529e95a8e0c78a71f102d019 | f75847d4d1ad96ee489256e0549ea4569537d4fae53f7659cd5479dab05e017f |
|
||||
|
||||
Read-only hash calculations verified every JPEG against its adjacent original capture manifest: fixed01 28/28, fixed01-reopen 11/11 (including warning image), draw01 38/38. No mismatches. For example draw04 SHA is 1aea20c087a78156ab476ce8269edd295a906a86cd45476147f8483363cab690; its manifest reports 1442 x 932 and the correct private draw01 executable path. The individual manifests remain the authoritative full capture-pin inventory.
|
||||
@@ -0,0 +1,158 @@
|
||||
# Isolated navigation oracle
|
||||
|
||||
Prepared against core commit `6520a89dc042dd433454f6cc63cb23b0e6df7911`. This local crate is not a production CLI feature or workspace member. It has not been compiled or executed by its author. The initial preparation made no generated fixture/admission claim; parent execution evidence and the tested Windows runtime recipe are appended below. The prior `.local/populated-projection-regression.patch` is unchanged.
|
||||
|
||||
## Parent build and first admission
|
||||
|
||||
From `C:/ar-nav-01a0d717`, after the exclusive frozen compiler gate permits it:
|
||||
|
||||
```powershell
|
||||
cargo build --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir .local/navigation-oracle/target
|
||||
```
|
||||
|
||||
The utility's Cargo.lock is initially an exact copy of the repository lock, used as a dependency seed. The first isolated build may update that local lock for this new package. Preserve its final bytes and the binary hash with the evidence. Subsequent reproducible builds should add `--locked --offline`. No Cargo command has been run in this task. Use Rust 1.90 or later, matching the repository's declared minimum. Dependencies are the repository path `oa-core` plus the same serde/serde_json major requirements already in the repository; no Python, shell helper or external server is required by the utility. The tested Windows gnullvm executable does require its matching libunwind.dll beside the executable; see the standalone runtime recipe below.
|
||||
|
||||
The `reviewed_core` field is a source-review pin, not an automatic runtime fingerprint. Record actual repository HEAD, tracked diff, utility hashes, final lock and executable hash when building. A later parent core change must not be attributed to this pin.
|
||||
|
||||
## Commands
|
||||
|
||||
```powershell
|
||||
$oracle = 'C:/ar-nav-01a0d717/.local/navigation-oracle-runtime/navigation-oracle.exe'
|
||||
& $oracle fixture C:/ar-nav-01a0d717/.local/native-draw-fixture
|
||||
& $oracle snapshot C:/path/to/saved/project.arcadia C:/path/to/new-baseline-snapshot
|
||||
& $oracle snapshot C:/path/to/saved/project.arcadia C:/path/to/new-after-snapshot
|
||||
& $oracle compare C:/path/to/new-baseline-snapshot C:/path/to/new-after-snapshot
|
||||
& $oracle compare C:/path/to/new-baseline-snapshot C:/path/to/new-after-snapshot --projection 00000000-0038-4000-8000-000000000001 Allocation
|
||||
```
|
||||
|
||||
Every output directory must be new, with an existing parent. Existing evidence is never overwritten. Fixture/snapshot writes that fail may leave partial output; preserve the failure, then choose another new directory. `compare` writes a JSON report to stdout and does not modify either snapshot. Exit 0 means that command completed successfully or comparison passed; 1 means a completed comparison failed; 2 means usage, loading, admission, IO or snapshot consistency failed. Capture stderr and the exit code as well as stdout.
|
||||
|
||||
For the projection trial: exact `compare` for selection/Properties; nominated-diagram `Allocation` comparison for switched state; exact baseline-versus-Undo, switched-versus-Redo and switched-versus-fresh-reopen comparisons. The utility does not infer which diagram changed. It never excuses unrelated fields, other diagrams, ID changes or reference changes.
|
||||
|
||||
## Snapshot contract
|
||||
|
||||
`snapshot` uses core `source_files::load_project` with the CLI's explicit policy: 64 MiB aggregate source, 256 files, include depth 32, 1024 include edges, 100000 graph objects, 250000 records and 500000 ordered references. It rejects only discovery/admission diagnostics whose actual enum is `validation::Severity::Error`, and returns full located compiler diagnostics on admission failure. Engineering warnings from successful compilation are retained in the report/snapshot. It compiles the reachable bundle with `source_project::compile_bundle` and exports the complete accepted Project to `project.json`.
|
||||
|
||||
`snapshot.json` contains the accepted Project, logical entry name and every reachable source path mapped to its exact raw captured disk bytes as an integer array. It preserves CR/LF, whitespace, comments and UTF-8 bytes; source is not regenerated or normalized. Disk expectations are checked against compiler input. Two complete source captures must match before evidence is written. This detects changes across the two observations but is not filesystem locking or an atomic multi-file snapshot; keep the saved project quiet.
|
||||
|
||||
The core loader rejects unsafe include paths, symlinks/junction escapes and invalid UTF-8. No snapshot logical path is used to write a file. Comparison revalidates logical paths, aggregate limits and UTF-8, recompiles all raw source, and requires that embedded Project and stored diagnostic strings exactly equal fresh compilation results. Compare reports diagnostics from that fresh compilation. Duplicate JSON source-path keys are rejected by a small serde visitor matching the core SourceBundle boundary pattern. Snapshot input itself is capped at 512 MiB. Source requiring UUID stabilization or migration is rejected so compiler-added text cannot be mistaken for original disk bytes: explicitly save the accepted current-version source first. Detached/unreachable editor buffers and unrelated disk files are outside this saved-project oracle.
|
||||
|
||||
## Comparison contract
|
||||
|
||||
Without an allowance, comparison requires complete Project equality, identical logical entry/path set and byte equality for every captured source file.
|
||||
|
||||
With `--projection DIAGRAM_UUID ProjectionKind`, clone the complete baseline Project and replace exactly the nominated diagram's projection. The after Project must equal that entire expected value. Accepted names are Overview, Decomposition, Allocation, FunctionalDataflow, Capabilities and Traceability; both the baseline and target must be architecture/string projections, so typed StateMachine/Scenario changes have no allowance.
|
||||
|
||||
Source still requires either exact bytes or exactly one changed source file whose complete prefix and suffix outside the compiler-indexed projection value match byte-for-byte. Both versions must have an explicit projection value in the same file and the same reachable path set. The JSON report includes both byte spans and original/replacement field text, plus whether outside-field bytes were exact. Omitted/defaulted projection declarations fail this narrow source allowance rather than broadening it silently. Project mismatches report up to 200 unexpected JSON paths; full snapshots remain available to inspect all differences. Matching counts alone never establishes conservation.
|
||||
|
||||
This is a saved-source oracle. It cannot inspect unsaved desktop state, prove GUI click ordering, prove exact in-memory source history, or establish native selection usability. Save/capture each relevant checkpoint and associate screenshots/action logs with it. Model-changing Draw actions deliberately fail this unchanged/projection-only comparator; inspect their precise creation delta separately instead of adding a broad allowance.
|
||||
|
||||
## Native Draw fixture contract
|
||||
|
||||
`fixture NEW_DIR` constructs typed records through `Project::add_element`, `ensure_diagram`, `place` and `reidentify`. It creates OA Activity source/target and SA Function source/target, with distinct labels and two Traceability diagrams declared OA and SA. Both diagrams contain all four work cards, placed in two uncluttered rows: OA at (60,60)/(520,60), SA at (60,300)/(520,300). Initially there are no relations or ports, allowing native Draw to create the Interaction or FunctionalExchange and its ports using the application's actual workflow.
|
||||
|
||||
UUIDv5 identities and aliases are deterministic; no random identity remains from the construction helpers. The generated `oracle.json` records exact element/view IDs and the source entry. The generator validates the complete Project, compiles the core `format_bundle` output, checks four projected cards per view, saves the bundle using `source_files::new_project`/`expect_new_file`/`save_project`, recompiles the saved source and requires full Project equality. It then writes Project JSON. Generated output is synthetic and must never be described as recovered user source or an already-run native acceptance result.
|
||||
|
||||
Use the SA mixed view to inspect/select OA work before invoking contextual Draw; use the OA mixed view for the SA work counterpart. Semantic source and target must follow the selected work layer even when the canvas perspective differs. Preserve the documented scope distinction: opening the contextual form retains the canvas; successful Apply may perform the existing explicit post-review navigation. The separate architecture palette limitation is not claimed repaired.
|
||||
|
||||
## Reviewed source identities and remaining checks
|
||||
|
||||
SHA-256 at preparation:
|
||||
|
||||
- `src/main.rs`: `69c2c90f5cfebf86f755ea47861e4ab059e7359e442993a996fb9e91e2caeb7c`
|
||||
- `Cargo.toml`: `ba95c25a04dbfd0d6d19bd59f466e4082f840723ebE8180e5061a4a157e139c8`
|
||||
- Repository/seed Cargo.lock: `2aa80d113c5112b31c0767b874845d60f555e4e8ae7eb3ecd0b4b91954b55378`
|
||||
- Core `source_files.rs`: `bb903f8c53456f69c5ac213a192a1eb0d19abf8344bd82a03be8e659ba7bc94f`
|
||||
- Core `source_project.rs`: `d872f2a8f7c4add53f2708bf3fdaea1b4be715d4240f2d9b93baf4624d3f72a7`
|
||||
- CLI limits source `crates/oa-cli/src/lib.rs`: `790f3bdef78025813262c9677d4db5813f32978a398f3b7fb4900427dc0c314e`
|
||||
|
||||
Parent review risks: uncompiled Rust/API compatibility; the isolated lock's initial adjustment; no atomic filesystem snapshot; snapshot memory grows with lossless byte arrays; strict stable-source/explicit-value requirements can reject otherwise valid but insufficiently prepared input. Useful oracle checks before trusting native results: identical snapshots pass, one allowed projection change passes only with the matching nominated view/value, an unrelated comment change fails exact bytes despite equal Project, and unrelated semantic/placement edits fail even under the projection allowance. Those checks are proposed, not executed.
|
||||
|
||||
## Review correction before first execution
|
||||
|
||||
The initial source and README are preserved verbatim under `revisions/01-pre-warning-repair/`, with their hashes. The initial capture check incorrectly rejected all discovery diagnostics, including legitimate `unallocated_leaf` warnings; no successful run is attributed to that version. The correction filters the actual Error severity, keeps compiler warnings, binds stored diagnostic strings to fresh compilation, guards typed baseline projections as well as targets, and rejects duplicate source-path JSON keys. `project.json` is a convenience export; the comparator admits the embedded model and raw sources from `snapshot.json` only.
|
||||
|
||||
Two Rust tests now cover nonfatal engineering warnings/error rejection, deleted/invented warning rejection, and duplicate raw-file-key rejection. Parent command after the frozen gate: `cargo test --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir .local/navigation-oracle/target`. These tests and the utility still have not been compiled or run by this author. Runtime fixture generation and the actual warning-bearing source-load path remain required parent checks.
|
||||
|
||||
## Retained filesystem verification module (not executed)
|
||||
|
||||
`src/verification.rs` adds one test, `verification::filesystem_evaluator_contract`. Before any full utility test run, the parent must set `NAVIGATION_ORACLE_QA_DIR` to a fresh absolute path whose parent exists. The test intentionally fails without that explicit destination and refuses an existing destination. It never deletes or replaces evidence.
|
||||
|
||||
```powershell
|
||||
$env:NAVIGATION_ORACLE_QA_DIR = 'C:/ar-nav-01a0d717/.local/navigation-oracle-qa-run-01'
|
||||
cargo test --offline --manifest-path .local/navigation-oracle/Cargo.toml --target-dir .local/navigation-oracle/target verification::filesystem_evaluator_contract -- --nocapture
|
||||
```
|
||||
|
||||
Use a different fresh path for every rerun, including after failure. Capture Cargo stdout/stderr and exit status. Under the supplied path, `checks.jsonl` records each completed control with its result and relevant errors; every source variant, snapshot, corrupt input and sentinel is retained. Comparison JSON reports also go to test stdout; `--nocapture` makes them available to the parent log. An interrupted or failed run retains partial evidence and has no final completion marker.
|
||||
|
||||
The test invokes actual `fixture`, `capture`, `snapshot`, `read_snapshot` and `compare` functions. Positive variants use `prepare_command` and `update_bundle_with_identity_map`, then the core new-project/save APIs; the core crate has no desktop Document type. The projection edit is a real EditDiagram command and the unrelated model edit a real Rename command. Corrupt snapshots are deliberately based on valid captures. Duplicate-key input is syntactically valid JSON with a duplicated existing path, and missing-include input omits an actual discovered dependency from a previously valid complete bundle.
|
||||
|
||||
Controls cover warning-bearing fixture generation/capture; identical snapshots; accepted projection-only change; wrong diagram/value; comment and CRLF changes with equal accepted Project; unrelated coherent semantic change; embedded-model and diagnostic tampering; duplicate/unsafe snapshot paths; invalid UTF-8 and missing include on disk; and preservation of an existing output sentinel for both snapshot and fixture commands. The comment/CRLF cases are checked both against the switched model in exact mode and against the original in projection-allowance mode. Both existing-output controls require a direct boxed std::io::Error with ErrorKind::AlreadyExists and record the actual kind/message; unrelated earlier errors cannot satisfy the refusal check. The previous module and README bytes are retained with hashes under revisions/04-before-output-refusal-check/.
|
||||
|
||||
This module adds no dependency or production behavior. Original reviewed main.rs `2cf2d1ced864ed339eb31470d42b370ba7d6de559c4679965fec4729984b08d6` and README are preserved under `revisions/03-before-filesystem-tests/`; the pre-attempt01 main.rs retained that entire source as an exact prefix and added only the two-line cfg(test) module declaration. The subsequent compiler repair is recorded below. Module SHA-256: `c7d6b184b7bfa579ec6dd2a777a0a417a65418f768d4b8cbf6afce7589ab9a25`. No Cargo, filesystem exercise or GUI run has occurred in this preparation task.
|
||||
|
||||
Bounded omissions: this exercise does not build a typed StateMachine/Scenario fixture to retest the architecture guard; it does not simulate concurrent filesystem races, interrupted multi-file writes, symlink/junction attacks or out-of-memory limits. It also cannot replace native Draw, selection or unsaved desktop Document-history checks.
|
||||
|
||||
## Attempt01 compiler repair (not rerun by this author)
|
||||
|
||||
Parent attempt01 exited 101 before any test executed: Rust E0382 at src/main.rs:261 identified the placement alias being moved by the assignment right-hand side before the left-hand side borrowed it to compute its ID. Evidence is ../navigation-oracle-runs/attempt01/tests.stderr. The isolated fixture generator now computes placement_id once before moving placement_alias and reuses that ID for reidentification and lookup. No alias clone, verification assertion, production repository file or dependency changed.
|
||||
|
||||
The exact pre-repair main.rs and README are preserved with SHA-256 records under revisions/05-before-placement-id-repair/. Main was da515eff05bd34eb0cb354e412db74dbdede72518c99839fc5d75d56cdd7c892; README was 82355e35120e8f0508d146306d37efa8c22eac8d3f0c59029d5cdaff28835257. The parent-adjusted isolated Cargo.lock is e97d03c226c2c3f2328b68bcb6d823df00c0569918fa9891d50504cd12e0af1d and was left untouched. This repair has not been compiled or run by its author; parent owns a rerun with fresh logs and QA root. Earlier no-execution statements describe preparation and remain no test-pass claims.
|
||||
## Tested standalone Windows runtime and parent execution evidence
|
||||
|
||||
This is a receipt review, not a new execution. Parent attempt02 at repository HEAD `c7065db76ecca3faa733ccb5f09b87fd0a0958be` built with Rust 1.98.1 gnullvm / LLVM-MinGW 20260826 UCRT and passed all three tests. Its authoritative input manifest, commands and result are `../navigation-oracle-runs/attempt02/result.json` (SHA-256 `1ed50fe9c42ff4a67084ed70ce934404f5549ca68bc54e6d2f1436e66ea8d22f`); logs and retained QA controls are alongside it and in `../navigation-oracle-qa-attempt02/`. This actual build attribution does not change the source-review `reviewed_core` pin of `6520a89dc042dd433454f6cc63cb23b0e6df7911`.
|
||||
|
||||
The first standalone fixture receipt, `../navigation-oracle-commands/draw-fixture/receipt.json`, records exit 3221225781 (`0xC0000135`, Windows DLL-not-found status), with no stdout or stderr. The same executable succeeded after staging beside it the matching `libunwind.dll`: `../navigation-oracle-commands/draw-fixture-runtime02/receipt.json` records exit 0, unchanged executable, empty stderr, and child PATH `C:\Windows\System32;C:\Windows`. Its stdout reports the saved `../native-draw-fixture/project.arcadia` and Project JSON. The intermediate `draw-fixture-runtime/failure.json` instead records a Python recorder `SystemRoot` key-case failure before the oracle started; it is not an oracle or fixture result.
|
||||
|
||||
Verified current private runtime files in `../navigation-oracle-runtime/`:
|
||||
|
||||
| File | Bytes | SHA-256 |
|
||||
| --- | ---: | --- |
|
||||
| navigation-oracle.exe | 114890752 | `bd2db2e177d9d21f44b45e5efa89071e00155d31509672bf5aea6a57915e09a6` |
|
||||
| libunwind.dll | 190976 | `f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4` |
|
||||
|
||||
The executable came from `C:/Users/User/Documents/Git/ARCADIA-Studio/.local/target-gnullvm/debug/navigation-oracle.exe`. A verified identical DLL is `C:/ar-save-01a0d717/.local/package46/extracted/ArcadiaStudio-0.2.0-preview.save-diagnostics.20261001-x86_64-pc-windows-gnullvm/libunwind.dll`. For another private runtime copy, use a new directory and verify both hashes before launch. This recipe reproduces the tested arrangement; the particular new paths below have not been executed:
|
||||
|
||||
```powershell
|
||||
$runtimeCopy = 'C:/ar-nav-01a0d717/.local/navigation-oracle-runtime-copy-01'
|
||||
$fixtureOutput = 'C:/ar-nav-01a0d717/.local/native-draw-fixture-copy-01'
|
||||
if ((Test-Path -LiteralPath $runtimeCopy) -or (Test-Path -LiteralPath $fixtureOutput)) { throw 'Choose fresh runtime/output paths' }
|
||||
New-Item -ItemType Directory -Path $runtimeCopy -ErrorAction Stop | Out-Null
|
||||
Copy-Item -LiteralPath 'C:/ar-nav-01a0d717/.local/navigation-oracle-runtime/navigation-oracle.exe' -Destination $runtimeCopy -ErrorAction Stop
|
||||
Copy-Item -LiteralPath 'C:/ar-nav-01a0d717/.local/navigation-oracle-runtime/libunwind.dll' -Destination $runtimeCopy -ErrorAction Stop
|
||||
$oracle = Join-Path $runtimeCopy 'navigation-oracle.exe'
|
||||
if ((Get-FileHash -LiteralPath $oracle -Algorithm SHA256).Hash -ne 'bd2db2e177d9d21f44b45e5efa89071e00155d31509672bf5aea6a57915e09a6') { throw 'Executable pin mismatch' }
|
||||
if ((Get-FileHash -LiteralPath (Join-Path $runtimeCopy 'libunwind.dll') -Algorithm SHA256).Hash -ne 'f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4') { throw 'Runtime pin mismatch' }
|
||||
$savedOraclePath = $env:PATH
|
||||
try {
|
||||
$env:PATH = "$env:SystemRoot\System32;$env:SystemRoot"
|
||||
& $oracle fixture $fixtureOutput
|
||||
if ($LASTEXITCODE -ne 0) { throw "Oracle exited $LASTEXITCODE" }
|
||||
} finally {
|
||||
$env:PATH = $savedOraclePath
|
||||
}
|
||||
```
|
||||
|
||||
The PATH change applies only to this shell and is restored; no compiler directory is needed for this standalone launch. For recorded commands, the existing `../record-native-oracle.py` (SHA-256 `abbe6fae3055673ca3c33f0f87cba21189dd2ca905f63acc2da69b0b605fbc23`) checks executable/DLL pins, sets the child system-only PATH, and retains argv, stdout, stderr, exit, repository HEAD and build-receipt hash in a fresh command directory. The Python recorder is optional evidence tooling, not a runtime dependency of the Rust executable.
|
||||
|
||||
## Fixed01 saved-evidence inventory for scoped review
|
||||
|
||||
At this review, `../navigation-oracle-commands/fixed01-*` contains ten snapshot receipts and seven compare receipts. All 17 record exit 0, empty stderr and an unchanged executable with the runtime pin above. Snapshots are `prepared`, `baseline`, `inspected`, `name-applied`, `name-undo`, `implicit-applied`, `implicit-undo`, `switched`, `projection-undo` and `projection-redo`, under `../navigation-native/fixed01/`. Each compare folder below contains its actual `receipt.json`, `stdout` JSON report and `stderr`:
|
||||
|
||||
| Compare receipt suffix after fixed01- | Before → after | Reported contract/result |
|
||||
| --- | --- | --- |
|
||||
| preparation-equality | prepared → baseline | Exact full Project and raw source, pass |
|
||||
| inspection-equality | baseline → inspected | Exact full Project and raw source, pass |
|
||||
| name-undo-equality | baseline → name-undo | Exact full Project and raw source, pass |
|
||||
| implicit-undo-equality | baseline → implicit-undo | Exact full Project and raw source, pass |
|
||||
| projection-conservation | baseline → switched | Only nominated projection change, pass |
|
||||
| projection-undo-equality | baseline → projection-undo | Exact full Project and raw source, pass |
|
||||
| projection-redo-equality | switched → projection-redo | Exact full Project and raw source, pass |
|
||||
|
||||
The projection report nominates diagram `00000000-0038-4000-8000-000000000001`; only `views/diagram_issue38_representative_mixed.arcadia` changes, from `Traceability` at byte span [338,350) to `Allocation` at [338,348). Full expected Project and entry/path set match; prefix/suffix outside the projection value are byte-exact. Its raw_source_bytes_equal is correctly false because that one permitted token changes. Six exact comparisons instead report no changed files and raw_source_bytes_equal true. These receipts preserve the existing `work_without_behavior_context` warning; success does not mean a warning-free model.
|
||||
|
||||
The separate parameterized `../check-native-name.py` (SHA-256 `617a189b546ded2c9133dd0884152cea265d2f86041a5ef983394e6063c82f72`) reviews full saved Project equality after exactly one nominated relation-name edit, and full raw source equality after exactly one unique declaration-header replacement. It fixes baseline/fixture paths and relation UUID `628cb4fc-fe63-5f6b-b364-6268f26b5740`; only the after-directory, expected name and new report stem are parameters. It is not a general parser/admission substitute: the pinned Rust snapshot receipts supply source admission. Intended invocation for the already-recorded implicit case was `python .local/check-native-name.py implicit-applied "Implicit assessment delivery" implicit-delta-check`; it now refuses overwriting the existing report. Use a fresh report stem for any separately authorized rerun.
|
||||
|
||||
`../navigation-native/fixed01/implicit-delta-check.json` is SHA-256 `eb14e21c5b75dd3ba63200ccf8860cb85aee6f5688b4db2626958060178f5f09`, reports all five checks true, and binds that evaluator hash, baseline snapshot `7695c2ff4aa735110cb198f6451be596c5ac59a65ae9ccd7c7602e0259cb5e9a`, after snapshot `86fe91f1eaf0e8a0a9a509c7f4d2e5f90ec5f2b01a9169e0678dc8342a7a282b`, and original fixture `9db1015369f450d5810c96a01fddb8a009d57f6aafbda5182703f15ded23d9c8`. The changed declaration is in `oa/behavior/process_oa_assessment_procedure.arcadia`. The earlier explicit-name `name-delta-check.json` also reports five true checks but has no evaluator_sha256 field; do not retrospectively attribute it to the current parameterized helper.
|
||||
|
||||
This inventory does not establish GUI click order, one-step interaction history, visible content, fresh reopening, or unsaved RAM conservation. Those claims require the parent's corresponding native capture/action evidence. No GUI, Rust/Python oracle command or Cargo test was executed during this README update. Pre-update README SHA-256 `496bc4eab252671232f6d2269b95fa0bc3596aad36341ca362cca9c140df3e08` is retained under `revisions/06-before-runtime-instructions/`; source and evaluator bytes are unchanged.
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\draw01\\bin\\arcadia-studio.exe",
|
||||
"id": 43582746,
|
||||
"title": "Cross-layer native Draw verification — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "27-enabled-functional-tool-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "81c25b3d2e1a189389b5abdb0b3987d5984fb3491a56600ff36c870b584efab7"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\fixed01\\bin\\arcadia-studio.exe",
|
||||
"id": 15008282,
|
||||
"title": "Restore safe water pressure — release 1 walkthrough * — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "20-click-away-undo-saved-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "6b57d48bfdf74e3b942cb57fadf5e3ade360a6838ae5f7178e4a30de0815ac83"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 141 KiB |
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\draw01\\bin\\arcadia-studio.exe",
|
||||
"id": 43582746,
|
||||
"title": "Cross-layer native Draw verification — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "03-sa-source-on-oa-menu-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "b4e365b03c7965b78205703e9f8211f9ef330a0f523a48089ef510edc4140631"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\peer01\\bin\\arcadia-studio.exe",
|
||||
"id": 25822524,
|
||||
"title": "Restore safe water pressure — release 1 walkthrough * — Arcadia Studio"
|
||||
},
|
||||
"accessibility": {
|
||||
"focused_element": "0 window Restore safe water pressure — release 1 walkthrough — Arcadia Studio Secondary Actions: Raise",
|
||||
"tree": "Window: \"Restore safe water pressure — release 1 walkthrough — Arcadia Studio\", App: arcadia-studio.exe.\n\t0 window Restore safe water pressure — release 1 walkthrough — Arcadia Studio Secondary Actions: Raise\n\t\t1 title bar (settable, string) Value: Restore safe water pressure — release 1 walkthrough — Arcadia Studio\n\t\t\t2 menu bar System ID: MenuBar\n\t\t\t\t3 menu item System\n\t\t\t4 button Minimize\n\t\t\t5 button Maximize\n\t\t\t6 button Close\n\nThe focused UI element is 0 window Restore safe water pressure — release 1 walkthrough — Arcadia Studio Secondary Actions: Raise."
|
||||
},
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "06-valid-pending-name-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "7dcac290662e627f12d14239f2775fb15370ee4cc5a82d1177bd4a8d0054e33e"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\relocation01-reopen\\bin\\arcadia-studio.exe",
|
||||
"id": 2360006,
|
||||
"title": "Cross-layer native Draw verification — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "09-inspection-no-undo-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "b863bcc65d39147b44f3075aa9aa377383e3868ca90a1bd1c7d1ee41840bc975"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\baseline01\\bin\\arcadia-studio.exe",
|
||||
"id": 53937404,
|
||||
"title": "Restore safe water pressure — release 1 walkthrough — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "04-edge-click-wrong-oa-view-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "9c437213d3baaa2817c1a134ff2735a9d2d45893cb7fb8afad39f14f08ffd93c"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
diagram mixed_oa "OA mixed Draw canvas" id "a60ffb09-36c2-5abc-bec5-6eab563c8cb8" {
|
||||
projection Traceability;
|
||||
}
|
||||
|
||||
node mixed_oa_oa_source id "5a8083e2-55ea-5fa3-94b4-3e5aafc1922f" {
|
||||
diagram @relocation_empty_sa;
|
||||
element @oa_source;
|
||||
position {
|
||||
x 60.0;
|
||||
y 60.0;
|
||||
};
|
||||
}
|
||||
|
||||
node mixed_oa_oa_target id "1f2bb997-b4af-5910-b91b-1948764cff4f" {
|
||||
diagram @mixed_oa;
|
||||
element @oa_target;
|
||||
position {
|
||||
x 520.0;
|
||||
y 60.0;
|
||||
};
|
||||
}
|
||||
|
||||
node mixed_oa_sa_source id "752822e9-1f95-5e9c-b1d3-58c325d875fa" {
|
||||
diagram @mixed_oa;
|
||||
element @sa_source;
|
||||
position {
|
||||
x 60.0;
|
||||
y 300.0;
|
||||
};
|
||||
}
|
||||
|
||||
node mixed_oa_sa_target id "eb2ce67e-c283-5689-8780-b54e1b06d7cc" {
|
||||
diagram @mixed_oa;
|
||||
element @sa_target;
|
||||
position {
|
||||
x 520.0;
|
||||
y 300.0;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"window": {
|
||||
"app": "process:C:\\ar-nav-01a0d717\\.local\\navigation-native\\fixed01\\bin\\arcadia-studio.exe",
|
||||
"id": 15008282,
|
||||
"title": "Restore safe water pressure — release 1 walkthrough — Arcadia Studio"
|
||||
},
|
||||
"accessibility": null,
|
||||
"screenshots": [
|
||||
{
|
||||
"filename": "06-oa-edge-inspected-0.jpg",
|
||||
"id": "screenshot-0",
|
||||
"width": 1442,
|
||||
"height": 932,
|
||||
"sha256": "4d95c38ee9b926dd0e68c0597514eaf60f0ee04f76a43e11d71a9073d32a61b1"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
--- a/crates/oa-desktop/src/connection_edit.rs
|
||||
+++ b/crates/oa-desktop/src/connection_edit.rs
|
||||
@@ -37,6 +37,26 @@
|
||||
let compatible = self.tool.targets(project, source);
|
||||
self.candidates = visible(view).filter(|id| compatible.contains(id)).collect();
|
||||
}
|
||||
+}
|
||||
+
|
||||
+/// Contextual drawing follows the visible semantic source, not the palette layer.
|
||||
+/// The caller separately validates the menu/context lifetime and pending drafts.
|
||||
+pub fn can_begin(project: &Project, diagram: Id, tool: Tool, source: Id) -> bool {
|
||||
+ let Some(diagram) = project.diagrams.get(&diagram) else {
|
||||
+ return false;
|
||||
+ };
|
||||
+ let Some(element) = project.elements.get(&source) else {
|
||||
+ return false;
|
||||
+ };
|
||||
+ diagram.projection.is_architecture()
|
||||
+ && (element.layer() == diagram.layer
|
||||
+ || diagram.projection == oa_core::ProjectionKind::Traceability)
|
||||
+ && Tool::for_layer(element.layer()).contains(&tool)
|
||||
+ && tool.can_start(project, source)
|
||||
+ && (tool != Tool::Allocation
|
||||
+ || tool.available_in(element.layer(), diagram.projection))
|
||||
+ && visible(&oa_core::projection::project_view(project, diagram.id))
|
||||
+ .any(|id| id == source)
|
||||
}
|
||||
|
||||
pub fn visible(view: &ProjectedView) -> impl Iterator<Item = Id> + '_ {
|
||||
--- a/crates/oa-desktop/src/model_menu.rs
|
||||
+++ b/crates/oa-desktop/src/model_menu.rs
|
||||
@@ -557,10 +557,7 @@
|
||||
&format!("Draw {}", tool.label().to_lowercase()),
|
||||
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
|
||||
app.active_diagram.is_some_and(|diagram| {
|
||||
- crate::connection_edit::visible(&projection::project_view(
|
||||
- p, diagram,
|
||||
- ))
|
||||
- .any(|element| element == id)
|
||||
+ crate::connection_edit::can_begin(p, diagram, *tool, id)
|
||||
}),
|
||||
));
|
||||
}
|
||||
--- a/crates/oa-desktop/src/app.rs
|
||||
+++ b/crates/oa-desktop/src/app.rs
|
||||
@@ -2751,7 +2751,23 @@
|
||||
e.kind() != Kind::Port && d.projection.includes(e.kind())
|
||||
})
|
||||
});
|
||||
- if show_in_mixed_view || menu.request.canvas.is_some() {
|
||||
+ // Draw acts on the visible current view even when opened from
|
||||
+ // Explorer; explicit Select/Open actions still navigate.
|
||||
+ let draw_in_current_view = match &*action {
|
||||
+ Message::Connection(crate::connection_edit::Event::Begin(tool, source)) => {
|
||||
+ *source == menu.request.object
|
||||
+ && self.active_diagram.is_some_and(|diagram| {
|
||||
+ crate::connection_edit::can_begin(
|
||||
+ self.project(),
|
||||
+ diagram,
|
||||
+ *tool,
|
||||
+ *source,
|
||||
+ )
|
||||
+ })
|
||||
+ }
|
||||
+ _ => false,
|
||||
+ };
|
||||
+ if show_in_mixed_view || draw_in_current_view || menu.request.canvas.is_some() {
|
||||
if !self.inspect_canvas_target(menu.request.object) {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -5042,8 +5042,30 @@
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
+ // Admission belongs to the caller: palette choice, contextual source, or
|
||||
+ // an already admitted session whose complete context is still current.
|
||||
+ fn arm_connection(
|
||||
+ &mut self,
|
||||
+ context: crate::route_edit::Context,
|
||||
+ tool: crate::connection_tool::Tool,
|
||||
+ ) -> crate::route_edit::Context {
|
||||
+ self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
+ let context = crate::route_edit::Context {
|
||||
+ epoch: self.canvas_epoch,
|
||||
+ ..context
|
||||
+ };
|
||||
+ self.connection = Some(crate::connection_edit::Session::new(
|
||||
+ self.project(),
|
||||
+ &project_view(self.project(), context.diagram),
|
||||
+ context,
|
||||
+ tool,
|
||||
+ ));
|
||||
+ self.selected_bend = None;
|
||||
+ self.status = tool.hint().into();
|
||||
+ context
|
||||
+ }
|
||||
fn edit_connection(&mut self, event: crate::connection_edit::Event) {
|
||||
- use crate::connection_edit::{Event, Session};
|
||||
+ use crate::connection_edit::Event;
|
||||
let Some(context) = self.route_context() else {
|
||||
return;
|
||||
};
|
||||
@@ -5059,10 +5081,17 @@
|
||||
}
|
||||
match event {
|
||||
Event::Begin(tool, source) => {
|
||||
- self.edit_connection(Event::Choose(Some(tool)));
|
||||
- if let Some(context) = self.route_context() {
|
||||
- self.edit_connection(Event::Endpoint(context, source));
|
||||
- }
|
||||
+ if !crate::connection_edit::can_begin(
|
||||
+ self.project(),
|
||||
+ context.diagram,
|
||||
+ tool,
|
||||
+ source,
|
||||
+ ) {
|
||||
+ self.status = "Choose a visible compatible source in this diagram.".into();
|
||||
+ return;
|
||||
+ }
|
||||
+ let context = self.arm_connection(context, tool);
|
||||
+ self.edit_connection(Event::Endpoint(context, source));
|
||||
}
|
||||
Event::Choose(Some(tool)) => {
|
||||
if !crate::connection_tool::Tool::for_layer(self.layer).contains(&tool) {
|
||||
@@ -5076,16 +5105,7 @@
|
||||
{
|
||||
return;
|
||||
}
|
||||
- self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
- let context = self.route_context().unwrap();
|
||||
- self.connection = Some(Session::new(
|
||||
- self.project(),
|
||||
- &project_view(self.project(), context.diagram),
|
||||
- context,
|
||||
- tool,
|
||||
- ));
|
||||
- self.selected_bend = None;
|
||||
- self.status = tool.hint().into();
|
||||
+ self.arm_connection(context, tool);
|
||||
}
|
||||
Event::Endpoint(captured, id) => {
|
||||
if captured != context {
|
||||
@@ -5164,8 +5184,11 @@
|
||||
Event::Restart(captured) => {
|
||||
if captured == context
|
||||
&& let Some(session) = &self.connection
|
||||
+ && session.context == context
|
||||
{
|
||||
- self.edit_connection(Event::Choose(Some(session.tool)));
|
||||
+ // A contextual mixed-layer tool need not be in this layer's
|
||||
+ // palette. Its unchanged session context retains admission.
|
||||
+ self.arm_connection(context, session.tool);
|
||||
}
|
||||
}
|
||||
Event::Choose(None) => {}
|
||||