Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu 9c79a6e0fb Verify Help preserves mixed-view caret and retires held Draw gestures 2026-10-02 00:45:40 -07:00
sorokin.oleg.edu 8e5b299e6e Merge verified navigation with delivered Help and Save 2026-10-02 00:28:17 -07:00
sorokin.oleg.edu d21977fca6 docs: publish verified navigation source and portable evidence 2026-10-02 00:20:04 -07:00
sorokin.oleg.edu 6018ae30b9 Follow accepted placement relocation without losing inspection context 2026-10-01 23:42:47 -07:00
sorokin.oleg.edu c7065db76e Preserve canvas context across property edits and contextual drawing 2026-10-01 22:49:47 -07:00
sorokin.oleg.edu bc69c6d449 docs: confirm save diagnostics preview publication and handoff 2026-10-01 20:32:01 -07:00
sorokin.oleg.edu e144e32e99 docs: preserve verified save diagnostics package and provenance 2026-10-01 20:24:08 -07:00
sorokin.oleg.edu ef28f1784e Refresh published Save As contribution checkpoint 2026-10-01 18:55:30 -07:00
sorokin.oleg.edu 8e59309cbc Bring published compact Help evidence into Save As contribution 2026-10-01 18:54:56 -07:00
sorokin.oleg.edu 046cdc1b46 Record independently verified protected Save As diagnostics 2026-10-01 18:53:52 -07:00
sorokin.oleg.edu 6ea7518e7b Link confirmed compact Help preview and exact asset readbacks 2026-10-01 18:24:37 -07:00
sorokin.oleg.edu f1fdca3713 Record exact compact Help package acceptance and independent review 2026-10-01 18:20:30 -07:00
sorokin.oleg.edu 0e83157e09 Explain protected source destination collisions during Save As 2026-10-01 18:13:23 -07:00
sorokin.oleg.edu ea23e22926 Record final compact Help gates and independent native acceptance 2026-10-01 17:50:26 -07:00
sorokin.oleg.edu 6e7c4ddaf0 Clarify Arrange cancellation and correct mounted typing fixture 2026-09-25 00:27:59 -07:00
Codex-ytr83r 6520a89dc0 test: establish independent inspection before preferred-view creation 2026-09-25 00:26:42 -07:00
sorokin.oleg.edu 5657a996ed Preserve editor input context through diagram help 2026-09-24 23:48:32 -07:00
Codex-ytr83r 3a7221b80c Distinguish connection tools with semantic palette icons 2026-09-24 23:31:02 -07:00
Codex-ytr83r ac69f555af Keep contextual properties and creation bound to their semantic targets 2026-09-24 23:30:56 -07:00
sorokin.oleg.edu f83068757a Preserve passive canvas geometry while contextual help is open 2026-09-24 23:10:46 -07:00
sorokin.oleg.edu e0812b3932 Compact diagram instructions and add contextual help overlay 2026-09-24 23:06:53 -07:00
Codex-ytr83r 603a59c178 Verify projection API and preserve same-object explicit navigation 2026-09-24 23:00:05 -07:00
Codex-ytr83r 0341bca846 Preserve authored projection content and contextual canvas selection 2026-09-24 22:40:21 -07:00
1195 changed files with 324712 additions and 5692 deletions
+5
View File
@@ -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
+60 -6
View File
@@ -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 {
+9 -3
View File
@@ -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;
+46
View File
@@ -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;
+1
View File
@@ -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()
+11 -167
View File
@@ -53,8 +53,6 @@ pub struct RoutedEdge {
pub struct EdgeLabelGeometry {
/// Empty for edge kinds without a permanently displayed label.
pub text: String,
/// Dedicated State-canvas two-block presentation; raw summary stays in text.
pub state_layout: Option<StateLabelLayout>,
/// Effective arc-length fraction, including an automatic collision-avoidance
/// choice. Use this when the first label drag becomes authored metadata.
pub fraction: f32,
@@ -63,108 +61,6 @@ pub struct EdgeLabelGeometry {
pub bounds: RoutingBounds,
}
/// Derived hard-line layout for the dedicated State projection, never persisted.
/// Excerpts are chosen on raw text before display-only separator/tab expansion.
#[derive(Debug, Clone, PartialEq)]
pub struct StateLabelLayout {
pub title_lines: Vec<String>,
pub summary_lines: Vec<String>,
size: Size,
}
impl StateLabelLayout {
pub const FONT_SIZE: f32 = 10.0;
pub const LINE_HEIGHT: f32 = 12.0;
pub const HORIZONTAL_PADDING: f32 = 5.0;
pub const MIN_HEIGHT: f32 = 24.0;
/// `summary` is the existing raw, globally shortened routing summary.
/// Measurement receives each final display line at FONT_SIZE, without breaks.
/// Invalid measurements fall back to the deterministic approximate width.
pub fn new(title: &str, summary: &str, measure: &dyn Fn(&str) -> f32) -> Self {
let budget = (7 * summary.chars().count() + 6) / 6;
let title = if title.chars().count() > budget {
title
.chars()
.take(budget.saturating_sub(1))
.chain(std::iter::once('…'))
.collect()
} else {
title.to_owned()
};
let title_lines = state_display_lines(&title);
let summary_lines = state_display_lines(summary);
let width = title_lines
.iter()
.chain(&summary_lines)
.map(|line| {
let width = measure(line);
if width.is_finite() && width >= 0.0 {
width
} else {
approximate_state_line_width(line)
}
})
.fold(0.0_f32, f32::max);
let size = Size {
width: width + 2.0 * Self::HORIZONTAL_PADDING,
height: (Self::LINE_HEIGHT * (title_lines.len() + summary_lines.len()) as f32)
.max(Self::MIN_HEIGHT),
};
Self {
title_lines,
summary_lines,
size,
}
}
pub fn size(&self) -> Size {
self.size
}
/// Local origin relative to EdgeLabelGeometry.bounds.position.
pub fn title_line_position(&self, index: usize) -> Position {
Position {
x: Self::HORIZONTAL_PADDING,
y: index as f32 * Self::LINE_HEIGHT,
}
}
/// Local origin relative to EdgeLabelGeometry.bounds.position.
pub fn summary_line_position(&self, index: usize) -> Position {
Position {
x: Self::HORIZONTAL_PADDING,
y: (self.title_lines.len() + index) as f32 * Self::LINE_HEIGHT,
}
}
}
fn state_display_lines(raw: &str) -> Vec<String> {
if raw.is_empty() {
return Vec::new();
}
let mut lines = vec![String::new()];
let mut chars = raw.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
lines.push(String::new());
}
'\n' | '\u{85}' | '\u{2028}' | '\u{2029}' => lines.push(String::new()),
'\t' => lines.last_mut().unwrap().push_str(" "),
_ => lines.last_mut().unwrap().push(ch),
}
}
lines
}
fn approximate_state_line_width(line: &str) -> f32 {
line.chars().count() as f32 * 7.0
}
/// Derived labels and navigation markers for the two named association ends.
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
#[derive(Debug, Clone, PartialEq)]
@@ -265,7 +161,6 @@ pub fn association_end_geometry(
source_end,
label: EdgeLabelGeometry {
text,
state_layout: None,
fraction: if source_end { 0.0 } else { 1.0 },
anchor: tip,
center,
@@ -541,24 +436,7 @@ fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id
/// Search caps are presentation limits, reported on the affected edge, never
/// semantic errors or a reason to discard an authored point.
pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView) -> RoutedView {
route_diagram_with_state_label_measurement(
project,
diagram,
view,
&approximate_state_line_width,
)
}
/// Route with frontend line measurements for dedicated State labels before collision placement.
/// The legacy entrypoint uses a deterministic 7-units-per-scalar approximation,
/// not a promise of shaped glyph containment. All other label kinds are unchanged.
pub fn route_diagram_with_state_label_measurement(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
measure: &dyn Fn(&str) -> f32,
) -> RoutedView {
route_diagram_impl(project, diagram, view, None, measure)
route_diagram_impl(project, diagram, view, None)
}
/// Transient route/label editing with unchanged projected nodes and ports.
@@ -573,26 +451,7 @@ pub fn route_edge_preview(
baseline: &RoutedView,
edge: Id,
) -> RoutedView {
route_edge_preview_with_state_label_measurement(
project,
diagram,
view,
baseline,
edge,
&approximate_state_line_width,
)
}
/// Preview counterpart using the same State text measurer as the settled scene.
pub fn route_edge_preview_with_state_label_measurement(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
baseline: &RoutedView,
edge: Id,
measure: &dyn Fn(&str) -> f32,
) -> RoutedView {
route_diagram_impl(project, diagram, view, Some((baseline, edge)), measure)
route_diagram_impl(project, diagram, view, Some((baseline, edge)))
}
fn route_diagram_impl(
@@ -600,7 +459,6 @@ fn route_diagram_impl(
diagram: &Diagram,
view: &ProjectedView,
preview: Option<(&RoutedView, Id)>,
measure: &dyn Fn(&str) -> f32,
) -> RoutedView {
let nodes: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n)).collect();
let rectangles: Vec<_> = nodes.values().map(|n| Rect::node(n)).collect();
@@ -781,14 +639,13 @@ fn route_diagram_impl(
.flatten();
routed.label = make_label(
project,
diagram,
diagram.view_options.exchange_labels,
edge.id,
&routed.points,
authored_label,
&obstacles,
&previous_labels,
&previous,
measure,
);
}
if !routed.label.text.is_empty() {
@@ -1488,7 +1345,6 @@ fn label_text(project: &Project, edge: Id) -> String {
fn empty_label() -> EdgeLabelGeometry {
EdgeLabelGeometry {
text: String::new(),
state_layout: None,
fraction: 0.5,
anchor: pos(0.0, 0.0),
center: pos(0.0, 0.0),
@@ -1506,16 +1362,14 @@ fn empty_label() -> EdgeLabelGeometry {
#[allow(clippy::too_many_arguments)]
fn make_label(
project: &Project,
diagram: &Diagram,
labels: crate::view_options::ExchangeLabels,
edge: Id,
points: &[Position],
authored: Option<&crate::EdgeLabel>,
obstacles: &[Rect],
previous_labels: &[RoutingBounds],
strokes: &[Segment],
measure: &dyn Fn(&str) -> f32,
) -> EdgeLabelGeometry {
let labels = diagram.view_options.exchange_labels;
let text = if labels != crate::view_options::ExchangeLabels::Name
&& project
.relations
@@ -1531,23 +1385,14 @@ fn make_label(
} else {
label_text(project, edge)
};
let state_layout = (diagram.projection.state_machine().is_some()
&& project
.relations
.get(&edge)
.is_some_and(|r| r.kind() == RelationKind::StateTransition))
.then(|| StateLabelLayout::new(project.named(edge), &text, measure));
let size = state_layout.as_ref().map_or_else(
|| Size {
width: if text.is_empty() {
0.0
} else {
text.chars().count() as f32 * 7.0 + 16.0
},
height: if text.is_empty() { 0.0 } else { 24.0 },
let size = Size {
width: if text.is_empty() {
0.0
} else {
text.chars().count() as f32 * 7.0 + 16.0
},
StateLabelLayout::size,
);
height: if text.is_empty() { 0.0 } else { 24.0 },
};
let mut offset = authored.map_or(pos(0.0, -18.0), |l| l.offset);
let geometry = |fraction, offset: Position| {
let anchor = point_along_polyline(points, fraction).unwrap_or(pos(0.0, 0.0));
@@ -1629,7 +1474,6 @@ fn make_label(
let (anchor, center, bounds) = geometry(fraction, offset);
EdgeLabelGeometry {
text,
state_layout,
fraction,
anchor,
center,
+11
View File
@@ -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)
+20 -10
View File
@@ -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()
+12 -7
View File
@@ -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,
+3 -1
View File
@@ -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(&current_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());
}
+12 -1
View File
@@ -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!(
-145
View File
@@ -1,145 +0,0 @@
use oa_core::{projection::project_diagram, routing::*, *};
fn fixture() -> (Project, Id, Id) {
let mut project = language::parse(include_str!(
"../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia"
))
.unwrap();
let edge = Id::parse_str("ea202922-0033-4000-8000-000000000041").unwrap();
let diagram = Id::parse_str("ea202922-0033-4000-8000-000000000200").unwrap();
let relation = project.relations.get_mut(&edge).unwrap();
relation.name = "Ready\nfor review".into();
if let RelationBody::StateTransition { guard, .. } = &mut relation.body {
*guard = "request_ready".into();
}
(project, diagram, edge)
}
#[test]
fn state_two_block_layout_separates_lines_and_keeps_authored_geometry_and_project() {
let (project, diagram, edge) = fixture();
let before = project.clone();
let diagram = &project.diagrams[&diagram];
let view = project_diagram(&project, diagram);
let routed = route_diagram(&project, diagram, &view);
let edge_geometry = &routed.edges[&edge];
let label = &edge_geometry.label;
let layout = label.state_layout.as_ref().unwrap();
assert_eq!(layout.title_lines, ["Ready", "for review"]);
assert_eq!(
layout.summary_lines,
["External · Completion [request_ready]"]
);
assert_eq!(label.text, "External · Completion [request_ready]");
assert_eq!(layout.title_line_position(1), Position { x: 5., y: 12. });
assert_eq!(layout.summary_line_position(0), Position { x: 5., y: 24. });
assert_eq!(
label.bounds.size,
Size {
width: 269.,
height: 36.
}
);
let authored = diagram
.routes
.iter()
.find(|r| r.edge == edge)
.unwrap()
.label
.as_ref()
.unwrap();
assert_eq!(label.fraction, authored.fraction);
let anchor = point_along_polyline(&edge_geometry.points, authored.fraction).unwrap();
assert_eq!(label.anchor, anchor);
assert_eq!(
label.center,
Position {
x: anchor.x + authored.offset.x,
y: anchor.y + authored.offset.y
}
);
assert_eq!(project, before);
}
#[test]
fn state_display_breaks_tabs_and_blank_lines_are_single_sourced() {
let summary = "A sufficiently long unchanged summary";
for title in [
"Ready\nfor review",
"Ready\r\nfor review",
"Ready\rfor review",
"Ready\u{85}for review",
"Ready\u{2028}for review",
"Ready\u{2029}for review",
] {
let layout = StateLabelLayout::new(title, summary, &|s| s.chars().count() as f32);
assert_eq!(layout.title_lines, ["Ready", "for review"]);
assert_eq!(layout.summary_line_position(0).y, 24.);
}
let layout = StateLabelLayout::new("Ready\n\nfor\treview\n", summary, &|s| {
s.chars().count() as f32
});
assert_eq!(layout.title_lines, ["Ready", "", "for review", ""]);
assert_eq!(layout.summary_line_position(0).y, 48.);
assert_eq!(layout.size().height, 60.);
let empty = StateLabelLayout::new("", "", &|_| 0.);
assert!(empty.title_lines.is_empty() && empty.summary_lines.is_empty());
assert_eq!(empty.size().height, 24.);
}
#[test]
fn state_summary_raw_prefix_and_title_budget_survive_display_normalization() {
let (mut project, diagram, edge) = fixture();
let relation = project.relations.get_mut(&edge).unwrap();
relation.name = "T".repeat(80);
if let RelationBody::StateTransition { guard, .. } = &mut relation.body {
*guard = format!("first\r\n{}", "guard".repeat(20));
}
let full = state_transition_summary(&project, &project.relations[&edge]);
let raw: String = full.chars().take(47).chain(std::iter::once('…')).collect();
let d = &project.diagrams[&diagram];
let routed = route_diagram(&project, d, &project_diagram(&project, d));
let label = &routed.edges[&edge].label;
assert_eq!(label.text, raw);
assert!(label.text.contains("\r\n"));
let layout = label.state_layout.as_ref().unwrap();
assert_eq!(layout.title_lines, [format!("{}…", "T".repeat(56))]);
assert_eq!(
layout.summary_lines,
raw.replace("\r\n", "\n")
.split('\n')
.map(str::to_owned)
.collect::<Vec<_>>()
);
assert_eq!(label.text.chars().count(), 48);
}
#[test]
fn supplied_measurement_controls_settled_and_preview_state_bounds_only() {
let (project, diagram, edge) = fixture();
let d = &project.diagrams[&diagram];
let view = project_diagram(&project, d);
let baseline = route_diagram(&project, d, &view);
let wide = |_: &str| 400.;
let measured = route_diagram_with_state_label_measurement(&project, d, &view, &wide);
let label = &measured.edges[&edge].label;
assert_eq!(
label.bounds.size,
Size {
width: 410.,
height: 36.
}
);
assert_eq!(label.center, baseline.edges[&edge].label.center);
assert_eq!(measured.edges[&edge].points, baseline.edges[&edge].points);
let preview =
route_edge_preview_with_state_label_measurement(&project, d, &view, &measured, edge, &wide);
assert_eq!(preview.edges[&edge].label, *label);
let mut ordinary = d.clone();
ordinary.projection = ProjectionKind::Allocation;
let ordinary_measured =
route_diagram_with_state_label_measurement(&project, &ordinary, &view, &wide);
let ordinary_default = route_diagram(&project, &ordinary, &view);
assert!(ordinary_measured.edges[&edge].label.state_layout.is_none());
assert_eq!(ordinary_measured, ordinary_default);
}
+329 -47
View File
@@ -4,8 +4,14 @@ 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 context_help_tests;
#[cfg(test)]
mod diagram_focus_tests;
pub(crate) mod diagram_removal;
#[cfg(test)]
@@ -19,6 +25,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;
@@ -376,11 +384,16 @@ pub enum Message {
ExactStrings(bool),
Page(Page),
Guide(crate::guide::Event),
ContextHelp(crate::guide::Topic),
CloseContextHelp,
ContextHelpScroll(crate::guide::ContextScroll),
Help,
Traceability(crate::views::traceability::Event),
ReviewLayerTraces,
Behavior(crate::behavior::Event),
Layer(Layer),
Select(Id),
CanvasSelect(crate::route_edit::Context, Id),
EditProperties(Id),
SelectNode(Id),
DiagramSelection(multiselect::Event),
@@ -521,6 +534,7 @@ pub struct App {
/// Home resumes this page; explicit Architecture navigation remains separate.
last_workspace_page: Page,
pub guide: crate::guide::State,
pub context_help: bool,
pub traceability: crate::views::traceability::State,
pub behavior: crate::behavior::State,
pub data_state: crate::data_view::State,
@@ -643,6 +657,7 @@ impl App {
page: Page::Diagram,
last_workspace_page: Page::Diagram,
guide: crate::guide::State::default(),
context_help: false,
traceability: Default::default(),
behavior: Default::default(),
data_state: Default::default(),
@@ -947,6 +962,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 +1026,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 +1049,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 +1213,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 +1265,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);
@@ -1294,6 +1395,11 @@ impl App {
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
if self.context_help {
iced::event::listen_with(crate::guide::context_scroll_key)
} else {
Subscription::none()
},
iced::event::listen_with(|event, _, _| {
matches!(
event,
@@ -1320,7 +1426,7 @@ impl App {
return Some(Message::Escape);
}
if key == keyboard::Key::Named(keyboard::key::Named::F1) {
return Some(Message::Page(Page::Guide));
return Some(Message::Help);
}
if key == keyboard::Key::Named(keyboard::key::Named::F3) {
return Some(Message::SourceNavigation(
@@ -1348,6 +1454,55 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
if let Message::ContextHelpScroll(scroll) = message {
return if self.context_help {
crate::guide::context_scroll_task(scroll)
} else {
Task::none()
};
}
// Help owns user input before any canvas/reference fast path can act.
// Passive results may finish; they retain their normal lifetime checks.
if self.context_help
&& !matches!(
message,
Message::Guide(_)
| Message::CloseContextHelp
| Message::Escape
| Message::Request(Pending::Close(_))
| Message::CheckpointWritten(..)
| Message::CheckpointTick
| Message::CheckpointLoaded(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::SourceViewport(_)
| Message::Lint(_)
| Message::Linted(..)
| Message::Saved(_)
| Message::Hosted(crate::hosted::Event::Completed(..))
| Message::Loaded(_)
| Message::IncludesLoaded(..)
| Message::SavePath(_)
| Message::SourceRecovered(_)
| Message::Exported(_)
| Message::SourceSymbolsReady(_)
| Message::SourceRenameReady(_)
| Message::DiagramUsagesReady(_)
| Message::ScenarioCanvas(
behavior::scenario::CanvasEvent::Scrolled(..)
| behavior::scenario::CanvasEvent::Restored(..)
| behavior::scenario::CanvasEvent::Viewport(..)
)
| Message::TopologyCanvas(
behavior::topology::CanvasEvent::Scrolled(..)
| behavior::topology::CanvasEvent::Viewport(..)
)
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
)
{
return Task::none();
}
let preview_read_only = self.process_preview_primary().is_some();
let selection_boundary = self.multiselect_boundary();
// Reject delayed gestures before they can retire newer input ownership.
@@ -1363,6 +1518,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, ..)
@@ -1900,6 +2060,10 @@ impl App {
message,
Message::Escape | Message::Request(Pending::Close(_))
) {
if self.context_help {
self.context_help = false;
return Task::none();
}
if self.hosted.visible {
self.hosted.visible = false;
return Task::none();
@@ -2091,6 +2255,7 @@ impl App {
}
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
if self.busy
|| self.context_help
|| self.hosted.visible
|| self.workflow.is_some()
|| self.architecture.is_some()
@@ -2118,6 +2283,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 +2884,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 +2915,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;
@@ -2787,6 +2977,35 @@ impl App {
);
}
}
Message::ContextHelp(topic) => {
self.guide.update(crate::guide::Event::Topic(topic));
self.context_help = true;
return iced::widget::operation::snap_to(
crate::guide::ARTICLE_SCROLL_ID,
iced::widget::scrollable::RelativeOffset::START,
);
}
Message::CloseContextHelp => {
self.context_help = false;
}
Message::ContextHelpScroll(_) => {}
Message::Help => {
let topic = match self.page {
Page::Diagram => Some(crate::guide::Topic::Nesting),
Page::Behavior => self.behavior.active_owner.and_then(|id| {
self.project()
.elements
.get(&id)
.map(|element| match element.kind() {
Kind::StateMachine => crate::guide::Topic::States,
Kind::Scenario => crate::guide::Topic::Scenarios,
_ => crate::guide::Topic::Chains,
})
}),
_ => None,
};
return self.update(topic.map_or(Message::Page(Page::Guide), Message::ContextHelp));
}
Message::ExactStrings(enabled) => {
if let Some(d) = &mut self.draft {
match d.set_exact(enabled) {
@@ -2889,6 +3108,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 +3135,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 +3576,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 +4602,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 +4621,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 +4650,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 +4792,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 +4812,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 +5233,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 +5272,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 +5292,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 +5353,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 +5371,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) => {}
@@ -5241,7 +5503,7 @@ impl App {
if route.waypoints.len() >= 128 {
return;
}
let geometry = crate::state_label::route_diagram(self.project(), diagram, &view);
let geometry = oa_core::routing::route_diagram(self.project(), diagram, &view);
let edge = &geometry.edges[&self.selected];
// Mandatory port leads are inside the card's clearance. A pin
// starts beyond those leads, including coincident short stubs.
@@ -5321,7 +5583,7 @@ impl App {
self.pan = iced::Vector::new(30., 30.);
return;
}
let geometry = crate::state_label::route_diagram(
let geometry = oa_core::routing::route_diagram(
self.project(),
&self.project().diagrams[&self.active_diagram.unwrap()],
&view,
@@ -5509,6 +5771,7 @@ fn write_snapshot_inner(
.map_err(|errors| crate::document::DocumentError::Source(errors).to_string())?,
);
}
let has_original_destination = previous.is_some();
let previous = previous.filter(|disk| {
path.parent()
.filter(|p| !p.as_os_str().is_empty())
@@ -5519,14 +5782,33 @@ fn write_snapshot_inner(
== Some(&disk.root)
&& entry == disk.bundle.entry
});
// A missing-file expectation colliding with an existing destination is
// not evidence that an observed dependency changed outside the editor.
// Keep save_project's conflict reporting unchanged for that latter case.
let saving_existing = previous.is_some();
let destination_error = |error: source_files::SourceFileError| match error {
source_files::SourceFileError::Conflict(paths) => {
let guidance = if saving_existing {
"Review the new included file's destination before saving."
} else if has_original_destination {
"Choose a fresh directory for Save As, or use Save All to update the open project's current entry."
} else {
"Choose a fresh directory for Save As."
};
format!(
"Source destination files already exist: {paths:?}. Existing files are protected. {guidance}"
)
}
other => other.to_string(),
};
let mut disk = match previous {
Some(disk) => disk,
None => source_files::new_project(&path, LIMITS).map_err(|e| e.to_string())?,
None => source_files::new_project(&path, LIMITS).map_err(&destination_error)?,
};
for path in snapshot.bundle.files.keys() {
if !disk.expectations.contains_key(path) {
source_files::expect_new_file(&mut disk, path, LIMITS)
.map_err(|e| e.to_string())?;
.map_err(&destination_error)?;
}
}
Some(source_files::save_project(&disk, &snapshot, LIMITS).map_err(|e| e.to_string())?)
+10 -4
View File
@@ -16,6 +16,7 @@ use std::{
pub const FINISH_EDIT: &str =
"Finish or cancel the current edit before selecting or moving elements.";
const ARRANGE_ARMED: &str = "Arrange next drag armed · presentation only · Esc cancels";
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stamp {
@@ -635,6 +636,7 @@ impl App {
}
pub(crate) fn allocation_input_owner(&self) -> bool {
self.page == Page::Diagram
&& !self.context_help
&& self
.active_diagram
.and_then(|id| self.project().diagrams.get(&id))
@@ -699,7 +701,12 @@ impl App {
| Message::DiagramUsagesReady(_)
| Message::DiagramRevealViewport(..)
) {
let clear_armed_status =
self.allocation_drag.arrange_next && self.status == ARRANGE_ARMED;
self.allocation_drag.retire();
if clear_armed_status {
self.status = "Arrange cancelled".into();
}
}
}
pub(super) fn allocation_boundary(&self) -> impl PartialEq + use<> {
@@ -841,8 +848,7 @@ impl App {
self.allocation_drag.retire();
if self.allocation_drag_clean() {
self.allocation_drag.arrange_next = true;
self.status =
"Arrange next drag armed · presentation only · Esc cancels".into();
self.status = ARRANGE_ARMED.into();
} else {
self.status = FINISH_EDIT.into();
}
@@ -920,7 +926,7 @@ impl App {
};
let frozen = affected.map(|affected| {
let d = &self.project().diagrams[&diagram];
let routed = crate::state_label::route_diagram(self.project(), d, &view);
let routed = routing::route_diagram(self.project(), d, &view);
Arc::new(Frozen::capture(
self.project(),
d,
@@ -1066,7 +1072,7 @@ impl App {
self.reconcile_multiselect();
}
} else {
let routed = Arc::new(crate::state_label::route_diagram(candidate.project(), placed.diagram(), placed.view()));
let routed = Arc::new(routing::route_diagram(candidate.project(), placed.diagram(), placed.view()));
preview = Some(Arc::new(Preview { token, candidate, placement: placed.into_preview(), routed }));
}
Ok(label)
@@ -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);
@@ -190,6 +193,7 @@ async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty
)
.await;
assert!(!f.app.allocation_drag.arrange_next);
assert_eq!(f.app.status, "Arrange cancelled");
mounted
.batch(
&mut f.app,
@@ -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);
}
}
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -14,7 +14,8 @@ pub(crate) fn root<'a, R: renderer::Renderer + 'a>(
content: Element<'a, Message, Theme, R>,
) -> Element<'a, Message, Theme, R> {
let modal = app.text_workspace.menu().is_none()
&& (app.behavior.creation.has_pending()
&& (app.context_help
|| app.behavior.creation.has_pending()
|| app.diagram_usages.is_some()
|| app.behavior.source_active()
|| (app.page == Page::Text
@@ -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[&current].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
);
}
+143
View File
@@ -1,5 +1,148 @@
use super::*;
fn saved_multifile_app(directory: &std::path::Path) -> (App, Id, PathBuf) {
let mut project = Project::empty();
let activity = project
.add_element(Kind::Activity, Layer::Oa, "Assess incident", None)
.unwrap();
let mut app = App::with_project(project);
let path = directory.join("project.arcadia");
complete_save(&mut app, path.clone());
assert!(app.document.bundle().files.len() > 1);
(app, activity, path)
}
fn rejected_save_preserves_editor(app: &mut App, path: PathBuf) -> String {
let snapshot = app.document.snapshot();
let revision = app.document.revision();
let undo = app.document.can_undo();
let redo = app.document.can_redo();
let draft = app.source_workspace.text().to_owned();
let original_path = app.path.clone();
let dirty = app.dirty();
let result = write_snapshot(
app.canvas_document,
revision,
snapshot.clone(),
path,
app.disk_project.clone(),
);
let error = match result {
Err(error) => error,
Ok(_) => panic!("Expected collision refusal"),
};
let _ = app.update(Message::Saved(Err(error.clone())));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.revision(), revision);
assert_eq!(app.document.can_undo(), undo);
assert_eq!(app.document.can_redo(), redo);
assert_eq!(app.source_workspace.text(), draft);
assert_eq!(app.path, original_path);
assert_eq!(app.dirty(), dirty);
error.message
}
#[test]
fn same_root_new_entry_collision_preserves_files_draft_and_history() {
let directory = tempfile::tempdir().unwrap();
let (mut app, activity, original) = saved_multifile_app(directory.path());
let bytes: Vec<_> = app
.document
.bundle()
.files
.keys()
.map(|p| (p.clone(), std::fs::read(directory.path().join(p)).unwrap()))
.collect();
app.execute(Command::Rename(activity, "Review incident".into()));
app.execute(Command::Rename(activity, "Evaluate incident".into()));
let _ = app.update(Message::Undo);
assert!(app.document.can_undo() && app.document.can_redo());
let cursor = app.source_workspace.files[&app.source_workspace.entry].cursor;
app.source_workspace.edit(
format!("{}\n// retained draft", app.source_workspace.text()),
cursor,
);
let destination = directory.path().join("renamed.arcadia");
let error = rejected_save_preserves_editor(&mut app, destination.clone());
assert!(error.contains("already exist") && error.contains("Save All"));
assert!(!error.contains("changed outside"));
assert!(!destination.exists());
assert_eq!(app.path, Some(original));
for (path, expected) in bytes {
assert_eq!(
std::fs::read(directory.path().join(path)).unwrap(),
expected
);
}
}
#[test]
fn fresh_root_existing_include_is_protected_before_any_bundle_write() {
let original = tempfile::tempdir().unwrap();
let destination = tempfile::tempdir().unwrap();
let (mut app, activity, _) = saved_multifile_app(original.path());
app.execute(Command::Rename(activity, "Review incident".into()));
let fragment = app.document.compiled().index.declarations[&activity]
.path
.clone();
let collision = destination.path().join(&fragment);
std::fs::create_dir_all(collision.parent().unwrap()).unwrap();
std::fs::write(&collision, b"// independent destination work").unwrap();
let target = destination.path().join("copy.arcadia");
let error = rejected_save_preserves_editor(&mut app, target.clone());
assert!(error.contains(&fragment) && error.contains("fresh directory"));
assert!(!error.contains("changed outside"));
assert!(!target.exists());
for path in app.document.bundle().files.keys() {
let file = destination.path().join(path);
if path == &fragment {
assert_eq!(
std::fs::read(file).unwrap(),
b"// independent destination work"
);
} else {
assert!(!file.exists(), "Unexpected write: {path}");
}
}
}
#[test]
fn observed_dependency_change_remains_external_conflict_and_normal_saves_work() {
let original = tempfile::tempdir().unwrap();
let fresh = tempfile::tempdir().unwrap();
let (mut app, activity, path) = saved_multifile_app(original.path());
app.execute(Command::Rename(activity, "Review incident".into()));
complete_save(&mut app, path.clone());
assert!(!app.dirty());
let fragment = app.document.compiled().index.declarations[&activity]
.path
.clone();
let entry_before = std::fs::read(&path).unwrap();
let original_fragment = std::fs::read(original.path().join(&fragment)).unwrap();
std::fs::write(original.path().join(&fragment), b"// external edit").unwrap();
app.execute(Command::Rename(activity, "Evaluate incident".into()));
let error = rejected_save_preserves_editor(&mut app, path.clone());
assert!(error.contains("changed outside") && error.contains(&fragment));
assert!(!error.contains("fresh directory"));
assert_eq!(std::fs::read(&path).unwrap(), entry_before);
assert_eq!(
std::fs::read(original.path().join(&fragment)).unwrap(),
b"// external edit"
);
// Restore the observed bytes, then exercise the legitimate existing Save All.
std::fs::write(original.path().join(&fragment), original_fragment).unwrap();
complete_save(&mut app, path);
assert!(!app.dirty());
let model = app.project().clone();
let target = fresh.path().join("copy.arcadia");
complete_save(&mut app, target.clone());
let saved = source_files::load_project(&target, LIMITS).unwrap();
let compiled = source_project::compile_bundle(&saved.bundle, LIMITS).unwrap();
assert_eq!(compiled.decoded.project, model);
assert_eq!(&saved.bundle, app.document.bundle());
assert!(!app.dirty());
}
fn complete_save(app: &mut App, path: PathBuf) {
let receipt = write_snapshot(
app.canvas_document,
+1 -1
View File
@@ -85,7 +85,7 @@ impl App {
};
let diagram = &self.project().diagrams[&context.diagram];
let view = projection::project_diagram(self.project(), diagram);
let bounds = crate::state_label::route_diagram(self.project(), diagram, &view).bounds;
let bounds = routing::route_diagram(self.project(), diagram, &view).bounds;
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
@@ -16,6 +16,7 @@ pub fn keyboard_enabled(app: &crate::app::App) -> bool {
/// Passive I/O suspends commands without changing the logical input owner.
pub(crate) fn keyboard_owner_enabled(app: &crate::app::App) -> bool {
!app.hosted.visible
&& !app.context_help
&& app.workflow.is_none()
&& app.file_prompt.is_none()
&& !app.delete_pending
+27 -86
View File
@@ -744,7 +744,7 @@ pub(crate) fn diagram_usages(
}
}
}
for edge in crate::state_label::route_diagram(project, diagram, view)
for edge in routing::route_diagram(project, diagram, view)
.edges
.values()
{
@@ -754,10 +754,10 @@ pub(crate) fn diagram_usages(
if edge.id == subject || edge.label.text.is_empty() {
continue;
}
// The shared plan paints the complete retained raw summary. Keep its
// original prefix here: display normalization must not shift field offsets.
let painted = &edge.label.text;
if let Some(role) = transition_label_role_in_text(project, relation, subject, painted) {
// Routing already shortens external summaries before the canvas applies
// its width limit. Compare against precisely that doubly shortened text.
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
if let Some(role) = transition_label_role_in_text(project, relation, subject, &painted) {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
@@ -1078,7 +1078,7 @@ impl StateCanvas<'_> {
}
let view = projection::project_diagram(self.project, &diagram);
let routed = if let Some(gesture) = state.bend.as_ref().filter(|_| bend_preview.is_some()) {
crate::state_label::route_edge_preview(
routing::route_edge_preview(
self.project,
&diagram,
&view,
@@ -1086,7 +1086,7 @@ impl StateCanvas<'_> {
gesture.route.edge,
)
} else {
crate::state_label::route_diagram(self.project, &diagram, &view)
routing::route_diagram(self.project, &diagram, &view)
};
let symbol_labels = symbol_labels(self.project, &view, &routed);
let scene = Arc::new(Scene {
@@ -1153,19 +1153,6 @@ impl StateCanvas<'_> {
.map(|n| n.element)
}
fn hit(&self, scene: &Scene, world: Point) -> Option<Hit> {
// State labels paint opaque backgrounds above nodes and rows. Select
// the last painted label at an overlapping pixel, including its padding.
// Endpoint/bend handling remains ahead of this ordinary hit path.
if let Some(edge) = scene.routed.edges.values().rev().find(|edge| {
edge.label.state_layout.is_some()
&& Rectangle::new(
point(edge.label.bounds.position),
Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
)
.contains(world)
}) {
return Some(Hit::Transition(edge.id));
}
let node = self.node_at(scene, world);
if let Some(node) = node {
if let Some((relation, _)) = state_action_rows(self.project, node)
@@ -1510,7 +1497,9 @@ impl StateCanvas<'_> {
format!("{} · {} · right-click to edit", r.name, routing::state_transition_summary(self.project, r))
}
}).unwrap_or_default(),
_ => "Select / move · drag a vertex · drag selected transition endpoints to reconnect · middle-drag to pan".into(),
// General gestures are available in Help; keep this stable slot
// for the active operation or the item under the pointer.
_ => String::new(),
},
}
}
@@ -2120,29 +2109,21 @@ impl canvas::Program<Event> for StateCanvas<'_> {
Size::new(label.bounds.size.width, label.bounds.size.height),
Color::WHITE,
);
if let Some(plan) = &label.state_layout {
for (line, offset, ink) in plan
.title_lines
.iter()
.enumerate()
.map(|(index, line)| (line, plan.title_line_position(index), color))
.chain(plan.summary_lines.iter().enumerate().map(|(index, line)| {
(line, plan.summary_line_position(index), style::MUTED)
}))
{
frame.fill_text(Text {
content: line.clone(),
position: point(label.bounds.position)
+ Vector::new(offset.x, offset.y),
size: routing::StateLabelLayout::FONT_SIZE.into(),
line_height: iced::widget::text::LineHeight::Absolute(
routing::StateLabelLayout::LINE_HEIGHT.into(),
),
color: ink,
..Default::default()
});
}
}
let width = label.bounds.size.width - 10.;
label_text(
frame,
short(self.project.named(edge.id), width, 10.),
point(label.bounds.position) + Vector::new(5., 0.),
10.,
color,
);
label_text(
frame,
short(&label.text, width, 10.),
point(label.bounds.position) + Vector::new(5., 12.),
10.,
style::MUTED,
);
if active
&& self
.current_selection()
@@ -2616,7 +2597,6 @@ mod tests {
include!("state_bend_tests.rs");
include!("state_multiselect_canvas_tests.rs");
include!("state_label_canvas_tests.rs");
struct Fixture {
project: Project,
@@ -2825,45 +2805,6 @@ mod tests {
assert!(!retained_label_field("1234A678", "1234…", 4));
}
#[test]
fn state_label_usage_keeps_raw_crlf_prefix_for_later_typed_fields() {
let mut f = fixture();
let first = f
.project
.add_element(Kind::Event, Layer::Sa, "漢\r\nB", None)
.unwrap();
let second = f
.project
.add_element(Kind::Event, Layer::Sa, "Visible", None)
.unwrap();
let edge = f
.project
.relate(RelationKind::StateTransition, "Ready\nfor review", f.a, f.b);
if let RelationBody::StateTransition { triggers, .. } =
&mut f.project.relations.get_mut(&edge).unwrap().body
{
*triggers = vec![first, second];
}
let diagram = &f.project.diagrams[&f.diagram];
let view = projection::project_diagram(&f.project, diagram);
let routed = crate::state_label::route_diagram(&f.project, diagram, &view);
let label = &routed.edges[&edge].label;
assert_eq!(label.text, "External · 漢\r\nB OR Visible");
assert_eq!(
label.state_layout.as_ref().unwrap().summary_lines,
["External · 漢", "B OR Visible"]
);
let usages = diagram_usages(&f.project, diagram, &view, second);
assert!(
usages
.iter()
.any(|u| u.target == Target::Edge(edge) && u.role == "transition trigger")
);
// Feeding normalized display text to raw-prefix matching would lose
// the second field at CRLF. Its raw name and source remain unchanged.
assert_eq!(f.project.named(first), "漢\r\nB");
}
#[test]
fn diagram_usage_external_labels_obey_routing_cutoff_while_internal_rows_retain_full_summary() {
use crate::diagram_usage::Target;
@@ -2898,9 +2839,9 @@ mod tests {
}
let diagram = &f.project.diagrams[&f.diagram];
let view = projection::project_diagram(&f.project, diagram);
let routed = crate::state_label::route_diagram(&f.project, diagram, &view);
let routed = routing::route_diagram(&f.project, diagram, &view);
let edge = &routed.edges[&external];
let painted = &edge.label.text;
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
// Independent painted-text oracle: 11 prefix characters + 35 As, then
// only the space before OR survives the routing limit.
assert_eq!(edge.label.text, format!("External · {} …", "A".repeat(35)));
+1 -1
View File
@@ -328,7 +328,7 @@ pub fn bend_route(
}
let stored = project.diagrams.get(&diagram)?;
let view = projection::project_diagram(project, stored);
let geometry = crate::state_label::route_diagram(project, stored, &view)
let geometry = oa_core::routing::route_diagram(project, stored, &view)
.edges
.remove(&relation)?;
(!geometry.custom_suspended).then_some(geometry)
@@ -1,463 +0,0 @@
// Included in state_canvas::tests. Fixed world points exercise the real scene
// and Program input, rather than choosing a successful point from returned bounds.
fn multiline_label_fixture() -> (Fixture, Id, Id) {
let mut f = fixture();
let work = f
.project
.add_element(Kind::Function, Layer::Sa, "Record request", None)
.unwrap();
let action = f
.project
.relate(RelationKind::StateAction, "Record shared work", f.a, work);
if let RelationBody::StateAction { phase, .. } =
&mut f.project.relations.get_mut(&action).unwrap().body
{
*phase = StateActionKind::Entry;
}
let generated = f
.project
.relate(RelationKind::StateTransition, "Ready\nfor review", f.b, f.c);
let edge = Id::from_u128(36_001);
let mut relation = f.project.relations.remove(&generated).unwrap();
relation.id = edge;
if let RelationBody::StateTransition { guard, .. } = &mut relation.body {
*guard = "request_ready".into();
}
f.project.relations.insert(edge, relation);
// B is (760,180), 224x94. Its bottom midpoint is (872,274).
// Fraction zero plus this authored offset fixes the label center at (440,260),
// over A's entry row (y244..270), independently of text width/routing choices.
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge,
waypoints: vec![],
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.5,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.5,
}),
label: Some(EdgeLabel {
fraction: 0.,
offset: Position { x: -432., y: -14. },
}),
});
validate_candidate(&f.project).unwrap();
(f, edge, action)
}
#[test]
fn state_multiline_label_owns_covered_row_and_node_pixels_but_not_exposed_row() {
let (f, edge, action) = multiline_label_fixture();
let before = f.project.clone();
for zoom in [0.5, 1., 2.] {
let canvas = canvas(&f, Tool::Select, zoom); // Nonzero pan and canvas origin.
let scene = canvas.scene(&State::default()).unwrap();
let label = &scene.routed.edges[&edge].label;
let layout = label
.state_layout
.as_ref()
.expect("dedicated State label plan");
assert_eq!(layout.title_lines, ["Ready", "for review"]);
assert_eq!(
layout.summary_lines,
["External · Completion [request_ready]"]
);
assert_eq!(label.center, Position { x: 440., y: 260. });
assert_eq!(label.bounds.size.height, 36.);
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
let row = state_action_rows(&f.project, node)[0].1;
assert!(row.contains(Point::new(440., 260.)));
assert!(!row.contains(Point::new(440., 272.)));
// Second title cell covers a row; summary covers the State header below it.
for at in [Point::new(440., 260.), Point::new(440., 272.)] {
assert_eq!(canvas.hit(&scene, at), Some(Hit::Transition(edge)));
let events = click(&canvas, &mut State::default(), at);
assert!(
matches!(events.as_slice(), [Event::Select(context, id)]
if *context == canvas.context && *id == edge),
"{events:?}"
);
}
let exposed = Point::new(200., 260.);
assert!(row.contains(exposed));
assert_eq!(
canvas.hit(&scene, exposed),
Some(Hit::Row {
placement: node.id,
relation: action
})
);
let events = click(&canvas, &mut State::default(), exposed);
assert!(
matches!(events.as_slice(), [Event::Select(_, id)] if *id == action),
"{events:?}"
);
for (at, object, target) in [
(Point::new(440., 272.), edge, Target::Edge(edge)),
(
exposed,
action,
Target::StateRow {
placement: node.id,
relation: action,
},
),
] {
let mut state = State::default();
let at = screen(&canvas, at);
motion(&canvas, &mut state, at, at);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
Some(Event::ContextMenu { object: actual, selection_target: Some(actual_target), .. })
if actual == object && actual_target == target)
);
}
}
assert_eq!(
f.project, before,
"Hit testing and input cannot author layout or semantics"
);
}
#[test]
fn state_overlapping_label_background_selects_last_painted_even_if_lower_is_selected() {
let (mut f, lower, _) = multiline_label_fixture();
let upper = Id::from_u128(36_002);
let mut relation = f.project.relations[&lower].clone();
relation.id = upper;
relation.alias = "second_multiline_transition".into();
f.project.relations.insert(upper, relation);
let mut route = f.project.diagrams[&f.diagram].routes[0].clone();
route.edge = upper;
// Reverse authored route order: stable edge paint order must still decide.
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.routes
.insert(0, route);
validate_candidate(&f.project).unwrap();
let mut canvas = canvas(&f, Tool::Select, 1.);
canvas.selected = lower;
let selection = group_selection(&canvas, [Target::Edge(lower)]);
canvas.selection = Some(&selection);
let scene = canvas.scene(&State::default()).unwrap();
assert_eq!(
scene.routed.edges[&lower].label.bounds,
scene.routed.edges[&upper].label.bounds
);
let at = Point::new(440., 260.);
assert_eq!(canvas.hit(&scene, at), Some(Hit::Transition(upper)));
let events = click(&canvas, &mut State::default(), at);
assert!(
matches!(events.as_slice(), [Event::Selection(SelectionEvent::Choose(_, Target::Edge(id), Choice::Replace))]
if *id == upper),
"{events:?}"
);
}
#[test]
fn state_multiline_label_and_exposed_row_keep_shift_identity_and_refuse_mixed_drag() {
let (f, edge, action) = multiline_label_fixture();
let before = f.project.clone();
let mut canvas = canvas(&f, Tool::Select, 0.75);
let row = Target::StateRow {
placement: placement(&f, f.a),
relation: action,
};
let selection = group_selection(&canvas, [Target::Edge(edge), row.clone()]);
let snapshot = selection
.snapshot(selection_context(canvas.context))
.unwrap();
canvas.selection = Some(&selection);
canvas.selected = edge;
for (world, target) in [
(Point::new(440., 260.), Target::Edge(edge)),
(Point::new(200., 260.), row),
] {
let at = screen(&canvas, world);
let mut state = State::default();
assert!(
canvas::Program::update(
&canvas,
&mut state,
&canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(
keyboard::Modifiers::SHIFT
)),
bounds(),
mouse::Cursor::Unavailable
)
.is_none()
);
motion(&canvas, &mut state, at, at);
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at),
Some(Event::Selection(SelectionEvent::Choose(context, actual, Choice::Toggle)))
if context == selection_context(canvas.context) && actual == target)
);
assert!(state.group.is_none() && state.press.is_none() && state.bend.is_none());
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
at
)
.is_none()
);
let mut state = State::default();
motion(&canvas, &mut state, at, at);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at
)
.is_none()
);
assert!(state.group.is_some() && state.press.is_none() && state.bend.is_none());
let end = at + Vector::new(30., 20.);
motion(&canvas, &mut state, end, end);
assert!(canvas.group_preview_key(&state, Some(bounds())).is_none());
assert!(
matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end),
Some(Event::Selection(SelectionEvent::Refuse(actual, _))) if actual == snapshot)
);
assert!(state.group.is_none() && state.bend_preview.is_none());
}
assert_eq!(
selection
.snapshot(selection_context(canvas.context))
.unwrap(),
snapshot
);
assert_eq!(f.project, before);
}
#[test]
fn state_multiline_label_group_preview_replaces_old_hit_rectangle_and_cancel_restores_it() {
let (f, edge, action) = multiline_label_fixture();
let before = f.project.clone();
let mut canvas = canvas(&f, Tool::Select, 0.75);
let selection = group_selection(
&canvas,
[
Target::Placement(placement(&f, f.b)),
Target::Placement(placement(&f, f.c)),
],
);
canvas.selection = Some(&selection);
canvas.selected = f.c;
let mut state = State::default();
let baseline = canvas.scene(&state).unwrap();
let old = Point::new(440., 260.);
let moved = Point::new(440., 580.);
assert_eq!(canvas.hit(&baseline, old), Some(Hit::Transition(edge)));
let start = screen(&canvas, Point::new(780., 200.));
let end = start + Vector::new(0., 320. * canvas.scale());
motion(&canvas, &mut state, start, end);
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
end
)
.is_none()
);
assert!(state.group.is_some());
motion(&canvas, &mut state, end, end);
let preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
assert_eq!(
preview.routed.edges[&edge].label.center,
Position { x: 440., y: 580. }
);
assert_eq!(
preview.routed.edges[&edge].label.state_layout,
baseline.routed.edges[&edge].label.state_layout
);
assert_eq!(canvas.hit(&preview, moved), Some(Hit::Transition(edge)));
assert_eq!(
canvas.hit(&preview, old),
Some(Hit::Row {
placement: placement(&f, f.a),
relation: action
})
);
assert_eq!(f.project, before, "Group preview is derived only");
assert!(
group_key(
&canvas,
&mut state,
keyboard::key::Named::Escape,
keyboard::Modifiers::empty()
)
.is_none()
);
assert!(state.group.is_none());
let restored = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
assert_eq!(
restored.routed.edges[&edge].label,
baseline.routed.edges[&edge].label
);
assert_eq!(canvas.hit(&restored, old), Some(Hit::Transition(edge)));
assert_ne!(canvas.hit(&restored, moved), Some(Hit::Transition(edge)));
assert!(
input(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
assert_eq!(f.project, before);
}
#[test]
fn state_multiline_label_group_commit_matches_fixed_project_and_one_step_history() {
let (f, edge, _) = multiline_label_fixture();
let b = placement(&f, f.b);
let c = placement(&f, f.c);
let before = f.project.clone();
// Declare the complete expected model before driving input. No event, preview,
// movement planner or resulting document supplies the expected displacement.
let mut expected = before.clone();
for id in [b, c] {
let node = expected
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&id)
.unwrap();
node.position.x += 40.;
node.position.y += 40.;
}
let original_route = before.diagrams[&f.diagram].routes[0].clone();
let mut document = Document::from_project(before.clone(), Default::default()).unwrap();
let original_source = document.bundle().clone();
let mut view_canvas = canvas(&f, Tool::Select, 0.75);
let selection = group_selection(&view_canvas, [Target::Placement(b), Target::Placement(c)]);
let snapshot = selection
.snapshot(selection_context(view_canvas.context))
.unwrap();
view_canvas.selection = Some(&selection);
view_canvas.selected = f.c;
let mut state = State::default();
let baseline = view_canvas.scene(&state).unwrap();
let baseline_label = &baseline.routed.edges[&edge].label;
assert_eq!(baseline_label.center, Position { x: 440., y: 260. });
// Explicit 0.75 scale: thirty screen pixels is the declared forty world
// units. The nonzero pan/origin and ordered pointer helpers stay in play.
let start = screen(&view_canvas, Point::new(780., 200.));
let end = start + Vector::new(30., 30.);
motion(&view_canvas, &mut state, start, end);
assert!(
input(
&view_canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
end
)
.is_none()
);
assert!(state.group.is_some());
motion(&view_canvas, &mut state, end, end);
let preview = view_canvas
.scene_with_bounds(&state, Some(bounds()))
.unwrap();
let preview_label = &preview.routed.edges[&edge].label;
assert_eq!(preview_label.center, Position { x: 480., y: 300. });
assert_eq!(
preview_label.bounds.position,
Position {
x: baseline_label.bounds.position.x + 40.,
y: baseline_label.bounds.position.y + 40.,
}
);
assert_eq!(preview_label.bounds.size, baseline_label.bounds.size);
assert_eq!(preview_label.state_layout, baseline_label.state_layout);
assert_eq!(
f.project, before,
"Preview must not author its derived label"
);
let event = input(
&view_canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end,
)
.unwrap();
let Event::Selection(SelectionEvent::Move(actual_snapshot, delta)) = event else {
panic!("Expected a group movement event, got {event:?}");
};
assert_eq!(actual_snapshot, snapshot);
assert_eq!(delta, Position { x: 40., y: 40. });
assert!(state.group.is_none() && state.press.is_none());
assert!(
input(
&view_canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
end
)
.is_none()
);
// This is the same production snapshot-to-command boundary used by App's
// SelectionEvent::Move handler; App admission has its existing separate tests.
let ids = crate::app::multiselect::placements(&actual_snapshot).unwrap();
let command =
oa_core::diagram_edit::translate_placements(document.project(), f.diagram, &ids, delta)
.unwrap();
document.execute(command).unwrap();
assert_eq!(document.project(), &expected);
assert_eq!(
document.project().diagrams[&f.diagram].routes[0],
original_route,
"Authored fraction, offset, attachments and empty waypoints remain exact"
);
let committed = StateCanvas {
project: document.project(),
..canvas(&f, Tool::Select, 0.75)
}
.scene(&State::default())
.unwrap();
assert_eq!(
committed.routed.edges[&edge].label, *preview_label,
"Measured settled label, including raw text and every line cell, matches preview"
);
let edited_source = document.bundle().clone();
assert_ne!(
edited_source, original_source,
"The group move is a real source edit"
);
assert!(document.undo());
assert_eq!(document.project(), &before);
assert_eq!(document.bundle(), &original_source);
assert!(
!document.undo(),
"One group release produces exactly one history entry"
);
assert!(document.redo());
assert_eq!(document.project(), &expected);
assert_eq!(document.bundle(), &edited_source);
assert!(!document.redo());
assert_eq!(
selection
.snapshot(selection_context(view_canvas.context))
.unwrap(),
snapshot
);
assert_eq!(f.project, before);
}
+9 -9
View File
@@ -225,13 +225,13 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
}
controls = controls
.push(action(
"Clear selection",
"Clear",
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
))
.push(
label(format!(
"Properties edit only {}",
crate::views::short(app.project().named(app.selected), 18)
"Properties: {} only",
crate::views::short(app.project().named(app.selected), 10)
))
.wrapping(iced::widget::text::Wrapping::None),
);
@@ -241,12 +241,12 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
} else {
"Use Select / move to select multiple elements"
};
column![
controls,
label(hint).wrapping(iced::widget::text::Wrapping::None)
]
.height(56)
.into()
controls
.push(crate::views::diagram_help(
crate::guide::Topic::States,
hint,
))
.into()
}
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
@@ -585,15 +585,13 @@ fn selection_controls(app: &App) -> Element<'_, Message> {
.wrapping(iced::widget::text::Wrapping::None),
);
}
// These two rows exist even with an empty set so selection never moves the
// canvas viewport or changes the camera's interpretation of the pointer.
column![
controls,
label("Shift+click toggles · drag selected cards to reorder · previews are read-only")
.wrapping(iced::widget::text::Wrapping::None)
]
.height(56)
.into()
// Keep the row even with an empty set so selection never moves the canvas.
controls
.push(crate::views::diagram_help(
crate::guide::Topic::Chains,
"Shift-click toggles selection. Drag selected local cards to reorder; reused previews are read-only.",
))
.into()
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
+18
View File
@@ -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()
+18 -214
View File
@@ -1,7 +1,6 @@
use crate::{
app::Message,
route_edit::{self, Context, Event as RouteEvent, Gesture, Handle, point, position},
state_label::route_diagram,
style,
views::short,
};
@@ -14,7 +13,7 @@ use iced::{
};
use oa_core::{
projection::{ProjectedNode, ProjectedView, project_view},
routing::{RoutedView, StateLabelLayout},
routing::{RoutedView, route_diagram},
*,
};
use std::{
@@ -35,9 +34,6 @@ mod lifecycle_tests;
#[path = "diagram/port_hit_tests.rs"]
mod port_hit_tests;
#[cfg(test)]
#[path = "diagram/state_label_input_tests.rs"]
mod state_label_input_tests;
#[cfg(test)]
#[path = "diagram/view_options_tests.rs"]
mod view_options_tests;
@@ -329,12 +325,7 @@ impl DiagramCanvas<'_> {
request.bend = Some(index);
return request;
}
if let Some(edge) = self.hit_state_label(&scene.routed, local) {
request.object = edge;
request.route_point =
routing::project_onto_polyline(&scene.routed.edges[&edge].points, position(world))
.map(|p| p.point);
} else if let Some(port) = self.hit_port(&scene.view, local) {
if let Some(port) = self.hit_port(&scene.view, local) {
request.object = port.element;
} else if let Some(node) = self.hit(&scene.view, local) {
request.object = self.project.diagrams[&self.diagram].nodes[&node].element;
@@ -433,35 +424,13 @@ impl DiagramCanvas<'_> {
})
.map(|(end, owner, _)| (end, owner))
}
fn hit_state_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
let world = self.world(p);
// State labels paint after nodes, in BTree order. Hit the frontmost first.
routed
.edges
.values()
.rev()
.find(|edge| {
edge.label.state_layout.is_some()
&& !edge.label.text.is_empty()
&& Rectangle::new(
point(edge.label.bounds.position),
Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
)
.contains(world)
})
.map(|edge| edge.id)
}
fn hit_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
if let Some(edge) = self.hit_state_label(routed, p) {
return Some(edge);
}
let p = self.world(p);
routed
.edges
.values()
.filter(|edge| {
edge.label.state_layout.is_none()
&& !edge.label.text.is_empty()
!edge.label.text.is_empty()
&& Rectangle::new(
point(edge.label.bounds.position),
Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
@@ -600,7 +569,7 @@ impl DiagramCanvas<'_> {
.as_ref()
.filter(|g| g.context == self.context)
{
crate::state_label::route_edge_preview(
oa_core::routing::route_edge_preview(
self.project,
&diagram,
&view,
@@ -993,11 +962,8 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
);
}
let label = &edge.label;
if (if label.state_layout.is_some() {
self.hit_state_label(&scene.routed, p) == Some(edge.id)
} else {
self.hit(view, p).is_none()
}) && !label.text.is_empty()
if self.hit(view, p).is_none()
&& !label.text.is_empty()
&& Rectangle::new(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
@@ -1016,9 +982,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
return Some(Action::capture());
}
}
if let Some(edge) = self.hit_state_label(&scene.routed, p) {
return Some(Action::publish(Message::Select(edge)).and_capture());
}
if let Some(port) = self.hit_port(view, p) {
if self.editable
&& let Some(owner) = view.nodes.iter().find(|n| n.id == port.owner)
@@ -1030,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
@@ -1047,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
@@ -1063,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));
@@ -1385,7 +1351,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
Stroke::default().with_color(color).with_width(1.7),
);
}
if routed.label.state_layout.is_none() && !routed.label.text.is_empty() {
if !routed.label.text.is_empty() {
let label = &routed.label;
f.fill_rectangle(
point(label.bounds.position),
@@ -1524,47 +1490,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
f.fill(&Path::circle(center, 1.5), color);
}
}
// State labels follow nodes, matching the dedicated State canvas layer.
for routed in scene.routed.edges.values() {
let label = &routed.label;
let active = self.selected == routed.id
|| highlighted.contains(&routed.id)
|| self.selection_contains(crate::diagram_usage::Target::Edge(routed.id));
if label.state_layout.is_some() && !label.text.is_empty() {
f.fill_rectangle(
point(label.bounds.position),
Size::new(label.bounds.size.width, label.bounds.size.height),
Color::WHITE,
);
if let Some(plan) = &label.state_layout {
for (lines, title) in [(&plan.title_lines, true), (&plan.summary_lines, false)]
{
for (index, line) in lines.iter().enumerate() {
let offset = if title {
plan.title_line_position(index)
} else {
plan.summary_line_position(index)
};
f.fill_text(Text {
content: line.clone(),
position: point(label.bounds.position)
+ Vector::new(offset.x, offset.y),
color: if title {
if active { style::TEAL } else { style::INK }
} else {
style::MUTED
},
size: StateLabelLayout::FONT_SIZE.into(),
line_height: iced::widget::text::LineHeight::Absolute(
StateLabelLayout::LINE_HEIGHT.into(),
),
..Default::default()
});
}
}
}
}
}
if let Some(bounds) = self.navigation_focus {
f.stroke(
&Path::rectangle(bounds.position(), bounds.size()),
@@ -2021,34 +1946,6 @@ impl std::fmt::Display for ExportError {
}
impl std::error::Error for ExportError {}
fn edge_label_svg(label: &routing::EdgeLabelGeometry) -> String {
if label.text.is_empty() {
return String::new();
}
let mut svg = format!(
"<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/>",
label.bounds.position.x,
label.bounds.position.y,
label.bounds.size.width,
label.bounds.size.height
);
if let Some(plan) = &label.state_layout {
for (lines, title) in [(&plan.title_lines, true), (&plan.summary_lines, false)] {
for (index, line) in lines.iter().enumerate() {
let offset = if title {
plan.title_line_position(index)
} else {
plan.summary_line_position(index)
};
svg.push_str(&format!("<text x=\"{}\" y=\"{}\" dominant-baseline=\"text-before-edge\" xml:space=\"preserve\" font-family=\"sans-serif\" font-size=\"{}\" fill=\"{}\">{}</text>", label.bounds.position.x + offset.x, label.bounds.position.y + offset.y, StateLabelLayout::FONT_SIZE, hex(if title { style::INK } else { style::MUTED }), escape(line)));
}
}
} else {
svg.push_str(&format!("<text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.center.x,label.center.y,escape(&label.text)));
}
svg
}
pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
let d = p
.diagrams
@@ -2135,8 +2032,8 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
.join(" ");
svg.push_str(&format!("<path data-edge=\"{}\" d=\"{path}\" fill=\"none\" stroke=\"#8999a9\" stroke-width=\"1.5\"{dashed}{arrow}><title>{}</title></path>",edge.id,escape(p.named(edge.id))));
let label = &route.label;
if label.state_layout.is_none() {
svg.push_str(&edge_label_svg(label));
if !label.text.is_empty() {
svg.push_str(&format!("<rect x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\" fill=\"white\"/><text x=\"{}\" y=\"{}\" text-anchor=\"middle\" dominant-baseline=\"central\" font-family=\"sans-serif\" font-size=\"13\" fill=\"#637487\">{}</text>",label.bounds.position.x,label.bounds.position.y,label.bounds.size.width,label.bounds.size.height,label.center.x,label.center.y,escape(&label.text)));
}
if let Some(relation) = p.relations.get(&edge.id) {
for end in routing::association_end_geometry(relation, route) {
@@ -2195,11 +2092,6 @@ pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
}
svg.push_str("</g>");
}
for route in routed.edges.values() {
if route.label.state_layout.is_some() {
svg.push_str(&edge_label_svg(&route.label));
}
}
svg.push_str("</svg>");
Ok(svg)
}
@@ -2552,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,
@@ -3057,94 +2949,6 @@ mod tests {
);
assert!(state.route_drag.is_none());
}
#[test]
fn state_svg_paints_separate_escaped_lines_at_shared_layout_origins() {
let plan = StateLabelLayout::new(
"Ready\r\nfor <review>&",
"External\nCompletion [ready]",
&|line| line.len() as f32 * 8.0,
);
let label = routing::EdgeLabelGeometry {
text: "External\nCompletion [ready]".into(),
fraction: 0.5,
anchor: Position { x: 0.0, y: 0.0 },
center: Position { x: 0.0, y: 0.0 },
bounds: routing::RoutingBounds {
position: Position { x: 100.0, y: 200.0 },
size: plan.size(),
},
state_layout: Some(plan),
};
let svg = edge_label_svg(&label);
assert_eq!(svg.matches("<text ").count(), 4);
for (y, color, text) in [
(200, "#18212c", "Ready"),
(212, "#18212c", "for &lt;review&gt;&amp;"),
(224, "#4b5968", "External"),
(236, "#4b5968", "Completion [ready]"),
] {
assert!(svg.contains(&format!("<text x=\"105\" y=\"{y}\" dominant-baseline=\"text-before-edge\" xml:space=\"preserve\" font-family=\"sans-serif\" font-size=\"10\" fill=\"{color}\">{text}</text>")), "{svg}");
}
assert!(svg.contains("height=\"48\""));
assert!(!svg.contains("text-anchor=\"middle\""));
}
#[test]
fn state_export_routes_measured_multiline_label_above_nodes() {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p
.add_element(Kind::State, Layer::Oa, "Source state", Some(region))
.unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "Target state", Some(region))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Ready\nfor <review>&", a, b);
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "state_export".into(),
name: "State export".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: vec![],
},
);
p.place(diagram, a, Position { x: 100.0, y: 100.0 });
p.place(diagram, b, Position { x: 600.0, y: 100.0 });
let view = project_view(&p, diagram);
let routed = route_diagram(&p, &p.diagrams[&diagram], &view);
let label = &routed.edges[&edge].label;
let plan = label.state_layout.as_ref().unwrap();
assert_eq!(plan.title_lines, ["Ready", "for <review>&"]);
assert_eq!(plan.summary_lines.len(), 1);
assert_eq!(label.bounds.size.height, 36.0);
let svg = export_svg(&p, diagram).unwrap();
let label_svg = edge_label_svg(label);
assert!(svg.ends_with(&format!("{label_svg}</svg>")));
assert!(svg.find("Target state</text>").unwrap() < svg.find("Ready</text>").unwrap());
assert!(svg.contains("for &lt;review&gt;&amp;</text>"));
assert!(svg.contains(&format!(
"x=\"{}\" y=\"{}\" dominant-baseline=\"text-before-edge\"",
label.bounds.position.x + 5.0,
label.bounds.position.y + 24.0
)));
}
#[test]
fn routed_svg_has_same_polyline_label_and_bounds_as_canvas_scene() {
let (mut p, diagram, edge) = route_fixture();
@@ -3253,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(),
@@ -3303,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(
@@ -196,27 +196,8 @@ impl DiagramCanvas<'_> {
let pointer = cursor.position()?;
let local = Point::new(pointer.x - bounds.x, pointer.y - bounds.y);
let scene = self.scene(state);
let world = self.world(local);
// Preserve foreground selected handles without bypassing Shift or the
// mixed-group admission/refusal lifecycle below.
let state_handle = self.project.diagrams[&self.diagram]
.projection
.state_machine()
.is_some()
&& scene.routed.edges.get(&self.selected).is_some_and(|edge| {
!edge.custom_suspended
&& (self.attachment_at(&scene, world).is_some()
|| edge
.pins
.iter()
.any(|pin| world.distance(point(*pin)) <= 8. / self.zoom))
});
let target = if self.allocation_surface() {
self.allocation_press_target(&scene, local)
} else if state_handle {
Some(Target::Edge(self.selected))
} else if let Some(id) = self.hit_state_label(&scene.routed, local) {
Some(Target::Edge(id))
} else if let Some(port) = self.hit_port(&scene.view, local) {
Some(Target::Port {
port: port.element,
@@ -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);
+1 -1
View File
@@ -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)
);
@@ -1,230 +0,0 @@
use super::*;
use crate::{
app::multiselect::{Choice, Event, context},
diagram_selection::Selection,
diagram_usage::Target,
};
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p
.add_element(Kind::State, Layer::Oa, "A", Some(region))
.unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "B", Some(region))
.unwrap();
let mut edges = [
p.relate(RelationKind::StateTransition, "Ready\nfor review", a, b),
p.relate(RelationKind::StateTransition, "Ready\nfor review", a, b),
];
edges.sort();
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "state_input".into(),
name: "State input".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
view_options: Default::default(),
nodes: BTreeMap::new(),
layout: None,
routes: edges
.iter()
.map(|edge| EdgeRoute {
edge: *edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 200., y: 150. }],
label: None,
})
.collect(),
},
);
let node = p.place(diagram, a, Position { x: 100., y: 100. });
p.place(diagram, b, Position { x: 650., y: 100. });
let routed = route_diagram(&p, &p.diagrams[&diagram], &project_view(&p, diagram));
for route in &mut p.diagrams.get_mut(&diagram).unwrap().routes {
let label = &routed.edges[&route.edge].label;
route.label = Some(EdgeLabel {
fraction: label.fraction,
offset: Position {
x: 200. - label.anchor.x,
y: 150. - label.anchor.y,
},
});
}
(p, diagram, edges[0], edges[1], node)
}
fn canvas(p: &Project, diagram: Id, selected: Id, zoom: f32) -> DiagramCanvas<'_> {
DiagramCanvas {
selection: None,
project: p,
view_index: None,
diagram,
selected,
navigation_focus: None,
zoom,
pan: Vector::new(100., 100.),
context: Context {
document: p.id,
diagram,
revision: 0,
epoch: 0,
},
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
allocation: None,
}
}
fn screen(canvas: &DiagramCanvas<'_>, y: f32) -> Point {
Point::new(
200. * canvas.zoom + canvas.pan.x,
y * canvas.zoom + canvas.pan.y,
)
}
fn pointer(
canvas: &DiagramCanvas<'_>,
state: &mut State,
event: mouse::Event,
at: Point,
) -> Option<Message> {
canvas::Program::update(
canvas,
state,
&canvas::Event::Mouse(event),
Rectangle::with_size(Size::new(1800., 1200.)),
mouse::Cursor::Available(at),
)
.and_then(|a| a.into_inner().0)
}
#[test]
fn state_generic_front_label_beats_node_and_selected_back_label_at_three_zooms() {
for zoom in [0.5, 1., 1.5] {
let (p, diagram, low, high, _) = fixture();
let mut canvas = canvas(&p, diagram, low, zoom);
// Ten world pixels below center remains on both labels but avoids bends.
let at = screen(&canvas, 160.);
let scene = canvas.scene(&State::default());
assert!(canvas.hit(&scene.view, at).is_some());
assert_eq!(canvas.hit_label(&scene.routed, at), Some(high));
// Selected bends have a screen radius: move further down at half zoom.
let at = if zoom == 0.5 {
screen(&canvas, 167.)
} else {
at
};
assert_eq!(
canvas
.context_request(
&State::default(),
at,
Rectangle::with_size(Size::new(1800., 1200.))
)
.object,
high
);
let mut state = State::default();
assert!(
matches!(pointer(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at), Some(Message::Select(id)) if id == high)
);
assert!(state.drag.is_none() && state.route_drag.is_none());
canvas.selected = high;
let mut state = State::default();
pointer(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
);
assert!(matches!(
state.route_drag.as_ref().map(|g| &g.handle),
Some(Handle::Label { .. })
));
canvas.editable = false;
let mut state = State::default();
assert!(
matches!(pointer(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at), Some(Message::Select(id)) if id == high)
);
assert!(state.drag.is_none() && state.route_drag.is_none());
}
}
#[test]
fn state_generic_shift_label_and_handle_keep_exact_targets_and_mixed_refusal() {
let (p, diagram, low, high, node) = fixture();
let base = canvas(&p, diagram, low, 1.);
let mut single_state = State::default();
assert!(
matches!(pointer(&base, &mut single_state, mouse::Event::ButtonPressed(mouse::Button::Left), screen(&base, 150.)), Some(Message::Route(RouteEvent::SelectBend(id, 0))) if id == low)
);
assert!(matches!(
single_state.route_drag.as_ref().map(|g| &g.handle),
Some(Handle::Bend(0))
));
let mut selection = Selection::new(context(base.context).scope);
selection.add(Target::Edge(low), |_| true);
selection.add(Target::Placement(node), |_| true);
let canvas = DiagramCanvas {
selection: Some(&selection),
..base
};
for (y, expected) in [(160., high), (150., low)] {
let mut state = State {
modifiers: iced::keyboard::Modifiers::SHIFT,
..State::default()
};
assert!(
matches!(pointer(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), screen(&canvas,y)), Some(Message::DiagramSelection(Event::Choose(_, Target::Edge(id), Choice::Toggle))) if id == expected)
);
assert!(state.drag.is_none() && state.route_drag.is_none());
}
let mut state = State::default();
let at = screen(&canvas, 150.);
assert_eq!(
canvas
.context_request(&state, at, Rectangle::with_size(Size::new(1800., 1200.)))
.bend,
Some(0)
);
pointer(
&canvas,
&mut state,
mouse::Event::ButtonPressed(mouse::Button::Left),
at,
);
assert!(state.group.is_some() && state.route_drag.is_none());
let moved = at + Vector::new(20., 20.);
pointer(
&canvas,
&mut state,
mouse::Event::CursorMoved { position: moved },
moved,
);
assert!(matches!(
pointer(
&canvas,
&mut state,
mouse::Event::ButtonReleased(mouse::Button::Left),
moved
),
Some(Message::DiagramSelection(Event::Refuse(_, _)))
));
assert!(state.route_drag.is_none() && state.drag.is_none());
}
+1 -1
View File
@@ -6,7 +6,7 @@ impl DiagramCanvas<'_> {
pub(crate) fn work_dirty_point(&self, bounds: Rectangle, screen: Point) -> bool {
let diagram = &self.project.diagrams[&self.diagram];
let view = self.projected(diagram);
let routed = crate::state_label::route_diagram(self.project, diagram, &view);
let routed = routing::route_diagram(self.project, diagram, &view);
self.work_dirty_press(
&view,
&routed,
+2 -2
View File
@@ -327,7 +327,7 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
"state machine view"
}
.into(),
bounds: routed_bounds(crate::state_label::route_diagram(p, diagram, &view).bounds),
bounds: routed_bounds(routing::route_diagram(p, diagram, &view).bounds),
});
}
for node in &view.nodes {
@@ -407,7 +407,7 @@ pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id
}
}
if view.edges.iter().any(|edge| edge.id == subject)
&& let Some(edge) = crate::state_label::route_diagram(p, diagram, &view)
&& let Some(edge) = routing::route_diagram(p, diagram, &view)
.edges
.get(&subject)
{
+27 -4
View File
@@ -48,6 +48,11 @@ pub enum Event {
pub(crate) const ARTICLE_SCROLL_ID: &str = "engineering-guide-article";
mod context_help;
pub(crate) use context_help::{
ContextScroll, context_scroll_key, context_scroll_task, protect_workspace,
};
impl State {
/// Returns whether navigation selected a different article.
pub fn update(&mut self, event: Event) -> bool {
@@ -310,7 +315,7 @@ fn article(topic: Topic) -> Article {
"Use the diagram selector or + View to work with a focused diagram. View properties edits its name, perspective, projection and Saved view visibility. Apply changes commits the view settings.",
"Choose Containment for semantic children, Architecture · allocated work for responsibilities, Functional dataflow for work and exchanges, or Missions & capabilities for outcomes. All relationships explores one layer; OA ↔ SA traceability can show placed elements from both layers and their correspondence lines alongside ordinary relationships.",
"Select an element and edit Semantic parent to change ownership. Use the Allocation palette tool to connect a performer to existing unallocated leaf work in Containment. The relationship can appear as a line there; Architecture · allocated work shows responsibility through nesting instead. Saved visibility filters can hide a line without removing the relationship.",
"Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange next drag or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.",
"Scroll to zoom and use Fit to frame the current view. In allocated-work views, dragging work changes its assignment and placement together. Choose Arrange or press Alt+A to move work without changing responsibility for that one drag; Escape cancels the armed action. Auto-layout arranges the whole view. Export SVG produces a shareable view.",
"Select a diagram node to access Placement, Fold / unfold and Hide. With the canvas focused, Delete hides its placement; Shift+Delete asks to remove the model element. Other views retain a hidden element. Undo restores either change.",
],
),
@@ -322,6 +327,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.",
@@ -334,7 +341,7 @@ fn article(topic: Topic) -> Article {
"Finish or cancel property and source edits, then drag an Activity or Function body in Architecture · allocated work. Aim at exposed performer space, including a collapsed performer's header. Read Destination above the canvas and the outlined target before releasing; release outside the canvas or press Escape to cancel.",
"Use Shift-click to add or remove cards from the selection, or right-click a card and choose Add to selection. Drag any selected work card to assign the whole work selection to one performer. Independent work can start on different performers. The inspector keeps its primary element during the drag; right-click a selected card and choose Inspect this element to change that primary without losing the group.",
"The move preserves work ownership and identities. A composite work card moves its leaves together when they have one coherent responsibility; if its own leaves are distributed across performers, choose specific leaves instead. Role responsibilities remain explicit: an outward drag that would leave a role-derived performer explains why it cannot remove that responsibility.",
"To move work and performers together without changing responsibility, select their cards and choose Arrange next drag or press Alt+A. This moves the selected work subtrees and each selected performer's contained cards together, if the complete layout stays readable and clear. Ports and connections cannot join a placement move. Dragging only performer cards normally moves them and their contained cards without changing assignments.",
"To move work and performers together without changing responsibility, select their cards and choose Arrange or press Alt+A. This moves the selected work subtrees and each selected performer's contained cards together, if the complete layout stays readable and clear. Ports and connections cannot join a placement move. Dragging only performer cards normally moves them and their contained cards without changing assignments.",
"Make room when work cards would overlap, and move the view or zoom in if the moving work is not readable. The destination name stays above the canvas while the target outline stays fixed during the drag. Hover Destination to inspect a long name: outside the canvas, the tooltip explicitly identifies the last target, and releasing cancels. Return to the canvas to choose the drop. A refused drop keeps the model and history unchanged. One Undo reverses an accepted assignment and placement together; Save all writes the corresponding source changes.",
],
),
@@ -463,6 +470,10 @@ fn article(topic: Topic) -> Article {
summary: "An operational Process or system Functional chain connects contributions of existing work and exchanges, including alternatives and reusable procedures. It explains how work contributes to an outcome.",
keywords: "process functional chain ordered occurrence sequence path involvement dataflow exchange repeated steps caller contextual qualified assembly common work focus reuse",
sections: &[
explain(
"Select a group",
"Use Select, then Shift-click to add or remove contributions from the selection. Drag selected local cards to reorder them in the same column. Reused previews are read-only; open their referenced definition to edit. Properties affect only the primary selected item. Help or F1 opens this guide without leaving the diagram; Close or Escape returns to editing.",
),
walkthrough(
"Describe a path through exchanges",
&[
@@ -569,6 +580,10 @@ fn article(topic: Topic) -> Article {
summary: "Describe the conditions in which the operation or system behaves differently. States and modes belong to regions within a state machine.",
keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal diagram draw reconnect local internal timed fold unfold placement pan zoom undo",
sections: &[
explain(
"Select a group",
"Choose Select / move, then Shift-click to add or remove elements from the selection. Properties affect only the primary selected item; Hide selected removes placements from this view. Help or F1 opens this guide without leaving the diagram; Close or Escape returns to editing.",
),
walkthrough(
"Build a state machine",
&[
@@ -1156,6 +1171,14 @@ fn topic_button(topic: Topic, selected: bool) -> iced::widget::Button<'static, M
}
fn article_view(topic: Topic) -> Element<'static, Message> {
article_content(topic, true)
}
pub(crate) fn context_view(state: &State) -> Element<'static, Message> {
article_content(state.topic, false)
}
fn article_content(topic: Topic, navigation: bool) -> Element<'static, Message> {
let article = article(topic);
let mut content = column![
text(topic.category()).size(11).color(style::MUTED),
@@ -1194,14 +1217,14 @@ fn article_view(topic: Topic) -> Element<'static, Message> {
}
content = content.push(container(body).padding(20).width(Fill).style(style::card));
}
if let Some((page, label)) = article.destination {
if navigation && let Some((page, label)) = article.destination {
content = content.push(
button(text(label).size(13))
.padding([11, 15])
.on_press(Message::Page(page)),
);
}
if topic == Topic::FirstProject {
if navigation && topic == Topic::FirstProject {
content = content.push(text("For a worked model, open the water-pressure walkthrough. Copy the whole examples/release1 folder and open the copied project.arcadia before editing; read its README.md. If you use Save as, also copy evidence/ beside the new entry: Save as copies model source, not external artifact targets.").size(13));
content = content.push(
button(text("Open water-pressure walkthrough").size(13))
+282
View File
@@ -0,0 +1,282 @@
//! Modal reading input and preservation of the mounted editing workspace.
use super::ARTICLE_SCROLL_ID;
use crate::app::Message;
use iced::Element;
#[derive(Debug, Clone, Copy)]
pub(crate) enum ContextScroll {
LineUp,
LineDown,
PageUp,
PageDown,
Home,
End,
}
pub(crate) fn context_scroll_key(
event: iced::Event,
status: iced::event::Status,
_: iced::window::Id,
) -> Option<Message> {
use iced::keyboard::{Event, Key, key::Named};
let iced::Event::Keyboard(Event::KeyPressed { key, modifiers, .. }) = event else {
return None;
};
if status == iced::event::Status::Captured || !modifiers.is_empty() {
return None;
}
let scroll = match key {
Key::Named(Named::ArrowUp) => ContextScroll::LineUp,
Key::Named(Named::ArrowDown) => ContextScroll::LineDown,
Key::Named(Named::PageUp) => ContextScroll::PageUp,
Key::Named(Named::PageDown) => ContextScroll::PageDown,
Key::Named(Named::Home) => ContextScroll::Home,
Key::Named(Named::End) => ContextScroll::End,
_ => return None,
};
Some(Message::ContextHelpScroll(scroll))
}
pub(crate) fn context_scroll_task(scroll: ContextScroll) -> iced::Task<Message> {
struct Scroll(ContextScroll);
impl<T> iced::advanced::widget::Operation<T> for Scroll {
fn traverse(
&mut self,
visit: &mut dyn FnMut(&mut dyn iced::advanced::widget::Operation<T>),
) {
visit(self);
}
fn scrollable(
&mut self,
id: Option<&iced::advanced::widget::Id>,
bounds: iced::Rectangle,
content_bounds: iced::Rectangle,
_: iced::Vector,
state: &mut dyn iced::advanced::widget::operation::Scrollable,
) {
use iced::advanced::widget::operation::scrollable::{AbsoluteOffset, RelativeOffset};
if id != Some(&iced::advanced::widget::Id::new(ARTICLE_SCROLL_ID)) {
return;
}
match self.0 {
ContextScroll::Home | ContextScroll::End => state.snap_to(RelativeOffset {
x: None,
y: Some(if matches!(self.0, ContextScroll::End) {
1.
} else {
0.
}),
}),
scroll => {
let y = match scroll {
ContextScroll::LineUp => -40.,
ContextScroll::LineDown => 40.,
ContextScroll::PageUp => -bounds.height * 0.9,
ContextScroll::PageDown => bounds.height * 0.9,
_ => unreachable!(),
};
// Iced clamps this movement to the actual content bounds.
state.scroll_by(AbsoluteOffset { x: 0., y }, bounds, content_bounds);
}
}
}
}
iced::advanced::widget::operate(Scroll(scroll))
}
/// Keep the mounted editor and its caret, but prevent modal input from reaching
/// widget-internal state before the App message guard can reject edits.
pub(crate) fn protect_workspace<'a, R: iced::advanced::renderer::Renderer + 'a>(
help_open: bool,
content: Element<'a, Message, iced::Theme, R>,
) -> Element<'a, Message, iced::Theme, R> {
Element::new(WorkspaceInput { help_open, content })
}
struct WorkspaceInput<'a, R> {
help_open: bool,
content: Element<'a, Message, iced::Theme, R>,
}
#[derive(Default)]
struct WorkspaceInputState {
help_open: bool,
}
impl<R: iced::advanced::renderer::Renderer> iced::advanced::Widget<Message, iced::Theme, R>
for WorkspaceInput<'_, R>
{
fn tag(&self) -> iced::advanced::widget::tree::Tag {
iced::advanced::widget::tree::Tag::of::<WorkspaceInputState>()
}
fn state(&self) -> iced::advanced::widget::tree::State {
iced::advanced::widget::tree::State::new(WorkspaceInputState::default())
}
fn children(&self) -> Vec<iced::advanced::widget::Tree> {
vec![iced::advanced::widget::Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut iced::advanced::widget::Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> iced::Size<iced::Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> iced::Size<iced::Length> {
self.content.as_widget().size_hint()
}
fn layout(
&mut self,
tree: &mut iced::advanced::widget::Tree,
renderer: &R,
limits: &iced::advanced::layout::Limits,
) -> iced::advanced::layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut iced::advanced::widget::Tree,
layout: iced::advanced::Layout<'_>,
renderer: &R,
operation: &mut dyn iced::advanced::widget::Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut iced::advanced::widget::Tree,
event: &iced::Event,
layout: iced::advanced::Layout<'_>,
cursor: iced::mouse::Cursor,
renderer: &R,
clipboard: &mut dyn iced::advanced::Clipboard,
shell: &mut iced::advanced::Shell<'_, Message>,
viewport: &iced::Rectangle,
) {
let entering = {
let state = tree.state.downcast_mut::<WorkspaceInputState>();
let entering = self.help_open && !state.help_open;
state.help_open = self.help_open;
entering
};
if entering {
// Retire held gestures without executing their release actions.
// Text controls keep their real caret/focus; drawing tools remain
// selected, but an old drag cannot resume when Help is dismissed.
for event in [
iced::mouse::Event::CursorLeft,
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left),
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Middle),
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Right),
] {
let mut discarded = Vec::new();
let mut cancellation = iced::advanced::Shell::new(&mut discarded);
self.content.as_widget_mut().update(
&mut tree.children[0],
&iced::Event::Mouse(event),
layout,
iced::mouse::Cursor::Unavailable,
renderer,
clipboard,
&mut cancellation,
viewport,
);
shell.request_redraw_at(cancellation.redraw_request());
if cancellation.is_layout_invalid() {
shell.invalidate_layout();
}
if cancellation.are_widgets_invalid() {
shell.invalidate_widgets();
}
}
}
if self.help_open
&& matches!(
event,
iced::Event::Keyboard(
iced::keyboard::Event::KeyPressed { .. }
| iced::keyboard::Event::KeyReleased { .. }
) | iced::Event::Mouse(_)
| iced::Event::Touch(_)
| iced::Event::InputMethod(_)
)
{
return;
}
// ModifiersChanged and passive window/redraw events still reach the
// editor, so releasing Shift during Help cannot stick on return.
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &iced::advanced::widget::Tree,
renderer: &mut R,
theme: &iced::Theme,
style: &iced::advanced::renderer::Style,
layout: iced::advanced::Layout<'_>,
cursor: iced::mouse::Cursor,
viewport: &iced::Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &iced::advanced::widget::Tree,
layout: iced::advanced::Layout<'_>,
cursor: iced::mouse::Cursor,
viewport: &iced::Rectangle,
renderer: &R,
) -> iced::mouse::Interaction {
if self.help_open {
iced::mouse::Interaction::None
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut iced::advanced::widget::Tree,
layout: iced::advanced::Layout<'b>,
renderer: &R,
viewport: &iced::Rectangle,
translation: iced::Vector,
) -> Option<iced::advanced::overlay::Element<'b, Message, iced::Theme, R>> {
if self.help_open {
None
} else {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
}
-1
View File
@@ -34,7 +34,6 @@ mod source_navigation;
mod source_rename;
mod source_tree;
mod source_workspace;
mod state_label;
mod style;
#[cfg(test)]
mod tests;
+1 -4
View File
@@ -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)
}),
));
}
-81
View File
@@ -1,81 +0,0 @@
//! Desktop routing uses the same font/shaping defaults as canvas text.
use iced::advanced::{
graphics::text::Paragraph,
text::{self, Paragraph as _},
};
use oa_core::{
Diagram, Id, Project,
projection::ProjectedView,
routing::{self, RoutedView, StateLabelLayout},
};
fn measure_line(line: &str) -> f32 {
let defaults = iced::widget::canvas::Text::default();
Paragraph::with_text(text::Text {
content: line,
bounds: iced::Size::INFINITE,
size: StateLabelLayout::FONT_SIZE.into(),
line_height: text::LineHeight::Absolute(StateLabelLayout::LINE_HEIGHT.into()),
font: defaults.font,
align_x: defaults.align_x,
align_y: defaults.align_y,
shaping: defaults.shaping,
wrapping: text::Wrapping::None,
})
.min_width()
}
pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView) -> RoutedView {
routing::route_diagram_with_state_label_measurement(project, diagram, view, &measure_line)
}
pub fn route_edge_preview(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
baseline: &RoutedView,
edge: Id,
) -> RoutedView {
routing::route_edge_preview_with_state_label_measurement(
project,
diagram,
view,
baseline,
edge,
&measure_line,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wide_glyphs_use_shaped_width_instead_of_character_estimate() {
let wide = "W".repeat(40);
let narrow = "i".repeat(40);
let width = measure_line(&wide);
assert!(width > measure_line(&narrow) * 2.0);
assert!(width > 40.0 * 7.0);
let plan = StateLabelLayout::new(&wide, &wide, &measure_line);
assert_eq!(
plan.size().width,
width + 2.0 * StateLabelLayout::HORIZONTAL_PADDING
);
}
#[test]
fn unicode_fallback_and_combining_text_have_finite_measured_bounds() {
for line in ["漢字 Ελληνικά العربية", "e\u{301} cafe\u{301}", "🙂 🧭"]
{
let width = measure_line(line);
assert!(width.is_finite() && width > 0.0, "{line:?}: {width}");
let plan = StateLabelLayout::new(line, line, &measure_line);
assert_eq!(
plan.size().width,
width + 2.0 * StateLabelLayout::HORIZONTAL_PADDING
);
}
assert!(measure_line("e\u{301}") < measure_line("ee"));
}
}
+151 -110
View File
@@ -25,6 +25,23 @@ pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> {
.on_press(m)
.style(style::nav)
}
/// Optional instructions belong beside the work, without taking a canvas row.
/// The button also opens keyboard-accessible help; the tooltip is only a preview.
pub(crate) fn diagram_help<'a>(
topic: crate::guide::Topic,
hint: impl Into<String>,
) -> View<'a, Message> {
tooltip(
action("Help · F1", Message::ContextHelp(topic)),
container(text(hint.into()).size(12))
.padding(10)
.max_width(360)
.style(style::card),
tooltip::Position::Bottom,
)
.into()
}
pub fn short(s: &str, limit: usize) -> String {
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
if s.graphemes(true).count() > limit {
@@ -240,11 +257,23 @@ pub fn view(app: &App) -> View<'_, Message> {
if !matches!(app.page, Page::Home | Page::Text | Page::Guide) {
body = body.push(inspector(app));
}
let diagram_feedback = diagram_feedback(app);
// Keep operation outcomes visible even while a drawing hint is active.
let (status, status_detail) = if app.busy {
("Working…".into(), "Working…".into())
} else if let Some(feedback) = diagram_feedback.filter(|feedback| *feedback != app.status) {
(
format!("{} · {}", short(&app.status, 60), short(&feedback, 80)),
format!("{}\n{}", app.status, feedback),
)
} else {
(short(&app.status, 145), app.status.clone())
};
let footer = container(
row![
container(tooltip(
text(short(if app.busy { "Working…" } else { &app.status }, 145)).size(11),
container(text(if app.busy { "Working…" } else { &app.status }).size(12))
text(status).size(11).wrapping(widget::text::Wrapping::None),
container(text(status_detail).size(12))
.padding(10)
.max_width(600)
.style(style::card),
@@ -267,6 +296,25 @@ pub fn view(app: &App) -> View<'_, Message> {
.padding([4, 16])
.style(style::panel);
let root = crate::allocation_input::root(app, column![header, task_nav, body, footer]);
let root = crate::guide::protect_workspace(app.context_help, root);
if app.context_help {
return modal_width(
root,
column![
row![
text("Diagram help").size(18),
Space::new().width(Fill),
action("Close · Esc", Message::CloseContextHelp),
]
.align_y(iced::Center),
crate::guide::context_view(&app.guide),
]
.spacing(12)
.height((app.window_size.height - 140.).max(300.))
.into(),
(app.window_size.width - 80.).min(900.),
);
}
if app.architecture.as_ref().is_some_and(|s| s.modal()) {
return modal_width(
root,
@@ -362,7 +410,8 @@ pub fn view(app: &App) -> View<'_, Message> {
(app.window_size.width - 32.).clamp(120., 760.),
);
}
root
// Match modal_width's first child so help does not recreate the editor tree.
widget::stack![root].into()
}
fn modal<'a>(base: View<'a, Message>, content: View<'a, Message>) -> View<'a, Message> {
modal_width(base, content, 800.)
@@ -447,7 +496,7 @@ fn diagram(app: &App) -> View<'_, Message> {
.active_diagram
.and_then(|id| app.project().diagrams.get(&id))
.map(|d| d.projection);
let modes = row![
let mut modes = row![
label("View content"),
pick_list(
[
@@ -462,7 +511,15 @@ fn diagram(app: &App) -> View<'_, Message> {
Message::Projection
)
.padding(8)
.text_size(12)
.text_size(12),
diagram_help(
crate::guide::Topic::Nesting,
if projection == Some(ProjectionKind::Traceability) {
"Shows OA and SA elements with correspondence lines. Place both ends of a trace to show it; port traces use their owner cards. Open Help for trace visibility and editing controls."
} else {
"Shift-click to add or remove selection. Right-click for actions; drag to arrange; scroll to zoom. Drag a connection line, bend or label to move it; Insert adds a bend and Escape cancels. Open Help for full editing controls."
},
)
]
.spacing(10)
.align_y(iced::Center);
@@ -555,6 +612,26 @@ fn diagram(app: &App) -> View<'_, Message> {
.center(Fill)
.into()
};
if projection == Some(ProjectionKind::Allocation) {
modes = modes.push(tooltip(
action(
if app.allocation_drag.arrange_next {
"Arrange ✓"
} else {
"Arrange"
},
Message::WorkDrag(crate::app::allocation_drag::Event::ArrangeNext),
),
container(text(format!(
"Arrange next drag · {}. Move work within its current allocation; Escape cancels.",
crate::allocation_input::ARRANGE_SHORTCUT
)))
.padding(10)
.max_width(420)
.style(style::card),
tooltip::Position::Bottom,
));
}
let mut tools = column![toolbar].spacing(8);
if app.active_diagram.is_some() {
tools = tools.push(modes);
@@ -568,82 +645,50 @@ fn diagram(app: &App) -> View<'_, Message> {
.feedback
.as_ref()
.map(|feedback| feedback.destination_identity())
.unwrap_or_else(|| "None · drag work to choose".into());
.unwrap_or_else(|| "—".into());
let inspection = app
.allocation_drag
.feedback
.as_ref()
.map(|feedback| feedback.inspection_identity())
.unwrap_or_else(|| destination.clone());
tools = tools.push(
container(tooltip(
text(format!("Destination: {}", short(&destination, 28)))
.size(13)
.wrapping(widget::text::Wrapping::None),
container(text(inspection).size(13))
.padding(10)
.max_width(420)
.style(style::card),
tooltip::Position::Top,
))
.id("allocation-destination")
.height(26)
.width(Fill)
.clip(true),
);
tools = tools.push(
row![
action(
&format!(
"{} · {}",
if app.allocation_drag.arrange_next {
"Arrange next drag: armed"
} else {
"Arrange next drag"
},
crate::allocation_input::ARRANGE_SHORTCUT
),
Message::WorkDrag(crate::app::allocation_drag::Event::ArrangeNext)
),
label("Drag work into a performer to assign · out to remove assignment")
]
.spacing(8)
.align_y(iced::Center),
);
let destination_control = container(tooltip(
text(format!("Destination: {}", short(&destination, 28)))
.size(13)
.wrapping(widget::text::Wrapping::None),
container(text(inspection).size(13))
.padding(10)
.max_width(420)
.style(style::card),
tooltip::Position::Top,
))
.id("allocation-destination")
.height(26)
.width(Fill)
.clip(true);
tools = tools.push(destination_control);
}
if let Some(diagram) = app.active_diagram {
let view = oa_core::projection::project_view(app.project(), diagram);
if projection == Some(ProjectionKind::Traceability) {
let correspondence_lines = view.edges.iter().filter(|edge| edge.trace).count();
tools = tools.push(
column![
label("Shows OA and SA elements together, with correspondence lines alongside ordinary relationships."),
label("Place both ends of a trace to show its line. Port traces use their owner cards; traces between relationships are reviewed on the Traceability page."),
row![
label(format!("{correspondence_lines} correspondence lines in this view")),
action(
"Add existing elements…",
Message::Architecture(crate::app::architecture_authoring::Event::Existing(None))
),
action("Review layer traces", Message::ReviewLayerTraces),
action("View visibility…", Message::Select(diagram)),
]
.spacing(7)
.align_y(iced::Center)
.wrap(),
row![
label(format!("{correspondence_lines} trace lines")),
action(
"Add existing elements…",
Message::Architecture(crate::app::architecture_authoring::Event::Existing(
None
))
),
action("Review layer traces", Message::ReviewLayerTraces),
action("View visibility…", Message::Select(diagram)),
]
.spacing(4),
.spacing(7)
.align_y(iced::Center)
.wrap(),
);
}
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
let promoted = edge.is_some_and(|edge| {
oa_core::projection::edge_endpoint_placements(
app.project(),
&app.project().diagrams[&diagram],
edge.id,
)
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
});
let selection = app.multiselect_snapshot();
let count = selection.as_ref().map_or(0, |s| s.targets().len());
let mut selection_controls =
@@ -662,66 +707,62 @@ fn diagram(app: &App) -> View<'_, Message> {
}
selection_controls = selection_controls
.push(action(
"Clear selection",
"Clear",
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
snapshot.context(),
)),
))
.push(
label(format!(
"Properties edit only {}",
short(app.project().named(app.selected), 36)
"Properties: {} only",
short(app.project().named(app.selected), 14)
))
.wrapping(widget::text::Wrapping::None),
);
}
let hint = if let Some(feedback) = &app.allocation_drag.feedback {
feedback.text.as_str()
} else if let Some(session) = &app.connection {
if session.source.is_some() {
if session.candidates.is_empty() {
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
} else {
"Choose a highlighted target · middle-drag or arrow keys pan · Esc cancels"
}
} else {
session.tool.hint()
}
} else if count > 1 {
if selection
.as_ref()
.is_some_and(|s| crate::app::multiselect::placements(s).is_some())
&& projection != Some(ProjectionKind::Allocation)
{
"Shift-click to add/remove · drag selected to move · right-click for actions · scroll to zoom"
} else {
"Shift-click to add/remove · right-click for selection actions · Esc clears selection"
}
} else if promoted {
"Expand endpoint containers to edit this connection route"
} else if edge.is_some() {
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
} else {
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
};
// Selection changes must not resize the measured canvas or refit its
// camera. Reserve both rows even when no group actions are available.
// Selection never moves the canvas. General controls live in Help;
// active gesture feedback uses the persistent application status bar.
tools = tools.push(
container(
column![
selection_controls,
label(hint).wrapping(widget::text::Wrapping::None)
]
.spacing(3),
)
.height(56)
.width(Fill)
.clip(true),
container(selection_controls)
.height(32)
.width(Fill)
.clip(true),
);
}
column![container(tools).padding(14), canvas].into()
}
fn diagram_feedback(app: &App) -> Option<String> {
if app.page != Page::Diagram {
return None;
}
if let Some(feedback) = &app.allocation_drag.feedback {
return Some(feedback.text.clone());
}
if let Some(session) = &app.connection {
return Some(
if session.source.is_none() {
session.tool.hint()
} else if session.candidates.is_empty() {
"No compatible targets visible. Expand containers or show an endpoint · Esc cancels"
} else {
"Choose a highlighted target · Esc cancels"
}
.into(),
);
}
let diagram = app.active_diagram?;
let view = oa_core::projection::project_view(app.project(), diagram);
let edge = view.edges.iter().find(|edge| edge.id == app.selected)?;
let (source, target) = oa_core::projection::edge_endpoint_placements(
app.project(),
&app.project().diagrams[&diagram],
edge.id,
)?;
(source != edge.source || target != edge.target)
.then(|| "Expand endpoint containers to edit this connection route".into())
}
fn inspector(app: &App) -> View<'_, Message> {
let p = app.project();
if let Some(target) = app.process_preview_primary() {
@@ -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;
}
+1 -1
View File
@@ -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):
+9 -2
View File
@@ -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.
+2
View File
@@ -2,6 +2,8 @@
The normative contract is [language.rst](language.rst); [scope.md](scope.md) explains deliberate differences from the complete Arcadia/Capella ecosystem. V2 uses one typed model and one validated mutation boundary across the desktop, text editor and REST service.
Desktop changes follow the [editing workspace UX review criteria](ui-review.md) for concise persistent content, contextual help, visible actionable feedback and native interaction review.
## Crate boundaries
| Crate | Responsibility | Does not depend on |
+20 -2
View File
@@ -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
+1 -1
View File
@@ -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": [
+27
View File
@@ -0,0 +1,27 @@
# Editing workspace UX review
These development criteria implement the [project goal](../PROJECT_GOAL.md): reduce system engineers' cognitive load, keep graphical editing intuitive, and provide concise, navigable built-in help. They also address the user's request tracked in Gitea issue #39 to prevent persistent instructional text from consuming editing space. They are review criteria, not a claim that any particular build has passed them.
## Persistent content and progressive disclosure
Populated editing workspaces prioritize model content, controls, current state and actionable feedback. General explanations, gesture tutorials and repeated shortcut lists belong in contextual Help, tooltips or explicitly opened details. Do not add permanent instructional paragraphs above a populated canvas merely to explain a feature.
Keep essential control labels and relevant status visible. Offer concise next-step guidance in empty states and while a user is performing a multi-step action. Put longer explanations behind a clearly labeled disclosure near the relevant control or context. Essential instructions must be accessible through an explicit keyboard-reachable route; a hover-only tooltip is insufficient. Reuse the built-in guide for broader modeling explanations.
Distinguish instruction from information needed to act safely. Current errors, pending edits, failed operations, fallback rendering, unavailable actions and recovery steps must remain apparent. Preserve consequential distinctions such as editing a shared definition, changing semantic order rather than saved positions, or applying properties to only one member of a selection. Long diagnostic detail may be disclosed on demand, but its visible summary must identify the problem and a usable next step.
## Stable editing context
Selection changes and gesture feedback must not unexpectedly move or resize the canvas, reset its camera, or change the interpretation of the pointer. Compact existing stable layout slots instead of conditionally removing them when their content is absent. Opening and closing help must preserve the model, selection and pending drafts, and return the user to a usable editing context. Explicitly requested panel changes may resize the workspace; verify their effect on focus and navigation.
## Native review
Exercise the changed workspace in the actual built application at its configured minimum window size and a representative normal size. Include an independent reviewer for material interaction or layout changes, following [the working agreement](../AGENTS.md). Review representative populated models as well as relevant empty, pending, failure and recovery states.
- Inspect whether instructional prose or unused space displaces useful model content. Compare before/after screenshots at the same window size, display scaling, model and panel state when assessing space savings.
- Perform the affected workflow: select, edit, navigate, cancel and recover as applicable. Change between empty, single and multiple selections; exercise affected gestures and inspect camera and pointer stability.
- Find and use the disclosed guidance by keyboard as well as pointer. Check focus, dismissal, return to editing, and preservation of drafts.
- Trigger relevant disabled or rejected operations. Confirm their reason and recovery action remain visible, accurate and reachable.
- Check text, controls, panels and warnings for clipping, overlap, excessive scrolling and lost actions at the minimum size. Do not trade recovered space for unreadable text or unexplained symbols.
Record the tested code version, runtime environment, workflows, observations and representative screenshots in the change's review evidence. Screenshots support layout findings; they do not establish working interaction, accessibility or state preservation. Fix material findings and recheck affected behavior before treating the change as verified.
+26 -6
View File
@@ -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
@@ -498,9 +524,3 @@ editing remains separate from semantic reconnect. The [decision and evidence
map](edge-attachments-20260922.md) records alternatives, lifecycle rules,
independent falsifiers, evaluators and the Process/Scenario applicability limits.
This is branch-specific work; it does not establish an integrated release.
## External State transition label measurement — 2026-09-24
Issue #36's reproduced collision comes from a two-line title painted in space reserved for one line, with bounds sized from its summary. Adopt one derived hard-line plan measured before routing, shared by paint/hit/preview/focus/SVG. Preserve raw excerpt provenance, authored centers/routes and model/source bytes. Desktop measures with the canvas Paragraph defaults; core retains an explicit approximate fallback. Opaque State label backgrounds own covered hits in reverse paint order while selected handles and group admission retain precedence.
The [contract](state-label-layout-contract.md) is authoritative for exact rules and alternatives. The [verification](experiments/state-label-layout-20260924/README.md) records the width-estimate and generic-hit falsifiers, independent review, scoped Windows outcomes, restored-window input limitation and reasons not to infer universal font or collision-free layout support. Revisit if new ink-overhang, shaping, routing-cost, or authored-occlusion evidence invalidates the bounded design. Dynamic node/row sizing and source/model changes were deliberately excluded.
+69 -38
View File
@@ -1,50 +1,81 @@
# Verified branch checkpoint — State label follow-up
# Navigation contribution checkpoint — 2026-10-02 UTC
This branch's code through **70bdcdf8fec1728e971cd90ad7f79a1df99777ff** corrects external State transition title/summary layout and measured label hit areas. The [contract](state-label-layout-contract.md) and [verification](experiments/state-label-layout-20260924/README.md) identify exact source, commands, native fixtures, original evidence and limits. Windows focused core68, full desktop1540/7 existing ignores, formatting, strict workspace/all-target Clippy, builds, and six maximized-window B/W/U Save/reopen checks pass. Restored-window coordinate input remains an explicit native-test limitation. This is branch-specific evidence, not merged-main or complete-application acceptance; live integration status is [issue36](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/36).
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.
The separate [September24 integrated prerelease](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.integrated.20260924) is published at **715036e132677dd2bc2fb37ed316d93dc9987aec** with owner publication receipt **76acfb11085ae36bd65971f5f1cf34f45d362ada**. The label follow-up does not replace that tag or its artifacts. Linux remains excluded. Later private analysis branch status must be read from Gitea and that branch, not inferred from this checkpoint.
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.
## Historical source checkpoint inherited from715036e — 2026-09-24 UTC
# Verified Windows source checkpoint — 2026-10-01 PDT
The source-preparation statements below describe that earlier checkpoint. Its then-pending prerelease gates were subsequently completed in the publication linked above; its other scoped evidence retains its original code versions.
This branch combines the released integrated foundation
`715036e132677dd2bc2fb37ed316d93dc9987aec` with compact diagram controls and
contextual Help production source `6e7c4ddaf0775198384a8c4882809ea8e14b2976`,
and the protected Save As destination diagnostic correction tested at
`0e83157e09d52e6152df91208e41693b62da2d46`.
[PROJECT_GOAL.md](../PROJECT_GOAL.md) remains authoritative; complete OA/SA
acceptance is unfinished.
This branch assembles peer contributions for the Windows prerelease in issue37.
[PROJECT_GOAL.md](../PROJECT_GOAL.md) remains authoritative. The complete OA/SA
application is unfinished; published previews and finite prototypes are intermediate.
**Linux/WSL builds remain excluded by the user's explicit request.**
**The current prerelease is Windows-only. Linux/WSL builds and checks remain
excluded by the user's explicit request, including after networking recovers.**
## Verified capabilities and evidence
## Assembled source and acceptance boundary
The [frozen integrated Windows preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.integrated.20260924)
provides the accepted 715 foundation and its combined test/package evidence.
It remains unchanged. The older pending-build description previously at the top
of this file is superseded by that published result.
Combined code through **4deced53b98cd4d7649ba7aa60fa4b9b30d2f3b8**, plus the
test-only import correction from **7c2b06b63f8a0fe14b3f001996bdf965b36a4e85**, adds the reviewed
Connection/Route early-admission correction to the assembled Scenario/Text,
ordinary/State/Process/Allocation selection, direct drawing, Traceability and
picker changes. The [source handoff](prerelease-integrated-source-20260924.md)
identifies the incorporated commits, exact new test paths and required gates.
The nine event regressions and existing completed-Text-Apply cases are retained.
Direct formatting and whitespace checks pass; this source checkpoint does not
claim combined Cargo, native, packaged or release acceptance. The independent
[event-fix review](reviews/prerelease37-event-admission-20260924.md) found no
further material source defect in its stated scope. A subsequent peer compile
found a missing `projection::` qualification in the new Route test helper;
the current code includes that one-line correction. The original compile failed
before running tests. The first combined compile at `897d9f04` subsequently
found a missing `text_editor` import in the Allocation Text Apply regression;
the current source includes that one-line correction too. Both failures remain
recorded in the source handoff. The subsequent full test run at `8ddfadf4`
failed the mounted State picker fixture: added selection controls invalidated
its canvas-relative clicks. Source successor `a9cd364eacbbffa0693bab05b5a2b65f9d105535`
measures an identified fixed-height picker slot and checks Escape capture,
retaining the exact preservation/reopen/Place/Undo assertions. The source
handoff links the preserved failed and interrupted attempts. Corrected combined
Windows execution and package acceptance remain pending.
The [compact Help contribution](compact-help-validation-20261001.md) removes
permanent canvas tutorial prose, keeps stable concise selection rows and full
allocation destinations, and supports contextual Help/F1 with keyboard reading,
exact caret/draft preservation and safe gesture retirement. Final focused gates
pass 125 tests; the earlier full-suite result retains its separate source scope.
[Fresh package validation](release-compact-help-validation-20261001.md) verifies
actual extracted desktop/server behavior, Save/Undo/Redo/reopen and complete
source/model oracles. This is a scoped development preview, not complete release
or acceptance of the separately owned follow-up union.
The [scoped compact Help Windows preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.compact-help.20261001)
is published from evidence checkpoint `f1fdca371394aec1148e67f33f4d8c6b53a0c350`.
Its ZIP, checksum and validation attachment were downloaded and matched after
publication; [publication receipts](experiments/compact-help-20261001/publication.md)
retain the exact asset hashes and provenance.
Live build, final-source and publication results belong in
[issue37](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/37).
Human workflows are described in [the preview checklist](../docs/preview-human-checks.md).
Private mixed-parser source is excluded from this application candidate.
Dense diagrams, remaining Scenario group adapters, quantities/calculations/
constraints, maintained libraries, broad accessibility/DPI and full OA/SA
acceptance remain unfinished. The independent projection/navigation, outgoing-drag,
icon, grid/performance and State-label contributions are outside this code.
The published compact Help ZIP retains misleading external-change wording for a
protected Save As destination collision. This branch's
[diagnostic correction](save-as-diagnostics-20261001.md) distinguishes existing
protected destination files from observed external edits and explains recovery.
Its 29 targeted checks, strict Clippy, matching GUI build, native normal/minimum
window workflows, eight complete byte checkpoints and parsed-model oracle pass;
independent review finds no material blocker in the bounded contribution.
The [fresh scoped package validation](release-save-diagnostics-validation-20261001.md)
checks the actual extracted correction: accurate protected-destination refusal,
Save All/fresh Save As recovery, actual external-edit protection, fresh reopen,
complete source/model oracles and 23 server assertions. Independent final review
finds no material blocker in this artifact. The [save-diagnostics Windows preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.save-diagnostics.20261001)
is published from evidence checkpoint `e144e32e9984477a258759a9dcc75f6e499fc224`;
[publication receipts](experiments/save-diagnostics-package-20261001/publication.md)
verify its ZIP, checksum and validation attachment. It remains outside the
separately owned follow-up union.
[Issue 46](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/46)
holds integration and delivery status. Live ownership and release coordination
remain in Gitea.
## Contribution-specific verification
+34
View File
@@ -0,0 +1,34 @@
# Compact diagram controls and contextual help
Branch-specific work for [issue 39](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/39), based on published preview source `715036e132677dd2bc2fb37ed316d93dc9987aec`.
This record does not accept the complete first release or the other diagram defects.
## Problem and choice
The user's screenshot showed two permanent Traceability explanation paragraphs and a separate generic gesture-hint row. Direct Windows inspection of the released water-pressure example reproduced these. At a 1100×700 client size (1102×732 captured window), the Traceability canvas began around window y=470 and ended around y=692, leaving about 220 pixels for modeling. At the normal 1440×900 client size its top was around y=417. The release was opened from the existing downloaded package; only an in-memory projection switch was made and Undo restored the clean model before closure.
The candidate moves general instructions into contextual Help, reusing the offline guide. Architecture, State and Process selection bars use a stable compact row. Allocation retains its full-width destination slot; a concise Arrange control sits beside the existing view-content and Help controls. Traceability retains its displayed-line count and existing actions. Active drawing/assignment feedback appears beside the operation outcome in the persistent status bar, with full text on hover. Operation errors must never be replaced by generic guidance.
Help opens as an overlay and closes with Close or Escape. F1 opens the relevant diagram topic. The workspace, selected group, pending source/property/behavior drafts and camera remain owned by their existing state. Background user commands are rejected before specialized canvas/reference dispatch. Passive results retain their established validity checks. The editor remains the first child of a stable Stack while Help opens and closes.
Rejected alternatives: ordinary navigation to the Guide page cleared selection and could reject Help while a behavior draft was pending; placing feedback beside the entire selection toolbar exhausted narrow-window width; unconditional status substitution hid operation failures. Independent source review identified these before acceptance. Hover-only help was rejected because the complete instructions need a keyboard-accessible route.
[Editing workspace UX review](../docs/ui-review.md) is the development/review contract requested by the user. It covers persistent prose, actionable feedback, context preservation and independent native interaction checks.
## Verification status
Clean source `f83068757af885572f6b3f932d07233dd1b94b7e` passed `cargo build -p oa-desktop --locked` on Windows gnullvm. Its candidate executable SHA-256 is `c1a9b196ec03e86f049fa2aadb2b86bfcaa3f0fbae582a80ee39ee8adf0758e3e`. The [independent source review](compact-context-help-source-review-20260925.md) records the corrected source findings, including passive viewport updates while Help is open.
Independent native review of that executable recovered approximately 61 pixels in normal Traceability, 114 pixels in minimum Traceability and 58 pixels in minimum Allocation. Help resizes, pointer-scrolls, selects the State topic, and closes through Close/Escape. The review also reproduced two acceptance failures: PageDown cannot scroll Help, and F1/Escape preserves a property or Process-creation draft but loses typing focus/caret continuation. These findings require correction and another native check; model-state unit tests alone do not cover internal widget focus. The idle State-canvas gesture sentence is also removed in the successor; active/pending and hovered-item feedback retain their stable slot.
The initial focused test run passed nine and failed one: the allocation test fixture used the default Overview projection before asserting allocation ownership. A separate ten-case mounted geometry run passed nine and failed the destination's existing 400-pixel minimum-width check. Sharing its row with the verbose Arrange action reduced that slot to 328 pixels at minimum size. The successor fixes the fixture explicitly and restores the full destination row without weakening its width check. The original failed logs are preserved in the [native/test evidence](experiments/compact-help-20260925/README.md). These diagnostic runs began before the successor edits and are not frozen-source acceptance of those edits.
Frozen successor `5657a996ed2cefb1fa84ef967b655eaf8f9f6817` passed formatting, strict all-target desktop Clippy and the Windows GUI build. The full desktop run passed 1,538 tests, ignored seven and failed one new mounted Help test before Help opened: its synthetic multi-character KeyPressed event inserted only the first character, as the actual Iced text input specifies. The final correction sends individual character events and retains the exact-caret, scrolling and IME assertions. The existing allocation width and ownership checks and new modifier/pointer cancellation checks passed. See the [preserved successor evidence](experiments/compact-help-20260925/candidate-5657/review.md) and logs; the failed run is not reported as a passing suite.
Independent native review of that exact executable confirmed keyboard Help scrolling, precise Inspector and Process-draft caret continuation after Help, resize, retained State tool state and complete allocation-error tooltips. Minimum Allocation recovers approximately 64 pixels with the full destination slot; minimum Traceability recovers 114 pixels. The reviewer found no new blocker within that scope.
Final source `6e7c4ddaf0775198384a8c4882809ea8e14b2976` passes formatting, strict desktop/all-target Clippy, GUI build and 125 focused Help/allocation/guide tests (zero failures/ignores). The corrected mounted caret test now executes its complete assertions. Unchanged evidence from the earlier full run is retained at its original source; the remaining 1,421 desktop cases were not rerun at the final source. The initial final GUI link failed when the disk filled; after the user expanded the disk, the same-source rebuild passed. The failed log remains preserved.
The [final native report](experiments/compact-help-20260925/candidate-6e7c/review.md) directly verifies cancellation status, guide wording, native group selection through context menus, primary Inspector changes, long-name controls at minimum size, exact caret return after both Help dismissal routes, and Apply/Undo/Redo/Undo recovery. Independent source review found no blocker in the final small delta. The watcher records exit 0 and empty stdout/stderr; all 37 copied model files still match the original after closure. An early transient OSK/modifier obstruction occurred before model loading and is recorded separately from app behavior. No blocking defect was found within this contribution's review scope. Shared integration and release admission remain in issue 39; full product completion is not claimed.
Required native checks: minimum and normal windows; Traceability and Allocation space comparison; multi-selection with long names; Help/F1/Close/Escape with property and behavior drafts; editor focus and canvas scroll/camera restoration; active feedback plus operation failure; State and Process controls; subsequent edit/Undo/Redo and source preservation. Linux remains deferred under the recorded user instruction.
@@ -0,0 +1,44 @@
# Compact contextual help: independent source review
Review date: 2026-09-25. Scope: Gitea issue #39 compact editing chrome and contextual Help, reviewed against base `715036e1` and the issue screenshot. Reviewed source includes `e0812b3932fbb36100516f0981dc9c646eeda56d` plus the subsequent local passive-geometry admission patch and regression test in this change. This is source-review evidence, not native acceptance or executed test evidence.
Criteria: [editing workspace UX review](../docs/ui-review.md), especially preserving pending edits and selection, stable canvas geometry, accessible disclosure, and retaining actionable feedback.
## Findings addressed in source
- The initial Help action used ordinary page navigation, clearing multiselection and selected bends, including before a pending behavior form rejected navigation. Contextual Help now overlays the existing workspace. Close/Escape changes reading state without navigating or applying drafts.
- App-level modal admission originally ran after allocation and reference-picker fast paths. Help now gates user messages at the beginning of `App::update`; allocation and behavior keyboard ownership are also disabled while Help is open.
- Combining live gesture feedback with multiselection controls could leave no width for feedback at the configured minimum window size. Selection controls now keep a compact, fixed-height row; current drawing/allocation feedback uses the existing application footer.
- Footer feedback initially replaced operation status, concealing errors while a promoted edge or drawing tool remained selected. The footer now retains operation status alongside feedback and exposes complete text in its tooltip.
- Conditional modal wrapping could recreate the editor widget tree. Both closed and open states now use the same outer Stack with the workspace as its first child.
- State/Process selection guidance removed from persistent chrome was missing from its target Help articles. The States and Chains articles now include group selection, primary-properties semantics and relevant tool/read-only guidance.
- The new early modal gate initially rejected passive behavior viewport/scroll notifications. It now admits Process/Scenario geometry and State viewport notifications to their existing context-validated handlers, preventing resize observations from being lost while Help is open.
## Regression coverage added
[`context_help_tests.rs`](../crates/oa-desktop/src/app/context_help_tests.rs) covers property drafts and multiselection, source drafts, pending state-transition forms, blocked background actions, contextual Help topic dispatch, Close/Escape, allocation input ownership, queued inspector reference-picker admission, and valid/stale Process viewport measurements during Help and after closing. Assertions include accepted-model snapshot identity and absence of history changes where applicable. Passive geometry intentionally validates document/owner/revision; stale pointer epochs alone do not invalidate measurements.
The reviewer did not compile or execute these tests. Suggested focused command: `cargo test -p oa-desktop context_help_tests`. The integration owner records actual execution and native results separately.
## Follow-up from native review
Native review of the subsequent candidate found that contextual Help did not respond to PageDown and that Escape reached a focused underlying text input, dropping its typing focus. The follow-up patch adds a Help-only keyboard subscription and article-ID-targeted scrolling (40-pixel arrows, 90% of the actual viewport for PageUp/Down, Home/End), using Iced's bounds-clamped scroll state. A stable workspace input barrier preserves the actual editor caret while suppressing key, pointer, touch and IME input behind Help; modifier changes and passive geometry continue. Opening Help retires held pointer gestures through cancellation releases with all authored actions discarded, while forwarding redraw/layout invalidation. This also prevents a held canvas pan or text selection from resuming after release behind Help.
New mounted regressions exercise the production App tree and subscriptions for keyboard scrolling, unchanged background scroll positions, precise Inspector caret return, IME exclusion, Shift release, and held-pan retirement. A real text-input widget regression checks selection-drag retirement while preserving its caret. The allocation ownership fixture was corrected to explicitly request Allocation, because `ensure_diagram` creates Overview. These follow-up changes were formatted and source-reviewed here; compilation, execution and native acceptance remain the integration owner's checks.
A second, read-only critic reviewed the follow-up independently of its implementation. It found and prompted corrections for stale modifiers, IME leakage and interrupted pointer gestures. Its final read found no further blocking source defect in the input barrier, cancellation, passive-event handling or relocated Arrange control. The separate native reviewer supplied the original keyboard/focus failures. Neither source review establishes executed or native acceptance of the successor.
## Scope and remaining verification
A final independent read-only source review covered the small post-`5657a996`
delta. It confirmed that the mounted typing fixture must send one KeyPressed per
character because Iced consumes the first scalar, with the original caret/IME
assertions retained. Arrange cancellation now replaces only its own obsolete
armed status, preserving unrelated errors; input epochs and model commands are
unchanged. The guide names the actual Arrange control. No blocking finding was
identified in that delta. Actual executed and native evidence remains separately
identified by source in the design record and evidence index.
Production changes reviewed: desktop App messages/admission, allocation input ownership, Escape bridging, behavior keyboard ownership, architecture/State/Process toolbar layout, guide article disclosure and footer feedback; supporting architecture/UI-review documentation. Regression tests and this record are review artifacts. No model schema, persistence format or authored-model transformation is introduced.
No further concrete source blocker was identified after the fixes above. Native checks remain necessary at 1100×700 and a representative normal size: Help discovery and keyboard reading/dismissal, focus and caret return, preservation of canvas camera/selection and form drafts, resize while Help is open, multiselection control visibility, allocation refusal/destination feedback, current operation failures, and stable canvas bounds through selection/gesture changes. Source inspection and App tests do not establish layout quality, keyboard scrolling, pointer behavior or usability.
@@ -0,0 +1,41 @@
# Compact diagram Help: Windows validation
The source contribution for [issue 39](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/39)
is based on released `715036e132677dd2bc2fb37ed316d93dc9987aec`; final production/test
source is `6e7c4ddaf0775198384a8c4882809ea8e14b2976`. It removes permanent canvas
instructions, preserves compact stable controls and offers contextual Help/F1.
Model schema, core, server, dependencies and persistence code are unchanged.
[Decision and acceptance boundary](compact-context-help-20260925.md),
[independent source review](compact-context-help-source-review-20260925.md),
[native reports, screenshots and gate logs](experiments/compact-help-20260925/README.md)
and [development UX criteria](../docs/ui-review.md) are authoritative for this change.
Windows gnullvm / Rust 1.98.1: formatting, strict desktop/all-target Clippy and the
GUI build pass. Final focused run: **125 passed, zero failed/ignored**. Earlier
full desktop execution at `5657a996` passed 1,538, failed one new test's typing
setup and ignored seven; the corrected test executes fully in the final passing
run. This is not a claim of a full-suite run at the final commit. The initial
final link failed with a full disk and is retained; the recovered build succeeds.
Final Studio SHA256:
`d55ecad4b0f75c0c9c26625e5441bef9c9360890b65bc13236c6ebfaf03d631e`.
Matching runtime SHA256:
`f45604a12e8bcd839fd86f667c1be1cc7c4f9da2b3c69ff381ac708f0e1ae7d4`.
Independent native checks pass group selection/primary properties, Help dismissal
and exact caret continuation, long names at 1100×700, Arrange cancellation,
guide consistency and Apply/Undo/Redo/Undo. Earlier versioned native checks cover
Process drafts, State tools, Help resize and actual operation refusals. Minimum
Allocation recovers about 64 pixels; minimum Traceability about 114 pixels.
Final process exit is 0, both logs are empty, and all 37 copied example files
remain unchanged. Native counts do not establish whole-Project equality; mounted
regressions provide the separately scoped model/history checks.
The application remains an unfinished development preview. The separate
projection/navigation, outgoing-drag, icon, grid/performance and State-label
contributions are outside this source. Dense routes/labels, remaining Scenario
group adapters, quantities/calculations/constraints, maintained libraries,
complete OA/SA acceptance and broad accessibility/DPI coverage remain unfinished.
Linux/WSL checks remain excluded by the user's recorded instruction. This change
does not accept the complete first release.
+11
View File
@@ -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.
@@ -0,0 +1,62 @@
# Compact Help native evidence
Branch-specific evidence for [issue 39](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/39).
The [design record](../../compact-context-help-20260925.md) owns the decision and current acceptance boundary.
`native/` contains the Windows baseline captures from published source
`715036e132677dd2bc2fb37ed316d93dc9987aec`.
`candidate-f830/` preserves the independent review of source
`f83068757af885572f6b3f932d07233dd1b94b7e`, including its two failed Help workflows,
original screenshots, exact executable/runtime hashes and successful build log.
These observations must not be relabeled as acceptance of a successor executable.
`candidate-5657/` preserves the next frozen source's build, strict all-target
Clippy, full desktop test log and independent native review. Formatting, Clippy
and build passed. Tests: 1,538 passed, one failed, seven ignored. The sole failure
was the new caret test's synthetic typing setup before Help opened; `help-failure.log`
records its exact rerun. The next correction uses one character per key event.
The native report records corrected Help keyboard/caret behavior, minimum-window
layout and real operation-refusal feedback, with explicit scope limitations.
Copied text logs have normalized line endings and trailing blank lines removed;
original local logs are retained. Image and fixture hashes are unchanged.
`candidate-6e7c/` preserves final source
`6e7c4ddaf0775198384a8c4882809ea8e14b2976`: formatting and strict desktop/all-target
Clippy pass, the recovered GUI build passes, and 125 focused Help/allocation/guide
tests pass with zero failures/ignores. `build-diskfull.log` preserves the first
failed link; no code change was needed for the recovered build. Its native report
and 15 screenshots cover cancellation, guide consistency, group/primary selection,
minimum-window long names, exact caret return and Undo/Redo/Undo. The runtime
receipt confirms exit 0 and empty logs. Post-close baseline comparison confirms
all 37 copied fixture files unchanged. The transient OSK observation happened
before loading the model and is explicitly retained. Prior source-specific
workflows remain attributed to their actual binaries.
Reproduce final code checks on the documented Windows gnullvm/Rust 1.98.1 toolchain:
```powershell
cargo fmt --all -- --check
cargo clippy -p oa-desktop --all-targets --locked -- -D warnings
cargo build -p oa-desktop --locked
cargo test -p oa-desktop --locked -- context_help_tests allocation_drag guide::tests --test-threads=1
```
The initial test compilation began at that commit; successor work was edited
while it ran. `tests.log` (nine passed, one failed fixture precondition) and
`geometry-tests.log` (nine passed, one failed destination-width invariant) are
diagnostic evidence from that cycle, not frozen-source acceptance. The geometry
run reused its test executable without recompiling. The first command's
`allocation_drag_full_tree_tests` filter matched no tests; the second used the
actual `full_tree` filter plus the affected mounted State/Process checks.
The review used a copy of `examples/release1/project.arcadia` from the published
Windows package. All 37 fixture files matched the baseline before the trial and
again after closing without saving. `baseline-proof.json` records those bytes.
The executable is a local development build, not a published release artifact.
Reproduce the build using the documented Windows gnullvm toolchain and
`cargo build -p oa-desktop --locked`. Place its matching `libunwind.dll` beside the
executable, launch it, and open an isolated copy of the release1 example. Perform
the workflows and use the window sizes in [the native report](candidate-f830/review.md).
The baseline captures establish layout comparison; the report records actual
interaction results and explicit unverified workflows.
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

@@ -0,0 +1,49 @@
{
"baseline_binary": "2c03bd6cc3f1110c219e4f57ab94874a121590f83779239a93289b7170664399",
"baseline_source": "715036e132677dd2bc2fb37ed316d93dc9987aec",
"model_files": {
"evidence/repair-check.md": "f4db17bdca4ecbb1bf0caef01b2da5f1f7f798ab3152b648ce6dd8696a6a68e5",
"expected-diagnostics.json": "3700393d76b940cfc076bf52861bd7b1f43370f8eabce3594531bd0bf1fc851e",
"model.json": "ef0ef4006c3e09d5db6e1b621256b3f7d6642f3a7b09cc34607cfbcca830c727",
"model.rs": "ddc2d92b0d7699a4cadfbb35ae379f0a68d17f4f5d7df82b4b1afcf8d5425fbe",
"oa/activities/activity_oa_restore_work.arcadia": "21cbd7176af8fad68453306f2371ff03e127a994b8796cdf044567332de9686f",
"oa/behavior/process_oa_assessment_procedure.arcadia": "23cabc213c264a16c8595414db6acb09248c1cef9cd699f1b0316e5bb82c062a",
"oa/behavior/process_oa_field_procedure.arcadia": "9c52086759b7344b4809ceb8d372f65647b16fc24153a03b887faa378c37fe96",
"oa/behavior/process_oa_response.arcadia": "675798bd19e6b40d03e7076b0e59b110cdaaab7b6d16c47c3ac80fa960072e84",
"oa/behavior/scenario_oa_conversation.arcadia": "e217e5394a0771dc16206ed9437a231da27905d2f19be64b3f48281c0a32fb6b",
"oa/behavior/scenario_oa_full_scenario.arcadia": "c5a285f2d7c61bd2a136107f267fbf9cddb1cba3f63ff36a6f53f7ab98f33df5",
"oa/behavior/state_machine_oa_monitor.arcadia": "e7349e1e5336282eed3f5a103129db33d3ae4ed500788ee7129e8fc193ac57f6",
"oa/capabilities.arcadia": "26a3d9b615a3251ff010e0b47127f2d16bc2dcd45ed95967208b0076d725843a",
"oa/model.arcadia": "069a5eaaf525089935cc9d73ae7b916866ed78c6534705d75739650ecdea7ada",
"oa/participants.arcadia": "591a75a9ecad280302be4d4ae0171ad616377ed11ead5e233848ca2dc0a71819",
"oa/types.arcadia": "f02c391d584674c879f662a418abba3a220f1fee88471ca49322ac9873de628d",
"project.arcadia": "b2674e6bf77cfe0f6f0ba79c81d2e96d92f39c02ca2c6266d80e91723bfb9fda",
"README.md": "113cfb1cf38a98b51bff60442d86a35d636a3d80ef206bece7d53d0aae67f2d5",
"sa/behavior/process_sa_assessment_procedure.arcadia": "5c414fbdaabf9f9e402b0441fcacf1cacf4dbbc6c822162f028de35abf59be2d",
"sa/behavior/process_sa_field_procedure.arcadia": "ece846fdf93857e71a9ab0efcbdf64a5c41c71180f62afa3f79077fffb53b8f2",
"sa/behavior/process_sa_response.arcadia": "af7fdda280a917cf626f6e077162d481f9aaad280f8e81330c07d7fe384b1c52",
"sa/behavior/scenario_sa_conversation.arcadia": "01339699ec3ee409418d9bb232542f2bcd9419f1e67b183e77700c56bb9f71d2",
"sa/behavior/scenario_sa_full_scenario.arcadia": "5dce6b00a3a441162e8dee3e23d9ca5ab7bed4a24de669b1fba03664a3086dda",
"sa/behavior/state_machine_sa_monitor.arcadia": "17dbcfa5bbfd10fc8e23a325fc950ce853c219b4aa89c74f96278cbb3f34acc8",
"sa/capabilities.arcadia": "81706a0ca9d7db2aa0350888b9c00d853885185db2e6b90557d3d51a11c3d645",
"sa/functions/function_sa_audit_thresholds.arcadia": "27f798f78f36e72b38edab3725af2feb0eaa010fd52a60b5c6b6bef57ad3b686",
"sa/functions/function_sa_restore_work.arcadia": "357c6acb80f86b4e8d8f5c98f9f79d140f85fbe09f3406b41b7ac6eba464ec8c",
"sa/model.arcadia": "805b90262e27090ae7da81a51bcefa49c97fb0b3d3220b4a72323b4c4351c395",
"sa/participants.arcadia": "ca144d6a8f45fe720b5d18c7b59c7671444baa8c37e5ce9117bcba34b3a6aa84",
"sa/types.arcadia": "540ddbc2bcd0ccf45298e3ea5660637341aff63ff65fd87ac2f26469d1adc064",
"traceability.arcadia": "474bd28d955ff7af7722f6e9a23220d816879e3f46202e79a233544b9c593116",
"transition-before.json": "2253e6434f741687e22327c37d520ed15c043bc36025498650bc2366bb6a874d",
"transition-request.json": "43b9a22e5f95ad3bc0ebd2478ead0e14406854abc989ae94ded9367b013d812a",
"views/diagram_oa_allocation_view.arcadia": "c45d7467839219d894804c1ef2d22ff4d4a5a84a6a273c526bafd4c1facde624",
"views/diagram_sa_allocation_view.arcadia": "352a3ca745517592b5c9b1239b5770f62f0466a329a91fd03457ef2b53c06ed6",
"views/diagram_sa_capability_view.arcadia": "8f18fcccbcddcf3c1ea0ffe727b5b23b77fc148c5da4fde953abe681e4c20cd2",
"views/diagram_sa_service_view.arcadia": "4c72cb7593d9c4be8d2600b0656a7a3a6ce84c45bb2f3e58a4ee5ae3bf91913c",
"views/diagram_sa_state_view.arcadia": "dd1bea81ac7dc549a53f9b190be6f13052e00ede345bd180444cc31edb892230"
},
"matching_files": 37,
"baseline_captures": {
"baseline-allocation-compact.png": "d92a0cca62a038dcf0894d92b37a64028342c2365e3fb8880ed597222123d96f",
"baseline-trace-compact.png": "b608a3d75612d8c06aacc094a7aebd12a0798ce30f2126c00bc3ad61a9103217",
"baseline-trace-normal.png": "6dfb6c595af4247aab3600da0e0a25ba4b11151436e9fc4a406f1036053f71d0"
}
}
@@ -0,0 +1,6 @@
{
"buildExit": 0,
"runtime": "F45604A12E8BCD839FD86F667C1BE1CC7C4F9DA2B3C69FF381AC708F0E1AE7D4",
"source": "5657a996ed2cefb1fa84ef967b655eaf8f9f6817",
"binary": "83CCEFF8B713491AFEE92FFCAFE62B02F5EEA6A3D899F6F334848FDC7BB0D88D"
}
@@ -0,0 +1,2 @@
Compiling oa-desktop v0.2.0 (C:\ar-compact-01a0d717\crates\oa-desktop)
Finished `dev` profile [unoptimized] target(s) in 2m 12s
@@ -0,0 +1,6 @@
Compiling oa-core v0.2.0 (C:\ar-compact-01a0d717\crates\oa-core)
Checking iced_widget v0.14.2 (C:\ar-compact-01a0d717\crates\vendor\iced_widget)
Compiling oa-server v0.2.0 (C:\ar-compact-01a0d717\crates\oa-server)
Compiling oa-desktop v0.2.0 (C:\ar-compact-01a0d717\crates\oa-desktop)
Checking iced v0.14.0
Finished `dev` profile [unoptimized] target(s) in 4m 09s
@@ -0,0 +1,16 @@
running 1 test
thread 'app::context_help_tests::mounted_context_help_keyboard_scroll_preserves_inspector_focus_and_exact_caret' (7368) panicked at crates\oa-desktop\src\app\context_help_tests.rs:389:5:
assertion `left == right` failed
left: "DSend"
right: "DRAFT Send"
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
test app::context_help_tests::mounted_context_help_keyboard_scroll_preserves_inspector_focus_and_exact_caret ... FAILED
failures:
failures:
app::context_help_tests::mounted_context_help_keyboard_scroll_preserves_inspector_focus_and_exact_caret
test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 1545 filtered out; finished in 1.56s
@@ -0,0 +1,32 @@
# Independent native successor review
Reviewed source `5657a996ed2cefb1fa84ef967b655eaf8f9f6817`; executable SHA-256 `83CCEFF8B713491AFEE92FFCAFE62B02F5EEA6A3D899F6F334848FDC7BB0D88D` independently rechecked after the run; runtime SHA-256 `F45604A12E8BCD839FD86F667C1BE1CC7C4F9DA2B3C69FF381AC708F0E1AE7D4` from build.json. Used Windows native Computer Use, the isolated `model/project.arcadia`, normal 1440x900 and minimum 1100x700 client sizes (1442x932 and 1102x732 captures). The prior candidate report remains historical under `../candidate-f830/review.md`.
## Result
Both prior blocking native findings are corrected in the exercised workflows. No new blocking finding was observed in this bounded retest. This is not complete product, accessibility, multiselection or first-release acceptance.
### Help keyboard reading and caret preservation
1. In normal Allocation, focused inspector Name, selected its text, typed `LEFT RIGHT`, and pressed Ctrl+Left to put the caret before `RIGHT`. Opened Help with F1. PageDown visibly scrolled to lower sections; PageUp returned to the top; End reached the bottom; Home returned to the top; Down moved one increment and Up reversed it. Evidence: `02-help-pagedown.png`, `03-help-end.png`.
2. Pressed Escape, then typed `X` without refocusing. Name became exactly `LEFT XRIGHT`. Reopened with F1, clicked visible Close, then typed `Y` without refocusing. Name became exactly `LEFT XYRIGHT`. Thus keyboard reading did not move the background caret, and both dismissal paths preserved input continuation. Evidence: `04-caret-after-escape.png`, `05-caret-after-close.png`. Revert restored the accepted property.
3. Opened Help again and resized the native window to its minimum width and height through the native system menu. Help reflowed with Close reachable; End still reached the bottom and Escape returned to the same diagram. Evidence: `06-help-minimum-resized.png`.
4. At minimum size, opened System functional chain `Handle a pressure alarm`, chose W, clicked blank canvas, and entered `LEFT RIGHT` in the pending contribution Name. Ctrl+Left, F1, End, Escape, then `X` produced exactly `LEFT XRIGHT` without refocusing. Create remained disabled with its missing-source explanation and Cancel reachable. Cancel preserved the accepted model. Evidence: `15-process-caret-restored.png`.
### Compact controls and actionable feedback
- Normal and minimum Allocation show projection, Help and Arrange together, with a separate full-width Destination slot. At minimum size the canvas begins near y=350 versus baseline y=414, approximately 64 pixels recovered. Changing from no selection to a selected work card and exercising refused drags did not move the canvas boundary. Evidence: `01-allocation-normal.png`, `07-allocation-minimum.png`.
- Clicking Arrange displays `Arrange ✓`; the tooltip explains one-drag scope, Alt+A and Escape. Escape removes the check; Alt+A arms it again. Evidence: `08-arrange-tooltip-armed.png`.
- With Arrange armed, dragged `Assess pressure alarm` from Control room toward Field technician. The operation was refused with the visible footer message `Make room or adjust the view, then drag again. Zoom in to read the work and destination.` The accepted model remained saved and the work stayed allocated to Control room. Evidence: `09-arrange-refusal.png`.
- Repeated the drag without Arrange. Core validation refused the reassignment with `exchange_allocation_performer: Actual activity allocations must agree with one permitted communication direction; review the means or the activity allocations.` The shortened footer remained visible, and pointing at it exposed the complete readable explanation. The selected activity and accepted model remained unchanged. Evidence: `10-assignment-refusal.png`, `11-refusal-tooltip.png`.
- Changed View content to Traceability. Count, Add existing elements, Review layer traces, View visibility and Help all remain reachable at minimum width. Canvas starts near y=356 versus baseline y=470, approximately 114 pixels recovered. Undid this temporary projection change. Evidence: `12-trace-minimum.png`.
- Opened saved State view `5 · SA pressure supervision`: the generic idle canvas instruction is removed. Choosing +Region still shows its active creation prompt. F1 opens the States topic; Escape returns with +Region still selected, without creating an object. Evidence: `13-state-idle.png`, `14-state-active-prompt.png`.
## Limitations and minor observations
- The existing State active prompt still truncates at minimum width (`Create Region ... machine or composite s...`). This retest confirms it was retained, not that every pre-existing State prompt has complete narrow-window readability.
- The compact control says `Arrange`, while the guide still says `Arrange next drag`; its tooltip explains that wording. Minor consistency cleanup was reported to the integration owner; no user workflow failed because of it.
- Modifier-click/native multiselection and long-name group toolbar layout were not independently operated because the supported Computer Use API has no modifier-click/key-hold operation. Do not infer them from the single-selection screenshots. The parent owns mounted-widget verification separately. Source-editor caret behavior and full Scenario workflows were not repeated in this bounded successor retest.
- The approved native launch API does not expose stdout/stderr or process exit status. After Alt+F4, refreshed native window enumeration confirmed both successor application and console windows disappeared. No crash dialog was observed; this is not an asserted exit code or log inspection.
No save was performed. A post-run hash comparison against baseline-proof.json found no changed files among all 37 copied fixture files. The UI returned to the saved baseline with 94 objects, 62 links and one model check before closure. Screenshots are supporting evidence; the results above describe actual input and observed responses.
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

@@ -0,0 +1,49 @@
{
"baseline_binary": "2c03bd6cc3f1110c219e4f57ab94874a121590f83779239a93289b7170664399",
"baseline_source": "715036e132677dd2bc2fb37ed316d93dc9987aec",
"model_files": {
"evidence/repair-check.md": "f4db17bdca4ecbb1bf0caef01b2da5f1f7f798ab3152b648ce6dd8696a6a68e5",
"expected-diagnostics.json": "3700393d76b940cfc076bf52861bd7b1f43370f8eabce3594531bd0bf1fc851e",
"model.json": "ef0ef4006c3e09d5db6e1b621256b3f7d6642f3a7b09cc34607cfbcca830c727",
"model.rs": "ddc2d92b0d7699a4cadfbb35ae379f0a68d17f4f5d7df82b4b1afcf8d5425fbe",
"oa/activities/activity_oa_restore_work.arcadia": "21cbd7176af8fad68453306f2371ff03e127a994b8796cdf044567332de9686f",
"oa/behavior/process_oa_assessment_procedure.arcadia": "23cabc213c264a16c8595414db6acb09248c1cef9cd699f1b0316e5bb82c062a",
"oa/behavior/process_oa_field_procedure.arcadia": "9c52086759b7344b4809ceb8d372f65647b16fc24153a03b887faa378c37fe96",
"oa/behavior/process_oa_response.arcadia": "675798bd19e6b40d03e7076b0e59b110cdaaab7b6d16c47c3ac80fa960072e84",
"oa/behavior/scenario_oa_conversation.arcadia": "e217e5394a0771dc16206ed9437a231da27905d2f19be64b3f48281c0a32fb6b",
"oa/behavior/scenario_oa_full_scenario.arcadia": "c5a285f2d7c61bd2a136107f267fbf9cddb1cba3f63ff36a6f53f7ab98f33df5",
"oa/behavior/state_machine_oa_monitor.arcadia": "e7349e1e5336282eed3f5a103129db33d3ae4ed500788ee7129e8fc193ac57f6",
"oa/capabilities.arcadia": "26a3d9b615a3251ff010e0b47127f2d16bc2dcd45ed95967208b0076d725843a",
"oa/model.arcadia": "069a5eaaf525089935cc9d73ae7b916866ed78c6534705d75739650ecdea7ada",
"oa/participants.arcadia": "591a75a9ecad280302be4d4ae0171ad616377ed11ead5e233848ca2dc0a71819",
"oa/types.arcadia": "f02c391d584674c879f662a418abba3a220f1fee88471ca49322ac9873de628d",
"project.arcadia": "b2674e6bf77cfe0f6f0ba79c81d2e96d92f39c02ca2c6266d80e91723bfb9fda",
"README.md": "113cfb1cf38a98b51bff60442d86a35d636a3d80ef206bece7d53d0aae67f2d5",
"sa/behavior/process_sa_assessment_procedure.arcadia": "5c414fbdaabf9f9e402b0441fcacf1cacf4dbbc6c822162f028de35abf59be2d",
"sa/behavior/process_sa_field_procedure.arcadia": "ece846fdf93857e71a9ab0efcbdf64a5c41c71180f62afa3f79077fffb53b8f2",
"sa/behavior/process_sa_response.arcadia": "af7fdda280a917cf626f6e077162d481f9aaad280f8e81330c07d7fe384b1c52",
"sa/behavior/scenario_sa_conversation.arcadia": "01339699ec3ee409418d9bb232542f2bcd9419f1e67b183e77700c56bb9f71d2",
"sa/behavior/scenario_sa_full_scenario.arcadia": "5dce6b00a3a441162e8dee3e23d9ca5ab7bed4a24de669b1fba03664a3086dda",
"sa/behavior/state_machine_sa_monitor.arcadia": "17dbcfa5bbfd10fc8e23a325fc950ce853c219b4aa89c74f96278cbb3f34acc8",
"sa/capabilities.arcadia": "81706a0ca9d7db2aa0350888b9c00d853885185db2e6b90557d3d51a11c3d645",
"sa/functions/function_sa_audit_thresholds.arcadia": "27f798f78f36e72b38edab3725af2feb0eaa010fd52a60b5c6b6bef57ad3b686",
"sa/functions/function_sa_restore_work.arcadia": "357c6acb80f86b4e8d8f5c98f9f79d140f85fbe09f3406b41b7ac6eba464ec8c",
"sa/model.arcadia": "805b90262e27090ae7da81a51bcefa49c97fb0b3d3220b4a72323b4c4351c395",
"sa/participants.arcadia": "ca144d6a8f45fe720b5d18c7b59c7671444baa8c37e5ce9117bcba34b3a6aa84",
"sa/types.arcadia": "540ddbc2bcd0ccf45298e3ea5660637341aff63ff65fd87ac2f26469d1adc064",
"traceability.arcadia": "474bd28d955ff7af7722f6e9a23220d816879e3f46202e79a233544b9c593116",
"transition-before.json": "2253e6434f741687e22327c37d520ed15c043bc36025498650bc2366bb6a874d",
"transition-request.json": "43b9a22e5f95ad3bc0ebd2478ead0e14406854abc989ae94ded9367b013d812a",
"views/diagram_oa_allocation_view.arcadia": "c45d7467839219d894804c1ef2d22ff4d4a5a84a6a273c526bafd4c1facde624",
"views/diagram_sa_allocation_view.arcadia": "352a3ca745517592b5c9b1239b5770f62f0466a329a91fd03457ef2b53c06ed6",
"views/diagram_sa_capability_view.arcadia": "8f18fcccbcddcf3c1ea0ffe727b5b23b77fc148c5da4fde953abe681e4c20cd2",
"views/diagram_sa_service_view.arcadia": "4c72cb7593d9c4be8d2600b0656a7a3a6ce84c45bb2f3e58a4ee5ae3bf91913c",
"views/diagram_sa_state_view.arcadia": "dd1bea81ac7dc549a53f9b190be6f13052e00ede345bd180444cc31edb892230"
},
"matching_files": 37,
"baseline_captures": {
"baseline-allocation-compact.png": "d92a0cca62a038dcf0894d92b37a64028342c2365e3fb8880ed597222123d96f",
"baseline-trace-compact.png": "b608a3d75612d8c06aacc094a7aebd12a0798ce30f2126c00bc3ad61a9103217",
"baseline-trace-normal.png": "6dfb6c595af4247aab3600da0e0a25ba4b11151436e9fc4a406f1036053f71d0"
}
}

Some files were not shown because too many files have changed in this diff Show More