Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bed7f825fd | ||
|
|
3480b54201 | ||
|
|
2a3b9ba822 | ||
|
|
7e348d967d | ||
|
|
f6a6010bae | ||
|
|
12a7352dde | ||
|
|
b8685416f5 | ||
|
|
26979c1093 | ||
|
|
53d01a3d44 | ||
|
|
3aa80de89c | ||
|
|
79cc9d5551 |
@@ -89,5 +89,3 @@ 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
|
||||
harness/experiments/analysis-mixed-syntax-20260923/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/state-multiselect-delivery-audit-20260923/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
|
||||
+2
-2
@@ -17,14 +17,14 @@ Apply these criteria to the scope requested by the user:
|
||||
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
|
||||
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
|
||||
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
|
||||
- A fresh independent review covers requirements, domain / semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
|
||||
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
|
||||
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
|
||||
|
||||
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
|
||||
|
||||
## Final handoff
|
||||
|
||||
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build / run / deployment / verification instructions. Refer to authoritative records instead of repeating the project history.
|
||||
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
|
||||
|
||||
## USER-OWNED APPLICATION SPECIFICATION
|
||||
|
||||
|
||||
@@ -94,11 +94,13 @@ Create named **Exchange categories** at the perspective root to group Work excha
|
||||
|
||||
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
|
||||
|
||||
In saved architecture and State views, **Shift+click** adds or removes an exact displayed item; its context menu also offers **Add to selection** and **Remove from selection**. Properties edits only the primary item. Drag a selected member to move a placement-only group in one operation, including visual descendants once and the authored pins of connections whose endpoint owners move together. A stationary click selects only that member; Escape during a drag cancels it and keeps the group. **Hide selected** removes only selected placements, preserving model elements, unselected descendant placements and other views; one Undo restores the operation. Groups containing connections, ports or State action/internal-transition rows cannot be moved or hidden as placement groups. State rows select their relation, never their owning placement. Dragging a group of allocated work cards and deleting a group from the model remain unavailable; these actions refuse the whole group instead of editing just its primary item. Selection itself is temporary and is not saved.
|
||||
|
||||
Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result.
|
||||
|
||||
For state diagrams, use **+ Region** inside a highlighted machine or State/Mode, then choose **+ State / symbol** and click its owning region. **Transition** supports dragging between compatible vertices or clicking the source and then the target. Select a drawn transition and drag its endpoint handle, or right-click **Reconnect source/target**. Right-click a state or the background for **New transition with details…** to create Local, Internal or timed transitions; an existing transition's **Edit transition** form edits its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local.
|
||||
|
||||
In the state view, **Select / move** drags vertices and containers without reparenting; container movement includes its descendants. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
|
||||
In the state view, **Select / move** drags vertices and containers without reparenting or grid snapping; container movement includes its descendants. Multiple selected placements share the same fractional displacement, including authored positions of boundary symbols. State route bends retain their separate 5-unit grid. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
//! Read and validate a saved acceptance fixture without rewriting its source.
|
||||
//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json
|
||||
use oa_core::{persistence, validate_candidate};
|
||||
use std::{fs::OpenOptions, io::Write, path::Path};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() != 2 {
|
||||
return Err("Expected input source/JSON and a new output JSON path".into());
|
||||
}
|
||||
let project = persistence::load(Path::new(&args[0]))?;
|
||||
validate_candidate(&project)?;
|
||||
let bytes = serde_json::to_vec_pretty(&project)?;
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&args[1])?;
|
||||
output.write_all(&bytes)?;
|
||||
output.write_all(b"\n")?;
|
||||
output.sync_all()?;
|
||||
println!(
|
||||
"Validated {} objects and {} views; saved a complete Project snapshot",
|
||||
project.identities().len(),
|
||||
project.diagrams.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
//! Native evidence fixture/checker; install as a temporary oa-core example.
|
||||
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
// Make the intended OA view first for default UUID-ordered view selection.
|
||||
if alias == "oa_view" {
|
||||
return Id::from_u128(0x29_0001);
|
||||
}
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000000029),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
|
||||
p.diagrams.insert(
|
||||
id(alias),
|
||||
Diagram {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: "Selection changes placement geometry only.".into(),
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
|
||||
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
|
||||
id(alias),
|
||||
NodePlacement {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
element: id(object),
|
||||
position,
|
||||
size,
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge: id(edge),
|
||||
waypoints: pins,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.25,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.7,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.45,
|
||||
offset: point(7., -19.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "multiselect_native".into();
|
||||
p.name = "Multiselect native".into();
|
||||
p.description =
|
||||
"Shift-select A and B; move together, retain C and engineering semantics.".into();
|
||||
for (alias, name, parent) in [
|
||||
("activity_a", "A - receive request", None),
|
||||
("activity_b", "B - assess request", None),
|
||||
("activity_c", "C - dispatch response", None),
|
||||
(
|
||||
"activity_child",
|
||||
"A.1 - classify request",
|
||||
Some(id("activity_a")),
|
||||
),
|
||||
] {
|
||||
element(&mut p, alias, name, Kind::Activity, Layer::Oa, parent);
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
"sa_function",
|
||||
"Technical request processing",
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
None,
|
||||
);
|
||||
for (alias, name, source, target) in [
|
||||
("internal", "Request assessment", "activity_a", "activity_b"),
|
||||
("external", "Response decision", "activity_b", "activity_c"),
|
||||
] {
|
||||
let old = p.relate(RelationKind::Interaction, name, id(source), id(target));
|
||||
reidentify(&mut p, old, id(alias))?;
|
||||
p.relations.get_mut(&id(alias)).unwrap().alias = alias.into();
|
||||
}
|
||||
diagram(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"Multiselect native - OA overview",
|
||||
Layer::Oa,
|
||||
);
|
||||
diagram(&mut p, "oa_other", "Other OA view - preserve", Layer::Oa);
|
||||
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
|
||||
let size = Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_a",
|
||||
"activity_a",
|
||||
point(80., 80.),
|
||||
Size {
|
||||
width: 310.,
|
||||
height: 260.,
|
||||
},
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_b",
|
||||
"activity_b",
|
||||
point(470., 120.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_c",
|
||||
"activity_c",
|
||||
point(780., 270.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_view",
|
||||
"node_child",
|
||||
"activity_child",
|
||||
point(120., 210.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"oa_other",
|
||||
"other_a",
|
||||
"activity_a",
|
||||
point(40., 60.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"sa_view",
|
||||
"sa_node",
|
||||
"sa_function",
|
||||
point(100., 110.),
|
||||
size,
|
||||
);
|
||||
p.diagrams.get_mut(&id("oa_view")).unwrap().routes = vec![
|
||||
route("internal", vec![point(420., 145.), point(420., 186.)]),
|
||||
route("external", vec![point(725., 150.), point(725., 336.)]),
|
||||
];
|
||||
p.traces.insert(
|
||||
id("trace"),
|
||||
Trace {
|
||||
id: id("trace"),
|
||||
alias: "preserved_refinement".into(),
|
||||
name: "Reviewed request refinement".into(),
|
||||
description: "Group geometry and Hide preserve this trace and both endpoints.".into(),
|
||||
oa: id("activity_a"),
|
||||
sa: id("sa_function"),
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(id("activity_a")).unwrap(),
|
||||
sa: p.properties(id("sa_function")).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn load(path: &Path) -> Result<Project> {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
let disk = source_files::load_project(path, ProjectLimits::default())?;
|
||||
// This deliberately partial engineering fixture has completeness
|
||||
// warnings (allocation/behavior context). They are not source errors.
|
||||
// Compilation, candidate validation and whole-Project equality still
|
||||
// reject malformed source or any unexpected model change.
|
||||
if disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
|
||||
}
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
|
||||
Ok(compiled.decoded.project)
|
||||
} else {
|
||||
Ok(persistence::load(path)?)
|
||||
}
|
||||
}
|
||||
fn equal(expected: &Project, actual: &Project) -> Result<()> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let e = serde_json::to_value(expected)?;
|
||||
let a = serde_json::to_value(actual)?;
|
||||
let fields: Vec<_> = e
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| e[*key] != a[*key])
|
||||
.cloned()
|
||||
.collect();
|
||||
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
|
||||
}
|
||||
fn roundtrip(p: &Project) -> Result<SourceBundle> {
|
||||
validate_candidate(p)?;
|
||||
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
|
||||
let bundle = format_bundle(p);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Roundtrip: {e:?}"))?;
|
||||
equal(p, &compiled.decoded.project)?;
|
||||
Ok(bundle)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?
|
||||
.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
fn generate(out: &Path) -> Result<()> {
|
||||
if out.exists() {
|
||||
return Err("Output directory already exists; choose a fresh path".into());
|
||||
}
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), source.as_bytes())?;
|
||||
}
|
||||
let names = [
|
||||
"project",
|
||||
"oa_view",
|
||||
"oa_other",
|
||||
"sa_view",
|
||||
"activity_a",
|
||||
"activity_b",
|
||||
"activity_c",
|
||||
"activity_child",
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_c",
|
||||
"node_child",
|
||||
"internal",
|
||||
"external",
|
||||
"trace",
|
||||
"sa_function",
|
||||
"sa_node",
|
||||
"other_a",
|
||||
];
|
||||
let ids: std::collections::BTreeMap<_, _> =
|
||||
names.into_iter().map(|name| (name, id(name))).collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture": "multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
|
||||
"move_members": ["node_a", "node_b", "node_child"], "fixed_member": "node_c",
|
||||
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
|
||||
"hide": "Remove node_a/node_b only; retain child, stored routes, all model records and other views",
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
|
||||
println!("PASS generated JSON and source bundle: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&before)?;
|
||||
validate_candidate(&actual)?;
|
||||
if before.id != id("project") {
|
||||
return Err("Not this fixture's baseline project".into());
|
||||
}
|
||||
let mut expected = before.clone();
|
||||
let mut delta = None;
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"hide" => {
|
||||
let view = expected
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.ok_or("Missing OA view")?;
|
||||
for alias in ["node_a", "node_b"] {
|
||||
view.nodes
|
||||
.remove(&id(alias))
|
||||
.ok_or("Baseline selected placement missing")?;
|
||||
}
|
||||
// references::remove_objects removes routes only when their semantic
|
||||
// edge is deleted. Hide deletes placements; both routes MUST survive.
|
||||
}
|
||||
"moved" => {
|
||||
let old = before
|
||||
.diagrams
|
||||
.get(&id("oa_view"))
|
||||
.ok_or("Missing baseline OA view")?;
|
||||
let new = actual
|
||||
.diagrams
|
||||
.get(&id("oa_view"))
|
||||
.ok_or("Missing saved OA view")?;
|
||||
let a = old
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing baseline A")?
|
||||
.position;
|
||||
let moved_a = new
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing saved A")?
|
||||
.position;
|
||||
let d = point(moved_a.x - a.x, moved_a.y - a.y);
|
||||
if !d.x.is_finite()
|
||||
|| !d.y.is_finite()
|
||||
|| d.x.abs().max(d.y.abs()) > 500.
|
||||
|| d.x.hypot(d.y) < 10.
|
||||
{
|
||||
return Err(format!(
|
||||
"Move delta must have length >=10 and each axis <=500 world units: {d:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if moved_a.x % 10. != 0. || moved_a.y % 10. != 0. {
|
||||
return Err("Saved anchor is not on 10-unit grid".into());
|
||||
}
|
||||
let view = expected.diagrams.get_mut(&id("oa_view")).unwrap();
|
||||
// Independent explicit fixture enumeration: no group/descendant/
|
||||
// routing planner is called to predict the result.
|
||||
for alias in ["node_a", "node_b", "node_child"] {
|
||||
let node = view
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.ok_or("Missing expected moved placement")?;
|
||||
node.position.x += d.x;
|
||||
node.position.y += d.y;
|
||||
}
|
||||
let internal = view
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id("internal"))
|
||||
.ok_or("Missing internal pins")?;
|
||||
for pin in &mut internal.waypoints {
|
||||
pin.x += d.x;
|
||||
pin.y += d.y;
|
||||
}
|
||||
delta = Some(d);
|
||||
}
|
||||
_ => return Err("Mode must be moved, hide, or unchanged".into()),
|
||||
}
|
||||
equal(&expected, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
|
||||
"comparison": "complete Project equality", "inferred_common_delta": delta,
|
||||
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[command, out] if command == "generate" => generate(Path::new(out)),
|
||||
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
|
||||
_ => Err("Usage: multiselect-native-producer generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
// Produce evidence through real validated commands; the checker itself uses
|
||||
// explicit fixture enumeration and never calls this movement implementation.
|
||||
fn moved(before: &Project, delta: Position) -> Project {
|
||||
let command = diagram_edit::translate_placements(
|
||||
before,
|
||||
id("oa_view"),
|
||||
&BTreeSet::from([id("node_a"), id("node_b")]),
|
||||
delta,
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command).unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn hidden(before: &Project) -> Project {
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("node_a")),
|
||||
Command::Delete(id("node_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let before = dir.path().join("before.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
// These assertions distinguish meaningful invariant corruptions from
|
||||
// malformed projects rejected before the evaluator even sees them.
|
||||
validate_candidate(baseline).expect("test baseline must be valid");
|
||||
validate_candidate(actual).expect("corruption must remain a valid model");
|
||||
persistence::save(&before, baseline)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &before, &saved)
|
||||
}
|
||||
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
|
||||
let error = through_saved_json(mode, before, actual)
|
||||
.expect_err(case)
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"{case}: unexpected rejection: {error}"
|
||||
);
|
||||
}
|
||||
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let baseline = out.join("baseline.json");
|
||||
let saved = out.join("saved.json");
|
||||
persistence::save(&saved, &fixture().unwrap()).unwrap();
|
||||
check("unchanged", &baseline, &saved).unwrap();
|
||||
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
|
||||
let bytes = fs::read(&baseline).unwrap();
|
||||
assert!(
|
||||
generate(&out).is_err(),
|
||||
"generation cannot overwrite evidence"
|
||||
);
|
||||
assert_eq!(fs::read(&baseline).unwrap(), bytes);
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
fs::write(&invalid, "project {").unwrap();
|
||||
assert!(load(&invalid).is_err(), "source errors must still reject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_group_command_outputs_pass_and_are_not_unchanged() {
|
||||
let before = fixture().unwrap();
|
||||
for delta in [point(40., 60.), point(-20., 30.)] {
|
||||
let actual = moved(&before, delta);
|
||||
through_saved_json("moved", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real movement cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_hide_command_outputs_pass_before_and_after_movement() {
|
||||
let initial = fixture().unwrap();
|
||||
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
|
||||
let actual = hidden(&before);
|
||||
through_saved_json("hide", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real Hide cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_double_moved_child_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let delta = point(40., 60.);
|
||||
let correct = moved(&before, delta);
|
||||
let initial = before.diagrams[&id("oa_view")].nodes[&id("node_child")].position;
|
||||
for position in [
|
||||
initial,
|
||||
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
|
||||
] {
|
||||
let mut bad = correct.clone();
|
||||
bad.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_child"))
|
||||
.unwrap()
|
||||
.position = position;
|
||||
mismatch("moved", &before, &bad, "child did not move exactly once");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
|
||||
let mut before = fixture().unwrap();
|
||||
let original_pins = edge(&mut before, "internal").waypoints.clone();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mut bad = correct.clone();
|
||||
edge(&mut bad, "internal").waypoints = original_pins;
|
||||
mismatch("moved", &before, &bad, "internal pins left behind");
|
||||
let mut bad = correct;
|
||||
for pin in &mut edge(&mut bad, "external").waypoints {
|
||||
pin.x += 40.;
|
||||
pin.y += 60.;
|
||||
}
|
||||
mismatch("moved", &before, &bad, "one-ended pins moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_attachments_and_label_data_must_remain_exact() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut EdgeRoute); 4] = [
|
||||
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|
||||
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|
||||
|r| r.label.as_mut().unwrap().offset.x += 40.,
|
||||
|r| r.label.as_mut().unwrap().fraction = 0.75,
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(edge(&mut bad, "internal"));
|
||||
mismatch("moved", &before, &bad, "relative route metadata changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut Project); 5] = [
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("activity_a"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_other"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("other_a"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("sa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("sa_node"))
|
||||
.unwrap()
|
||||
.position
|
||||
.y += 60.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_c"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.traces
|
||||
.get_mut(&id("trace"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(&mut bad);
|
||||
mismatch(
|
||||
"moved",
|
||||
&before,
|
||||
&bad,
|
||||
"record outside permitted movement changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_small_excessive_and_offgrid_moves_cannot_satisfy_move_evidence() {
|
||||
let before = fixture().unwrap();
|
||||
for (delta, reason) in [
|
||||
(point(0., 0.), "length >=10"),
|
||||
(point(3., 0.), "length >=10"),
|
||||
(point(510., 0.), "each axis <=500"),
|
||||
(point(20.5, 0.), "10-unit grid"),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
let error = through_saved_json("moved", &before, &actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(reason), "{delta:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_must_preserve_child_routes_and_semantic_objects() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = hidden(&before);
|
||||
let mut missing_child = correct.clone();
|
||||
missing_child
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&id("node_child"));
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_child,
|
||||
"Hide removed unselected child placement",
|
||||
);
|
||||
let mut missing_routes = correct;
|
||||
missing_routes
|
||||
.diagrams
|
||||
.get_mut(&id("oa_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.clear();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_routes,
|
||||
"Hide removed persistent route records",
|
||||
);
|
||||
let mut semantic_delete = Session::new(before.clone());
|
||||
semantic_delete
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("activity_a")),
|
||||
Command::Delete(id("activity_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
semantic_delete.project(),
|
||||
"Hide became semantic deletion",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
|
||||
let before = fixture().unwrap();
|
||||
let mut actual = before.clone();
|
||||
actual
|
||||
.elements
|
||||
.get_mut(&id("activity_b"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" edited");
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"geometry equality is not complete Project equality",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Read-only saved-State geometry evidence for a predeclared native bend trial.
|
||||
//!
|
||||
//! Usage:
|
||||
//! state_bend_routing_snapshot INPUT.arcadia NEW_OUTPUT.json
|
||||
//!
|
||||
//! Loads and validates the complete Project, then independently projects/routes
|
||||
//! every saved State view through core APIs. The complete Project permits exact
|
||||
//! semantic and presentation comparison; derived geometry permits declaring a
|
||||
//! segment and insertion position before native actions. No desktop command,
|
||||
//! planner, gesture, or pin insertion helper is called. Input is never written;
|
||||
//! output uses create_new and fails rather than overwriting an existing file.
|
||||
//!
|
||||
//! Limits: this shares production core routing, so it is not an independent
|
||||
//! mathematical test of that router, nor evidence of UI picking/gesture success.
|
||||
//! It does not generate expected mutations or adapt an oracle to observed edits.
|
||||
//! The acceptance recipe must declare the intended segment/index/point and exact
|
||||
//! Project mutation before UI execution, and bind this executable/source/input
|
||||
//! and output hashes externally. Warning strings are diagnostic Debug output,
|
||||
//! not a stable interchange schema. Missing/hidden/internal transitions have no
|
||||
//! routed edge; their semantic records remain in the complete Project snapshot.
|
||||
|
||||
use oa_core::{persistence, projection, routing, validate_candidate};
|
||||
use serde_json::json;
|
||||
use std::{fs::OpenOptions, io::Write, path::Path};
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let args: Vec<_> = std::env::args_os().skip(1).collect();
|
||||
if args.len() != 2 {
|
||||
return Err("Expected input source/JSON and a new output JSON path".into());
|
||||
}
|
||||
let project = persistence::load(Path::new(&args[0]))?;
|
||||
validate_candidate(&project)?;
|
||||
let mut views = Vec::new();
|
||||
for (diagram_id, diagram) in &project.diagrams {
|
||||
let Some(machine) = diagram.projection.state_machine() else {
|
||||
continue;
|
||||
};
|
||||
let projected = projection::project_diagram(&project, diagram);
|
||||
let routed = routing::route_diagram(&project, diagram, &projected);
|
||||
let edges: Vec<_> = routed
|
||||
.edges
|
||||
.iter()
|
||||
.map(|(edge_id, edge)| {
|
||||
json!({
|
||||
"edge": edge_id,
|
||||
"id": edge.id,
|
||||
"points": edge.points,
|
||||
"pins": edge.pins,
|
||||
"pin_fractions": edge.pin_fractions,
|
||||
"custom_suspended": edge.custom_suspended,
|
||||
"warnings": edge.warnings.iter().map(|warning| format!("{warning:?}")).collect::<Vec<_>>(),
|
||||
"label": {
|
||||
"text": edge.label.text,
|
||||
"fraction": edge.label.fraction,
|
||||
"anchor": edge.label.anchor,
|
||||
"center": edge.label.center,
|
||||
"bounds": {
|
||||
"position": edge.label.bounds.position,
|
||||
"size": edge.label.bounds.size
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
views.push(json!({
|
||||
"diagram": diagram_id,
|
||||
"machine": machine,
|
||||
"projected": projected,
|
||||
"routed_bounds": {
|
||||
"position": routed.bounds.position,
|
||||
"size": routed.bounds.size
|
||||
},
|
||||
"routed_edges": edges
|
||||
}));
|
||||
}
|
||||
// Preserve the canonical typed Project serialization (including f32
|
||||
// formatting). Serializing it through Value directly promotes f32 to f64.
|
||||
let project_json: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&project)?)?;
|
||||
let snapshot = json!({
|
||||
"schema": "state-bend-routing-snapshot/v1",
|
||||
"project": project_json,
|
||||
"state_views": views
|
||||
});
|
||||
// Finish all reading, validation, routing and serialization before creating
|
||||
// the new evidence file. A failed load cannot leave an apparent snapshot.
|
||||
let mut bytes = serde_json::to_vec_pretty(&snapshot)?;
|
||||
bytes.push(b'\n');
|
||||
let mut output = OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&args[1])?;
|
||||
output.write_all(&bytes)?;
|
||||
output.sync_all()?;
|
||||
println!(
|
||||
"Validated {} objects and {} saved views; snapshotted {} State views and the complete Project",
|
||||
project.identities().len(),
|
||||
project.diagrams.len(),
|
||||
views.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,911 @@
|
||||
//! Deterministic persisted-State native fixture and complete-Project checker.
|
||||
//! This invariant evaluator derives delta from saved A, NOT from pointer input.
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::{fs, io::Write, path::Path};
|
||||
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
|
||||
|
||||
fn id(alias: &str) -> Id {
|
||||
// Dedicated State view is the smallest diagram UUID, for native opening.
|
||||
if alias == "state_view" {
|
||||
return Id::from_u128(0x29_0101);
|
||||
}
|
||||
Id::new_v5(
|
||||
&Id::from_u128(0xc1192026_0923_5000_8000_000000002929),
|
||||
alias.as_bytes(),
|
||||
)
|
||||
}
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn element(p: &mut Project, alias: &str, name: &str, kind: Kind, layer: Layer, parent: Option<Id>) {
|
||||
p.elements.insert(
|
||||
id(alias),
|
||||
Element {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: format!("Preserve engineering record {alias}."),
|
||||
parent,
|
||||
body: ElementBody::new(kind, layer).unwrap(),
|
||||
},
|
||||
);
|
||||
}
|
||||
fn diagram(p: &mut Project, alias: &str, name: &str, layer: Layer) {
|
||||
p.diagrams.insert(
|
||||
id(alias),
|
||||
Diagram {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: "Selection changes placement geometry only.".into(),
|
||||
layer,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn place(p: &mut Project, view: &str, alias: &str, object: &str, position: Position, size: Size) {
|
||||
p.diagrams.get_mut(&id(view)).unwrap().nodes.insert(
|
||||
id(alias),
|
||||
NodePlacement {
|
||||
id: id(alias),
|
||||
alias: alias.into(),
|
||||
element: id(object),
|
||||
position,
|
||||
size,
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
);
|
||||
}
|
||||
fn route(edge: &str, pins: Vec<Position>) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge: id(edge),
|
||||
waypoints: pins,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.25,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.7,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.45,
|
||||
offset: point(7., -19.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
fn transition(
|
||||
p: &mut Project,
|
||||
alias: &str,
|
||||
name: &str,
|
||||
source: &str,
|
||||
target: &str,
|
||||
kind: TransitionKind,
|
||||
) -> Result<()> {
|
||||
let old = p.relate(RelationKind::StateTransition, name, id(source), id(target));
|
||||
reidentify(p, old, id(alias))?;
|
||||
let relation = p.relations.get_mut(&id(alias)).unwrap();
|
||||
relation.alias = alias.into();
|
||||
relation.description = format!("Preserve transition {alias}.");
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
guard,
|
||||
..
|
||||
} = &mut relation.body
|
||||
{
|
||||
*transition_kind = kind;
|
||||
*guard = if alias == "between" {
|
||||
"request_ready"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
.into();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn fixture() -> Result<Project> {
|
||||
let mut p = Project::empty();
|
||||
p.id = id("project");
|
||||
p.alias = "state_multiselect_native".into();
|
||||
p.name = "State group native".into();
|
||||
p.description = "Shift-select A and B; move their placements together. C and shared engineering records stay fixed.".into();
|
||||
for (alias, name, kind, parent) in [
|
||||
("crew", "Operations crew", Kind::Entity, None),
|
||||
(
|
||||
"machine",
|
||||
"Request lifecycle",
|
||||
Kind::StateMachine,
|
||||
Some(id("crew")),
|
||||
),
|
||||
("region", "Main region", Kind::Region, Some(id("machine"))),
|
||||
("state_a", "A - Processing", Kind::State, Some(id("region"))),
|
||||
("state_b", "B - Ready", Kind::State, Some(id("region"))),
|
||||
("state_c", "C - Complete", Kind::State, Some(id("region"))),
|
||||
(
|
||||
"inner",
|
||||
"A nested region",
|
||||
Kind::Region,
|
||||
Some(id("state_a")),
|
||||
),
|
||||
(
|
||||
"state_leaf",
|
||||
"A.1 - Classify",
|
||||
Kind::State,
|
||||
Some(id("inner")),
|
||||
),
|
||||
("entry", "Enter A", Kind::EntryPoint, Some(id("inner"))),
|
||||
("exit", "Leave A", Kind::ExitPoint, Some(id("inner"))),
|
||||
("shared_work", "Record request", Kind::Activity, None),
|
||||
] {
|
||||
element(&mut p, alias, name, kind, Layer::Oa, parent);
|
||||
}
|
||||
element(
|
||||
&mut p,
|
||||
"sa_function",
|
||||
"Technical record request",
|
||||
Kind::Function,
|
||||
Layer::Sa,
|
||||
None,
|
||||
);
|
||||
for (alias, name, source, target, kind) in [
|
||||
(
|
||||
"between",
|
||||
"Ready\nfor review",
|
||||
"state_a",
|
||||
"state_b",
|
||||
TransitionKind::External,
|
||||
),
|
||||
(
|
||||
"external",
|
||||
"Complete request",
|
||||
"state_b",
|
||||
"state_c",
|
||||
TransitionKind::External,
|
||||
),
|
||||
(
|
||||
"entry_route",
|
||||
"Begin classification",
|
||||
"entry",
|
||||
"state_leaf",
|
||||
TransitionKind::Local,
|
||||
),
|
||||
(
|
||||
"exit_route",
|
||||
"Classification complete",
|
||||
"state_leaf",
|
||||
"exit",
|
||||
TransitionKind::External,
|
||||
),
|
||||
(
|
||||
"refresh",
|
||||
"Refresh record",
|
||||
"state_a",
|
||||
"state_a",
|
||||
TransitionKind::Internal,
|
||||
),
|
||||
] {
|
||||
transition(&mut p, alias, name, source, target, kind)?;
|
||||
}
|
||||
for (alias, source, phase) in [
|
||||
("action_a", "state_a", StateActionKind::Entry),
|
||||
("action_c", "state_c", StateActionKind::Do),
|
||||
] {
|
||||
let old = p.relate(
|
||||
RelationKind::StateAction,
|
||||
"Record shared work",
|
||||
id(source),
|
||||
id("shared_work"),
|
||||
);
|
||||
reidentify(&mut p, old, id(alias))?;
|
||||
let relation = p.relations.get_mut(&id(alias)).unwrap();
|
||||
relation.alias = alias.into();
|
||||
relation.description = format!("Preserve shared-work action {alias}.");
|
||||
if let RelationBody::StateAction { phase: value, .. } = &mut relation.body {
|
||||
*value = phase;
|
||||
}
|
||||
}
|
||||
for (alias, name) in [
|
||||
("state_view", "State multiselect - main"),
|
||||
("state_other", "Other State view - preserve"),
|
||||
] {
|
||||
diagram(&mut p, alias, name, Layer::Oa);
|
||||
p.diagrams.get_mut(&id(alias)).unwrap().projection = ProjectionKind::StateMachine {
|
||||
machine: id("machine"),
|
||||
};
|
||||
}
|
||||
diagram(&mut p, "sa_view", "SA engineering - preserve", Layer::Sa);
|
||||
// The main machine and outer Region are deliberately unplaced to keep
|
||||
// the target headers readable in a 1442 x 932 native window after Fit.
|
||||
// A starts at the authored origin so saved A recovers the exact f32 delta;
|
||||
// subtracting a nonzero original can round it differently from sibling XY.
|
||||
for (alias, object, x, y, width, height) in [
|
||||
("node_a", "state_a", 0., 0., 340., 450.),
|
||||
("node_b", "state_b", 410., 30., 210., 150.),
|
||||
("node_c", "state_c", 410., 330., 210., 140.),
|
||||
("node_inner", "inner", 25., 220., 290., 200.),
|
||||
("node_leaf", "state_leaf", 60., 290., 180., 90.),
|
||||
("node_entry", "entry", -210., 280., 24., 24.),
|
||||
("node_exit", "exit", 870., 350., 24., 24.),
|
||||
] {
|
||||
place(
|
||||
&mut p,
|
||||
"state_view",
|
||||
alias,
|
||||
object,
|
||||
point(x, y),
|
||||
Size { width, height },
|
||||
);
|
||||
}
|
||||
let size = Size {
|
||||
width: 224.,
|
||||
height: 94.,
|
||||
};
|
||||
place(
|
||||
&mut p,
|
||||
"state_other",
|
||||
"other_a",
|
||||
"state_a",
|
||||
point(40., 60.),
|
||||
Size {
|
||||
width: 340.,
|
||||
height: 220.,
|
||||
},
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"state_other",
|
||||
"other_b",
|
||||
"state_b",
|
||||
point(460., 80.),
|
||||
size,
|
||||
);
|
||||
place(
|
||||
&mut p,
|
||||
"sa_view",
|
||||
"sa_node",
|
||||
"sa_function",
|
||||
point(100., 110.),
|
||||
size,
|
||||
);
|
||||
p.diagrams.get_mut(&id("state_view")).unwrap().routes = vec![
|
||||
route("between", vec![point(365., 70.), point(365., 120.)]),
|
||||
route("external", vec![point(660., 140.), point(660., 360.)]),
|
||||
route("entry_route", vec![point(40., 280.), point(40., 320.)]),
|
||||
route("exit_route", vec![point(290., 335.), point(290., 370.)]),
|
||||
];
|
||||
p.traces.insert(
|
||||
id("trace"),
|
||||
Trace {
|
||||
id: id("trace"),
|
||||
alias: "preserved_refinement".into(),
|
||||
name: "Reviewed recording refinement".into(),
|
||||
description:
|
||||
"Moving states or hiding placements must preserve shared work and its refinement."
|
||||
.into(),
|
||||
oa: id("shared_work"),
|
||||
sa: id("sa_function"),
|
||||
kind: TraceKind::Refinement,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: p.properties(id("shared_work")).unwrap(),
|
||||
sa: p.properties(id("sa_function")).unwrap(),
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
validate_candidate(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
fn load(path: &Path) -> Result<Project> {
|
||||
if path
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"))
|
||||
{
|
||||
let disk = source_files::load_project(path, ProjectLimits::default())?;
|
||||
// This deliberately partial engineering fixture has completeness
|
||||
// warnings (allocation/behavior context). They are not source errors.
|
||||
// Compilation, candidate validation and whole-Project equality still
|
||||
// reject malformed source or any unexpected model change.
|
||||
if disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.severity == validation::Severity::Error)
|
||||
{
|
||||
return Err(format!("Source discovery diagnostics: {:?}", disk.diagnostics).into());
|
||||
}
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
.map_err(|e| format!("Source compile diagnostics: {e:?}"))?;
|
||||
Ok(compiled.decoded.project)
|
||||
} else {
|
||||
Ok(persistence::load(path)?)
|
||||
}
|
||||
}
|
||||
fn equal(expected: &Project, actual: &Project) -> Result<()> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let e = serde_json::to_value(expected)?;
|
||||
let a = serde_json::to_value(actual)?;
|
||||
let fields: Vec<_> = e
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.filter(|key| e[*key] != a[*key])
|
||||
.cloned()
|
||||
.collect();
|
||||
Err(format!("Whole Project mismatch in {fields:?}; no partial PASS").into())
|
||||
}
|
||||
fn roundtrip(p: &Project) -> Result<SourceBundle> {
|
||||
validate_candidate(p)?;
|
||||
equal(p, &persistence::decode(&serde_json::to_vec(p)?)?)?;
|
||||
let bundle = format_bundle(p);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default())
|
||||
.map_err(|e| format!("Roundtrip: {e:?}"))?;
|
||||
equal(p, &compiled.decoded.project)?;
|
||||
Ok(bundle)
|
||||
}
|
||||
fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
fs::create_dir_all(path.parent().ok_or("Missing parent directory")?)?;
|
||||
fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path)?
|
||||
.write_all(bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
fn generate(out: &Path) -> Result<()> {
|
||||
if out.exists() {
|
||||
return Err("Output directory already exists; choose a fresh path".into());
|
||||
}
|
||||
let p = fixture()?;
|
||||
let bundle = roundtrip(&p)?;
|
||||
write_new(&out.join("baseline.json"), &serde_json::to_vec_pretty(&p)?)?;
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&out.join("source").join(path), source.as_bytes())?;
|
||||
}
|
||||
let names = [
|
||||
"crew",
|
||||
"machine",
|
||||
"region",
|
||||
"inner",
|
||||
"entry",
|
||||
"exit",
|
||||
"shared_work",
|
||||
"node_inner",
|
||||
"node_entry",
|
||||
"node_exit",
|
||||
"entry_route",
|
||||
"exit_route",
|
||||
"refresh",
|
||||
"action_a",
|
||||
"action_c",
|
||||
"other_b",
|
||||
"project",
|
||||
"state_view",
|
||||
"state_other",
|
||||
"sa_view",
|
||||
"state_a",
|
||||
"state_b",
|
||||
"state_c",
|
||||
"state_leaf",
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_c",
|
||||
"node_leaf",
|
||||
"between",
|
||||
"external",
|
||||
"trace",
|
||||
"sa_function",
|
||||
"sa_node",
|
||||
"other_a",
|
||||
];
|
||||
let ids: std::collections::BTreeMap<_, _> =
|
||||
names.into_iter().map(|name| (name, id(name))).collect();
|
||||
write_new(
|
||||
&out.join("manifest.json"),
|
||||
&serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"fixture": "state-multiselect-native-20260923", "ids": ids, "source_entry": bundle.entry,
|
||||
"move_members": ["node_a", "node_b", "node_inner", "node_leaf", "node_entry", "node_exit"],
|
||||
"translated_routes": ["between", "entry_route", "exit_route"],
|
||||
"fixed_route": "external",
|
||||
"window": [1442, 932],
|
||||
"target_headers_world": {"A": [40, 35], "B": [450, 65]},
|
||||
"delta_limits": "finite, nonzero length >=0.01, each axis <=300; no grid quantization",
|
||||
"receipt": "Pin baseline.json, manifest.json and generation command externally before interaction; baseline identity alone does not authenticate evidence", "fixed_member": "node_c",
|
||||
"evaluation": "Whole Project invariant comparison; saved A supplies bounded shared delta, not an exact pointer oracle",
|
||||
"hide": "Remove node_a/node_b only; retain nested Region/leaf/entry/exit, stored routes, all model records and other views",
|
||||
}))?,
|
||||
)?;
|
||||
equal(&p, &load(&out.join("baseline.json"))?)?;
|
||||
equal(&p, &load(&out.join("source/project.arcadia"))?)?;
|
||||
println!("PASS generated JSON and source bundle: {}", out.display());
|
||||
Ok(())
|
||||
}
|
||||
fn check(mode: &str, baseline: &Path, saved: &Path) -> Result<()> {
|
||||
let before = load(baseline)?;
|
||||
let actual = load(saved)?;
|
||||
validate_candidate(&before)?;
|
||||
validate_candidate(&actual)?;
|
||||
if before.id != id("project") {
|
||||
return Err("Not this fixture's baseline project".into());
|
||||
}
|
||||
let mut expected = before.clone();
|
||||
let mut delta = None;
|
||||
match mode {
|
||||
"unchanged" => {}
|
||||
"hide" => {
|
||||
let view = expected
|
||||
.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.ok_or("Missing State view")?;
|
||||
for alias in ["node_a", "node_b"] {
|
||||
view.nodes
|
||||
.remove(&id(alias))
|
||||
.ok_or("Baseline selected placement missing")?;
|
||||
}
|
||||
// references::remove_objects removes routes only when their semantic
|
||||
// edge is deleted. Hide deletes placements; both routes MUST survive.
|
||||
}
|
||||
"moved" => {
|
||||
let old = before
|
||||
.diagrams
|
||||
.get(&id("state_view"))
|
||||
.ok_or("Missing baseline State view")?;
|
||||
let new = actual
|
||||
.diagrams
|
||||
.get(&id("state_view"))
|
||||
.ok_or("Missing saved State view")?;
|
||||
let a = old
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing baseline A")?
|
||||
.position;
|
||||
let moved_a = new
|
||||
.nodes
|
||||
.get(&id("node_a"))
|
||||
.ok_or("Missing saved A")?
|
||||
.position;
|
||||
let d = point(moved_a.x - a.x, moved_a.y - a.y);
|
||||
if !d.x.is_finite()
|
||||
|| !d.y.is_finite()
|
||||
|| d.x.abs().max(d.y.abs()) > 300.
|
||||
|| d.x.hypot(d.y) < 0.01
|
||||
{
|
||||
return Err(format!(
|
||||
"Move delta must have length >=0.01 and each axis <=300 world units: {d:?}"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let view = expected.diagrams.get_mut(&id("state_view")).unwrap();
|
||||
// Independent explicit fixture enumeration: no group/descendant/
|
||||
// routing planner is called to predict the result.
|
||||
for alias in [
|
||||
"node_a",
|
||||
"node_b",
|
||||
"node_inner",
|
||||
"node_leaf",
|
||||
"node_entry",
|
||||
"node_exit",
|
||||
] {
|
||||
let node = view
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.ok_or("Missing expected moved placement")?;
|
||||
node.position.x += d.x;
|
||||
node.position.y += d.y;
|
||||
}
|
||||
for alias in ["between", "entry_route", "exit_route"] {
|
||||
let route = view
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.ok_or("Missing explicitly expected moved route")?;
|
||||
for pin in &mut route.waypoints {
|
||||
pin.x += d.x;
|
||||
pin.y += d.y;
|
||||
}
|
||||
}
|
||||
delta = Some(d);
|
||||
}
|
||||
_ => return Err("Mode must be moved, hide, or unchanged".into()),
|
||||
}
|
||||
equal(&expected, &actual)?;
|
||||
roundtrip(&actual)?;
|
||||
println!(
|
||||
"{}",
|
||||
serde_json::to_string_pretty(&serde_json::json!({
|
||||
"result": "PASS", "mode": mode, "baseline": baseline, "saved": saved,
|
||||
"comparison": "complete Project equality", "inferred_common_delta": delta,
|
||||
"limits": "Invariant evaluator only; no pointer/action/focus/Undo/source-byte acceptance claim",
|
||||
}))?
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<_> = std::env::args().skip(1).collect();
|
||||
match args.as_slice() {
|
||||
[command, out] if command == "generate" => generate(Path::new(out)),
|
||||
[command, mode, baseline, saved] if command == "check" => check(mode, Path::new(baseline), Path::new(saved)),
|
||||
_ => Err("Usage: state_multiselect_native generate DIRECTORY | check moved|hide|unchanged BASELINE SAVED".into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
// Produce evidence through real validated commands; the checker itself uses
|
||||
// explicit fixture enumeration and never calls this movement implementation.
|
||||
fn moved(before: &Project, delta: Position) -> Project {
|
||||
let command = diagram_edit::translate_placements(
|
||||
before,
|
||||
id("state_view"),
|
||||
&BTreeSet::from([id("node_a"), id("node_b")]),
|
||||
delta,
|
||||
)
|
||||
.unwrap();
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command).unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn hidden(before: &Project) -> Project {
|
||||
let mut session = Session::new(before.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("node_a")),
|
||||
Command::Delete(id("node_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
session.project().clone()
|
||||
}
|
||||
fn through_saved_json(mode: &str, baseline: &Project, actual: &Project) -> Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let before = dir.path().join("before.json");
|
||||
let saved = dir.path().join("saved.json");
|
||||
// These assertions distinguish meaningful invariant corruptions from
|
||||
// malformed projects rejected before the evaluator even sees them.
|
||||
validate_candidate(baseline).expect("test baseline must be valid");
|
||||
validate_candidate(actual).expect("corruption must remain a valid model");
|
||||
persistence::save(&before, baseline)?;
|
||||
persistence::save(&saved, actual)?;
|
||||
check(mode, &before, &saved)
|
||||
}
|
||||
fn mismatch(mode: &str, before: &Project, actual: &Project, case: &str) {
|
||||
let error = through_saved_json(mode, before, actual)
|
||||
.expect_err(case)
|
||||
.to_string();
|
||||
assert!(
|
||||
error.contains("Whole Project mismatch"),
|
||||
"{case}: unexpected rejection: {error}"
|
||||
);
|
||||
}
|
||||
fn edge<'a>(p: &'a mut Project, alias: &str) -> &'a mut EdgeRoute {
|
||||
p.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == id(alias))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_json_and_real_source_bundle_are_accepted_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let out = dir.path().join("fixture");
|
||||
generate(&out).unwrap();
|
||||
let baseline = out.join("baseline.json");
|
||||
let saved = out.join("saved.json");
|
||||
persistence::save(&saved, &fixture().unwrap()).unwrap();
|
||||
check("unchanged", &baseline, &saved).unwrap();
|
||||
check("unchanged", &baseline, &out.join("source/project.arcadia")).unwrap();
|
||||
let bytes = fs::read(&baseline).unwrap();
|
||||
assert!(
|
||||
generate(&out).is_err(),
|
||||
"generation cannot overwrite evidence"
|
||||
);
|
||||
assert_eq!(fs::read(&baseline).unwrap(), bytes);
|
||||
let invalid = dir.path().join("invalid.arcadia");
|
||||
fs::write(&invalid, "project {").unwrap();
|
||||
assert!(load(&invalid).is_err(), "source errors must still reject");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_group_command_outputs_pass_and_are_not_unchanged() {
|
||||
let before = fixture().unwrap();
|
||||
for delta in [
|
||||
point(7.25, -2.75),
|
||||
point(-20.5, 30.125),
|
||||
point(0.25, 0.),
|
||||
point(0.01, 10.),
|
||||
point(0.1, -0.2),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
through_saved_json("moved", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real movement cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_hide_command_outputs_pass_before_and_after_movement() {
|
||||
let initial = fixture().unwrap();
|
||||
for before in [initial.clone(), moved(&initial, point(40., 60.))] {
|
||||
let actual = hidden(&before);
|
||||
through_saved_json("hide", &before, &actual).unwrap();
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"real Hide cannot count as unchanged",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_and_double_moved_child_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let delta = point(40., 60.);
|
||||
let correct = moved(&before, delta);
|
||||
for alias in ["node_inner", "node_leaf", "node_entry", "node_exit"] {
|
||||
let initial = before.diagrams[&id("state_view")].nodes[&id(alias)].position;
|
||||
for position in [
|
||||
initial,
|
||||
point(initial.x + 2. * delta.x, initial.y + 2. * delta.y),
|
||||
] {
|
||||
let mut bad = correct.clone();
|
||||
bad.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(alias))
|
||||
.unwrap()
|
||||
.position = position;
|
||||
mismatch("moved", &before, &bad, "child did not move exactly once");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_pins_left_behind_and_external_pins_moved_are_rejected() {
|
||||
let mut before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
for alias in ["between", "entry_route", "exit_route"] {
|
||||
let original_pins = edge(&mut before, alias).waypoints.clone();
|
||||
let mut bad = correct.clone();
|
||||
edge(&mut bad, alias).waypoints = original_pins;
|
||||
mismatch("moved", &before, &bad, "internal pins left behind");
|
||||
}
|
||||
let mut bad = correct;
|
||||
for pin in &mut edge(&mut bad, "external").waypoints {
|
||||
pin.x += 40.;
|
||||
pin.y += 60.;
|
||||
}
|
||||
mismatch("moved", &before, &bad, "one-ended pins moved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manual_attachments_and_label_data_must_remain_exact() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut EdgeRoute); 4] = [
|
||||
|r| r.source_attachment.as_mut().unwrap().offset = 0.65,
|
||||
|r| r.target_attachment.as_mut().unwrap().side = PortAnchorSide::Top,
|
||||
|r| r.label.as_mut().unwrap().offset.x += 40.,
|
||||
|r| r.label.as_mut().unwrap().fraction = 0.75,
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(edge(&mut bad, "between"));
|
||||
mismatch("moved", &before, &bad, "relative route metadata changed");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_other_view_fixed_node_and_trace_drift_are_rejected() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = moved(&before, point(40., 60.));
|
||||
let mutations: [fn(&mut Project); 9] = [
|
||||
|p| p.traces.get_mut(&id("trace")).unwrap().baseline = None,
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("shared_work"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
if let RelationBody::StateAction { phase, .. } =
|
||||
&mut p.relations.get_mut(&id("action_a")).unwrap().body
|
||||
{
|
||||
*phase = StateActionKind::Exit;
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
if let RelationBody::StateTransition { guard, .. } =
|
||||
&mut p.relations.get_mut(&id("refresh")).unwrap().body
|
||||
{
|
||||
*guard = "changed_refresh_guard".into();
|
||||
}
|
||||
},
|
||||
|p| {
|
||||
p.elements
|
||||
.get_mut(&id("state_a"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("state_other"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("other_a"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("sa_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("sa_node"))
|
||||
.unwrap()
|
||||
.position
|
||||
.y += 60.
|
||||
},
|
||||
|p| {
|
||||
p.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id("node_c"))
|
||||
.unwrap()
|
||||
.position
|
||||
.x += 40.
|
||||
},
|
||||
|p| {
|
||||
p.traces
|
||||
.get_mut(&id("trace"))
|
||||
.unwrap()
|
||||
.description
|
||||
.push_str(" drift")
|
||||
},
|
||||
];
|
||||
for mutate in mutations {
|
||||
let mut bad = correct.clone();
|
||||
mutate(&mut bad);
|
||||
mismatch(
|
||||
"moved",
|
||||
&before,
|
||||
&bad,
|
||||
"record outside permitted movement changed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_and_excessive_moves_cannot_satisfy_move_evidence() {
|
||||
let before = fixture().unwrap();
|
||||
for (delta, reason) in [
|
||||
(point(0., 0.), "length >=0.01"),
|
||||
(point(310., 0.), "each axis <=300"),
|
||||
] {
|
||||
let actual = moved(&before, delta);
|
||||
let error = through_saved_json("moved", &before, &actual)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains(reason), "{delta:?}: {error}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_must_preserve_child_routes_and_semantic_objects() {
|
||||
let before = fixture().unwrap();
|
||||
let correct = hidden(&before);
|
||||
let mut missing_child = correct.clone();
|
||||
missing_child
|
||||
.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&id("node_leaf"));
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_child,
|
||||
"Hide removed unselected child placement",
|
||||
);
|
||||
let mut missing_routes = correct;
|
||||
missing_routes
|
||||
.diagrams
|
||||
.get_mut(&id("state_view"))
|
||||
.unwrap()
|
||||
.routes
|
||||
.clear();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
&missing_routes,
|
||||
"Hide removed persistent route records",
|
||||
);
|
||||
let mut semantic_delete = Session::new(before.clone());
|
||||
semantic_delete
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Delete(id("state_a")),
|
||||
Command::Delete(id("state_b")),
|
||||
]))
|
||||
.unwrap();
|
||||
mismatch(
|
||||
"hide",
|
||||
&before,
|
||||
semantic_delete.project(),
|
||||
"Hide became semantic deletion",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unchanged_rejects_valid_semantic_drift_without_geometry_changes() {
|
||||
let before = fixture().unwrap();
|
||||
let mut actual = before.clone();
|
||||
actual
|
||||
.elements
|
||||
.get_mut(&id("state_b"))
|
||||
.unwrap()
|
||||
.name
|
||||
.push_str(" edited");
|
||||
mismatch(
|
||||
"unchanged",
|
||||
&before,
|
||||
&actual,
|
||||
"geometry equality is not complete Project equality",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_and_hidden_source_inputs_are_checked_against_complete_expected_projects() {
|
||||
let before = fixture().unwrap();
|
||||
for (mode, actual) in [
|
||||
("moved", moved(&before, point(7.25, -2.75))),
|
||||
("hide", hidden(&before)),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let baseline = dir.path().join("baseline.json");
|
||||
persistence::save(&baseline, &before).unwrap();
|
||||
let bundle = roundtrip(&actual).unwrap();
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&dir.path().join("saved").join(path), source.as_bytes()).unwrap();
|
||||
}
|
||||
check(
|
||||
mode,
|
||||
&baseline,
|
||||
&dir.path().join("saved").join(&bundle.entry),
|
||||
)
|
||||
.unwrap();
|
||||
let mut corrupt = actual.clone();
|
||||
corrupt.elements.get_mut(&id("shared_work")).unwrap().name =
|
||||
"Unrequested source edit".into();
|
||||
let bundle = roundtrip(&corrupt).unwrap();
|
||||
for (path, source) in &bundle.files {
|
||||
write_new(&dir.path().join("corrupt").join(path), source.as_bytes()).unwrap();
|
||||
}
|
||||
let error = check(
|
||||
mode,
|
||||
&baseline,
|
||||
&dir.path().join("corrupt").join(&bundle.entry),
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(error.contains("Whole Project mismatch"), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Pure diagram edits shared by movement previews and committed transactions.
|
||||
use crate::{Command, Id, MAX_COORDINATE, ModelError, Position, Project, projection};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
/// Translate selected placements and their projected descendants simultaneously.
|
||||
///
|
||||
/// Membership and geometry are read from one snapshot, including folded children.
|
||||
/// Overlapping roots move each placement once. Route pins follow the combined
|
||||
/// movement of their actual endpoint owners (and State boundaries). Semantic
|
||||
/// ownership and other views are untouched. Apply the returned batch through the
|
||||
/// normal validated command path; its selected identity is not a primary selection.
|
||||
pub fn translate_placements(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
selected: &BTreeSet<Id>,
|
||||
delta: Position,
|
||||
) -> Result<Command, ModelError> {
|
||||
let view = project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| ModelError("Movement requires an existing diagram.".into()))?;
|
||||
if selected.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Movement requires at least one placement.".into(),
|
||||
));
|
||||
}
|
||||
if selected.iter().any(|id| !view.nodes.contains_key(id)) {
|
||||
return Err(ModelError(
|
||||
"Every selected placement must belong to the diagram.".into(),
|
||||
));
|
||||
}
|
||||
if !delta.x.is_finite() || !delta.y.is_finite() {
|
||||
return Err(ModelError("Movement displacement must be finite.".into()));
|
||||
}
|
||||
if delta.x == 0. && delta.y == 0. {
|
||||
return Ok(Command::Batch(vec![]));
|
||||
}
|
||||
let mut children: BTreeMap<Id, Vec<Id>> = BTreeMap::new();
|
||||
for (child, parent) in projection::parents(project, view) {
|
||||
if let Some(parent) = parent {
|
||||
children.entry(parent).or_default().push(child);
|
||||
}
|
||||
}
|
||||
let mut moved = BTreeSet::new();
|
||||
let mut pending: Vec<_> = selected.iter().copied().collect();
|
||||
while let Some(id) = pending.pop() {
|
||||
if moved.insert(id) {
|
||||
pending.extend(children.get(&id).into_iter().flatten().copied());
|
||||
}
|
||||
}
|
||||
let deltas: BTreeMap<_, _> = moved.iter().map(|id| (*id, delta)).collect();
|
||||
let routes = projection::translated_routes(project, view, &deltas);
|
||||
let bounded = |value: f32| value.is_finite() && value.abs() <= MAX_COORDINATE;
|
||||
let mut commands = Vec::with_capacity(moved.len() + routes.len());
|
||||
for id in moved {
|
||||
let mut node = view.nodes[&id].clone();
|
||||
node.position.x += delta.x;
|
||||
node.position.y += delta.y;
|
||||
if ![
|
||||
node.position.x,
|
||||
node.position.y,
|
||||
node.size.width,
|
||||
node.size.height,
|
||||
]
|
||||
.into_iter()
|
||||
.all(bounded)
|
||||
|| node.size.width <= 0.
|
||||
|| node.size.height <= 0.
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Movement produces invalid placement bounds.".into(),
|
||||
));
|
||||
}
|
||||
commands.push(Command::EditNode {
|
||||
diagram,
|
||||
original: id,
|
||||
node,
|
||||
});
|
||||
}
|
||||
for route in routes {
|
||||
if route
|
||||
.waypoints
|
||||
.iter()
|
||||
.any(|point| !bounded(point.x) || !bounded(point.y))
|
||||
{
|
||||
return Err(ModelError("Movement produces invalid route bounds.".into()));
|
||||
}
|
||||
commands.push(Command::SetEdgeRoute { diagram, route });
|
||||
}
|
||||
Ok(Command::Batch(commands))
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod diagram_edit;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod operations;
|
||||
|
||||
@@ -1502,11 +1502,45 @@ pub struct Diagram {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeRoute {
|
||||
pub edge: Id,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub source_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub target_attachment: Option<EdgeAttachment>,
|
||||
#[serde(default)]
|
||||
pub waypoints: Vec<Position>,
|
||||
#[serde(default)]
|
||||
pub label: Option<EdgeLabel>,
|
||||
}
|
||||
|
||||
/// Presentation preference on one end of a connection. Real ports take precedence.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeAttachment {
|
||||
pub side: PortAnchorSide,
|
||||
pub offset: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum EdgeEnd {
|
||||
Source,
|
||||
Target,
|
||||
}
|
||||
|
||||
impl EdgeRoute {
|
||||
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment,
|
||||
EdgeEnd::Target => self.target_attachment,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
|
||||
match end {
|
||||
EdgeEnd::Source => self.source_attachment = attachment,
|
||||
EdgeEnd::Target => self.target_attachment = attachment,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct EdgeLabel {
|
||||
|
||||
+132
-38
@@ -1,7 +1,12 @@
|
||||
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
|
||||
//! callers share this derived geometry for drawing, picking, fitting and export.
|
||||
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
|
||||
use crate::{
|
||||
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
|
||||
RelationBody, RelationKind, Size,
|
||||
};
|
||||
mod attachments;
|
||||
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
|
||||
use std::fmt;
|
||||
@@ -41,6 +46,7 @@ pub struct RoutedEdge {
|
||||
// Derived solver state, never persisted. A preview must inherit the same
|
||||
// remaining work budget as this edge had in stable-ID settled traversal.
|
||||
search_budget_before: usize,
|
||||
attachments: [Option<EdgeAttachment>; 2],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
@@ -173,6 +179,7 @@ pub fn association_end_geometry(
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RouteWarning {
|
||||
CoincidentAttachments,
|
||||
PinInsideObstacle { pin: usize, obstacle: Id },
|
||||
NoClearPath,
|
||||
SearchBudgetExhausted,
|
||||
@@ -181,6 +188,9 @@ pub enum RouteWarning {
|
||||
impl fmt::Display for RouteWarning {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::CoincidentAttachments => f.write_str(
|
||||
"The two attachments coincide; move either end to separate the loop tips.",
|
||||
),
|
||||
Self::PinInsideObstacle { pin, .. } => {
|
||||
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
|
||||
}
|
||||
@@ -374,7 +384,14 @@ struct Endpoint {
|
||||
point: Position,
|
||||
stub: Position,
|
||||
}
|
||||
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
|
||||
fn endpoints(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
port: Option<Id>,
|
||||
view: &ProjectedView,
|
||||
attachment: Option<EdgeAttachment>,
|
||||
) -> Vec<Endpoint> {
|
||||
if let Some(port) = port.and_then(|id| {
|
||||
view.ports
|
||||
.iter()
|
||||
@@ -389,26 +406,18 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
|
||||
),
|
||||
}];
|
||||
}
|
||||
let r = Rect::node(node);
|
||||
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
|
||||
vec![
|
||||
Endpoint {
|
||||
point: pos(r.left, mid.y),
|
||||
stub: pos(r.left - PORT_STUB, mid.y),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(r.right, mid.y),
|
||||
stub: pos(r.right + PORT_STUB, mid.y),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(mid.x, r.top),
|
||||
stub: pos(mid.x, r.top - PORT_STUB),
|
||||
},
|
||||
Endpoint {
|
||||
point: pos(mid.x, r.bottom),
|
||||
stub: pos(mid.x, r.bottom + PORT_STUB),
|
||||
},
|
||||
if let Some(attachment) = attachment {
|
||||
return vec![attachments::endpoint(project, diagram, node, attachment)];
|
||||
}
|
||||
[
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
|
||||
@@ -507,6 +516,12 @@ fn route_diagram_impl(
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
|
||||
let custom_suspended = promoted;
|
||||
let route = if promoted { None } else { stored };
|
||||
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
|
||||
semantic_port_endpoint(project, edge.id, end)
|
||||
.is_none()
|
||||
.then(|| route.and_then(|r| r.attachment(end)))
|
||||
.flatten()
|
||||
});
|
||||
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
|
||||
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
|
||||
|
||||
@@ -517,7 +532,10 @@ fn route_diagram_impl(
|
||||
});
|
||||
if let Some(cached) = cached {
|
||||
budget = cached.search_budget_before;
|
||||
if cached.pins == pins && cached.custom_suspended == custom_suspended {
|
||||
if cached.pins == pins
|
||||
&& cached.attachments == attachments
|
||||
&& cached.custom_suspended == custom_suspended
|
||||
{
|
||||
// Label-only editing cannot change the connection beneath the
|
||||
// pointer, including a settled fallback with routing warnings.
|
||||
for &point in &cached.points {
|
||||
@@ -543,9 +561,22 @@ fn route_diagram_impl(
|
||||
}
|
||||
let (points, pin_lengths, failure) = route_edge(
|
||||
edge,
|
||||
source,
|
||||
target,
|
||||
view,
|
||||
&endpoints(
|
||||
project,
|
||||
diagram,
|
||||
source,
|
||||
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
|
||||
view,
|
||||
attachments[0],
|
||||
),
|
||||
&endpoints(
|
||||
project,
|
||||
diagram,
|
||||
target,
|
||||
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
|
||||
view,
|
||||
attachments[1],
|
||||
),
|
||||
&pins,
|
||||
&obstacles,
|
||||
&previous,
|
||||
@@ -574,6 +605,7 @@ fn route_diagram_impl(
|
||||
warnings,
|
||||
custom_suspended,
|
||||
search_budget_before,
|
||||
attachments,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -679,6 +711,46 @@ struct RoutingSpace<'a> {
|
||||
xs: Vec<f32>,
|
||||
ys: Vec<f32>,
|
||||
}
|
||||
|
||||
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
|
||||
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
|
||||
let normal = pos(
|
||||
direction(a.stub.x - a.point.x),
|
||||
direction(a.stub.y - a.point.y),
|
||||
);
|
||||
let tangent = pos(-normal.y, normal.x);
|
||||
let candidates: Vec<_> = [36., 72., 144., 288.]
|
||||
.into_iter()
|
||||
.flat_map(|extent| {
|
||||
[-1., 1.].into_iter().map(move |side| {
|
||||
[
|
||||
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
|
||||
pos(
|
||||
a.stub.x + (normal.x + tangent.x * side) * extent,
|
||||
a.stub.y + (normal.y + tangent.y * side) * extent,
|
||||
),
|
||||
pos(
|
||||
a.stub.x + tangent.x * side * extent,
|
||||
a.stub.y + tangent.y * side * extent,
|
||||
),
|
||||
]
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// These are computed loop guards, not authored hard pins. Prefer a clear
|
||||
// orientation/size before asking the bounded router to connect them.
|
||||
candidates
|
||||
.iter()
|
||||
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
|
||||
.or_else(|| {
|
||||
candidates.iter().find(|p| {
|
||||
p.iter()
|
||||
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
|
||||
})
|
||||
})
|
||||
.copied()
|
||||
.unwrap_or(candidates[0])
|
||||
}
|
||||
impl<'a> RoutingSpace<'a> {
|
||||
fn new(obstacles: &'a [Rect]) -> Self {
|
||||
Self {
|
||||
@@ -692,24 +764,41 @@ impl<'a> RoutingSpace<'a> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn route_edge(
|
||||
edge: &ProjectedEdge,
|
||||
source: &ProjectedNode,
|
||||
target: &ProjectedNode,
|
||||
view: &ProjectedView,
|
||||
source_ends: &[Endpoint],
|
||||
target_ends: &[Endpoint],
|
||||
pins: &[Position],
|
||||
obstacles: &[Rect],
|
||||
previous: &[Segment],
|
||||
budget: &mut usize,
|
||||
) -> EdgePath {
|
||||
// Coincident authored tips are valid presentation, including on self loops.
|
||||
// Preserve them and add an external detour; never index an empty candidate
|
||||
// list or turn the connection into an out-and-back degenerate stub.
|
||||
let coincident = edge.source == edge.target
|
||||
&& source_ends.len() == 1
|
||||
&& target_ends.len() == 1
|
||||
&& source_ends[0].point == target_ends[0].point
|
||||
&& source_ends[0].stub == target_ends[0].stub;
|
||||
let authored_pin_count = pins.len();
|
||||
let mut loop_pins = Vec::new();
|
||||
if coincident {
|
||||
let a = source_ends[0];
|
||||
loop_pins.extend_from_slice(pins);
|
||||
loop_pins.extend(coincident_detour(a, obstacles));
|
||||
}
|
||||
let pins = if coincident { &loop_pins[..] } else { pins };
|
||||
let space = RoutingSpace::new(obstacles);
|
||||
let mut candidates = Vec::new();
|
||||
for a in endpoints(source, edge.source_port, view) {
|
||||
for b in endpoints(target, edge.target_port, view) {
|
||||
for &a in source_ends {
|
||||
for &b in target_ends {
|
||||
// A real self-relation must leave and return at distinct anchors.
|
||||
// Reusing one anchor makes the cheapest path a degenerate stub.
|
||||
if edge.source == edge.target
|
||||
&& edge.source_port.is_none()
|
||||
&& edge.target_port.is_none()
|
||||
&& a.point == b.point
|
||||
&& a.stub == b.stub
|
||||
&& !coincident
|
||||
{
|
||||
continue;
|
||||
}
|
||||
@@ -729,10 +818,9 @@ fn route_edge(
|
||||
// Try all side combinations cheaply before spending the bounded grid budget.
|
||||
for search in [false, true] {
|
||||
for &(estimate, a, b) in &candidates {
|
||||
if best
|
||||
.as_ref()
|
||||
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
|
||||
{
|
||||
if best.as_ref().is_some_and(|(cost, _, _)| {
|
||||
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
if obstacles
|
||||
@@ -748,7 +836,7 @@ fn route_edge(
|
||||
let mut occupied = previous.to_vec();
|
||||
record_segments(&mut occupied, &points);
|
||||
let mut pin_lengths = Vec::new();
|
||||
let mut distance = PORT_STUB;
|
||||
let mut distance = manhattan(a.point, a.stub);
|
||||
let mut start = a.stub;
|
||||
let mut failed = false;
|
||||
for (index, end) in pins
|
||||
@@ -792,15 +880,20 @@ fn route_edge(
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some((_, points, pin_lengths)) = best {
|
||||
return (points, pin_lengths, None);
|
||||
if let Some((_, points, mut pin_lengths)) = best {
|
||||
pin_lengths.truncate(authored_pin_count);
|
||||
return (
|
||||
points,
|
||||
pin_lengths,
|
||||
coincident.then_some(RouteWarning::CoincidentAttachments),
|
||||
);
|
||||
}
|
||||
// Recoverable fallback retains every hard pin, in order, and the actual port
|
||||
// leads. Its warning is deliberately visible to consumers.
|
||||
let (_, a, b) = candidates[0];
|
||||
let mut points = vec![a.point, a.stub];
|
||||
let mut pin_lengths = Vec::new();
|
||||
let mut distance = PORT_STUB;
|
||||
let mut distance = manhattan(a.point, a.stub);
|
||||
let mut start = a.stub;
|
||||
for (index, end) in pins
|
||||
.iter()
|
||||
@@ -833,6 +926,7 @@ fn route_edge(
|
||||
start = end;
|
||||
}
|
||||
points.push(b.point);
|
||||
pin_lengths.truncate(authored_pin_count);
|
||||
(
|
||||
points,
|
||||
pin_lengths,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! One boundary definition shared by persisted routing and attachment gestures.
|
||||
use super::*;
|
||||
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
|
||||
|
||||
/// Semantic authority, independent of whether projection currently exposes a port.
|
||||
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
|
||||
let endpoint = if let Some(relation) = project.relations.get(&edge) {
|
||||
match (&relation.body, end) {
|
||||
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
|
||||
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
|
||||
(_, EdgeEnd::Source) => relation.source(),
|
||||
(_, EdgeEnd::Target) => relation.target(),
|
||||
}
|
||||
} else {
|
||||
let trace = project.traces.get(&edge)?;
|
||||
match end {
|
||||
EdgeEnd::Source => trace.oa,
|
||||
EdgeEnd::Target => trace.sa,
|
||||
}
|
||||
};
|
||||
project
|
||||
.elements
|
||||
.get(&endpoint)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.map(|_| endpoint)
|
||||
}
|
||||
|
||||
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
|
||||
/// measured over the complete projected bounding side, including its corners.
|
||||
/// Terminate's X uses its outermost stroke intersection rather than a contour.
|
||||
pub fn attachment_point(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
attachment: &EdgeAttachment,
|
||||
) -> Position {
|
||||
let vertical = matches!(
|
||||
attachment.side,
|
||||
PortAnchorSide::Left | PortAnchorSide::Right
|
||||
);
|
||||
let (extent, depth) = if vertical {
|
||||
(node.size.height, node.size.width)
|
||||
} else {
|
||||
(node.size.width, node.size.height)
|
||||
};
|
||||
let along = attachment.offset.clamp(0., 1.) * extent;
|
||||
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
|
||||
let kind = diagram
|
||||
.projection
|
||||
.state_machine()
|
||||
.and_then(|_| project.elements.get(&node.element))
|
||||
.map(|e| e.kind());
|
||||
let inset = match kind {
|
||||
Some(Kind::Choice) => depth * 0.5 * t.abs(),
|
||||
Some(Kind::Terminate) => {
|
||||
depth
|
||||
* attachment
|
||||
.offset
|
||||
.clamp(0., 1.)
|
||||
.min(1. - attachment.offset.clamp(0., 1.))
|
||||
}
|
||||
Some(
|
||||
Kind::Initial
|
||||
| Kind::Junction
|
||||
| Kind::Final
|
||||
| Kind::ShallowHistory
|
||||
| Kind::DeepHistory
|
||||
| Kind::EntryPoint
|
||||
| Kind::ExitPoint,
|
||||
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
|
||||
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
|
||||
_ => {
|
||||
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
|
||||
let corner = (radius - along.min(extent - along)).max(0.);
|
||||
radius - (radius * radius - corner * corner).max(0.).sqrt()
|
||||
}
|
||||
};
|
||||
match attachment.side {
|
||||
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
|
||||
PortAnchorSide::Right => pos(
|
||||
node.position.x + node.size.width - inset,
|
||||
node.position.y + along,
|
||||
),
|
||||
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
|
||||
PortAnchorSide::Bottom => pos(
|
||||
node.position.x + along,
|
||||
node.position.y + node.size.height - inset,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Choose a cardinal bounding side, then map its fraction onto the painted
|
||||
/// contour. Contour distances cannot distinguish the sides of a circle at its
|
||||
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
|
||||
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
|
||||
/// rounds to hundredths: source/API values retain their complete precision.
|
||||
pub fn attachment_from_point(
|
||||
node: &ProjectedNode,
|
||||
cursor: Position,
|
||||
previous: Option<PortAnchorSide>,
|
||||
hysteresis: f32,
|
||||
) -> EdgeAttachment {
|
||||
let candidate = |side| EdgeAttachment {
|
||||
side,
|
||||
offset: projection::port_anchor_offset(node, side, cursor),
|
||||
};
|
||||
// Measure side intent at the box, not from a distant overshoot: Euclidean
|
||||
// distance differences converge outside a small symbol and would otherwise
|
||||
// let hysteresis retain a perpendicular side indefinitely.
|
||||
let boundary_cursor = pos(
|
||||
cursor
|
||||
.x
|
||||
.clamp(node.position.x, node.position.x + node.size.width),
|
||||
cursor
|
||||
.y
|
||||
.clamp(node.position.y, node.position.y + node.size.height),
|
||||
);
|
||||
let distance = |side| {
|
||||
length(
|
||||
projection::port_anchor_position(node, side, candidate(side).offset),
|
||||
boundary_cursor,
|
||||
)
|
||||
};
|
||||
let mut side = [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
|
||||
.unwrap();
|
||||
let hysteresis = hysteresis
|
||||
.max(0.)
|
||||
.min(node.size.width.min(node.size.height) * 0.2);
|
||||
if let Some(old) = previous
|
||||
&& distance(old) <= distance(side) + hysteresis
|
||||
{
|
||||
side = old;
|
||||
}
|
||||
let mut attachment = candidate(side);
|
||||
attachment.offset = (attachment.offset * 100.).round() / 100.;
|
||||
attachment
|
||||
}
|
||||
|
||||
pub(super) fn endpoint(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
node: &ProjectedNode,
|
||||
attachment: EdgeAttachment,
|
||||
) -> Endpoint {
|
||||
let point = attachment_point(project, diagram, node, &attachment);
|
||||
let stub = match attachment.side {
|
||||
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
|
||||
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
|
||||
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
|
||||
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
|
||||
};
|
||||
Endpoint { point, stub }
|
||||
}
|
||||
@@ -93,6 +93,18 @@ fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
Context::Diagram => {
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
|
||||
route
|
||||
.entry("source_attachment".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
route
|
||||
.entry("target_attachment".to_owned())
|
||||
.or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
}
|
||||
Context::Node => {
|
||||
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
|
||||
}
|
||||
@@ -244,7 +256,12 @@ fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
|
||||
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
|
||||
for route in routes {
|
||||
if let Some(route) = route.as_object_mut() {
|
||||
omit_defaults(route, &object(json!({"waypoints": [], "label": null})));
|
||||
omit_defaults(
|
||||
route,
|
||||
&object(
|
||||
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,6 +643,20 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
let mut routed_edges = BTreeSet::new();
|
||||
for (index, route) in d.routes.iter().enumerate() {
|
||||
for attachment in [route.source_attachment, route.target_attachment]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
|
||||
error(
|
||||
&mut out,
|
||||
d.id,
|
||||
"invalid_attachment_offset",
|
||||
"routes",
|
||||
"Connection attachment offset must be finite and between zero and one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if !routed_edges.insert(route.edge) {
|
||||
error(
|
||||
&mut out,
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
use oa_core::{projection::*, routing::*, *};
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
|
||||
EdgeAttachment { side, offset }
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Report", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.place(d, a, pos(50., 50.));
|
||||
p.place(d, b, pos(750., 200.));
|
||||
(p, d, a, b, edge)
|
||||
}
|
||||
fn stored(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
}
|
||||
}
|
||||
fn routed(p: &Project, d: Id) -> RoutedView {
|
||||
let diagram = &p.diagrams[&d];
|
||||
route_diagram(p, diagram, &project_diagram(p, diagram))
|
||||
}
|
||||
fn near(a: f32, b: f32) {
|
||||
assert!((a - b).abs() < 0.002, "{a} != {b}");
|
||||
}
|
||||
fn orthogonal(points: &[Position]) {
|
||||
assert!(points.len() >= 2);
|
||||
for span in points.windows(2) {
|
||||
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
|
||||
}
|
||||
}
|
||||
fn length(points: &[Position]) -> f32 {
|
||||
points
|
||||
.windows(2)
|
||||
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
|
||||
.sum()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
|
||||
let (mut p, d, a, _, edge) = fixture();
|
||||
let initial = routed(&p, d);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
|
||||
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
|
||||
route.waypoints = vec![pos(460., 350.)];
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
|
||||
let full = routed(&p, d);
|
||||
assert_eq!(
|
||||
preview, full,
|
||||
"attachment-only changes must invalidate the label-only path cache"
|
||||
);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
assert_eq!(
|
||||
full.edges[&edge].points[0],
|
||||
pos(
|
||||
n.position.x + n.size.width * 0.25,
|
||||
n.position.y + n.size.height
|
||||
)
|
||||
);
|
||||
assert_eq!(full.edges[&edge].pins, route.waypoints);
|
||||
orthogonal(&full.edges[&edge].points);
|
||||
let node = p
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == a)
|
||||
.unwrap();
|
||||
node.position.x += 75.;
|
||||
node.size.width += 100.;
|
||||
node.size.height += 50.;
|
||||
let moved = routed(&p, d);
|
||||
near(
|
||||
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
|
||||
100.,
|
||||
);
|
||||
near(
|
||||
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
|
||||
50.,
|
||||
);
|
||||
assert_eq!(p.diagrams[&d].routes[0], route);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
|
||||
let (mut p, d, _, _, edge) = fixture();
|
||||
let baseline = routed(&p, d);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
|
||||
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
|
||||
assert_eq!(preview, routed(&p, d));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, a, pos(0., 0.));
|
||||
p.place(d, b, pos(600., 0.));
|
||||
let diagram = p.diagrams[&d].clone();
|
||||
let view = project_diagram(&p, &diagram);
|
||||
let expected = route_diagram(&p, &diagram, &view);
|
||||
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
|
||||
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
assert_eq!(routed(&p, d), expected);
|
||||
let mut hidden = view.clone();
|
||||
hidden.ports.clear();
|
||||
for e in &mut hidden.edges {
|
||||
e.source_port = None;
|
||||
e.target_port = None;
|
||||
}
|
||||
let absent = route_diagram(&p, &diagram, &hidden);
|
||||
assert_eq!(
|
||||
route_diagram(&p, &p.diagrams[&d], &hidden),
|
||||
absent,
|
||||
"hidden ports must not activate a dormant node preference"
|
||||
);
|
||||
}
|
||||
|
||||
fn state(kind: Kind) -> (Project, 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, Layer::Oa, "A", Some(region)).unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::State, Layer::Oa, "B", Some(region))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
p.place(d, a, pos(100., 100.));
|
||||
p.place(d, b, pos(600., 100.));
|
||||
(p, d, a, edge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
|
||||
for kind in [
|
||||
Kind::Initial,
|
||||
Kind::Junction,
|
||||
Kind::Final,
|
||||
Kind::Choice,
|
||||
Kind::ShallowHistory,
|
||||
Kind::DeepHistory,
|
||||
Kind::EntryPoint,
|
||||
Kind::ExitPoint,
|
||||
Kind::Terminate,
|
||||
Kind::Fork,
|
||||
Kind::Join,
|
||||
Kind::State,
|
||||
] {
|
||||
let (p, d, a, _) = state(kind);
|
||||
let diagram = &p.diagrams[&d];
|
||||
let view = project_diagram(&p, diagram);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
for offset in [0., 0.2, 0.5, 0.8, 1.] {
|
||||
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
|
||||
let x = (at.x - n.position.x) / n.size.width;
|
||||
let y = (at.y - n.position.y) / n.size.height;
|
||||
match kind {
|
||||
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
|
||||
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
|
||||
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
|
||||
Kind::State => {
|
||||
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
|
||||
if offset == 0. || offset == 1. {
|
||||
assert!(
|
||||
x != 0. && x != 1. || y != 0. && y != 1.,
|
||||
"rounded corner cannot use bounding-box corner"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
|
||||
let (mut p, d, _, edge) = state(Kind::Choice);
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
|
||||
route.waypoints = vec![pos(380., 40.)];
|
||||
route.label = Some(EdgeLabel {
|
||||
fraction: 0.3,
|
||||
offset: pos(0., 0.),
|
||||
});
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
orthogonal(&r.points);
|
||||
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
|
||||
let pin = r.pins[0];
|
||||
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
|
||||
near(
|
||||
r.pin_fractions[0],
|
||||
length(&r.points[..=pin_index]) / length(&r.points),
|
||||
);
|
||||
assert_eq!(
|
||||
r.label.anchor,
|
||||
point_along_polyline(&r.points, 0.3).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
|
||||
for kind in [
|
||||
Kind::Initial,
|
||||
Kind::Choice,
|
||||
Kind::Terminate,
|
||||
Kind::Fork,
|
||||
Kind::State,
|
||||
] {
|
||||
let (p, d, a, _) = state(kind);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for overshoot in [0., 20.] {
|
||||
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
|
||||
let chosen =
|
||||
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
|
||||
assert_eq!(
|
||||
chosen,
|
||||
anchor(PortAnchorSide::Top, 0.5),
|
||||
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
|
||||
);
|
||||
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
|
||||
near(painted.x, n.position.x + n.size.width / 2.);
|
||||
near(
|
||||
painted.y,
|
||||
n.position.y
|
||||
+ if kind == Kind::Terminate {
|
||||
n.size.height / 2.
|
||||
} else {
|
||||
0.
|
||||
},
|
||||
);
|
||||
}
|
||||
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
|
||||
let stable =
|
||||
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
|
||||
assert_eq!(
|
||||
stable.side,
|
||||
PortAnchorSide::Right,
|
||||
"corner hysteresis stays stable"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
|
||||
let (mut p, d, a, edge) = state(Kind::State);
|
||||
if let RelationBody::StateTransition { target, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*target = a;
|
||||
}
|
||||
for pins in [vec![], vec![pos(400., 30.)]] {
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
|
||||
route.target_attachment = route.source_attachment;
|
||||
route.waypoints = pins.clone();
|
||||
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
orthogonal(&r.points);
|
||||
assert_eq!(r.points.first(), r.points.last());
|
||||
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
|
||||
assert_eq!(r.pins, pins);
|
||||
assert_eq!(r.pin_fractions.len(), pins.len());
|
||||
assert!(
|
||||
r.warnings.contains(&RouteWarning::CoincidentAttachments),
|
||||
"{:?}",
|
||||
r.warnings
|
||||
);
|
||||
assert_eq!(routed(&p, d), result, "deterministic fallback");
|
||||
assert_eq!(p.diagrams[&d].routes[0], route);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
|
||||
for blocker_side in [-1., 1.] {
|
||||
let (mut p, d, a, edge) = state(Kind::State);
|
||||
if let RelationBody::StateTransition { target, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*target = a;
|
||||
}
|
||||
let mut route = stored(edge);
|
||||
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
|
||||
route.target_attachment = route.source_attachment;
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
|
||||
let tip = attachment_point(
|
||||
&p,
|
||||
&p.diagrams[&d],
|
||||
source,
|
||||
&anchor(PortAnchorSide::Top, 0.5),
|
||||
);
|
||||
let region = p.elements[&a].parent;
|
||||
let blocker = p
|
||||
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
|
||||
.unwrap();
|
||||
let left = if blocker_side > 0. {
|
||||
tip.x + 30.
|
||||
} else {
|
||||
tip.x - 30. - 224.
|
||||
};
|
||||
p.place(d, blocker, pos(left, tip.y - 144.));
|
||||
let result = routed(&p, d);
|
||||
let r = &result.edges[&edge];
|
||||
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
|
||||
orthogonal(&r.points);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
|
||||
for segment in r.points.windows(2) {
|
||||
for step in 0..=100 {
|
||||
let t = step as f32 / 100.;
|
||||
let q = pos(
|
||||
segment[0].x + (segment[1].x - segment[0].x) * t,
|
||||
segment[0].y + (segment[1].y - segment[0].y) * t,
|
||||
);
|
||||
assert!(
|
||||
!(q.x > n.position.x - CLEARANCE
|
||||
&& q.x < n.position.x + n.size.width + CLEARANCE
|
||||
&& q.y > n.position.y - CLEARANCE
|
||||
&& q.y < n.position.y + n.size.height + CLEARANCE),
|
||||
"route crosses blocker: {q:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
r.pins.is_empty(),
|
||||
"generated detours must never become authored pins"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
use oa_core::{language, projection::*, routing::*, *};
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn authored(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![pos(520., 420.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
offset: pos(17., -23.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn routed(project: &Project, diagram: Id) -> RoutedView {
|
||||
let d = &project.diagrams[&diagram];
|
||||
route_diagram(project, d, &project_diagram(project, d))
|
||||
}
|
||||
|
||||
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
|
||||
let mut p = language::parse(
|
||||
r#"arcadia 7;
|
||||
project lifecycle "Attachment lifecycle" {
|
||||
oa_activity group "Group" { oa_activity first "First" {} }
|
||||
oa_activity second "Second" {}
|
||||
oa_activity replacement "Replacement" {}
|
||||
interaction signal "Signal" { source @first; target @second; }
|
||||
diagram view "View" {}
|
||||
diagram other "Other" {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let (d, other, group, first, second, replacement, edge) = (
|
||||
id(&p, "view"),
|
||||
id(&p, "other"),
|
||||
id(&p, "group"),
|
||||
id(&p, "first"),
|
||||
id(&p, "second"),
|
||||
id(&p, "replacement"),
|
||||
id(&p, "signal"),
|
||||
);
|
||||
for diagram in [d, other] {
|
||||
p.place(diagram, group, pos(20., 20.));
|
||||
p.place(diagram, first, pos(100., 130.));
|
||||
p.place(diagram, second, pos(750., 150.));
|
||||
p.place(diagram, replacement, pos(750., 570.));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(authored(edge));
|
||||
}
|
||||
(p, d, other, group, first, replacement, edge)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
|
||||
let (mut p, d, other, group, _, _, edge) = fixture();
|
||||
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
|
||||
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
|
||||
let before = p.clone();
|
||||
let expanded = routed(&p, d);
|
||||
let other_geometry = routed(&p, other);
|
||||
let parent = p.diagrams[&d]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == group)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let folded = routed(&p, d);
|
||||
assert!(folded.edges[&edge].custom_suspended);
|
||||
assert_eq!(folded.edges[&edge].pins, []);
|
||||
assert!(
|
||||
folded.edges[&edge]
|
||||
.warnings
|
||||
.contains(&RouteWarning::PromotedEndpoints)
|
||||
);
|
||||
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
|
||||
let mut automatic = p.clone();
|
||||
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
|
||||
assert_eq!(
|
||||
folded,
|
||||
routed(&automatic, d),
|
||||
"attachments, pins and label must all be dormant on promotion"
|
||||
);
|
||||
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
|
||||
assert_eq!(routed(&p, other), other_geometry);
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = false;
|
||||
assert_eq!(p, before);
|
||||
assert_eq!(routed(&p, d), expanded);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
|
||||
let (p, d, other, _, _, replacement, edge) = fixture();
|
||||
let before = routed(&p, d);
|
||||
let mut relation = p.relations[&edge].clone();
|
||||
let RelationBody::Interaction { target, .. } = &mut relation.body else {
|
||||
unreachable!()
|
||||
};
|
||||
*target = replacement;
|
||||
let mut expected = p.clone();
|
||||
expected.relations.insert(edge, relation.clone());
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::EditRelation(edge, relation))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&expected,
|
||||
"reconnect changes only the semantic endpoint; all per-view records remain exact"
|
||||
);
|
||||
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
|
||||
for diagram in [d, other] {
|
||||
let current = session.project();
|
||||
let view = project_diagram(current, ¤t.diagrams[&diagram]);
|
||||
let node = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == replacement)
|
||||
.unwrap();
|
||||
let preference = current.diagrams[&diagram].routes[0]
|
||||
.target_attachment
|
||||
.unwrap();
|
||||
let expected_tip =
|
||||
attachment_point(current, ¤t.diagrams[&diagram], node, &preference);
|
||||
let result = routed(current, diagram);
|
||||
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
|
||||
assert_eq!(
|
||||
result.edges[&edge].pins,
|
||||
p.diagrams[&diagram].routes[0].waypoints
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
routed(session.project(), d).edges[&edge].points.last(),
|
||||
before.edges[&edge].points.last()
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(routed(session.project(), d), before);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
|
||||
let mut p = Project::empty();
|
||||
let system = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let actor = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let function = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let mut ports = Vec::new();
|
||||
for (owner, name, contract) in [
|
||||
(
|
||||
system,
|
||||
"Output",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
},
|
||||
),
|
||||
(
|
||||
actor,
|
||||
"Input",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In,
|
||||
},
|
||||
),
|
||||
(
|
||||
function,
|
||||
"Function output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
),
|
||||
] {
|
||||
let port = Id::new_v4();
|
||||
p.elements
|
||||
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
|
||||
ports.push(port);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
"Function allocation",
|
||||
system,
|
||||
function,
|
||||
);
|
||||
let boundary = p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Boundary",
|
||||
ports[0],
|
||||
ports[1],
|
||||
);
|
||||
let allocation = p.relate(
|
||||
RelationKind::PortAllocation,
|
||||
"Port allocation",
|
||||
ports[0],
|
||||
ports[2],
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, system, pos(0., 0.));
|
||||
p.place(d, actor, pos(700., 0.));
|
||||
p.place(d, function, pos(0., 450.));
|
||||
let original = p.clone();
|
||||
let baseline = routed(&p, d);
|
||||
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
|
||||
assert!(baseline.edges.contains_key(&edge));
|
||||
assert_eq!(
|
||||
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
|
||||
Some(ports[0])
|
||||
);
|
||||
assert_eq!(
|
||||
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
|
||||
Some(target)
|
||||
);
|
||||
let mut route = authored(edge);
|
||||
route.waypoints.clear();
|
||||
route.label = None;
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(route);
|
||||
}
|
||||
assert_eq!(routed(&p, d), baseline);
|
||||
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
|
||||
hidden.ports.clear();
|
||||
for edge in &mut hidden.edges {
|
||||
edge.source_port = None;
|
||||
edge.target_port = None;
|
||||
}
|
||||
assert_eq!(
|
||||
route_diagram(&p, &p.diagrams[&d], &hidden),
|
||||
route_diagram(&original, &original.diagrams[&d], &hidden),
|
||||
"filtered direct Port endpoints must not activate their stored preferences"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
use oa_core::{projection::*, routing::*, *};
|
||||
|
||||
#[test]
|
||||
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
|
||||
let mut project = Project::empty();
|
||||
let oa = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Send", None)
|
||||
.unwrap();
|
||||
let sa = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
|
||||
let id = Id::new_v4();
|
||||
project.elements.insert(
|
||||
id,
|
||||
Element {
|
||||
id,
|
||||
alias: format!("port_{}", id.simple()),
|
||||
name: "Status port".into(),
|
||||
description: "Existing legacy port retained by Item trace".into(),
|
||||
parent: Some(owner),
|
||||
body: ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::InOut,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
let trace = Trace {
|
||||
id: Id::new_v4(),
|
||||
alias: "status_correspondence".into(),
|
||||
name: "Status correspondence".into(),
|
||||
description: "Preserve this semantic evidence".into(),
|
||||
oa: ports[0],
|
||||
sa: ports[1],
|
||||
kind: TraceKind::Item,
|
||||
baseline: None,
|
||||
};
|
||||
project.traces.insert(trace.id, trace.clone());
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
let owners = [
|
||||
project.place(diagram, oa, Position { x: 100., y: 100. }),
|
||||
project.place(diagram, sa, Position { x: 800., y: 300. }),
|
||||
];
|
||||
validate_candidate(&project).unwrap();
|
||||
let semantic_elements = project.elements.clone();
|
||||
let semantic_relations = project.relations.clone();
|
||||
let semantic_traces = project.traces.clone();
|
||||
let view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
|
||||
assert!(edge.trace);
|
||||
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
|
||||
assert_eq!((edge.source_port, edge.target_port), (None, None));
|
||||
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&owners[index])
|
||||
.unwrap()
|
||||
.port_anchors
|
||||
.push(PortAnchor {
|
||||
port: ports[index],
|
||||
side,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
validate_candidate(&project).unwrap();
|
||||
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
|
||||
for index in 0..2 {
|
||||
let marker = moved_view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == ports[index] && port.owner == owners[index])
|
||||
.unwrap();
|
||||
let points = &moved.edges[&trace.id].points;
|
||||
let tip = if index == 0 {
|
||||
points[0]
|
||||
} else {
|
||||
*points.last().unwrap()
|
||||
};
|
||||
assert_eq!(tip, marker.position);
|
||||
let old = &initial.edges[&trace.id].points;
|
||||
assert_ne!(
|
||||
tip,
|
||||
if index == 0 {
|
||||
old[0]
|
||||
} else {
|
||||
*old.last().unwrap()
|
||||
}
|
||||
);
|
||||
}
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: trace.id,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.9,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.1,
|
||||
}),
|
||||
});
|
||||
validate_candidate(&project).unwrap();
|
||||
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
assert_eq!(dormant_view, moved_view);
|
||||
assert_eq!(
|
||||
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
|
||||
moved
|
||||
);
|
||||
assert_eq!(project.elements, semantic_elements);
|
||||
assert_eq!(project.relations, semantic_relations);
|
||||
assert_eq!(project.traces, semantic_traces);
|
||||
assert_eq!(project.traces[&trace.id], trace);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Route the exact native acceptance fixture and intended saved checkpoints.
|
||||
use oa_core::{projection::project_diagram, routing::route_diagram, *};
|
||||
|
||||
const FIXTURE: &str =
|
||||
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn set(
|
||||
project: &mut Project,
|
||||
view: &str,
|
||||
edge: &str,
|
||||
end: EdgeEnd,
|
||||
attachment: Option<(PortAnchorSide, f32)>,
|
||||
) {
|
||||
let view = id(project, view);
|
||||
let edge = id(project, edge);
|
||||
let route = project
|
||||
.diagrams
|
||||
.get_mut(&view)
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == edge)
|
||||
.unwrap();
|
||||
route.set_attachment(
|
||||
end,
|
||||
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
|
||||
);
|
||||
}
|
||||
|
||||
fn clear(project: &Project, checkpoint: &str) {
|
||||
validate_candidate(project).unwrap();
|
||||
for (view, edges) in [
|
||||
("dispatch_view", &["dispatch_request"][..]),
|
||||
("readiness_view", &["activate", "initialize", "finish"][..]),
|
||||
] {
|
||||
let diagram = &project.diagrams[&id(project, view)];
|
||||
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
|
||||
for alias in edges {
|
||||
let edge = &routed.edges[&id(project, alias)];
|
||||
assert!(
|
||||
edge.warnings.is_empty(),
|
||||
"{checkpoint}/{view}/{alias}: {:?}",
|
||||
edge.warnings
|
||||
);
|
||||
let stored = diagram
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == id(project, alias))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
edge.pins, stored.waypoints,
|
||||
"{checkpoint}/{alias}: manual pins retained"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
|
||||
use EdgeEnd::{Source, Target};
|
||||
use PortAnchorSide::{Bottom, Top};
|
||||
let mut project = language::parse(FIXTURE).unwrap();
|
||||
let baseline = project.clone();
|
||||
clear(&project, "B");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Source,
|
||||
Some((Top, 0.25)),
|
||||
);
|
||||
clear(&project, "O1");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Target,
|
||||
Some((Bottom, 0.75)),
|
||||
);
|
||||
clear(&project, "O2");
|
||||
set(
|
||||
&mut project,
|
||||
"dispatch_view",
|
||||
"dispatch_request",
|
||||
Source,
|
||||
None,
|
||||
);
|
||||
clear(&project, "O3");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"activate",
|
||||
Source,
|
||||
Some((Top, 0.25)),
|
||||
);
|
||||
clear(&project, "S1");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"activate",
|
||||
Target,
|
||||
Some((Bottom, 0.75)),
|
||||
);
|
||||
clear(&project, "S2");
|
||||
set(&mut project, "readiness_view", "activate", Target, None);
|
||||
clear(&project, "S3");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"initialize",
|
||||
Source,
|
||||
Some((Top, 0.5)),
|
||||
);
|
||||
clear(&project, "S4");
|
||||
set(
|
||||
&mut project,
|
||||
"readiness_view",
|
||||
"finish",
|
||||
Source,
|
||||
Some((Top, 0.5)),
|
||||
);
|
||||
clear(&project, "S5");
|
||||
assert_eq!(project.elements, baseline.elements);
|
||||
assert_eq!(project.relations, baseline.relations);
|
||||
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
|
||||
let view = id(&project, alias);
|
||||
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
|
||||
}
|
||||
for alias in ["dispatch_view", "readiness_view"] {
|
||||
let view = id(&project, alias);
|
||||
assert_eq!(
|
||||
project.diagrams[&view].nodes,
|
||||
baseline.diagrams[&view].nodes
|
||||
);
|
||||
for route in &project.diagrams[&view].routes {
|
||||
let original = baseline.diagrams[&view]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == route.edge)
|
||||
.unwrap();
|
||||
assert_eq!(route.waypoints, original.waypoints);
|
||||
assert_eq!(route.label, original.label);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
use oa_core::{language, persistence, source_project::*, *};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const SOURCE: &str = r#"arcadia 7;
|
||||
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
|
||||
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
|
||||
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
|
||||
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
|
||||
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
|
||||
label { fraction 0.5; offset { x 3; y -7; }; };
|
||||
source_attachment { side Left; offset 0.25; };
|
||||
target_attachment { side Bottom; offset 1; };
|
||||
}];
|
||||
}
|
||||
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
|
||||
}
|
||||
"#;
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn original() -> Project {
|
||||
language::parse(SOURCE).unwrap()
|
||||
}
|
||||
|
||||
fn set(diagram: Id, route: EdgeRoute) -> Command {
|
||||
Command::SetEdgeRoute { diagram, route }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let route = &project.diagrams[&view].routes[0];
|
||||
assert_eq!(
|
||||
route.source_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
route.target_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 1.0
|
||||
})
|
||||
);
|
||||
for text in [SOURCE.to_owned(), language::format(&project)] {
|
||||
assert_eq!(language::parse(&text).unwrap(), project);
|
||||
}
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
|
||||
project
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(&project), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
project
|
||||
);
|
||||
|
||||
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
|
||||
assert_eq!(automatic.source_attachment, None);
|
||||
assert_eq!(automatic.target_attachment, None);
|
||||
let encoded = serde_json::to_value(automatic).unwrap();
|
||||
assert!(encoded.get("source_attachment").is_none());
|
||||
assert!(encoded.get("target_attachment").is_none());
|
||||
let mut with_null = encoded.clone();
|
||||
with_null["source_attachment"] = Value::Null;
|
||||
with_null["target_attachment"] = Value::Null;
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
|
||||
*automatic
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
|
||||
*automatic
|
||||
);
|
||||
|
||||
// Old source without either new field still means automatic presentation.
|
||||
let old = SOURCE
|
||||
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
|
||||
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
|
||||
let old = language::parse(&old).unwrap();
|
||||
assert!(
|
||||
old.diagrams
|
||||
.values()
|
||||
.flat_map(|d| &d.routes)
|
||||
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
|
||||
);
|
||||
assert!(!language::format(&old).contains("_attachment"));
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
|
||||
old
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
|
||||
let project = original();
|
||||
let edge = id(&project, "signal");
|
||||
for bad in [
|
||||
json!({}),
|
||||
json!({"side":"Top"}),
|
||||
json!({"offset":0.5}),
|
||||
json!({"side":"Diagonal","offset":0.5}),
|
||||
json!({"side":"Top","offset":null}),
|
||||
json!({"side":"Top","offset":0.5,"unknown":true}),
|
||||
json!({"side":"Top","offset":"NaN"}),
|
||||
] {
|
||||
for field in ["source_attachment", "target_attachment"] {
|
||||
let mut route = json!({"edge":edge});
|
||||
route[field] = bad.clone();
|
||||
assert!(
|
||||
serde_json::from_value::<EdgeRoute>(route).is_err(),
|
||||
"{field}: {bad}"
|
||||
);
|
||||
}
|
||||
}
|
||||
for bad in [
|
||||
"{}",
|
||||
"{ side Top; }",
|
||||
"{ offset 0.5; }",
|
||||
"{ side Diagonal; offset 0.5; }",
|
||||
"{ side Top; offset null; }",
|
||||
"{ side Top; offset 0.5; unknown true; }",
|
||||
"{ side Top; offset -0.01; }",
|
||||
"{ side Top; offset 1.01; }",
|
||||
"{ side Top; offset NaN; }",
|
||||
"{ side Top; offset 1e999; }",
|
||||
] {
|
||||
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
|
||||
assert!(language::parse(&source).is_err(), "{bad}");
|
||||
let bundle = SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([("p.arcadia".into(), source)]),
|
||||
};
|
||||
assert!(
|
||||
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
|
||||
"{bad}"
|
||||
);
|
||||
}
|
||||
for version in [1, 2] {
|
||||
let legacy =
|
||||
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
|
||||
assert!(
|
||||
language::parse(&legacy).is_err(),
|
||||
"legacy route extension must not be silently admitted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let first = id(&project, "first");
|
||||
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
|
||||
for end in [EdgeEnd::Source, EdgeEnd::Target] {
|
||||
let mut route = project.diagrams[&view].routes[0].clone();
|
||||
route.set_attachment(
|
||||
end,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset,
|
||||
}),
|
||||
);
|
||||
let mut session = Session::new(project.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::Rename(first, "Must roll back".into()),
|
||||
set(view, route)
|
||||
]))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &project);
|
||||
assert!(!session.can_undo() && !session.can_redo());
|
||||
}
|
||||
}
|
||||
for offset in [-0.01, 1.01] {
|
||||
let mut value = serde_json::to_value(&project).unwrap();
|
||||
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
|
||||
json!(offset);
|
||||
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
|
||||
let project = original();
|
||||
let view = id(&project, "view");
|
||||
let edge = id(&project, "signal");
|
||||
let mut session = Session::new(project.clone());
|
||||
let mut route = project.diagrams[&view].routes[0].clone();
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.75,
|
||||
});
|
||||
let command = set(view, route.clone());
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
|
||||
command
|
||||
);
|
||||
session.execute(command).unwrap();
|
||||
let mut expected = project.clone();
|
||||
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&expected,
|
||||
"only this view/end changes; semantic fields and other view stay exact"
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &project);
|
||||
assert!(!session.can_undo() && session.can_redo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
route.source_attachment = None;
|
||||
session.execute(set(view, route.clone())).unwrap();
|
||||
let mut reset = expected.clone();
|
||||
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
|
||||
assert_eq!(
|
||||
session.project(),
|
||||
&reset,
|
||||
"opposite attachment, pins and label survive endpoint reset"
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session
|
||||
.execute(Command::RemoveEdgeRoute {
|
||||
diagram: view,
|
||||
edge,
|
||||
})
|
||||
.unwrap();
|
||||
let mut removed = expected.clone();
|
||||
removed.diagrams.get_mut(&view).unwrap().routes.clear();
|
||||
assert_eq!(session.project(), &removed);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
|
||||
let current = compile_bundle(&SourceBundle {
|
||||
entry: "p.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
|
||||
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
|
||||
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap();
|
||||
let mut project = current.decoded.project.clone();
|
||||
let view = id(&project, "view");
|
||||
project.diagrams.get_mut(&view).unwrap().routes[0]
|
||||
.source_attachment
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.offset = 0.375;
|
||||
let changed = update_bundle(¤t, &project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(changed.decoded.project, project);
|
||||
for path in ["p.arcadia", "model.arcadia"] {
|
||||
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
|
||||
}
|
||||
assert_eq!(
|
||||
changed.bundle.files["views.arcadia"],
|
||||
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&changed.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
changed.bundle
|
||||
);
|
||||
assert_eq!(
|
||||
update_bundle(&changed, &project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
changed.bundle
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
use oa_core::{diagram_edit::translate_placements, source_project::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn route(edge: Id, x: f32, y: f32) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.2,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.8,
|
||||
}),
|
||||
waypoints: vec![point(x, y)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: point(3., -9.),
|
||||
}),
|
||||
}
|
||||
}
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
foreign: Id,
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add_element(Kind::Activity, Layer::Oa, "A", None).unwrap();
|
||||
let b = p.add_element(Kind::Activity, Layer::Oa, "B", None).unwrap();
|
||||
let c = p.add_element(Kind::Activity, Layer::Oa, "C", None).unwrap();
|
||||
let child = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
|
||||
.unwrap();
|
||||
let internal = p.relate(RelationKind::Interaction, "Internal", a, b);
|
||||
let external = p.relate(RelationKind::Interaction, "External", b, c);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
let na = p.place(d, a, point(10., 20.));
|
||||
let nb = p.place(d, b, point(400., 20.));
|
||||
let nc = p.place(d, child, point(30., 80.));
|
||||
p.place(d, c, point(800., 20.));
|
||||
p.diagrams.get_mut(&d).unwrap().routes =
|
||||
vec![route(internal, 320., 150.), route(external, 720., 170.)];
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&na)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let other = Id::new_v4();
|
||||
let mut other_view = p.diagrams[&d].clone();
|
||||
other_view.id = other;
|
||||
other_view.alias = "other_view".into();
|
||||
other_view.nodes.clear();
|
||||
other_view.routes.clear();
|
||||
p.diagrams.insert(other, other_view);
|
||||
let foreign = p.place(other, a, point(-40., -50.));
|
||||
validate_candidate(&p).unwrap();
|
||||
Fixture {
|
||||
project: p,
|
||||
diagram: d,
|
||||
a: na,
|
||||
b: nb,
|
||||
child: nc,
|
||||
foreign,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn simultaneous_siblings_translate_internal_pins_once_and_preserve_complete_project() {
|
||||
let f = fixture();
|
||||
let before = f.project.clone();
|
||||
let command = translate_placements(
|
||||
&before,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a, f.b]),
|
||||
point(40., 60.),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
before, f.project,
|
||||
"command construction must not mutate input"
|
||||
);
|
||||
// Independently enumerated expected edits, without the projection helpers.
|
||||
let mut expected = before.clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
|
||||
view.nodes.get_mut(&f.b).unwrap().position = point(440., 80.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(70., 140.);
|
||||
view.routes[0].waypoints = vec![point(360., 210.)];
|
||||
let mut session = Session::new(before.clone());
|
||||
session.execute(command.clone()).unwrap();
|
||||
assert_eq!(session.project(), &expected);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
assert!(
|
||||
!session.can_undo(),
|
||||
"one movement must be one history entry"
|
||||
);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &expected);
|
||||
assert!(!session.can_redo());
|
||||
|
||||
// Independent one-node moves cannot see the shared endpoint displacement.
|
||||
let mut sequential = Session::new(before.clone());
|
||||
for (node, position) in [(f.a, point(50., 80.)), (f.b, point(440., 80.))] {
|
||||
sequential
|
||||
.execute(Command::MoveNode {
|
||||
diagram: f.diagram,
|
||||
node,
|
||||
position,
|
||||
with_descendants: true,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(
|
||||
sequential.project().diagrams[&f.diagram].routes[0],
|
||||
before.diagrams[&f.diagram].routes[0]
|
||||
);
|
||||
assert_ne!(sequential.project(), &expected);
|
||||
|
||||
let json = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(serde_json::from_str::<Project>(&json).unwrap(), expected);
|
||||
let mut bundle = format_bundle(&before);
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// preserve movement rationale\n");
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let prepared = prepare_command(&before, &command).unwrap();
|
||||
let updated = update_bundle_with_identity_map(
|
||||
&original,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, expected);
|
||||
assert!(updated.bundle.files[&bundle.entry].starts_with("// preserve movement rationale\n"));
|
||||
let reread = compile_bundle(&updated.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reread.decoded.project, expected);
|
||||
assert_eq!(reread.bundle, updated.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_folded_ancestor_and_hidden_descendant_moves_each_once() {
|
||||
let f = fixture();
|
||||
assert!(
|
||||
!projection::project_view(&f.project, f.diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.id == f.child)
|
||||
);
|
||||
let ancestor = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a]),
|
||||
point(-15., 25.),
|
||||
)
|
||||
.unwrap();
|
||||
let overlap = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a, f.child]),
|
||||
point(-15., 25.),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(overlap, ancestor);
|
||||
let after = prepare_command(&f.project, &overlap).unwrap().project;
|
||||
assert_eq!(
|
||||
after.diagrams[&f.diagram].nodes[&f.child].position,
|
||||
point(15., 105.)
|
||||
);
|
||||
assert_eq!(
|
||||
after.diagrams[&f.diagram].routes,
|
||||
f.project.diagrams[&f.diagram].routes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_movements_refuse_atomically_and_zero_has_no_history() {
|
||||
let f = fixture();
|
||||
let selected = BTreeSet::from([f.a, f.b]);
|
||||
for (diagram, ids, delta) in [
|
||||
(Id::new_v4(), selected.clone(), point(1., 1.)),
|
||||
(f.diagram, BTreeSet::new(), point(1., 1.)),
|
||||
(
|
||||
f.diagram,
|
||||
BTreeSet::from([f.a, Id::new_v4()]),
|
||||
point(1., 1.),
|
||||
),
|
||||
(f.diagram, BTreeSet::from([f.a, f.foreign]), point(0., 0.)),
|
||||
(f.diagram, selected.clone(), point(f32::NAN, 1.)),
|
||||
(f.diagram, selected.clone(), point(1., f32::INFINITY)),
|
||||
(f.diagram, selected.clone(), point(MAX_COORDINATE, 0.)),
|
||||
(f.diagram, selected.clone(), point(f32::MAX, f32::MAX)),
|
||||
] {
|
||||
let before = f.project.clone();
|
||||
assert!(translate_placements(&f.project, diagram, &ids, delta).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
let mut bad_route = f.project.clone();
|
||||
bad_route.diagrams.get_mut(&f.diagram).unwrap().routes[0].waypoints =
|
||||
vec![point(MAX_COORDINATE, 0.)];
|
||||
validate_candidate(&bad_route).unwrap();
|
||||
let snapshot = bad_route.clone();
|
||||
assert!(translate_placements(&bad_route, f.diagram, &selected, point(1., 0.)).is_err());
|
||||
assert_eq!(bad_route, snapshot);
|
||||
let zero = translate_placements(&f.project, f.diagram, &selected, point(0., -0.)).unwrap();
|
||||
assert_eq!(zero, Command::Batch(vec![]));
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session.execute(zero).unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
assert_eq!(session.revision(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_component_port_endpoints_follow_their_owner_placements() {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
|
||||
.unwrap();
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
for (id, owner, name, orientation) in [
|
||||
(output, a, "Output", ComponentPortOrientation::Out),
|
||||
(input, b, "Input", ComponentPortOrientation::In),
|
||||
] {
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
name,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let edge = p.relate(RelationKind::BoundaryExchange, "Boundary", output, input);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let na = p.place(d, a, point(0., 0.));
|
||||
let nb = p.place(d, b, point(400., 0.));
|
||||
for (node, port, side, offset) in [
|
||||
(na, output, PortAnchorSide::Bottom, 0.3),
|
||||
(nb, input, PortAnchorSide::Top, 0.7),
|
||||
] {
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.port_anchors
|
||||
.push(PortAnchor { port, side, offset });
|
||||
}
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route(edge, 300., 100.));
|
||||
validate_candidate(&p).unwrap();
|
||||
let command = translate_placements(&p, d, &BTreeSet::from([na, nb]), point(10., -20.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
let view = expected.diagrams.get_mut(&d).unwrap();
|
||||
view.nodes.get_mut(&na).unwrap().position = point(10., -20.);
|
||||
view.nodes.get_mut(&nb).unwrap().position = point(410., -20.);
|
||||
view.routes[0].waypoints = vec![point(310., 80.)];
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_movement_follows_visual_performers_and_same_performer_work_groups() {
|
||||
let mut p = Project::empty();
|
||||
let performer_a = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer A", None)
|
||||
.unwrap();
|
||||
let performer_b = p
|
||||
.add_element(Kind::Entity, Layer::Oa, "Performer B", None)
|
||||
.unwrap();
|
||||
let mixed = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Mixed work", None)
|
||||
.unwrap();
|
||||
let branch = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "A work", Some(mixed))
|
||||
.unwrap();
|
||||
let leaf_a = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "A leaf", Some(branch))
|
||||
.unwrap();
|
||||
let leaf_b = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "B leaf", Some(mixed))
|
||||
.unwrap();
|
||||
p.relate(RelationKind::Allocation, "A performs", performer_a, leaf_a);
|
||||
p.relate(RelationKind::Allocation, "B performs", performer_b, leaf_b);
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::Allocation;
|
||||
let na = p.place(d, performer_a, point(0., 0.));
|
||||
p.place(d, performer_b, point(600., 0.));
|
||||
let nm = p.place(d, mixed, point(1000., 0.));
|
||||
let branch_node = p.place(d, branch, point(20., 80.));
|
||||
let leaf_node = p.place(d, leaf_a, point(40., 160.));
|
||||
p.place(d, leaf_b, point(620., 80.));
|
||||
validate_candidate(&p).unwrap();
|
||||
|
||||
// Mixed work retains semantic ownership of both branches, but its leaves
|
||||
// have different performers. A work is visually nested under Performer A,
|
||||
// with its own same-performer leaf nested inside it.
|
||||
let command =
|
||||
translate_placements(&p, d, &BTreeSet::from([na, branch_node]), point(15., -25.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
let view = expected.diagrams.get_mut(&d).unwrap();
|
||||
view.nodes.get_mut(&na).unwrap().position = point(15., -25.);
|
||||
view.nodes.get_mut(&branch_node).unwrap().position = point(35., 55.);
|
||||
view.nodes.get_mut(&leaf_node).unwrap().position = point(55., 135.);
|
||||
assert_eq!(after, expected);
|
||||
|
||||
// Moving the semantic parent does not move children displayed elsewhere.
|
||||
let command = translate_placements(&p, d, &BTreeSet::from([nm]), point(15., -25.)).unwrap();
|
||||
let after = prepare_command(&p, &command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&nm)
|
||||
.unwrap()
|
||||
.position = point(1015., -25.);
|
||||
assert_eq!(after, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traceability_movement_is_flat_even_for_a_semantic_parent() {
|
||||
let mut f = fixture();
|
||||
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Traceability;
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let command = translate_placements(
|
||||
&f.project,
|
||||
f.diagram,
|
||||
&BTreeSet::from([f.a]),
|
||||
point(40., 60.),
|
||||
)
|
||||
.unwrap();
|
||||
let after = prepare_command(&f.project, &command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.a)
|
||||
.unwrap()
|
||||
.position = point(50., 80.);
|
||||
assert_eq!(
|
||||
after, expected,
|
||||
"flat view movement must preserve semantic descendants, routes, other views and all semantics"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_entry_pins_follow_the_boundary_only_when_it_also_moves() {
|
||||
let mut p = Project::empty();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let composite = p
|
||||
.add_element(Kind::State, Layer::Oa, "Composite", Some(region))
|
||||
.unwrap();
|
||||
let inner = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Inner", Some(composite))
|
||||
.unwrap();
|
||||
let entry = p
|
||||
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(inner))
|
||||
.unwrap();
|
||||
let leaf = p
|
||||
.add_element(Kind::State, Layer::Oa, "Leaf", Some(inner))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::StateTransition, "Enter", entry, leaf);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut p.relations.get_mut(&edge).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Local;
|
||||
}
|
||||
let d = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
let boundary = p.place(d, composite, point(0., 0.));
|
||||
let ne = p.place(d, entry, point(20., 80.));
|
||||
let nl = p.place(d, leaf, point(100., 80.));
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route(edge, 90., 100.));
|
||||
validate_candidate(&p).unwrap();
|
||||
let endpoints =
|
||||
translate_placements(&p, d, &BTreeSet::from([ne, nl]), point(10., 20.)).unwrap();
|
||||
let after = prepare_command(&p, &endpoints).unwrap().project;
|
||||
assert_eq!(after.diagrams[&d].routes, p.diagrams[&d].routes);
|
||||
let with_boundary =
|
||||
translate_placements(&p, d, &BTreeSet::from([boundary, ne, nl]), point(10., 20.)).unwrap();
|
||||
let after = prepare_command(&p, &with_boundary).unwrap().project;
|
||||
assert_eq!(
|
||||
after.diagrams[&d].routes[0].waypoints,
|
||||
vec![point(100., 120.)]
|
||||
);
|
||||
assert_eq!(after.diagrams[&d].nodes[&ne].position, point(30., 100.));
|
||||
}
|
||||
@@ -441,6 +441,8 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
|
||||
diagram: d,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![pos(500., 420.)],
|
||||
label: None,
|
||||
},
|
||||
|
||||
@@ -70,6 +70,8 @@ fn fixture() -> Fixture {
|
||||
fn route(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
@@ -689,6 +691,8 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
|
||||
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
|
||||
EdgeRoute {
|
||||
edge: f.edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ fn route(p: &Project, d: Id) -> RoutedView {
|
||||
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
|
||||
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins,
|
||||
label,
|
||||
});
|
||||
|
||||
@@ -117,6 +117,8 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: fe,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: Some(authored.clone()),
|
||||
});
|
||||
|
||||
@@ -301,6 +301,8 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
|
||||
let pins = vec![point(690., 480.), point(690., 230.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: out,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: None,
|
||||
});
|
||||
@@ -362,11 +364,15 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
|
||||
let pins = vec![point(570., 460.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.local,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: Some(label.clone()),
|
||||
});
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.external,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![point(690., 230.)],
|
||||
label: None,
|
||||
});
|
||||
@@ -422,6 +428,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
|
||||
let placement = f.p.place(f.d, orphan, point(30., 750.));
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
});
|
||||
@@ -456,6 +464,8 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
|
||||
diagram: other,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
},
|
||||
@@ -583,6 +593,8 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
|
||||
let pins = vec![point(730., 130.), point(1060., 130.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.self_edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.25,
|
||||
@@ -652,6 +664,8 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
|
||||
let pins = vec![point(280., 390.), point(280., 470.)];
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.local,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: pins.clone(),
|
||||
label: None,
|
||||
});
|
||||
@@ -730,6 +744,8 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
|
||||
);
|
||||
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
|
||||
edge: f.self_edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.3,
|
||||
|
||||
@@ -748,6 +748,8 @@ fn allocation_artifact_and_manual_trace_cascades_are_named_and_refused() {
|
||||
fn route(edge: Id) -> EdgeRoute {
|
||||
EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![xy(420., 400.), xy(420., 550.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
|
||||
@@ -60,6 +60,7 @@ impl Root<'_> {
|
||||
return false;
|
||||
}
|
||||
let canvas = DiagramCanvas {
|
||||
selection: self.app.diagram_selection.as_ref(),
|
||||
allocation: Some(self.app.allocation_capability()),
|
||||
project: self.app.project(),
|
||||
view_index: Some(self.app.document.view_index()),
|
||||
@@ -70,6 +71,7 @@ impl Root<'_> {
|
||||
pan: self.app.pan,
|
||||
context: self.app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: false,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
+186
-15
@@ -15,13 +15,18 @@ mod diagram_reveal;
|
||||
mod diagram_selection_tests;
|
||||
mod diagram_usages;
|
||||
mod hosted;
|
||||
pub(crate) mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
mod reference_authoring;
|
||||
#[cfg(test)]
|
||||
mod review_navigation_tests;
|
||||
#[cfg(test)]
|
||||
mod source_indentation_tests;
|
||||
pub(crate) mod source_rename;
|
||||
mod source_symbols;
|
||||
#[cfg(test)]
|
||||
mod state_multiselect_focus_tests;
|
||||
#[cfg(test)]
|
||||
mod state_multiselect_tests;
|
||||
pub(crate) mod text_workspace;
|
||||
#[cfg(test)]
|
||||
mod transition_context_tests;
|
||||
@@ -366,6 +371,7 @@ pub enum Message {
|
||||
Select(Id),
|
||||
EditProperties(Id),
|
||||
SelectNode(Id),
|
||||
DiagramSelection(multiselect::Event),
|
||||
SelectProject,
|
||||
Search(String),
|
||||
StartTransition,
|
||||
@@ -429,7 +435,6 @@ pub enum Message {
|
||||
Pan(iced::Vector),
|
||||
Fit,
|
||||
SourceEdit(text_editor::Action),
|
||||
SourceIndent(crate::source_navigation::SymbolContext, bool),
|
||||
ApplySource,
|
||||
RevertSource,
|
||||
SourceUndo,
|
||||
@@ -496,6 +501,7 @@ pub struct App {
|
||||
pub camera_epoch: u64,
|
||||
camera_boundary_epoch: u64,
|
||||
pub selected_bend: Option<(Id, usize)>,
|
||||
pub selected_attachment: Option<(crate::route_edit::Context, Id, EdgeEnd)>,
|
||||
pub exact_strings: bool,
|
||||
pub document: Document,
|
||||
pub page: Page,
|
||||
@@ -509,6 +515,7 @@ pub struct App {
|
||||
pub layer: Layer,
|
||||
pub selected: Id,
|
||||
pub selected_node: Option<Id>,
|
||||
pub diagram_selection: Option<crate::diagram_selection::Selection>,
|
||||
pub draft: Option<Draft>,
|
||||
pub active_diagram: Option<Id>,
|
||||
pub search: String,
|
||||
@@ -616,6 +623,7 @@ impl App {
|
||||
camera_epoch: 0,
|
||||
camera_boundary_epoch: 0,
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
exact_strings: false,
|
||||
document,
|
||||
page: Page::Diagram,
|
||||
@@ -628,6 +636,7 @@ impl App {
|
||||
layer,
|
||||
selected,
|
||||
selected_node: None,
|
||||
diagram_selection: None,
|
||||
draft,
|
||||
active_diagram,
|
||||
search: String::new(),
|
||||
@@ -1106,6 +1115,9 @@ impl App {
|
||||
previous != (self.layer, self.active_diagram)
|
||||
}
|
||||
fn select(&mut self, id: Id) {
|
||||
if id != self.selected {
|
||||
self.selected_attachment = None;
|
||||
}
|
||||
if self.selected == id {
|
||||
return;
|
||||
}
|
||||
@@ -1282,6 +1294,38 @@ impl App {
|
||||
])
|
||||
}
|
||||
pub fn update(&mut self, message: Message) -> Task<Message> {
|
||||
let selection_boundary = self.multiselect_boundary();
|
||||
// Reject delayed gestures before they can retire newer input ownership.
|
||||
if let Message::DiagramSelection(event)
|
||||
| Message::StateDiagram(crate::behavior::state_edit::Event::Selection(event)) = &message
|
||||
&& !self.multiselect_envelope(event)
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Message::StateDiagram(event) = &message
|
||||
&& let Some(captured) = event.context()
|
||||
&& self.state_context().is_none_or(|current| {
|
||||
crate::behavior::state_edit::validate_context(self.project(), captured, current)
|
||||
.is_err()
|
||||
})
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(
|
||||
&message,
|
||||
Message::StateDiagram(
|
||||
crate::behavior::state_edit::Event::Select(..)
|
||||
| crate::behavior::state_edit::Event::Move { .. }
|
||||
| crate::behavior::state_edit::Event::Remove(_)
|
||||
)
|
||||
) && self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
// A queued singleton cannot retire the current group's navigation
|
||||
// or allocation ownership before its edit is refused.
|
||||
return Task::none();
|
||||
}
|
||||
// Captured Text commands validate their origin before they can
|
||||
// invalidate that same origin or pass into existing command handlers.
|
||||
if let Message::TextWorkspace(event) = message {
|
||||
@@ -1334,7 +1378,11 @@ impl App {
|
||||
self.reconcile_pending_diagram_reveal(&message);
|
||||
self.allocation_before(&message);
|
||||
if let Message::WorkDrag(event) = message {
|
||||
return self.allocation_event(event);
|
||||
let task = self.allocation_event(event);
|
||||
if self.multiselect_boundary() != selection_boundary {
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
return task;
|
||||
}
|
||||
let allocation_before = self.allocation_boundary();
|
||||
if let Message::BehaviorControl(intent) = message {
|
||||
@@ -1367,6 +1415,11 @@ impl App {
|
||||
self.camera_epoch = self.camera_epoch.wrapping_add(1);
|
||||
self.camera_boundary_epoch = self.canvas_epoch;
|
||||
}
|
||||
if self.multiselect_boundary() != selection_boundary
|
||||
|| (self.diagram_selection.is_none() && self.multiselect_context().is_some())
|
||||
{
|
||||
self.reconcile_multiselect();
|
||||
}
|
||||
task
|
||||
}
|
||||
fn behavior_control(&mut self, intent: behavior::canvas_workspace::Intent) -> Task<Message> {
|
||||
@@ -1730,6 +1783,7 @@ impl App {
|
||||
message,
|
||||
Message::Page(_) | Message::Layer(_) | Message::Diagram(_)
|
||||
) {
|
||||
self.diagram_selection = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
self.selected_bend = None;
|
||||
}
|
||||
@@ -2308,8 +2362,17 @@ impl App {
|
||||
"Apply or revert pending edits before using modeling actions.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(*action, Message::DiagramSelection(_)) {
|
||||
return self.update(*action);
|
||||
}
|
||||
self.select(menu.request.object);
|
||||
self.selected_node = menu.request.placement;
|
||||
if self.page == Page::Behavior && menu.request.canvas.is_some() {
|
||||
self.behavior.diagram.placement = menu.request.placement;
|
||||
self.selected_node = None;
|
||||
} else {
|
||||
self.selected_node = menu.request.placement;
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
self.selected_bend = menu.request.bend.map(|i| (menu.request.object, i));
|
||||
return self.update(*action);
|
||||
}
|
||||
@@ -2332,6 +2395,15 @@ impl App {
|
||||
} else if self.behavior.creation.has_pending() {
|
||||
self.behavior.creation.acknowledge_commit();
|
||||
self.status = "Behavior creation cancelled".into();
|
||||
} else if self.page == Page::Behavior
|
||||
&& matches!(
|
||||
self.behavior.diagram.tool,
|
||||
behavior::state_edit::Tool::AdjustAttachment { .. }
|
||||
)
|
||||
{
|
||||
// Menu arming invalidates canvas keyboard ownership. Escape
|
||||
// must also cancel before the next canvas click grants it.
|
||||
self.behavior.diagram.tool = behavior::state_edit::Tool::Select;
|
||||
}
|
||||
if self.connection.take().is_some() {
|
||||
self.status = "Drawing cancelled · Select / move is active".into();
|
||||
@@ -2458,6 +2530,7 @@ impl App {
|
||||
self.status = "Property draft reverted".into();
|
||||
}
|
||||
Message::Select(id) => self.select(id),
|
||||
Message::DiagramSelection(event) => self.multiselect_event(event),
|
||||
Message::EditProperties(id) => {
|
||||
self.select(id);
|
||||
self.inspector_tab = crate::navigation::InspectorTab::Properties;
|
||||
@@ -3115,7 +3188,21 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
Message::DiagramRemove(intent) => self.remove_from_diagram(intent),
|
||||
Message::DiagramRemove(intent) => {
|
||||
if self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
// A pre-redraw singleton Delete cannot remove just one
|
||||
// member of a selection changed earlier in the same batch.
|
||||
if Some(intent.context) == self.route_context() {
|
||||
self.status =
|
||||
"Selection changed. Use Hide selected or press Delete again.".into();
|
||||
}
|
||||
} else {
|
||||
self.remove_from_diagram(intent);
|
||||
}
|
||||
}
|
||||
Message::DiagramTraverse(back) => {
|
||||
return if back {
|
||||
iced::widget::operation::focus_previous()
|
||||
@@ -3124,6 +3211,13 @@ impl App {
|
||||
};
|
||||
}
|
||||
Message::Delete => {
|
||||
if self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = "Group model-deletion review is not available yet. Select one element or use Hide selected.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if self.selected != self.project().id && self.apply() {
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
@@ -3255,6 +3349,13 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::HideSelected => {
|
||||
if let Some(snapshot) = self
|
||||
.multiselect_snapshot()
|
||||
.filter(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.multiselect_event(multiselect::Event::Hide(snapshot));
|
||||
return Task::none();
|
||||
}
|
||||
if self.apply()
|
||||
&& let Some(node) = self.selected_node
|
||||
{
|
||||
@@ -3284,6 +3385,13 @@ impl App {
|
||||
}
|
||||
Message::Route(event) => self.edit_route(event),
|
||||
Message::MoveNode(node, position) => {
|
||||
if self
|
||||
.multiselect_snapshot()
|
||||
.is_some_and(|s| s.targets().len() > 1)
|
||||
{
|
||||
self.status = "Selection changed. Start the group drag again.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let original = self.selected;
|
||||
if self.apply()
|
||||
&& let Some(diagram) = self.active_diagram
|
||||
@@ -3317,16 +3425,10 @@ impl App {
|
||||
self.pan += delta;
|
||||
}
|
||||
Message::Fit => self.fit(),
|
||||
Message::SourceEdit(_) | Message::SourceIndent(..) => {
|
||||
Message::SourceEdit(action) => {
|
||||
self.source_jump = None;
|
||||
let before = self.source_editor.text();
|
||||
match message {
|
||||
Message::SourceEdit(action) => self.source_editor.perform(action),
|
||||
Message::SourceIndent(_, outdent) => {
|
||||
crate::source_indentation::apply(&mut self.source_editor, outdent);
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
self.source_editor.perform(action);
|
||||
let after = self.source_editor.text();
|
||||
if after != before {
|
||||
self.source_navigation.refresh(&after);
|
||||
@@ -4532,7 +4634,7 @@ impl App {
|
||||
}
|
||||
fn edit_route(&mut self, event: crate::route_edit::Event) {
|
||||
use crate::route_edit::{Event, insert_pin, stored_route};
|
||||
if self.connection.is_some() {
|
||||
if self.connection.is_some() || self.page != Page::Diagram {
|
||||
return;
|
||||
}
|
||||
let Some(context) = self.route_context() else {
|
||||
@@ -4560,6 +4662,75 @@ impl App {
|
||||
.filter(|(id, _)| *id == self.selected)
|
||||
.map(|(_, i)| i);
|
||||
let command = match event {
|
||||
Event::ChooseAttachment(end) => {
|
||||
if crate::route_edit::attachment_node(
|
||||
self.project(),
|
||||
diagram,
|
||||
&view,
|
||||
self.selected,
|
||||
end,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
self.selected_attachment = Some((context, self.selected, end));
|
||||
self.selected_bend = None;
|
||||
self.status = format!(
|
||||
"Drag the {} attachment handle along its element boundary · Esc cancels",
|
||||
if end == EdgeEnd::Source {
|
||||
"source"
|
||||
} else {
|
||||
"target"
|
||||
}
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
Event::ResetAttachment(end) => {
|
||||
if crate::route_edit::attachment_node(
|
||||
self.project(),
|
||||
diagram,
|
||||
&view,
|
||||
self.selected,
|
||||
end,
|
||||
)
|
||||
.is_none()
|
||||
|| route.attachment(end).is_none()
|
||||
{
|
||||
return;
|
||||
}
|
||||
route.set_attachment(end, None);
|
||||
selected_bend = None;
|
||||
Command::SetEdgeRoute {
|
||||
diagram: context.diagram,
|
||||
route,
|
||||
}
|
||||
}
|
||||
Event::CommitAttachment(expected, changed, end, owner) => {
|
||||
if expected != context
|
||||
|| changed.edge != self.selected
|
||||
|| crate::route_edit::attachment_node(
|
||||
self.project(),
|
||||
diagram,
|
||||
&view,
|
||||
self.selected,
|
||||
end,
|
||||
)
|
||||
.is_none_or(|node| node.id != owner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// A boundary gesture owns exactly one presentation field.
|
||||
let mut intended = route.clone();
|
||||
intended.set_attachment(end, changed.attachment(end));
|
||||
if changed != intended || intended == route {
|
||||
return;
|
||||
}
|
||||
selected_bend = None;
|
||||
Command::SetEdgeRoute {
|
||||
diagram: context.diagram,
|
||||
route: intended,
|
||||
}
|
||||
}
|
||||
Event::SelectBend(id, index) => {
|
||||
if id == self.selected && index < route.waypoints.len() {
|
||||
self.selected_bend = Some((id, index));
|
||||
|
||||
@@ -59,6 +59,7 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
@@ -69,6 +70,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -66,6 +66,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
let camera = (app.zoom, app.pan);
|
||||
let bounds = iced::Rectangle::new(iced::Point::ORIGIN, iced::Size::new(600., 380.));
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -75,6 +76,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -221,6 +221,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
y: (local.y - app.pan.y) / app.zoom,
|
||||
};
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -230,6 +231,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: true,
|
||||
connection: None,
|
||||
@@ -297,6 +299,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
let diagrams = app.project().diagrams.clone();
|
||||
let camera = (app.zoom, app.pan);
|
||||
let canvas = crate::diagram::DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -306,6 +309,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -154,3 +154,440 @@ async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
|
||||
}
|
||||
}
|
||||
}
|
||||
async fn modifier_focus_fixture() -> (App, Mounted, Id, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "First team", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Second team", None)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
||||
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let _ = app.update(Message::Select(a));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
app.zoom = 1.;
|
||||
app.pan = Vector::ZERO;
|
||||
app.fit_to_view = false;
|
||||
mounted.settle(&mut app).await;
|
||||
(app, mounted, diagram, first, second)
|
||||
}
|
||||
|
||||
fn modifier_node_point(mounted: &mut Mounted, app: &App, diagram: Id, placement: Id) -> Point {
|
||||
let bounds = mounted
|
||||
.geometry(app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let node = projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.find(|n| n.id == placement)
|
||||
.unwrap();
|
||||
Point::new(
|
||||
bounds.x + node.position.x + 40.,
|
||||
bounds.y + node.position.y + 35.,
|
||||
)
|
||||
}
|
||||
|
||||
fn modifier_canvas_focused(mounted: &mut Mounted, app: &App) -> bool {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.expect("production focus::Owner is mounted")
|
||||
.2
|
||||
}
|
||||
|
||||
fn modifier_event(shift: bool) -> iced::Event {
|
||||
iced::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(if shift {
|
||||
iced::keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
iced::keyboard::Modifiers::default()
|
||||
}))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_before_canvas_focus_toggles_exact_node_without_granting_keyboard_focus() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("clean Inspector name input");
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"modifier tracking must not acquire focus"
|
||||
);
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(messages.iter().any(|m| matches!(m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
||||
)), "Shift already held before the first canvas press must reach its exact-target adapter");
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.selected_node, Some(second));
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_unfocused_typing_and_delete_remain_denied_after_passive_modifiers() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
// Revoke every field's focus through the real widget operation. No widget
|
||||
// has this off-window rectangle, so neither Inspector nor canvas owns keys.
|
||||
mounted.operate(
|
||||
&app,
|
||||
&mut FocusField(Rectangle {
|
||||
x: -1000.,
|
||||
y: -1000.,
|
||||
width: 1.,
|
||||
height: 1.,
|
||||
}),
|
||||
);
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
let before = app.document.snapshot();
|
||||
let selection = app.multiselect_snapshot().unwrap();
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
modifier_event(false),
|
||||
crate::behavior::canvas_workspace::tests::character("x"),
|
||||
key(Named::Delete),
|
||||
key(Named::F2),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(_)
|
||||
| Message::DiagramRemove(_)
|
||||
| Message::HideSelected
|
||||
| Message::Delete
|
||||
)));
|
||||
assert!(!rename_emitted(&messages));
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_suspended_modifiers_are_passive_and_survive_until_resumed_click() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true), key(Named::Delete)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut app, at).await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"passive I/O cannot grant canvas focus"
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
mounted.click(&mut app, at).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_held_before_suspension_survives_without_another_modifier_event() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("clean Inspector name input");
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(
|
||||
!modifier_canvas_focused(&mut mounted, &app),
|
||||
"restoring passive modifiers must not grant keyboard focus"
|
||||
);
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
// No modifier transition follows the subtree replacement during I/O.
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(messages.iter().any(|m| matches!(m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == second
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first), Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_shift_release_or_window_unfocus_during_suspension_does_not_restore_stale_shift() {
|
||||
use crate::diagram_usage::Target;
|
||||
for release in [
|
||||
modifier_event(false),
|
||||
iced::Event::Window(iced::window::Event::Unfocused),
|
||||
] {
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let before = app.document.snapshot();
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[modifier_event(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
mounted
|
||||
.batch(&mut app, &[release], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
// A further I/O cycle must restore the updated owner state too.
|
||||
app.busy = true;
|
||||
mounted.settle(&mut app).await;
|
||||
app.busy = false;
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(first)]
|
||||
);
|
||||
let at = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
let messages = mounted.click(&mut app, at).await;
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(
|
||||
_,
|
||||
_,
|
||||
multiselect::Choice::Toggle
|
||||
))
|
||||
)));
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_window_unfocus_resets_held_shift_before_next_canvas_press() {
|
||||
use crate::diagram_usage::Target;
|
||||
let (mut app, mut mounted, diagram, first, second) = modifier_focus_fixture().await;
|
||||
let at_first = modifier_node_point(&mut mounted, &app, diagram, first);
|
||||
mounted.click(&mut app, at_first).await;
|
||||
let before = app.document.snapshot();
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
modifier_event(true),
|
||||
iced::Event::Window(iced::window::Event::Unfocused),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!modifier_canvas_focused(&mut mounted, &app));
|
||||
// No ModifiersChanged(empty) follows: a lost window must itself reset it.
|
||||
let at_second = modifier_node_point(&mut mounted, &app, diagram, second);
|
||||
mounted.click(&mut app, at_second).await;
|
||||
assert_eq!(
|
||||
app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(second)]
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_selection_strip_keeps_canvas_and_fitted_camera_fixed_for_empty_single_group_and_mixed()
|
||||
{
|
||||
use crate::diagram_usage::Target;
|
||||
let mut project = Project::empty();
|
||||
let a = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "First activity", None)
|
||||
.unwrap();
|
||||
let b = project
|
||||
.add_element(
|
||||
Kind::Activity,
|
||||
Layer::Oa,
|
||||
&format!(
|
||||
"Second activity {}",
|
||||
"with a very long primary name ".repeat(20)
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let edge = project.relate(RelationKind::Interaction, "Activity exchange", a, b);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let first = project.place(diagram, a, Position { x: 80., y: 80. });
|
||||
let second = project.place(diagram, b, Position { x: 500., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Oa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(1442., 932.);
|
||||
let _ = app.update(Message::Select(diagram));
|
||||
app.fit_to_view = true;
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let baseline = app.document.snapshot();
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let camera = (app.zoom, app.pan, app.fit_to_view, app.canvas_size);
|
||||
assert!(camera.2, "exercise the native resize-triggered refit path");
|
||||
assert!(app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert!(bounds.width > 500. && bounds.height > 300.);
|
||||
for (target, expected) in [
|
||||
(
|
||||
Some(Target::Placement(first)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![
|
||||
Target::Placement(first),
|
||||
Target::Placement(second),
|
||||
Target::Edge(edge),
|
||||
],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(edge)),
|
||||
vec![Target::Placement(first), Target::Placement(second)],
|
||||
),
|
||||
(
|
||||
Some(Target::Placement(second)),
|
||||
vec![Target::Placement(first)],
|
||||
),
|
||||
(None, vec![]),
|
||||
] {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let event = match target {
|
||||
Some(target) => {
|
||||
multiselect::Event::Choose(context, target, multiselect::Choice::Toggle)
|
||||
}
|
||||
None => multiselect::Event::Clear(context),
|
||||
};
|
||||
let task = app.update(Message::DiagramSelection(event));
|
||||
mounted.perform(&mut app, task).await;
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(app.multiselect_snapshot().unwrap().targets(), expected);
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID),
|
||||
bounds,
|
||||
"selection feedback must not change the measured canvas rectangle"
|
||||
);
|
||||
assert_eq!(
|
||||
(app.zoom, app.pan, app.fit_to_view, app.canvas_size),
|
||||
camera
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.draft_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -924,6 +924,8 @@ async fn mounted_bend_delete_has_priority_and_semantic_edge_delete_is_confirmed(
|
||||
diagram,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 400., y: -100. }],
|
||||
label: None,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
//! Exact saved-view selection and atomic group commands.
|
||||
use super::*;
|
||||
use crate::diagram_selection::{Context, Scope, Selection, Snapshot};
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) enum Choice {
|
||||
Replace,
|
||||
Add,
|
||||
Remove,
|
||||
Toggle,
|
||||
Inspect,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) enum Event {
|
||||
Choose(Context, Target, Choice),
|
||||
Collapse(Snapshot, Target),
|
||||
Clear(Context),
|
||||
Move(Snapshot, Position),
|
||||
Hide(Snapshot),
|
||||
Refuse(Snapshot, String),
|
||||
}
|
||||
|
||||
pub(crate) fn context(c: crate::route_edit::Context) -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: c.document,
|
||||
surface: Surface::Saved(c.diagram),
|
||||
},
|
||||
revision: c.revision,
|
||||
epoch: c.epoch,
|
||||
}
|
||||
}
|
||||
|
||||
/// The entire typed set must support a placement action. Never filter a mixture.
|
||||
pub(crate) fn placements(snapshot: &Snapshot) -> Option<std::collections::BTreeSet<Id>> {
|
||||
snapshot.primary()?;
|
||||
if snapshot.targets().is_empty() {
|
||||
return None;
|
||||
}
|
||||
snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.map(|target| match target {
|
||||
Target::Placement(id) => Some(*id),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn visible(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
view: &projection::ProjectedView,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
match target {
|
||||
Target::Placement(id) => view.nodes.iter().any(|n| n.id == *id),
|
||||
Target::Port { port, placement } => view
|
||||
.ports
|
||||
.iter()
|
||||
.any(|p| p.element == *port && p.owner == *placement),
|
||||
Target::Edge(id) => view.edges.iter().any(|e| e.id == *id),
|
||||
Target::StateRow {
|
||||
placement,
|
||||
relation,
|
||||
} => crate::behavior::state_canvas::visible_row_target(
|
||||
project, diagram, view, *placement, *relation,
|
||||
),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(crate) fn multiselect_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_document,
|
||||
self.document.revision(),
|
||||
self.page,
|
||||
self.active_diagram,
|
||||
self.selected,
|
||||
self.selected_node,
|
||||
(self.page == Page::Behavior).then(|| self.state_view_identity()),
|
||||
(self.page == Page::Behavior).then_some(self.behavior.diagram.placement),
|
||||
)
|
||||
}
|
||||
pub(crate) fn multiselect_context(&self) -> Option<Context> {
|
||||
match self.page {
|
||||
Page::Diagram => self.route_context().map(context),
|
||||
Page::Behavior => {
|
||||
let current = self.state_context()?;
|
||||
crate::behavior::state_edit::validate_context(self.project(), current, current)
|
||||
.ok()?;
|
||||
Some(context(current))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_target_visible(&self, target: &Target) -> bool {
|
||||
let Some(Context {
|
||||
scope:
|
||||
Scope {
|
||||
surface: Surface::Saved(diagram),
|
||||
..
|
||||
},
|
||||
..
|
||||
}) = self.multiselect_context()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
visible(self.project(), diagram, &view, target)
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_snapshot(&self) -> Option<Snapshot> {
|
||||
self.diagram_selection
|
||||
.as_ref()?
|
||||
.snapshot(self.multiselect_context()?)
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_envelope(&self, event: &Event) -> bool {
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
return false;
|
||||
};
|
||||
match event {
|
||||
Event::Choose(c, ..) | Event::Clear(c) => *c == current,
|
||||
Event::Move(s, _) | Event::Hide(s) | Event::Refuse(s, _) | Event::Collapse(s, _) => {
|
||||
self.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|selection| selection.matches(s, current))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn legacy_target(&self, diagram: Id, view: &projection::ProjectedView) -> Option<Target> {
|
||||
let placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
if let Some(id) = placement
|
||||
&& view.nodes.iter().any(|node| {
|
||||
node.id == id && (node.element == self.selected || node.id == self.selected)
|
||||
})
|
||||
{
|
||||
return Some(Target::Placement(id));
|
||||
}
|
||||
if self.page == Page::Behavior {
|
||||
// Keep a row's exact occurrence while inspecting its relation.
|
||||
// Its owner placement is never a node-inspection or Hide target.
|
||||
if let Some(target @ Target::StateRow { relation, .. }) =
|
||||
self.diagram_selection.as_ref().and_then(|s| s.primary())
|
||||
&& *relation == self.selected
|
||||
&& visible(self.project(), diagram, view, target)
|
||||
{
|
||||
return Some(target.clone());
|
||||
}
|
||||
if let Some(node) = view.nodes.iter().find(|node| {
|
||||
crate::behavior::state_canvas::visible_row_target(
|
||||
self.project(),
|
||||
diagram,
|
||||
view,
|
||||
node.id,
|
||||
self.selected,
|
||||
)
|
||||
}) {
|
||||
return Some(Target::StateRow {
|
||||
placement: node.id,
|
||||
relation: self.selected,
|
||||
});
|
||||
}
|
||||
}
|
||||
if let Some(port) = view.ports.iter().find(|port| port.element == self.selected) {
|
||||
return Some(Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
});
|
||||
}
|
||||
view.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == self.selected)
|
||||
.then_some(Target::Edge(self.selected))
|
||||
}
|
||||
|
||||
pub(crate) fn reconcile_multiselect(&mut self) {
|
||||
let Some(current) = self.multiselect_context() else {
|
||||
self.diagram_selection = None;
|
||||
return;
|
||||
};
|
||||
let Surface::Saved(diagram) = current.scope.surface else {
|
||||
return;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
let legacy = self.legacy_target(diagram, &view);
|
||||
let lost_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| !visible(self.project(), diagram, &view, target));
|
||||
let primary_placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
let held_primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.filter(|s| s.scope() == current.scope)
|
||||
.and_then(|s| s.primary())
|
||||
.is_some_and(|target| match target {
|
||||
Target::Placement(id) => primary_placement == Some(*id),
|
||||
Target::Port { port, .. } => self.selected == *port,
|
||||
Target::Edge(id) => self.selected == *id,
|
||||
Target::StateRow { relation, .. } => {
|
||||
self.selected == *relation && primary_placement.is_none()
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
let project = self.document.project();
|
||||
let selection = self
|
||||
.diagram_selection
|
||||
.get_or_insert_with(|| Selection::new(current.scope));
|
||||
selection.reconcile(current.scope, |target| {
|
||||
visible(project, diagram, &view, target)
|
||||
});
|
||||
// Navigation/property selection remains explicit. A removed exact target
|
||||
// is never rebound through its semantic identity to a different placement.
|
||||
if held_primary || lost_primary {
|
||||
if selection.primary() != legacy.as_ref() {
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
} else if selection.primary() != legacy.as_ref() {
|
||||
if let Some(target) = legacy {
|
||||
selection.replace(target, |target| visible(project, diagram, &view, target));
|
||||
} else {
|
||||
selection.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect_multiselect_primary(&mut self) {
|
||||
let diagram = self
|
||||
.multiselect_context()
|
||||
.and_then(|c| match c.scope.surface {
|
||||
Surface::Saved(diagram) => Some(diagram),
|
||||
_ => None,
|
||||
});
|
||||
let primary = self
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.and_then(|s| s.primary())
|
||||
.cloned();
|
||||
self.selected_node = None;
|
||||
if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement = None;
|
||||
}
|
||||
match primary {
|
||||
Some(Target::Placement(id)) => {
|
||||
if let Some(node) = diagram
|
||||
.and_then(|d| self.project().diagrams.get(&d))
|
||||
.and_then(|d| d.nodes.get(&id))
|
||||
{
|
||||
self.selected = node.element;
|
||||
if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement = Some(id);
|
||||
} else {
|
||||
self.selected_node = Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Target::Port { port, .. }) => self.selected = port,
|
||||
Some(Target::Edge(id)) => self.selected = id,
|
||||
Some(Target::StateRow { relation, .. }) => self.selected = relation,
|
||||
_ => self.selected = diagram.unwrap_or(self.project().id),
|
||||
}
|
||||
self.selected_bend = None;
|
||||
self.selected_attachment = None;
|
||||
self.refresh_draft();
|
||||
}
|
||||
|
||||
pub(crate) fn multiselect_event(&mut self, event: Event) {
|
||||
if !self.multiselect_envelope(&event) {
|
||||
return;
|
||||
}
|
||||
if !diagram_removal::enabled(self)
|
||||
|| self.architecture.is_some()
|
||||
|| self.connection.is_some()
|
||||
|| (self.page == Page::Behavior
|
||||
&& self.behavior.diagram.tool != crate::behavior::state_edit::Tool::Select)
|
||||
{
|
||||
self.status =
|
||||
"Finish or cancel the active edit before changing the diagram selection.".into();
|
||||
return;
|
||||
}
|
||||
let current = self.multiselect_context().unwrap();
|
||||
let Surface::Saved(diagram) = current.scope.surface else {
|
||||
return;
|
||||
};
|
||||
let view = projection::project_view_with_index(
|
||||
self.project(),
|
||||
diagram,
|
||||
self.document.view_index(),
|
||||
);
|
||||
match event {
|
||||
Event::Collapse(_, target) => {
|
||||
if !visible(self.project(), diagram, &view, &target) {
|
||||
return;
|
||||
}
|
||||
let project = self.document.project();
|
||||
self.diagram_selection
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.replace(target, |target| visible(project, diagram, &view, target));
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Choose(_, target, choice) => {
|
||||
if !visible(self.project(), diagram, &view, &target) {
|
||||
return;
|
||||
}
|
||||
self.reconcile_multiselect();
|
||||
let project = self.document.project();
|
||||
let selection = self.diagram_selection.as_mut().unwrap();
|
||||
let valid = |target: &Target| visible(project, diagram, &view, target);
|
||||
match choice {
|
||||
Choice::Replace => {
|
||||
selection.replace(target, valid);
|
||||
}
|
||||
Choice::Add => {
|
||||
selection.add(target, valid);
|
||||
}
|
||||
Choice::Remove => {
|
||||
selection.remove(&target);
|
||||
}
|
||||
Choice::Toggle => {
|
||||
selection.toggle(target, valid);
|
||||
}
|
||||
Choice::Inspect => {
|
||||
selection.set_primary(&target);
|
||||
}
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Clear(_) => {
|
||||
if let Some(selection) = &mut self.diagram_selection {
|
||||
selection.clear();
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
}
|
||||
Event::Refuse(_, reason) => self.status = reason,
|
||||
Event::Move(snapshot, delta) => {
|
||||
let Some(ids) = placements(&snapshot) else {
|
||||
self.status = "Move requires only placed elements. Remove ports, rows and connections from the selection.".into();
|
||||
return;
|
||||
};
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(self.project(), diagram, &view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.project().diagrams[&diagram].projection == ProjectionKind::Allocation
|
||||
&& ids.iter().any(|id| {
|
||||
self.project().elements
|
||||
[&self.project().diagrams[&diagram].nodes[id].element]
|
||||
.kind()
|
||||
.is_work()
|
||||
})
|
||||
{
|
||||
self.status =
|
||||
"Group allocation drag is not available yet. The selection was retained."
|
||||
.into();
|
||||
return;
|
||||
}
|
||||
if delta.x == 0. && delta.y == 0. {
|
||||
return;
|
||||
}
|
||||
match oa_core::diagram_edit::translate_placements(
|
||||
self.project(),
|
||||
diagram,
|
||||
&ids,
|
||||
delta,
|
||||
) {
|
||||
Ok(command) => {
|
||||
if self.execute(command) {
|
||||
self.inspect_multiselect_primary();
|
||||
self.status = format!(
|
||||
"Moved {} selected elements · Undo restores the group",
|
||||
ids.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
Event::Hide(snapshot) => {
|
||||
let Some(ids) = placements(&snapshot) else {
|
||||
self.status =
|
||||
"Hide requires only placed elements. Model objects remain unchanged."
|
||||
.into();
|
||||
return;
|
||||
};
|
||||
if !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| visible(self.project(), diagram, &view, target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if self.execute(Command::Batch(
|
||||
ids.iter().copied().map(Command::Delete).collect(),
|
||||
)) {
|
||||
if let Some(selection) = &mut self.diagram_selection {
|
||||
selection.clear();
|
||||
}
|
||||
self.inspect_multiselect_primary();
|
||||
self.status = format!("Hid {} placements · Model objects retained", ids.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
//! Saved-view group operations through the real App message boundary.
|
||||
use super::multiselect::{Choice as GroupChoice, Event as GroupEvent};
|
||||
use super::*;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
fn point(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
edge: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let child = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Prepare", Some(a))
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Signal", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Sa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
let a = p.place(diagram, a, point(10., 20.));
|
||||
let b = p.place(diagram, b, point(500., 20.));
|
||||
let child = p.place(diagram, child, point(40., 100.));
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.3,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.7,
|
||||
}),
|
||||
waypoints: vec![point(370., 150.)],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: point(5., -9.),
|
||||
}),
|
||||
});
|
||||
let other = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Operational work", None)
|
||||
.unwrap();
|
||||
let other_diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.place(other_diagram, other, point(-50., -100.));
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = Layer::Sa;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
a,
|
||||
b,
|
||||
child,
|
||||
edge,
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(app: &mut App, event: GroupEvent) {
|
||||
let _ = app.update(Message::DiagramSelection(event));
|
||||
}
|
||||
fn choose(app: &mut App, target: Target, choice: GroupChoice) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
dispatch(app, GroupEvent::Choose(context, target, choice));
|
||||
}
|
||||
fn select_pair(f: &mut Fixture) {
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_singleton_move_or_delete_cannot_partially_edit_a_new_group() {
|
||||
for model in [false, true] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let object = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
|
||||
for message in [
|
||||
Message::MoveNode(f.b, point(700., 600.)),
|
||||
Message::DiagramRemove(super::diagram_removal::Intent {
|
||||
context: f.app.route_context().unwrap(),
|
||||
object,
|
||||
placement: Some(f.b),
|
||||
model,
|
||||
}),
|
||||
Message::Delete,
|
||||
] {
|
||||
let _ = f.app.update(message);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_hide_action_applies_to_the_complete_visible_group() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
let _ = f.app.update(Message::HideSelected);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
fn assert_source_matches_model(app: &App) {
|
||||
let compiled = oa_core::source_project::compile_bundle(app.document.bundle(), LIMITS).unwrap();
|
||||
assert_eq!(&compiled.decoded.project, app.project());
|
||||
assert_eq!(&compiled.bundle, app.document.bundle());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toggled_group_moves_model_and_source_once_and_keeps_primary_through_history() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(snapshot.primary(), Some(&Target::Placement(f.b)));
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(50., 80.);
|
||||
view.nodes.get_mut(&f.b).unwrap().position = point(540., 80.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(80., 160.);
|
||||
view.routes[0].waypoints = vec![point(410., 210.)];
|
||||
dispatch(
|
||||
&mut f.app,
|
||||
GroupEvent::Move(snapshot.clone(), point(40., 60.)),
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_source_matches_model(&f.app);
|
||||
assert_eq!(f.app.selected_node, Some(f.b));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
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());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
assert_eq!(f.app.selected_node, Some(f.b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestor_and_child_selection_moves_child_exactly_once() {
|
||||
let mut f = fixture();
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
choose(&mut f.app, Target::Placement(f.child), GroupChoice::Toggle);
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(snapshot.targets().len(), 2);
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.get_mut(&f.a).unwrap().position = point(0., 45.);
|
||||
view.nodes.get_mut(&f.child).unwrap().position = point(30., 125.);
|
||||
dispatch(&mut f.app, GroupEvent::Move(snapshot, point(-10., 25.)));
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.selected_node, Some(f.child));
|
||||
assert_source_matches_model(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_edge_or_port_selection_refuses_the_entire_move() {
|
||||
for include_port in [false, true] {
|
||||
let mut f = fixture();
|
||||
choose(&mut f.app, Target::Placement(f.a), GroupChoice::Replace);
|
||||
let extra = if include_port {
|
||||
let view = projection::project_view(f.app.project(), f.diagram);
|
||||
let port = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.owner == f.a)
|
||||
.expect("functional exchange exposes its typed owner port");
|
||||
Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
}
|
||||
} else {
|
||||
Target::Edge(f.edge)
|
||||
};
|
||||
choose(&mut f.app, extra.clone(), GroupChoice::Toggle);
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[Target::Placement(f.a), extra]);
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
dispatch(
|
||||
&mut f.app,
|
||||
GroupEvent::Move(selection.clone(), point(40., 60.)),
|
||||
);
|
||||
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_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selection.targets()
|
||||
);
|
||||
assert!(f.app.status.contains("Move requires only placed elements"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_operation_envelopes_have_no_selection_history_status_or_allocation_side_effects() {
|
||||
// Isolate each lifetime component, including the same visible set recreated
|
||||
// by a remove/re-add cycle. Accepted setup changes are outside the assertion.
|
||||
for stale_by in ["epoch", "revision", "document", "selection generation"] {
|
||||
for action in ["move", "hide", "refuse", "collapse"] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
match stale_by {
|
||||
"epoch" => f.app.canvas_epoch += 1,
|
||||
"revision" => {
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.a].element;
|
||||
// Advance only the document revision, retaining this canvas
|
||||
// and selection epoch to independently test revision checking.
|
||||
f.app
|
||||
.document
|
||||
.execute(Command::Describe(semantic, "New revision".into()))
|
||||
.unwrap();
|
||||
}
|
||||
"document" => f.app.canvas_document = Id::new_v4(),
|
||||
_ => {
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
choose(&mut f.app, Target::Placement(f.b), GroupChoice::Toggle);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
stale.targets()
|
||||
);
|
||||
}
|
||||
}
|
||||
f.app.status = "Current feedback must survive".into();
|
||||
f.app.allocation_drag.arrange_next = true;
|
||||
let before = f.app.document.snapshot();
|
||||
let selection = f.app.multiselect_snapshot();
|
||||
let legacy = (f.app.selected, f.app.selected_node);
|
||||
let revision = f.app.document.revision();
|
||||
let epochs = (
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch,
|
||||
);
|
||||
let history = (f.app.document.can_undo(), f.app.document.can_redo());
|
||||
let draft = f
|
||||
.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
let event = match action {
|
||||
"move" => GroupEvent::Move(stale, point(99., 100.)),
|
||||
"hide" => GroupEvent::Hide(stale),
|
||||
"collapse" => GroupEvent::Collapse(stale, Target::Placement(f.a)),
|
||||
_ => GroupEvent::Refuse(stale, "Obsolete refusal".into()),
|
||||
};
|
||||
dispatch(&mut f.app, event);
|
||||
assert_eq!(
|
||||
f.app.project(),
|
||||
&before.decoded.project,
|
||||
"{stale_by}/{action}"
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
assert_eq!(f.app.multiselect_snapshot(), selection);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), legacy);
|
||||
assert_eq!(
|
||||
(
|
||||
f.app.canvas_epoch,
|
||||
f.app.camera_epoch,
|
||||
f.app.allocation_drag.epoch
|
||||
),
|
||||
epochs
|
||||
);
|
||||
assert_eq!(
|
||||
(f.app.document.can_undo(), f.app.document.can_redo()),
|
||||
history
|
||||
);
|
||||
assert_eq!(f.app.status, "Current feedback must survive");
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(
|
||||
f.app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_valid_and_invalid_property_drafts_refuse_group_edits_without_applying_them() {
|
||||
for (path, value) in [("/name", "Unapplied name"), ("/id", "-")] {
|
||||
for hide in [false, true] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let _ = f.app.update(Message::Field(path.into(), value.into()));
|
||||
assert!(f.app.draft_dirty());
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let draft =
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap();
|
||||
let event = if hide {
|
||||
GroupEvent::Hide(snapshot.clone())
|
||||
} else {
|
||||
GroupEvent::Move(snapshot.clone(), point(20., 30.))
|
||||
};
|
||||
dispatch(&mut f.app, event);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
snapshot.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(f.app.draft.as_ref().unwrap().recovery_snapshot()).unwrap(),
|
||||
draft
|
||||
);
|
||||
assert!(f.app.status.contains("Finish or cancel"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hiding_a_group_removes_only_exact_placements_and_roundtrips_in_one_history_step() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let snapshot = f.app.multiselect_snapshot().unwrap();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = f.app.project().clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
dispatch(&mut f.app, GroupEvent::Hide(snapshot));
|
||||
assert_eq!(
|
||||
f.app.project(),
|
||||
&expected,
|
||||
"engineering objects, descendant placement, authored route and opposite-layer view survive"
|
||||
);
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
assert_source_matches_model(&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());
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &after.bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
fn assert_surviving_primary(f: &Fixture) {
|
||||
let selection = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(selection.targets(), &[Target::Placement(f.a)]);
|
||||
assert_eq!(selection.primary(), Some(&Target::Placement(f.a)));
|
||||
assert_eq!(f.app.selected_node, Some(f.a));
|
||||
assert_eq!(
|
||||
f.app.selected,
|
||||
f.app.project().diagrams[&f.diagram].nodes[&f.a].element
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_primary_placement_from_model_preserves_exact_survivor_after_undo() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let _ = f.app.update(Message::Command(Command::Delete(f.b)));
|
||||
assert!(
|
||||
!f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&f.b)
|
||||
);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_primary_model_object_then_undo_does_not_restore_removed_selection_membership() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&f.b].element;
|
||||
let _ = f.app.update(Message::Command(Command::Delete(semantic)));
|
||||
assert!(!f.app.project().elements.contains_key(&semantic));
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_surviving_primary(&f);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replacing_primary_occurrence_with_same_semantic_placement_does_not_retarget_selection() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let mut candidate = f.app.project().clone();
|
||||
let view = candidate.diagrams.get_mut(&f.diagram).unwrap();
|
||||
let mut replacement = view.nodes.remove(&f.b).unwrap();
|
||||
let replacement_id = Id::new_v4();
|
||||
replacement.id = replacement_id;
|
||||
replacement.alias = "replacement_receive".into();
|
||||
replacement.position = point(800., 300.);
|
||||
view.nodes.insert(replacement_id, replacement);
|
||||
// Replace preserves the semantic inspector identity. Its normal sync used
|
||||
// to rediscover the new occurrence through that identity and steal the set.
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Command(Command::Replace(candidate.clone())));
|
||||
assert_eq!(f.app.project(), &candidate);
|
||||
assert_surviving_primary(&f);
|
||||
assert!(
|
||||
!f.app
|
||||
.multiselect_snapshot()
|
||||
.unwrap()
|
||||
.targets()
|
||||
.contains(&Target::Placement(replacement_id))
|
||||
);
|
||||
assert_source_matches_model(&f.app);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert!(
|
||||
f.app.project().diagrams[&f.diagram]
|
||||
.nodes
|
||||
.contains_key(&f.b)
|
||||
);
|
||||
assert_surviving_primary(&f);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_surface_navigation_and_return_never_resurrect_the_group() {
|
||||
for destination in [Page::Catalog, Page::Behavior] {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let stale = f.app.multiselect_snapshot().unwrap();
|
||||
let _ = f.app.update(Message::Page(destination));
|
||||
assert!(f.app.multiselect_snapshot().is_none());
|
||||
let _ = f.app.update(Message::Page(Page::Diagram));
|
||||
let live = f.app.multiselect_snapshot().unwrap();
|
||||
assert!(
|
||||
live.targets().len() <= 1,
|
||||
"navigation may inspect one exact member but must not restore the group"
|
||||
);
|
||||
dispatch(&mut f.app, GroupEvent::Move(stale, point(400., 500.)));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
fn open_placement_menu(f: &mut Fixture, placement: Id) {
|
||||
let semantic = f.app.project().diagrams[&f.diagram].nodes[&placement].element;
|
||||
let mut request = crate::model_menu::Request::object(semantic, iced::Point::new(100., 100.));
|
||||
request.placement = Some(placement);
|
||||
request.canvas = f.app.route_context();
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
assert!(f.app.model_menu.is_some());
|
||||
}
|
||||
|
||||
fn menu_action(app: &App, caption: &str) -> Message {
|
||||
crate::model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
crate::context_menu::Entry::Action { label, message, .. } if label == caption => {
|
||||
message
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("enabled menu action {caption:?} must exist"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_dismissal_preserves_group_and_add_keeps_pre_menu_members() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
let child = f.child;
|
||||
open_placement_menu(&mut f, child);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
let _ = f.app.update(Message::Menu(crate::model_menu::Event::Close));
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
selected.targets()
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().primary(),
|
||||
selected.primary()
|
||||
);
|
||||
open_placement_menu(&mut f, child);
|
||||
let add = menu_action(&f.app, "Add to selection");
|
||||
let _ = f.app.update(add);
|
||||
let selected = f.app.multiselect_snapshot().unwrap();
|
||||
assert_eq!(
|
||||
selected.targets(),
|
||||
&[
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::Placement(child)
|
||||
]
|
||||
);
|
||||
assert_eq!(selected.primary(), Some(&Target::Placement(child)));
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_member_context_menu_exposes_and_executes_group_hide_not_singleton_deletion() {
|
||||
let mut f = fixture();
|
||||
select_pair(&mut f);
|
||||
let before = f.app.document.snapshot();
|
||||
let member = f.a;
|
||||
open_placement_menu(&mut f, member);
|
||||
let popup = crate::model_menu::popup(&f.app).unwrap();
|
||||
for entry in &popup.entries {
|
||||
if let crate::context_menu::Entry::Action {
|
||||
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, action))),
|
||||
..
|
||||
} = entry
|
||||
{
|
||||
assert!(
|
||||
!matches!(
|
||||
action.as_ref(),
|
||||
Message::HideSelected | Message::Delete | Message::DiagramRemove(_)
|
||||
),
|
||||
"a selected-member group menu must not expose singleton removal"
|
||||
);
|
||||
}
|
||||
}
|
||||
let hide = menu_action(&f.app, "Hide selected placements");
|
||||
let _ = f.app.update(hide);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let view = expected.diagrams.get_mut(&f.diagram).unwrap();
|
||||
view.nodes.remove(&f.a);
|
||||
view.nodes.remove(&f.b);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert!(!f.app.delete_pending);
|
||||
assert!(f.app.model_menu.is_none());
|
||||
assert_source_matches_model(&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());
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
//! Controller acceptance: one indentation gesture is one draft transaction.
|
||||
use super::*;
|
||||
|
||||
fn fixture(source: &str) -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Text;
|
||||
app.source_workspace = Workspace::new(
|
||||
&oa_core::source_project::SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: std::collections::BTreeMap::from([("project.arcadia".into(), source.into())]),
|
||||
},
|
||||
true,
|
||||
);
|
||||
app.show_source_file(false);
|
||||
app
|
||||
}
|
||||
fn indent(app: &mut App, outdent: bool) {
|
||||
let message = Message::SourceIndent(app.source_symbol_context(), outdent);
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indentation_updates_draft_snapshot_version_and_single_undo_redo_without_model_change() {
|
||||
let source = "雨\r\n🚀\r\n";
|
||||
let mut app = fixture(source);
|
||||
app.source_editor.perform(text_editor::Action::SelectAll);
|
||||
let version = app.source_version;
|
||||
let revision = app.source_workspace.revision();
|
||||
let model = app.document.revision();
|
||||
indent(&mut app, false);
|
||||
let after = " 雨\r\n 🚀\r\n";
|
||||
assert_eq!(app.source_editor.text(), after);
|
||||
assert_eq!(app.source_snapshot.as_ref(), after);
|
||||
assert_eq!(app.source_workspace.files["project.arcadia"].text, after);
|
||||
assert!(app.source_workspace.has_edits());
|
||||
assert_eq!(app.source_version, version + 1);
|
||||
assert_eq!(app.source_workspace.revision(), revision + 1);
|
||||
assert_eq!(app.document.revision(), model);
|
||||
let _ = app.update(Message::SourceUndo);
|
||||
assert_eq!(app.source_editor.text(), source);
|
||||
assert!(!app.source_workspace.has_edits());
|
||||
let _ = app.update(Message::SourceRedo);
|
||||
assert_eq!(app.source_editor.text(), after);
|
||||
assert_eq!(app.document.revision(), model);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_hidden_and_modal_indentation_is_rejected_before_navigation_invalidation() {
|
||||
for rejected in 0..7 {
|
||||
let mut app = fixture("alpha");
|
||||
let mut context = app.source_symbol_context();
|
||||
match rejected {
|
||||
0 => context.path = "elsewhere.arcadia".into(),
|
||||
1 => context.session = Id::new_v4(),
|
||||
2 => app.page = Page::Home,
|
||||
3 => app.busy = true,
|
||||
4 => app.delete_pending = true,
|
||||
5 => app.hosted.visible = true,
|
||||
_ => app.pending = Some(Pending::New),
|
||||
}
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let version = app.source_version;
|
||||
let _ = app.update(Message::SourceIndent(context, false));
|
||||
assert_eq!(app.source_editor.text(), "alpha");
|
||||
assert_eq!(app.source_version, version);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_outdent_does_not_add_history_or_increment_source_version() {
|
||||
let mut app = fixture("alpha");
|
||||
let version = app.source_version;
|
||||
indent(&mut app, true);
|
||||
assert_eq!(app.source_version, version);
|
||||
assert!(!app.source_workspace.undo(false));
|
||||
}
|
||||
@@ -33,9 +33,6 @@ fn admission_notice(error: query::AdmissionError) -> String {
|
||||
impl App {
|
||||
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
|
||||
match message {
|
||||
Message::SourceIndent(context, _) => {
|
||||
context == &self.source_symbol_context() && self.source_navigation_available()
|
||||
}
|
||||
Message::SourceSymbols(context, event) => {
|
||||
context.session == self.source_workspace.session()
|
||||
&& context.path == self.source_workspace.active
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
//! Production App tree and its actual keyboard subscription, without a canvas click.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{
|
||||
advanced::subscription,
|
||||
futures::{StreamExt, stream, task::noop_waker_ref},
|
||||
keyboard::key::Named,
|
||||
mouse,
|
||||
};
|
||||
use std::task::{Context as PollContext, Poll};
|
||||
|
||||
async fn press(mounted: &mut Mounted, app: &mut App, named: Named) -> Vec<Message> {
|
||||
let input = key(named);
|
||||
let (mut messages, statuses) = mounted.input_with_status(
|
||||
app,
|
||||
std::slice::from_ref(&input),
|
||||
mouse::Cursor::Unavailable,
|
||||
);
|
||||
let interaction = subscription::Event::Interaction {
|
||||
window: iced::window::Id::unique(),
|
||||
event: input,
|
||||
status: statuses[0],
|
||||
};
|
||||
for recipe in subscription::into_recipes(app.subscription()) {
|
||||
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
|
||||
let mut cx = PollContext::from_waker(noop_waker_ref());
|
||||
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
for message in messages.iter().cloned() {
|
||||
let task = app.update(message);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
mounted.settle(app).await;
|
||||
messages
|
||||
}
|
||||
|
||||
async fn armed() -> (App, Mounted, Id) {
|
||||
let mut f = fixture();
|
||||
let diagram = create_view(&mut f).diagram;
|
||||
let mut project = f.app.project().clone();
|
||||
let relation = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
|
||||
let mut app = App::with_project(project);
|
||||
app.window_size = iced::Size::new(1800., 1100.);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, relation));
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
app.behavior.diagram.fit = false;
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::ContextMenu {
|
||||
context,
|
||||
object: relation,
|
||||
placement: None,
|
||||
selection_target: None,
|
||||
anchor: iced::Point::new(450., 400.),
|
||||
bend: None,
|
||||
route_segment: None,
|
||||
},
|
||||
);
|
||||
mounted.settle(&mut app).await;
|
||||
let token = app.model_menu.as_ref().unwrap().token;
|
||||
send(
|
||||
&mut app,
|
||||
Message::Menu(crate::model_menu::Event::Invoke(
|
||||
token,
|
||||
Box::new(Message::StateDiagram(Event::Choose(
|
||||
Tool::AdjustAttachment {
|
||||
relation,
|
||||
end: EdgeEnd::Source,
|
||||
},
|
||||
))),
|
||||
)),
|
||||
);
|
||||
mounted.settle(&mut app).await;
|
||||
assert!(app.model_menu.is_none());
|
||||
assert_eq!(
|
||||
app.behavior.diagram.tool,
|
||||
Tool::AdjustAttachment {
|
||||
relation,
|
||||
end: EdgeEnd::Source
|
||||
}
|
||||
);
|
||||
(app, mounted, relation)
|
||||
}
|
||||
|
||||
fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) {
|
||||
let id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
|
||||
let (_, bounds, focused) = mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.find(|(candidate, _, _)| candidate.as_ref() == Some(&id))
|
||||
.unwrap();
|
||||
(bounds, focused)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() {
|
||||
let (mut app, mut mounted, _) = armed().await;
|
||||
let before = app.document.snapshot();
|
||||
let history = (app.document.can_undo(), app.document.can_redo());
|
||||
let camera = (
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit,
|
||||
);
|
||||
assert!(!canvas_focus(&mut mounted, &app).1);
|
||||
let messages = press(&mut mounted, &mut app, Named::Escape).await;
|
||||
assert!(messages.iter().any(|m| matches!(m, Message::Escape)));
|
||||
assert_eq!(app.behavior.diagram.tool, Tool::Select);
|
||||
assert!(!canvas_focus(&mut mounted, &app).1);
|
||||
press(&mut mounted, &mut app, Named::Delete).await;
|
||||
assert!(
|
||||
!app.delete_pending,
|
||||
"Escape must not grant canvas Delete ownership"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit
|
||||
),
|
||||
camera
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() {
|
||||
let (mut app, mut mounted, _) = armed().await;
|
||||
let before = app.document.snapshot();
|
||||
// Existing modal Escape consumes the first key without canceling the tool underneath.
|
||||
send(&mut app, Message::Delete);
|
||||
assert!(app.delete_pending && app.delete_target.is_some());
|
||||
mounted.settle(&mut app).await;
|
||||
press(&mut mounted, &mut app, Named::Escape).await;
|
||||
assert!(!app.delete_pending);
|
||||
assert!(matches!(
|
||||
app.behavior.diagram.tool,
|
||||
Tool::AdjustAttachment { .. }
|
||||
));
|
||||
// A real canvas click grants ownership; inner-canvas Escape remains live.
|
||||
let bounds = canvas_focus(&mut mounted, &app).0;
|
||||
mounted
|
||||
.click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.))
|
||||
.await;
|
||||
assert!(canvas_focus(&mut mounted, &app).1);
|
||||
let messages = press(&mut mounted, &mut app, Named::Escape).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select))))
|
||||
);
|
||||
assert_eq!(app.behavior.diagram.tool, Tool::Select);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn state_bend_delayed_commit_after_same_view_selection_is_inert_and_live_commit_works() {
|
||||
let (mut app, _, relation, _) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let next = app.project().relations[&relation].target();
|
||||
let waypoints = vec![Position { x: 715., y: -425. }];
|
||||
let delayed = Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: waypoints.clone(),
|
||||
};
|
||||
send(&mut app, Message::Select(next));
|
||||
assert_eq!(app.selected, next);
|
||||
assert_eq!(
|
||||
app.state_context(),
|
||||
Some(context),
|
||||
"same-view selection does not retire the context stamp"
|
||||
);
|
||||
let before = app.document.snapshot();
|
||||
let camera = (
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit,
|
||||
);
|
||||
event(&mut app, delayed);
|
||||
assert_eq!(app.selected, next);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert_eq!(
|
||||
(
|
||||
app.behavior.diagram.zoom,
|
||||
app.behavior.diagram.pan,
|
||||
app.behavior.diagram.fit
|
||||
),
|
||||
camera
|
||||
);
|
||||
send(&mut app, Message::Select(relation));
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: waypoints.clone(),
|
||||
},
|
||||
);
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let route = expected
|
||||
.diagrams
|
||||
.get_mut(&context.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.iter_mut()
|
||||
.find(|route| route.edge == relation)
|
||||
.unwrap();
|
||||
route.waypoints = waypoints;
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_source_model_agree(&app);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"live drag remains a single transaction"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action() {
|
||||
let (mut app, _, relation, _) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let point = Position { x: 500., y: -400. };
|
||||
event(
|
||||
&mut app,
|
||||
Event::ContextMenu {
|
||||
context,
|
||||
object: relation,
|
||||
placement: None,
|
||||
selection_target: None,
|
||||
anchor: iced::Point::ORIGIN,
|
||||
bend: Some(1),
|
||||
route_segment: Some((2, point)),
|
||||
},
|
||||
);
|
||||
let menu = crate::model_menu::popup(&app).unwrap();
|
||||
let mut add = false;
|
||||
let mut remove = false;
|
||||
let mut delete = false;
|
||||
for entry in menu.entries {
|
||||
if let crate::context_menu::Entry::Action {
|
||||
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, message))),
|
||||
..
|
||||
} = entry
|
||||
{
|
||||
match *message {
|
||||
Message::StateDiagram(Event::AddBend {
|
||||
relation: id,
|
||||
segment: Some((2, at)),
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(id, relation);
|
||||
assert_eq!(at, point);
|
||||
add = true;
|
||||
}
|
||||
Message::StateDiagram(Event::RemoveBend {
|
||||
relation: id,
|
||||
index: 1,
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(id, relation);
|
||||
remove = true;
|
||||
}
|
||||
Message::Delete => delete = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(add && remove && delete);
|
||||
}
|
||||
|
||||
fn setup() -> (App, Id, Id, Id) {
|
||||
let mut f = fixture();
|
||||
let diagram = create_view(&mut f).diagram;
|
||||
let mut project = f.app.project().clone();
|
||||
let relation = project.relate(RelationKind::StateTransition, "Bend trial", f.leaf, f.b);
|
||||
let second = state_edit::create_view(&project, f.machine).unwrap();
|
||||
project = prepare_command(&project, &second.command).unwrap().project;
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: relation,
|
||||
waypoints: vec![
|
||||
Position { x: 650., y: -400. },
|
||||
Position { x: 300., y: -400. },
|
||||
Position { x: 650., y: -400. },
|
||||
],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
offset: Position { x: 9., y: -23. },
|
||||
}),
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.234567,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.75,
|
||||
}),
|
||||
});
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, relation));
|
||||
(app, diagram, relation, f.a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_commands_preserve_other_fields_views_and_exact_history() {
|
||||
let (mut app, diagram, relation, _) = setup();
|
||||
let baseline = app.document.snapshot();
|
||||
let points = vec![Position { x: 715., y: 425. }];
|
||||
let mut expected = app.project().clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points.clone();
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: points.clone(),
|
||||
},
|
||||
);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_source_model_agree(&app);
|
||||
let changed = app.document.snapshot();
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: points,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&changed, &app.document.snapshot()),
|
||||
"equal bends must not add history"
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &changed.bundle);
|
||||
let context = app.state_context().unwrap();
|
||||
event(&mut app, Event::ClearBends { context, relation });
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.clear();
|
||||
assert_eq!(app.project(), &expected);
|
||||
let cleared = app.document.snapshot();
|
||||
let context = app.state_context().unwrap();
|
||||
event(&mut app, Event::ClearBends { context, relation });
|
||||
assert!(Arc::ptr_eq(&cleared, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_self_loop_add_move_remove_clear_preserves_project_and_source_history() {
|
||||
let (app, diagram, relation, _) = setup();
|
||||
let mut project = app.project().clone();
|
||||
let RelationBody::StateTransition { source, target, .. } =
|
||||
&mut project.relations.get_mut(&relation).unwrap().body
|
||||
else {
|
||||
panic!("State transition fixture")
|
||||
};
|
||||
*target = *source;
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.clear();
|
||||
oa_core::validate_candidate(&project).unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, relation));
|
||||
let baseline = app.document.snapshot();
|
||||
let geometry = state_edit::bend_route(app.project(), diagram, relation).unwrap();
|
||||
assert!(!geometry.custom_suspended);
|
||||
assert!(geometry.pins.is_empty());
|
||||
assert!(
|
||||
geometry.points.len() >= 4,
|
||||
"actual external self-loop routing"
|
||||
);
|
||||
let segment = 1;
|
||||
let point = Position {
|
||||
x: (geometry.points[segment].x + geometry.points[segment + 1].x) / 2.,
|
||||
y: (geometry.points[segment].y + geometry.points[segment + 1].y) / 2.,
|
||||
};
|
||||
|
||||
// Each operation must alter only the complete Project's waypoint list;
|
||||
// Undo and Redo must restore exact serialized source, not just geometry.
|
||||
fn check(app: &mut App, diagram: Id, mutation: Event, points: Vec<Position>) {
|
||||
let before = app.document.snapshot();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points;
|
||||
event(app, mutation);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_source_model_agree(app);
|
||||
let after = app.document.snapshot();
|
||||
assert_ne!(after.bundle, before.bundle);
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
send(app, Message::Redo);
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert_eq!(app.document.bundle(), &after.bundle);
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((segment, point)),
|
||||
},
|
||||
vec![point],
|
||||
);
|
||||
let moved = Position {
|
||||
x: point.x + 45.,
|
||||
y: point.y - 35.,
|
||||
};
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: vec![moved],
|
||||
},
|
||||
vec![moved],
|
||||
);
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: 0,
|
||||
},
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
send(&mut app, Message::Undo);
|
||||
let context = app.state_context().unwrap();
|
||||
check(
|
||||
&mut app,
|
||||
diagram,
|
||||
Event::ClearBends { context, relation },
|
||||
vec![],
|
||||
);
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_source_model_agree(&app);
|
||||
// Clear is a new source-preserving edit and may retain empty-list trivia.
|
||||
// Exact original bytes are restored by history, not by reauthoring a value.
|
||||
for _ in 0..3 {
|
||||
assert!(app.document.can_undo());
|
||||
send(&mut app, Message::Undo);
|
||||
}
|
||||
assert_eq!(app.project(), &baseline.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &baseline.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_insertion_uses_captured_traversal_segment_and_removal_only_changes_pins() {
|
||||
let (app, diagram, relation, _) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let p = app.project();
|
||||
let geometry = state_edit::bend_route(p, diagram, relation).unwrap();
|
||||
let original = &p.diagrams[&diagram].routes[0];
|
||||
let reverse = geometry
|
||||
.points
|
||||
.windows(2)
|
||||
.position(|pair| pair[0] == original.waypoints[0] && pair[1] == original.waypoints[1])
|
||||
.unwrap();
|
||||
for (segment, expected_index) in [(reverse, 1), (reverse + 1, 2)] {
|
||||
let point = Position { x: 500., y: -400. };
|
||||
let planned = state_edit::plan(
|
||||
p,
|
||||
context,
|
||||
&Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((segment, point)),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let actual = prepare_command(p, &planned.command).unwrap().project;
|
||||
let mut expected = p.clone();
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.insert(expected_index, point);
|
||||
assert_eq!(actual, expected);
|
||||
let removed = state_edit::plan(
|
||||
&actual,
|
||||
context,
|
||||
&Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: expected_index,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
prepare_command(&actual, &removed.command).unwrap().project,
|
||||
*p
|
||||
);
|
||||
}
|
||||
let default = state_edit::plan(
|
||||
p,
|
||||
context,
|
||||
&Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let Command::SetEdgeRoute { route, .. } = default.command else {
|
||||
panic!("route command")
|
||||
};
|
||||
assert_eq!(route.waypoints.len(), original.waypoints.len() + 1);
|
||||
assert_eq!(route.source_attachment, original.source_attachment);
|
||||
assert_eq!(route.target_attachment, original.target_attachment);
|
||||
assert_eq!(route.label, original.label);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bend_stale_nonfinite_capacity_hidden_and_folded_requests_are_inert() {
|
||||
let (mut app, diagram, relation, composite) = setup();
|
||||
let context = app.state_context().unwrap();
|
||||
let baseline = app.document.snapshot();
|
||||
for mutation in [
|
||||
Event::SetBends {
|
||||
context: Context {
|
||||
epoch: context.epoch + 1,
|
||||
..context
|
||||
},
|
||||
relation,
|
||||
waypoints: vec![],
|
||||
},
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: vec![Position { x: f32::NAN, y: 0. }],
|
||||
},
|
||||
Event::SetBends {
|
||||
context,
|
||||
relation,
|
||||
waypoints: vec![Position { x: 0., y: 0. }; 129],
|
||||
},
|
||||
Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((usize::MAX, Position { x: 0., y: 0. })),
|
||||
},
|
||||
Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: usize::MAX,
|
||||
},
|
||||
] {
|
||||
event(&mut app, mutation);
|
||||
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
}
|
||||
let mut p = app.project().clone();
|
||||
p.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints =
|
||||
vec![Position { x: 500., y: -400. }; 128];
|
||||
assert!(
|
||||
state_edit::plan(
|
||||
&p,
|
||||
context,
|
||||
&Event::AddBend {
|
||||
context,
|
||||
relation,
|
||||
segment: Some((1, Position { x: 500., y: -400. }))
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut hidden = app.project().clone();
|
||||
hidden
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(relation);
|
||||
assert!(state_edit::bend_route(&hidden, diagram, relation).is_none());
|
||||
assert!(state_edit::plan(&hidden, context, &Event::ClearBends { context, relation }).is_err());
|
||||
let owner = node(&app, diagram, composite);
|
||||
let mut folded = app.project().clone();
|
||||
folded
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert!(state_edit::bend_route(&folded, diagram, relation).is_none());
|
||||
assert!(state_edit::plan(&folded, context, &Event::ClearBends { context, relation }).is_err());
|
||||
let mut internal = app.project().clone();
|
||||
internal.diagrams.get_mut(&diagram).unwrap().routes.clear();
|
||||
if let RelationBody::StateTransition {
|
||||
source,
|
||||
target,
|
||||
transition_kind,
|
||||
..
|
||||
} = &mut internal.relations.get_mut(&relation).unwrap().body
|
||||
{
|
||||
*target = *source;
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
assert!(state_edit::bend_route(&internal, diagram, relation).is_none());
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Unapplied bend owner name".into()),
|
||||
);
|
||||
let draft = app.draft.as_ref().unwrap().value.clone();
|
||||
event(&mut app, Event::ClearBends { context, relation });
|
||||
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
|
||||
assert_eq!(app.draft.as_ref().unwrap().value, draft);
|
||||
}
|
||||
@@ -114,6 +114,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
match event {
|
||||
Event::Selection(event) => self.multiselect_event(event),
|
||||
Event::Remove(intent) => self.remove_from_diagram(intent),
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
@@ -156,11 +157,17 @@ impl App {
|
||||
context,
|
||||
object,
|
||||
placement,
|
||||
selection_target,
|
||||
anchor,
|
||||
bend,
|
||||
route_segment,
|
||||
} => {
|
||||
let mut request = crate::model_menu::Request::object(object, anchor);
|
||||
request.canvas = Some(context);
|
||||
request.placement = placement;
|
||||
request.selection_target = selection_target.map(Box::new);
|
||||
request.bend = bend;
|
||||
request.route_segment = route_segment;
|
||||
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
}
|
||||
Event::EditTransition(_, id) => {
|
||||
@@ -172,6 +179,24 @@ impl App {
|
||||
)));
|
||||
}
|
||||
Event::Choose(tool) => {
|
||||
if let Tool::AdjustAttachment { relation, end } = tool {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before adjusting an attachment.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(context) = self.state_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
if state_edit::attachment_node(self.project(), context.diagram, relation, end)
|
||||
.is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.selected = relation;
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
}
|
||||
if !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
@@ -210,8 +235,37 @@ impl App {
|
||||
let Some(context) = self.state_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
if let Event::SetBends { relation, .. } = &mutation
|
||||
&& *relation != self.selected
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if let Event::SetAttachment {
|
||||
relation,
|
||||
end,
|
||||
attachment,
|
||||
..
|
||||
} = &mutation
|
||||
{
|
||||
let existing = self.project().diagrams[&context.diagram]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.edge == *relation)
|
||||
.and_then(|route| route.attachment(*end));
|
||||
if existing == *attachment {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
match state_edit::plan(self.project(), context, &mutation) {
|
||||
Ok(plan) => {
|
||||
if let Command::SetEdgeRoute { diagram, route } = &plan.command
|
||||
&& crate::route_edit::stored_route(
|
||||
&self.project().diagrams[diagram],
|
||||
route.edge,
|
||||
) == *route
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
if self.execute(plan.command) {
|
||||
self.behavior.diagram.diagram = Some(plan.diagram);
|
||||
self.behavior.diagram.placement = plan.placement;
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! App transaction and draft boundaries for captured state-canvas messages.
|
||||
use super::*;
|
||||
#[path = "state_attachment_escape_tests.rs"]
|
||||
mod attachment_escape;
|
||||
#[path = "state_bend_tests.rs"]
|
||||
mod bends;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
state_edit::{self, Context, Endpoint, Event, Tool},
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
//! State selection layout and modifier admission on the mounted production App.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
canvas_workspace::tests::{Mounted, key},
|
||||
state_edit,
|
||||
};
|
||||
use crate::diagram_usage::Target;
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
use iced::{Point, Rectangle, Size, Vector, keyboard, mouse};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
a_element: Id,
|
||||
b_element: Id,
|
||||
edge: Id,
|
||||
action: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut p = Project::empty();
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Operating modes", None)
|
||||
.unwrap();
|
||||
let region = p
|
||||
.add_element(Kind::Region, Layer::Oa, "Main region", Some(machine))
|
||||
.unwrap();
|
||||
let a_element = p
|
||||
.add_element(Kind::State, Layer::Oa, "First state", Some(region))
|
||||
.unwrap();
|
||||
let b_element = p
|
||||
.add_element(
|
||||
Kind::State,
|
||||
Layer::Oa,
|
||||
&format!("Second state {}", "with a long primary name ".repeat(20)),
|
||||
Some(region),
|
||||
)
|
||||
.unwrap();
|
||||
let work = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Reusable work", None)
|
||||
.unwrap();
|
||||
let action = p.relate(RelationKind::StateAction, "On entry", a_element, work);
|
||||
let edge = p.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Next state",
|
||||
a_element,
|
||||
b_element,
|
||||
);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::StateMachine { machine };
|
||||
let mut placements = vec![];
|
||||
// All State elements, including machine/Region, are initially placed.
|
||||
// A fixture already missing its containers would not test picker insertion.
|
||||
for (element, x, y, width, height) in [
|
||||
(machine, 0., 0., 800., 500.),
|
||||
(region, 20., 70., 760., 410.),
|
||||
(a_element, 60., 160., 230., 180.),
|
||||
(b_element, 430., 160., 260., 180.),
|
||||
] {
|
||||
let node = p.place(diagram, element, Position { x, y });
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.size = oa_core::Size { width, height };
|
||||
placements.push(node);
|
||||
}
|
||||
let a = placements[2];
|
||||
let b = placements[3];
|
||||
validate_candidate(&p).unwrap();
|
||||
assert!(state_edit::unplaced(&p, diagram).is_empty());
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Behavior;
|
||||
app.layer = Layer::Oa;
|
||||
app.behavior.active_owner = Some(machine);
|
||||
app.behavior.diagram = crate::behavior::state_view::State {
|
||||
diagram: Some(diagram),
|
||||
fit: true,
|
||||
..Default::default()
|
||||
};
|
||||
app.selected = diagram;
|
||||
app.selected_node = None;
|
||||
app.window_size = Size::new(1442., 932.);
|
||||
app.refresh_draft();
|
||||
app.reconcile_multiselect();
|
||||
assert_eq!(app.state_context().unwrap().diagram, diagram);
|
||||
Fixture {
|
||||
app,
|
||||
diagram,
|
||||
a,
|
||||
b,
|
||||
a_element,
|
||||
b_element,
|
||||
edge,
|
||||
action,
|
||||
}
|
||||
}
|
||||
|
||||
fn owner(mounted: &mut Mounted, app: &App) -> (Rectangle, bool) {
|
||||
let geometry = mounted.geometry(app);
|
||||
let (_, bounds, focused) = geometry
|
||||
.focused
|
||||
.into_iter()
|
||||
.find(|(id, _, _)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
})
|
||||
.expect("real State focus::Owner rectangle must be measurable");
|
||||
(bounds, focused)
|
||||
}
|
||||
fn camera(app: &App) -> (f32, Vector, bool, Option<Size>) {
|
||||
let state = &app.behavior.diagram;
|
||||
(state.zoom, state.pan, state.fit, state.viewport)
|
||||
}
|
||||
async fn selection(mounted: &mut Mounted, app: &mut App, target: Option<Target>) {
|
||||
let context = app.multiselect_context().unwrap();
|
||||
let event = match target {
|
||||
Some(target) => multiselect::Event::Choose(context, target, multiselect::Choice::Toggle),
|
||||
None => multiselect::Event::Clear(context),
|
||||
};
|
||||
let task = app.update(Message::DiagramSelection(event));
|
||||
mounted.perform(app, task).await;
|
||||
mounted.settle(app).await;
|
||||
}
|
||||
fn node_point(mounted: &mut Mounted, f: &Fixture, placement: Id) -> Point {
|
||||
let bounds = owner(mounted, &f.app).0;
|
||||
let node = &f.app.project().diagrams[&f.diagram].nodes[&placement];
|
||||
let state = &f.app.behavior.diagram;
|
||||
Point::new(
|
||||
bounds.x + (node.position.x + 40.) * state.zoom + state.pan.x,
|
||||
bounds.y + (node.position.y + 25.) * state.zoom + state.pan.y,
|
||||
)
|
||||
}
|
||||
fn modifiers(shift: bool) -> iced::Event {
|
||||
iced::Event::Keyboard(keyboard::Event::ModifiersChanged(if shift {
|
||||
keyboard::Modifiers::SHIFT
|
||||
} else {
|
||||
keyboard::Modifiers::default()
|
||||
}))
|
||||
}
|
||||
struct FocusField(Rectangle);
|
||||
impl Operation for FocusField {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&iced::advanced::widget::Id>,
|
||||
bounds: Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if bounds == self.0 {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_selection_and_missing_picker_never_resize_or_refit_the_canvas() {
|
||||
let mut f = fixture();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
let baseline = f.app.document.snapshot();
|
||||
let bounds = owner(&mut mounted, &f.app).0;
|
||||
let fitted = camera(&f.app);
|
||||
assert!(
|
||||
fitted.2,
|
||||
"keep Fit active to expose any resize-triggered camera jump"
|
||||
);
|
||||
assert!(fitted.3.is_some());
|
||||
assert!(bounds.width > 400. && bounds.height > 200.);
|
||||
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
|
||||
assert!(f.app.multiselect_snapshot().unwrap().targets().is_empty());
|
||||
for (target, expected) in [
|
||||
(Some(Target::Placement(f.a)), vec![Target::Placement(f.a)]),
|
||||
(
|
||||
Some(Target::Placement(f.b)),
|
||||
vec![Target::Placement(f.a), Target::Placement(f.b)],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(f.edge)),
|
||||
vec![
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::Edge(f.edge),
|
||||
],
|
||||
),
|
||||
(
|
||||
Some(Target::Edge(f.edge)),
|
||||
vec![Target::Placement(f.a), Target::Placement(f.b)],
|
||||
),
|
||||
(
|
||||
Some(Target::StateRow {
|
||||
placement: f.a,
|
||||
relation: f.action,
|
||||
}),
|
||||
vec![
|
||||
Target::Placement(f.a),
|
||||
Target::Placement(f.b),
|
||||
Target::StateRow {
|
||||
placement: f.a,
|
||||
relation: f.action,
|
||||
},
|
||||
],
|
||||
),
|
||||
(None, vec![]),
|
||||
] {
|
||||
selection(&mut mounted, &mut f.app, target).await;
|
||||
assert_eq!(f.app.multiselect_snapshot().unwrap().targets(), expected);
|
||||
assert_eq!(
|
||||
owner(&mut mounted, &f.app).0,
|
||||
bounds,
|
||||
"selection strip changed the measured State canvas rectangle"
|
||||
);
|
||||
assert_eq!(
|
||||
camera(&f.app),
|
||||
fitted,
|
||||
"selection feedback silently refitted the camera"
|
||||
);
|
||||
assert_eq!(f.app.project(), &baseline.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &baseline.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.draft_dirty());
|
||||
}
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.b))).await;
|
||||
let captured = f.app.multiselect_snapshot().unwrap();
|
||||
let task = f
|
||||
.app
|
||||
.update(Message::DiagramSelection(multiselect::Event::Hide(
|
||||
captured,
|
||||
)));
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let missing: std::collections::BTreeSet<_> = state_edit::unplaced(f.app.project(), f.diagram)
|
||||
.into_iter()
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing,
|
||||
std::collections::BTreeSet::from([f.a_element, f.b_element])
|
||||
);
|
||||
let mut expected = baseline.decoded.project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.a);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.remove(&f.b);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(
|
||||
owner(&mut mounted, &f.app).0,
|
||||
bounds,
|
||||
"the first unplaced picker changed canvas bounds"
|
||||
);
|
||||
assert_eq!(camera(&f.app), fitted);
|
||||
let hidden_source = f.app.document.bundle().clone();
|
||||
let task = f.app.update(Message::Undo);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert!(state_edit::unplaced(f.app.project(), f.diagram).is_empty());
|
||||
assert_eq!(f.app.project(), &baseline.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &baseline.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(
|
||||
owner(&mut mounted, &f.app).0,
|
||||
bounds,
|
||||
"removing the unplaced picker changed canvas bounds"
|
||||
);
|
||||
assert_eq!(camera(&f.app), fitted);
|
||||
let task = f.app.update(Message::Redo);
|
||||
mounted.perform(&mut f.app, task).await;
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &hidden_source);
|
||||
assert_eq!(owner(&mut mounted, &f.app).0, bounds);
|
||||
assert_eq!(camera(&f.app), fitted);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_shift_held_in_clean_inspector_toggles_on_first_canvas_press() {
|
||||
let mut f = fixture();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
|
||||
let field = mounted
|
||||
.geometry(&f.app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|bounds| bounds.x > f.app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("State Inspector field is mounted");
|
||||
mounted.operate(&f.app, &mut FocusField(field));
|
||||
assert!(!owner(&mut mounted, &f.app).1);
|
||||
let before = f.app.document.snapshot();
|
||||
mounted
|
||||
.batch(&mut f.app, &[modifiers(true)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
!owner(&mut mounted, &f.app).1,
|
||||
"passive Shift must not acquire State canvas focus"
|
||||
);
|
||||
let at = node_point(&mut mounted, &f, f.b);
|
||||
let messages = mounted.click(&mut f.app, at).await;
|
||||
assert!(messages.iter().any(|message| matches!(message,
|
||||
Message::DiagramSelection(multiselect::Event::Choose(_, Target::Placement(id), multiselect::Choice::Toggle)) if *id == f.b
|
||||
)), "StateCanvas must map its typed selection event to the common App boundary");
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
assert_eq!(f.app.behavior.diagram.placement, Some(f.b));
|
||||
assert_eq!(f.app.selected_node, None);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.draft_dirty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_state_busy_suspension_tracks_shift_without_granting_focus_or_commands() {
|
||||
let mut f = fixture();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
selection(&mut mounted, &mut f.app, Some(Target::Placement(f.a))).await;
|
||||
// Explicitly revoke focus rather than relying on incidental initial focus.
|
||||
mounted.operate(
|
||||
&f.app,
|
||||
&mut FocusField(Rectangle {
|
||||
x: -1000.,
|
||||
y: -1000.,
|
||||
width: 1.,
|
||||
height: 1.,
|
||||
}),
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
let at = node_point(&mut mounted, &f, f.b);
|
||||
f.app.busy = true;
|
||||
mounted.settle(&mut f.app).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[modifiers(true), key(keyboard::key::Named::Delete)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted.click(&mut f.app, at).await;
|
||||
assert!(!owner(&mut mounted, &f.app).1);
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a)]
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
f.app.busy = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert!(!owner(&mut mounted, &f.app).1);
|
||||
// No second modifier event: the passive suspended update must survive.
|
||||
mounted.click(&mut f.app, at).await;
|
||||
assert_eq!(
|
||||
f.app.multiselect_snapshot().unwrap().targets(),
|
||||
&[Target::Placement(f.a), Target::Placement(f.b)]
|
||||
);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert!(!f.app.delete_pending);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -158,6 +158,14 @@ impl Mounted {
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<Message> {
|
||||
self.input_with_status(app, events, cursor).0
|
||||
}
|
||||
pub fn input_with_status(
|
||||
&mut self,
|
||||
app: &App,
|
||||
events: &[iced::Event],
|
||||
cursor: mouse::Cursor,
|
||||
) -> (Vec<Message>, Vec<iced::event::Status>) {
|
||||
let mut messages = vec![];
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
@@ -165,7 +173,7 @@ impl Mounted {
|
||||
std::mem::take(&mut self.cache),
|
||||
&mut self.renderer,
|
||||
);
|
||||
let _ = ui.update(
|
||||
let (_, statuses) = ui.update(
|
||||
events,
|
||||
cursor,
|
||||
&mut self.renderer,
|
||||
@@ -173,7 +181,7 @@ impl Mounted {
|
||||
&mut messages,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
messages
|
||||
(messages, statuses)
|
||||
}
|
||||
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
|
||||
let mut ready = vec![];
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
// Included in state_canvas::tests: exercise the real canvas event path.
|
||||
|
||||
fn bend_fixture(authored: bool) -> (Fixture, Id) {
|
||||
let mut f = fixture();
|
||||
let relation = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Standby to stopped",
|
||||
f.b,
|
||||
f.c,
|
||||
);
|
||||
if authored {
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: relation,
|
||||
waypoints: vec![Position {
|
||||
x: 650.25,
|
||||
y: 340.75,
|
||||
}],
|
||||
source_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 0.5,
|
||||
}),
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25,
|
||||
}),
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.43,
|
||||
offset: Position { x: 28., y: -16. },
|
||||
}),
|
||||
});
|
||||
}
|
||||
validate_candidate(&f.project).unwrap();
|
||||
(f, relation)
|
||||
}
|
||||
|
||||
fn bend_segment(scene: &Scene, relation: Id) -> (usize, Point) {
|
||||
let edge = &scene.routed.edges[&relation];
|
||||
edge.points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
|
||||
.map(|(i, p)| {
|
||||
(
|
||||
i,
|
||||
Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.),
|
||||
(p[1].x - p[0].x).abs() + (p[1].y - p[0].y).abs(),
|
||||
)
|
||||
})
|
||||
.filter(|(_, p, length)| {
|
||||
*length > 40. && edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.)
|
||||
})
|
||||
.max_by(|a, b| a.2.total_cmp(&b.2))
|
||||
.map(|(i, p, _)| (i, p))
|
||||
.expect("fixture has an exposed interior route segment")
|
||||
}
|
||||
|
||||
fn bend_press(canvas: &StateCanvas<'_>, state: &mut State, at: Point) {
|
||||
motion(canvas, state, at, at);
|
||||
let event = input(
|
||||
canvas,
|
||||
state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
at,
|
||||
);
|
||||
assert!(
|
||||
!matches!(event, Some(Event::SetBends { .. })),
|
||||
"press alone cannot commit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_segment_drag_inserts_one_pin_using_ordered_pointer_at_three_zooms() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let (f, relation) = bend_fixture(false);
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let (_, midpoint) = bend_segment(&baseline, relation);
|
||||
let start = screen(&canvas, midpoint);
|
||||
let destination = Position {
|
||||
x: ((midpoint.x + 45.) / 5.).round() * 5.,
|
||||
y: ((midpoint.y + 30.) / 5.).round() * 5.,
|
||||
};
|
||||
let release = screen(&canvas, point(destination));
|
||||
let later_batch_cursor = release + Vector::new(137., 83.);
|
||||
motion(&canvas, &mut state, start, later_batch_cursor);
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
later_batch_cursor,
|
||||
);
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
motion(&canvas, &mut state, release, later_batch_cursor);
|
||||
assert_eq!(
|
||||
state.bend_preview.as_ref().unwrap().waypoints,
|
||||
vec![destination]
|
||||
);
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].pins,
|
||||
vec![destination]
|
||||
);
|
||||
let event = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
later_batch_cursor,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&event, Event::SetBends { context, relation: id, waypoints }
|
||||
if *context == canvas.context && *id == relation && *waypoints == vec![destination])
|
||||
);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
later_batch_cursor
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let after = prepare_command(
|
||||
&f.project,
|
||||
&state_edit::plan(&f.project, canvas.context, &event)
|
||||
.unwrap()
|
||||
.command,
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let stored = &after.diagrams[&f.diagram].routes[0];
|
||||
assert_eq!(stored.waypoints, vec![destination]);
|
||||
assert!(
|
||||
stored.source_attachment.is_none()
|
||||
&& stored.target_attachment.is_none()
|
||||
&& stored.label.is_none()
|
||||
);
|
||||
assert_eq!(after.relations, f.project.relations);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_authored_pin_move_preserves_full_project_and_exact_undo_redo() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let (mut f, relation) = bend_fixture(true);
|
||||
let mut other = f.project.diagrams[&f.diagram].clone();
|
||||
other.id = Id::new_v4();
|
||||
other.alias = "bend_reference_view".into();
|
||||
other.nodes = other
|
||||
.nodes
|
||||
.into_values()
|
||||
.map(|mut node| {
|
||||
node.id = Id::new_v4();
|
||||
node.alias = format!("bend_reference_{}", node.alias);
|
||||
(node.id, node)
|
||||
})
|
||||
.collect();
|
||||
let other_id = other.id;
|
||||
f.project.diagrams.insert(other_id, other.clone());
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let start = screen(&canvas, point(original.waypoints[0]));
|
||||
let destination = Position { x: 690., y: 365. };
|
||||
let release = screen(&canvas, point(destination));
|
||||
bend_press(&canvas, &mut state, start);
|
||||
motion(&canvas, &mut state, release, release);
|
||||
let preview = state.bend_preview.as_ref().unwrap();
|
||||
let mut intended = original.clone();
|
||||
intended.waypoints[0] = destination;
|
||||
assert_eq!(preview, &intended);
|
||||
assert_eq!(
|
||||
f.project.diagrams[&f.diagram].routes[0], original,
|
||||
"preview is transient"
|
||||
);
|
||||
let preview_scene = canvas.scene(&state).unwrap();
|
||||
let event = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
release,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&event, Event::SetBends { waypoints, .. } if waypoints == &intended.waypoints)
|
||||
);
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = intended;
|
||||
let mut document = Document::from_project(f.project.clone(), Default::default()).unwrap();
|
||||
let before = document.bundle().clone();
|
||||
document.execute(plan.command).unwrap();
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.project().diagrams[&other_id], other);
|
||||
let committed_view = projection::project_view(document.project(), f.diagram);
|
||||
let committed = routing::route_diagram(
|
||||
document.project(),
|
||||
&document.project().diagrams[&f.diagram],
|
||||
&committed_view,
|
||||
);
|
||||
let preview_edge = &preview_scene.routed.edges[&relation];
|
||||
let committed_edge = &committed.edges[&relation];
|
||||
assert_eq!(preview_edge.points, committed_edge.points);
|
||||
assert_eq!(preview_edge.pin_fractions, committed_edge.pin_fractions);
|
||||
assert_eq!(preview_edge.warnings, committed_edge.warnings);
|
||||
let edited = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &f.project);
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert!(!document.undo(), "one drag adds one history operation");
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &expected);
|
||||
assert_eq!(document.bundle(), &edited);
|
||||
assert!(!document.redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_stationary_and_out_and_back_restore_off_grid_or_automatic_route() {
|
||||
for authored in [false, true] {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for travel in [false, true] {
|
||||
let (f, relation) = bend_fixture(authored);
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let world = if authored {
|
||||
point(scene.routed.edges[&relation].pins[0])
|
||||
} else {
|
||||
bend_segment(&scene, relation).1
|
||||
};
|
||||
let start = screen(&canvas, world);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
if travel {
|
||||
let away = start + Vector::new(45., 30.) * zoom;
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(state.bend_preview.is_some());
|
||||
motion(&canvas, &mut state, start, start);
|
||||
}
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
start
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
scene.routed.edges[&relation].points
|
||||
);
|
||||
assert_eq!(
|
||||
f.project.diagrams[&f.diagram].routes.len(),
|
||||
usize::from(authored)
|
||||
);
|
||||
if authored {
|
||||
assert_eq!(
|
||||
f.project.diagrams[&f.diagram].routes[0].waypoints,
|
||||
vec![Position {
|
||||
x: 650.25,
|
||||
y: 340.75
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_cancel_on_camera_context_focus_and_pointer_interruptions() {
|
||||
for cancellation in 0..16 {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let start = screen(&canvas, point(original.waypoints[0]));
|
||||
let release = start + Vector::new(45., 30.);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
motion(&canvas, &mut state, release, release);
|
||||
assert!(state.bend_preview.is_some(), "case {cancellation}");
|
||||
let mut release_bounds = bounds();
|
||||
match cancellation {
|
||||
0 => canvas.zoom = 2.,
|
||||
1 => canvas.pan.x += 10.,
|
||||
2 => canvas.context.epoch += 1,
|
||||
3 => canvas.context.revision += 1,
|
||||
4 => canvas.context.document = Id::new_v4(),
|
||||
5 => canvas.tool = Tool::Transition,
|
||||
6 => canvas.editable = false,
|
||||
7 | 8 => {
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(if cancellation == 7 {
|
||||
mouse::Button::Right
|
||||
} else {
|
||||
mouse::Button::Middle
|
||||
}),
|
||||
release,
|
||||
);
|
||||
}
|
||||
9 => {
|
||||
canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(release),
|
||||
);
|
||||
}
|
||||
10 => {
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
},
|
||||
release,
|
||||
);
|
||||
}
|
||||
11 => {
|
||||
input(&canvas, &mut state, mouse::Event::CursorLeft, release);
|
||||
}
|
||||
12 => {
|
||||
motion(&canvas, &mut state, Point::new(-100., -100.), release);
|
||||
}
|
||||
13 => release_bounds.x += 25.,
|
||||
15 => canvas.selected = f.b,
|
||||
_ => {
|
||||
let escape = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let event = canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&escape,
|
||||
bounds(),
|
||||
mouse::Cursor::Available(release),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0);
|
||||
assert!(matches!(event, Some(Event::Choose(Tool::Select))));
|
||||
}
|
||||
}
|
||||
let event = input_at_bounds(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
release,
|
||||
release_bounds,
|
||||
);
|
||||
assert!(
|
||||
!matches!(event, Some(Event::SetBends { .. })),
|
||||
"late release committed case {cancellation}"
|
||||
);
|
||||
assert!(
|
||||
state.bend_preview.is_none(),
|
||||
"stale preview survived case {cancellation}"
|
||||
);
|
||||
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_context_menu_distinguishes_authored_pin_from_computed_segment() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let pin = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
|
||||
motion(&canvas, &mut state, pin, pin);
|
||||
assert!(
|
||||
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), pin),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: Some(0), route_segment: None, .. })
|
||||
if context == canvas.context && object == relation && anchor == pin)
|
||||
);
|
||||
let (index, midpoint) = bend_segment(&scene, relation);
|
||||
let at = screen(&canvas, midpoint);
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(
|
||||
matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
|
||||
Some(Event::ContextMenu { context, object, placement: None, anchor, bend: None, route_segment: Some((segment, world)), .. })
|
||||
if context == canvas.context && object == relation && anchor == at && segment == index && point(world).distance(midpoint) < 0.001)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_internal_rows_and_folded_proxies_cannot_acquire_pins() {
|
||||
for internal in [false, true] {
|
||||
let mut f = fixture();
|
||||
let relation = if internal {
|
||||
let id = f
|
||||
.project
|
||||
.relate(RelationKind::StateTransition, "Internal check", f.a, f.a);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut f.project.relations.get_mut(&id).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
id
|
||||
} else {
|
||||
let id = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Hidden child to standby",
|
||||
f.leaf,
|
||||
f.b,
|
||||
);
|
||||
let parent = placement(&f, f.a);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
id
|
||||
};
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let scene = canvas.scene(&state).unwrap();
|
||||
let world = if internal {
|
||||
assert!(!scene.routed.edges.contains_key(&relation));
|
||||
let node = scene.view.nodes.iter().find(|n| n.element == f.a).unwrap();
|
||||
internal_rows(&f.project, node)
|
||||
.into_iter()
|
||||
.find(|(r, _)| r.id == relation)
|
||||
.unwrap()
|
||||
.1
|
||||
.center()
|
||||
} else {
|
||||
assert!(scene.routed.edges[&relation].custom_suspended);
|
||||
bend_segment(&scene, relation).1
|
||||
};
|
||||
let start = screen(&canvas, world);
|
||||
motion(&canvas, &mut state, start, start);
|
||||
assert!(matches!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
start
|
||||
),
|
||||
Some(Event::ContextMenu {
|
||||
bend: None,
|
||||
route_segment: None,
|
||||
..
|
||||
})
|
||||
));
|
||||
bend_press(&canvas, &mut state, start);
|
||||
let away = start + Vector::new(30., 20.);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert!(!matches!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
away
|
||||
),
|
||||
Some(Event::SetBends { .. })
|
||||
));
|
||||
assert!(f.project.diagrams[&f.diagram].routes.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_right_click_discards_provisional_indexes_before_building_menu() {
|
||||
for click_pin in [true, false] {
|
||||
let (mut f, relation) = bend_fixture(true);
|
||||
let route = &mut f.project.diagrams.get_mut(&f.diagram).unwrap().routes[0];
|
||||
route.waypoints = vec![Position { x: 650., y: 340. }, Position { x: 650., y: 390. }];
|
||||
validate_candidate(&f.project).unwrap();
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let edge = &baseline.routed.edges[&relation];
|
||||
let candidates: Vec<_> = edge.points.windows(2).enumerate()
|
||||
.filter(|(i, _)| *i > 0 && *i + 2 < edge.points.len())
|
||||
.map(|(i, p)| (i, Point::new((p[0].x + p[1].x) / 2., (p[0].y + p[1].y) / 2.)))
|
||||
.filter(|(_, p)| edge.pins.iter().all(|pin| p.distance(point(*pin)) > 20.))
|
||||
.filter(|(_, p)| matches!(canvas.hit(&baseline, *p), Some(Hit::Transition(id)) if id == relation))
|
||||
.filter(|(_, p)| !Rectangle::new(point(edge.label.bounds.position), Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height)).contains(*p))
|
||||
.collect();
|
||||
let (_, insertion_at) = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(i, p)| routing::pin_insertion_index(edge, *i, position(*p)) == 0)
|
||||
.expect("exposed segment before both authored pins");
|
||||
let menu_segment = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|(_, p)| p.distance(insertion_at) > 30.)
|
||||
.expect("a distinct committed segment for the menu control");
|
||||
let start = screen(&canvas, insertion_at);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
let away = start + Vector::new(45., 30.);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
let preview = state.bend_preview.as_ref().unwrap();
|
||||
assert_eq!(preview.waypoints.len(), 3);
|
||||
assert_eq!(
|
||||
&preview.waypoints[1..],
|
||||
original.waypoints.as_slice(),
|
||||
"provisional insertion shifts both stored indices"
|
||||
);
|
||||
|
||||
// With the left gesture still active, moving to the last stored pin also
|
||||
// moves provisional pin0 there. The preview's first hit is therefore
|
||||
// pin0, but a menu built after cancellation must identify stored pin1.
|
||||
let menu_world = if click_pin {
|
||||
point(original.waypoints[1])
|
||||
} else {
|
||||
menu_segment.1
|
||||
};
|
||||
let menu_window = screen(&canvas, menu_world);
|
||||
motion(&canvas, &mut state, menu_window, menu_window);
|
||||
assert!(state.bend_preview.is_some());
|
||||
let menu = input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
menu_window,
|
||||
)
|
||||
.unwrap();
|
||||
let Event::ContextMenu {
|
||||
context,
|
||||
object,
|
||||
bend,
|
||||
route_segment,
|
||||
..
|
||||
} = menu
|
||||
else {
|
||||
panic!("expected context menu after cancelling drag")
|
||||
};
|
||||
assert_eq!(context, canvas.context);
|
||||
assert_eq!(object, relation);
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert_eq!(
|
||||
canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
edge.points
|
||||
);
|
||||
if click_pin {
|
||||
assert_eq!(
|
||||
bend,
|
||||
Some(1),
|
||||
"menu index must address the stored route, not the provisional route"
|
||||
);
|
||||
assert!(route_segment.is_none());
|
||||
let remove = Event::RemoveBend {
|
||||
context,
|
||||
relation,
|
||||
index: bend.unwrap(),
|
||||
};
|
||||
let plan = state_edit::plan(&f.project, context, &remove).unwrap();
|
||||
let after = prepare_command(&f.project, &plan.command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0]
|
||||
.waypoints
|
||||
.remove(1);
|
||||
assert_eq!(
|
||||
after, expected,
|
||||
"remove menu action must delete exactly the addressed original pin"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
bend.is_none(),
|
||||
"provisional pin under cursor must not leak into menu"
|
||||
);
|
||||
let (segment, at) = route_segment.expect("committed route segment remains actionable");
|
||||
assert_eq!(segment, menu_segment.0);
|
||||
assert!(point(at).distance(menu_world) < 0.001);
|
||||
}
|
||||
assert!(
|
||||
input(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
menu_window
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_bends_draw_before_update_excludes_preview_after_camera_or_bounds_change() {
|
||||
for change in 0..4 {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
|
||||
let start = screen(&canvas, point(baseline.routed.edges[&relation].pins[0]));
|
||||
let release = start + Vector::new(45., 30.);
|
||||
bend_press(&canvas, &mut state, start);
|
||||
motion(&canvas, &mut state, release, release);
|
||||
let displayed_preview = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
|
||||
assert_ne!(
|
||||
displayed_preview.routed.edges[&relation].pins,
|
||||
baseline.routed.edges[&relation].pins
|
||||
);
|
||||
let mut changed_bounds = bounds();
|
||||
match change {
|
||||
0 => canvas.zoom = 2.,
|
||||
1 => canvas.pan.y += 25.,
|
||||
2 => changed_bounds.x += 35.,
|
||||
_ => changed_bounds.width -= 90.,
|
||||
}
|
||||
// No update event between camera/layout change and this draw-time read.
|
||||
let shown = canvas
|
||||
.scene_with_bounds(&state, Some(changed_bounds))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
shown.routed.edges[&relation].pins, baseline.routed.edges[&relation].pins,
|
||||
"stale draw-time pins for case {change}"
|
||||
);
|
||||
assert_eq!(
|
||||
shown.routed.edges[&relation].points, baseline.routed.edges[&relation].points,
|
||||
"stale cached preview for case {change}"
|
||||
);
|
||||
let event = input_at_bounds(
|
||||
&canvas,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
release,
|
||||
changed_bounds,
|
||||
);
|
||||
assert!(!matches!(event, Some(Event::SetBends { .. })));
|
||||
assert!(state.bend_preview.is_none());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,10 +20,12 @@ pub enum Tool {
|
||||
Region,
|
||||
Transition,
|
||||
Reconnect { relation: Id, endpoint: Endpoint },
|
||||
AdjustAttachment { relation: Id, end: EdgeEnd },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Selection(crate::app::multiselect::Event),
|
||||
Remove(crate::app::diagram_removal::Intent),
|
||||
Choose(Tool),
|
||||
CreateView(Id),
|
||||
@@ -37,7 +39,10 @@ pub enum Event {
|
||||
context: Context,
|
||||
object: Id,
|
||||
placement: Option<Id>,
|
||||
selection_target: Option<crate::diagram_usage::Target>,
|
||||
anchor: iced::Point,
|
||||
bend: Option<usize>,
|
||||
route_segment: Option<(usize, Position)>,
|
||||
},
|
||||
CreateAt {
|
||||
context: Context,
|
||||
@@ -66,13 +71,40 @@ pub enum Event {
|
||||
endpoint: Endpoint,
|
||||
vertex: Id,
|
||||
},
|
||||
SetAttachment {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
end: EdgeEnd,
|
||||
placement: Id,
|
||||
attachment: Option<EdgeAttachment>,
|
||||
},
|
||||
SetBends {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
waypoints: Vec<Position>,
|
||||
},
|
||||
AddBend {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
segment: Option<(usize, Position)>,
|
||||
},
|
||||
RemoveBend {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
index: usize,
|
||||
},
|
||||
ClearBends {
|
||||
context: Context,
|
||||
relation: Id,
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn context(&self) -> Option<Context> {
|
||||
match self {
|
||||
Self::Remove(intent) => Some(intent.context),
|
||||
Self::Choose(_) | Self::CreateView(_) => None,
|
||||
// Group events carry their own exact selection envelope.
|
||||
Self::Selection(_) | Self::Choose(_) | Self::CreateView(_) => None,
|
||||
Self::Select(c, _)
|
||||
| Self::Pan(c, _)
|
||||
| Self::Zoom(c, _)
|
||||
@@ -84,7 +116,12 @@ impl Event {
|
||||
| Self::Place { context: c, .. }
|
||||
| Self::Move { context: c, .. }
|
||||
| Self::Connect { context: c, .. }
|
||||
| Self::Reconnect { context: c, .. } => Some(*c),
|
||||
| Self::Reconnect { context: c, .. }
|
||||
| Self::SetAttachment { context: c, .. }
|
||||
| Self::SetBends { context: c, .. }
|
||||
| Self::AddBend { context: c, .. }
|
||||
| Self::RemoveBend { context: c, .. }
|
||||
| Self::ClearBends { context: c, .. } => Some(*c),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,6 +286,54 @@ pub fn can_reconnect(
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Only the actual visible transition owner can receive a visual attachment.
|
||||
/// Folded promotion, semantic ports and internal-transition rows are excluded.
|
||||
pub fn attachment_node(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
relation: Id,
|
||||
end: EdgeEnd,
|
||||
) -> Option<projection::ProjectedNode> {
|
||||
machine(project, diagram).ok()?;
|
||||
let relation_record = project.relations.get(&relation)?;
|
||||
if relation_record.kind() != RelationKind::StateTransition
|
||||
|| oa_core::routing::semantic_port_endpoint(project, relation, end).is_some()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let view = projection::project_view(project, diagram);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == relation)?;
|
||||
let stored = project.diagrams.get(&diagram)?;
|
||||
if projection::edge_endpoint_placements(project, stored, relation)?
|
||||
!= (edge.source, edge.target)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let owner = match end {
|
||||
EdgeEnd::Source => edge.source,
|
||||
EdgeEnd::Target => edge.target,
|
||||
};
|
||||
view.nodes.iter().find(|node| node.id == owner).cloned()
|
||||
}
|
||||
|
||||
/// Actual visible State transition geometry, never an internal row or folded proxy.
|
||||
pub fn bend_route(
|
||||
project: &Project,
|
||||
diagram: Id,
|
||||
relation: Id,
|
||||
) -> Option<oa_core::routing::RoutedEdge> {
|
||||
machine(project, diagram).ok()?;
|
||||
if project.relations.get(&relation)?.kind() != RelationKind::StateTransition {
|
||||
return None;
|
||||
}
|
||||
let stored = project.diagrams.get(&diagram)?;
|
||||
let view = projection::project_diagram(project, stored);
|
||||
let geometry = oa_core::routing::route_diagram(project, stored, &view)
|
||||
.edges
|
||||
.remove(&relation)?;
|
||||
(!geometry.custom_suspended).then_some(geometry)
|
||||
}
|
||||
|
||||
fn checked(project: &Project, plan: Plan) -> Result<Plan, ModelError> {
|
||||
prepare_command(project, &plan.command)?;
|
||||
Ok(plan)
|
||||
@@ -492,6 +577,82 @@ pub fn plan(project: &Project, current: Context, event: &Event) -> Result<Plan,
|
||||
relation,
|
||||
None,
|
||||
),
|
||||
Event::SetAttachment {
|
||||
relation,
|
||||
end,
|
||||
placement,
|
||||
attachment,
|
||||
..
|
||||
} => {
|
||||
attachment_node(project, diagram, relation, end)
|
||||
.filter(|owner| owner.id == placement)
|
||||
.ok_or_else(|| {
|
||||
error("Choose the actual visible transition endpoint in this view.")
|
||||
})?;
|
||||
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
|
||||
route.set_attachment(end, attachment);
|
||||
(Command::SetEdgeRoute { diagram, route }, relation, None)
|
||||
}
|
||||
Event::SetBends { relation, .. }
|
||||
| Event::AddBend { relation, .. }
|
||||
| Event::RemoveBend { relation, .. }
|
||||
| Event::ClearBends { relation, .. } => {
|
||||
let geometry = bend_route(project, diagram, relation)
|
||||
.ok_or_else(|| error("Choose an actual visible transition route in this view."))?;
|
||||
let mut route = crate::route_edit::stored_route(&project.diagrams[&diagram], relation);
|
||||
match event {
|
||||
Event::SetBends { waypoints, .. } => {
|
||||
if waypoints.len() > 128 {
|
||||
return Err(error("A route supports at most 128 bends."));
|
||||
}
|
||||
for &point in waypoints {
|
||||
finite(point)?;
|
||||
}
|
||||
route.waypoints = waypoints.clone();
|
||||
}
|
||||
Event::AddBend { segment, .. } => {
|
||||
let (segment, point) = segment
|
||||
.or_else(|| {
|
||||
geometry
|
||||
.points
|
||||
.windows(2)
|
||||
.enumerate()
|
||||
.filter(|(index, _)| {
|
||||
*index > 0 && index + 2 < geometry.points.len()
|
||||
})
|
||||
.max_by(|(_, a), (_, b)| {
|
||||
let length = |s: &[Position]| {
|
||||
(s[1].x - s[0].x).abs() + (s[1].y - s[0].y).abs()
|
||||
};
|
||||
length(a).total_cmp(&length(b))
|
||||
})
|
||||
.map(|(index, span)| {
|
||||
(
|
||||
index,
|
||||
Position {
|
||||
x: (span[0].x + span[1].x) / 2.,
|
||||
y: (span[0].y + span[1].y) / 2.,
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
.ok_or_else(|| error("This route has no interior segment for a bend."))?;
|
||||
crate::route_edit::insert_pin_on_segment(&mut route, &geometry, segment, point)
|
||||
.ok_or_else(|| {
|
||||
error("The bend segment is stale, invalid, or at capacity.")
|
||||
})?;
|
||||
}
|
||||
Event::RemoveBend { index, .. } => {
|
||||
if *index >= route.waypoints.len() {
|
||||
return Err(error("This bend no longer exists."));
|
||||
}
|
||||
route.waypoints.remove(*index);
|
||||
}
|
||||
Event::ClearBends { .. } => route.waypoints.clear(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
(Command::SetEdgeRoute { diagram, route }, relation, None)
|
||||
}
|
||||
_ => {
|
||||
return Err(error(
|
||||
"Handle this navigation or tool request without a model command.",
|
||||
@@ -580,6 +741,85 @@ mod tests {
|
||||
plan.diagram
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_reset_preserves_other_end_pins_label_and_rejects_ineligible_owners() {
|
||||
let mut f = fixture();
|
||||
let diagram = create(&mut f);
|
||||
let c = context(diagram);
|
||||
let edge = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Leave work",
|
||||
f.leaf,
|
||||
f.target,
|
||||
);
|
||||
let attachment = EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.3,
|
||||
};
|
||||
let mut route = crate::route_edit::stored_route(&f.project.diagrams[&diagram], edge);
|
||||
route.source_attachment = Some(attachment);
|
||||
route.target_attachment = Some(attachment);
|
||||
route.waypoints.push(Position { x: 700., y: 700. });
|
||||
// Retain the exact label value alongside the other route components.
|
||||
route.label = Some(EdgeLabel {
|
||||
fraction: 0.5,
|
||||
offset: Position { x: 10., y: 20. },
|
||||
});
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(route.clone());
|
||||
let owner = attachment_node(&f.project, diagram, edge, EdgeEnd::Source).unwrap();
|
||||
let event = Event::SetAttachment {
|
||||
context: c,
|
||||
relation: edge,
|
||||
end: EdgeEnd::Source,
|
||||
placement: owner.id,
|
||||
attachment: None,
|
||||
};
|
||||
let accepted = prepare_command(&f.project, &plan(&f.project, c, &event).unwrap().command)
|
||||
.unwrap()
|
||||
.project;
|
||||
route.source_attachment = None;
|
||||
assert_eq!(accepted.diagrams[&diagram].routes, vec![route]);
|
||||
assert_eq!(accepted.relations, f.project.relations);
|
||||
let wrong = Event::SetAttachment {
|
||||
context: c,
|
||||
relation: edge,
|
||||
end: EdgeEnd::Source,
|
||||
placement: node(&f.project, diagram, f.target),
|
||||
attachment: Some(attachment),
|
||||
};
|
||||
assert!(plan(&f.project, c, &wrong).is_err());
|
||||
let composite = node(&f.project, diagram, f.composite);
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&composite)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert!(attachment_node(&f.project, diagram, edge, EdgeEnd::Source).is_none());
|
||||
assert!(plan(&f.project, c, &event).is_err());
|
||||
let internal = f.project.relate(
|
||||
RelationKind::StateTransition,
|
||||
"Internal",
|
||||
f.target,
|
||||
f.target,
|
||||
);
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind, ..
|
||||
} = &mut f.project.relations.get_mut(&internal).unwrap().body
|
||||
{
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Source).is_none());
|
||||
assert!(attachment_node(&f.project, diagram, internal, EdgeEnd::Target).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_initializes_only_its_machine_in_one_source_and_model_history_entry() {
|
||||
let f = fixture();
|
||||
@@ -763,6 +1003,8 @@ mod tests {
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![pin],
|
||||
label: None,
|
||||
});
|
||||
|
||||
@@ -161,6 +161,53 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
);
|
||||
entries.push(item("Edit transition", edit, true));
|
||||
if let Some(context) = context {
|
||||
let bend_geometry = state_edit::bend_route(project, context.diagram, id);
|
||||
let bend_count = bend_geometry.as_ref().map_or(0, |route| route.pins.len());
|
||||
let can_add = bend_geometry.as_ref().is_some_and(|route| {
|
||||
bend_count < 128
|
||||
&& request.route_segment.map_or_else(
|
||||
|| route.points.len() >= 4,
|
||||
|(index, point)| {
|
||||
index
|
||||
.checked_add(2)
|
||||
.is_some_and(|end| end <= route.points.len())
|
||||
&& point.x.is_finite()
|
||||
&& point.y.is_finite()
|
||||
},
|
||||
)
|
||||
});
|
||||
entries.push(item(
|
||||
if request.route_segment.is_some() {
|
||||
"Add bend here"
|
||||
} else {
|
||||
"Add bend"
|
||||
},
|
||||
diagram_event(Event::AddBend {
|
||||
context,
|
||||
relation: id,
|
||||
segment: request.route_segment,
|
||||
}),
|
||||
can_add,
|
||||
));
|
||||
if let Some(index) = request.bend {
|
||||
entries.push(item(
|
||||
"Remove bend",
|
||||
diagram_event(Event::RemoveBend {
|
||||
context,
|
||||
relation: id,
|
||||
index,
|
||||
}),
|
||||
bend_geometry.is_some() && index < bend_count,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Clear bends",
|
||||
diagram_event(Event::ClearBends {
|
||||
context,
|
||||
relation: id,
|
||||
}),
|
||||
bend_geometry.is_some() && bend_count > 0,
|
||||
));
|
||||
let visible = projection::project_view(project, context.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
@@ -178,6 +225,44 @@ pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
visible,
|
||||
));
|
||||
}
|
||||
for (caption, reset, end) in [
|
||||
(
|
||||
"Adjust source attachment",
|
||||
"Reset source attachment",
|
||||
EdgeEnd::Source,
|
||||
),
|
||||
(
|
||||
"Adjust target attachment",
|
||||
"Reset target attachment",
|
||||
EdgeEnd::Target,
|
||||
),
|
||||
] {
|
||||
let owner = state_edit::attachment_node(project, context.diagram, id, end);
|
||||
entries.push(item(
|
||||
caption,
|
||||
diagram_event(Event::Choose(Tool::AdjustAttachment { relation: id, end })),
|
||||
owner.is_some(),
|
||||
));
|
||||
if let Some(owner) = owner {
|
||||
let authored = project.diagrams[&context.diagram]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|route| route.edge == id)
|
||||
.and_then(|route| route.attachment(end))
|
||||
.is_some();
|
||||
entries.push(item(
|
||||
reset,
|
||||
diagram_event(Event::SetAttachment {
|
||||
context,
|
||||
relation: id,
|
||||
end,
|
||||
placement: owner.id,
|
||||
attachment: None,
|
||||
}),
|
||||
authored,
|
||||
));
|
||||
}
|
||||
}
|
||||
if !visible {
|
||||
entries.push(Entry::title(
|
||||
"Use transition details for hidden or internal endpoints",
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
// Included in state_canvas::tests. Drive production Program input and projection.
|
||||
|
||||
#[test]
|
||||
fn state_singleton_selection_bend_uses_five_unit_grid_and_escape_keeps_selection() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
for cancel in [false, true] {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let original = f.project.diagrams[&f.diagram].routes[0].clone();
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
canvas.selected = relation;
|
||||
let selection = group_selection(&canvas, [Target::Edge(relation)]);
|
||||
let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap();
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let start = screen(&canvas, point(original.waypoints[0]));
|
||||
// A fractional pointer target distinguishes route snapping from the
|
||||
// unsnapped State placement policy, at every supported test zoom.
|
||||
let release = screen(&canvas, Point::new(683.75, 362.25));
|
||||
let expected_pin = Position { x: 685., y: 360. };
|
||||
motion(&canvas, &mut state, start, release);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left), release).is_none());
|
||||
assert!(matches!(state.press, Some(Press::Bend { relation: id }) if id == relation));
|
||||
assert!(state.group.is_none());
|
||||
motion(&canvas, &mut state, release, release);
|
||||
let mut expected_route = original.clone();
|
||||
expected_route.waypoints[0] = expected_pin;
|
||||
assert_eq!(state.bend_preview.as_ref(), Some(&expected_route));
|
||||
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].pins, vec![expected_pin]);
|
||||
assert_eq!(f.project.diagrams[&f.diagram].routes[0], original);
|
||||
if cancel {
|
||||
let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape,
|
||||
keyboard::Modifiers::empty());
|
||||
assert!(matches!(escape, Some(Event::Choose(Tool::Select))),
|
||||
"Escape cancels the bend before it can emit selection Clear: {escape:?}");
|
||||
assert!(state.press.is_none() && state.bend.is_none() && state.bend_preview.is_none());
|
||||
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points,
|
||||
baseline.routed.edges[&relation].points);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), release).is_none());
|
||||
} else {
|
||||
let event = input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), release).unwrap();
|
||||
assert!(matches!(&event, Event::SetBends { context, relation: id, waypoints }
|
||||
if *context == canvas.context && *id == relation && *waypoints == vec![expected_pin]));
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let actual = prepare_command(&f.project, &plan.command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.get_mut(&f.diagram).unwrap().routes[0] = expected_route;
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot);
|
||||
assert!(state.group.is_none());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_singleton_selection_endpoint_reconnect_stationary_cancel_and_commit() {
|
||||
for outcome in 0..3 {
|
||||
let mut f = fixture();
|
||||
let relation = f.project.relate(RelationKind::StateTransition, "Go standby", f.a, f.b);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let selection = group_selection(&canvas, [Target::Edge(relation)]);
|
||||
let snapshot = selection.snapshot(selection_context(canvas.context)).unwrap();
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
let baseline = canvas.scene(&state).unwrap();
|
||||
let handle = screen(&canvas, point(*baseline.routed.edges[&relation].points.last().unwrap()));
|
||||
let target = screen(&canvas, header(&canvas, f.c));
|
||||
motion(&canvas, &mut state, handle, target);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left), target).is_none());
|
||||
assert!(matches!(state.press,
|
||||
Some(Press::Reconnect { relation: id, endpoint: Endpoint::Target, start: Some(at) })
|
||||
if id == relation && at == handle));
|
||||
assert!(state.group.is_none() && state.bend.is_none());
|
||||
if outcome > 0 { motion(&canvas, &mut state, target, target); }
|
||||
if outcome == 1 {
|
||||
let escape = group_key(&canvas, &mut state, keyboard::key::Named::Escape,
|
||||
keyboard::Modifiers::empty());
|
||||
assert!(matches!(escape, Some(Event::Choose(Tool::Select))),
|
||||
"Escape cancels reconnect without clearing the exact edge: {escape:?}");
|
||||
assert!(state.press.is_none());
|
||||
}
|
||||
let event = input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), target);
|
||||
if outcome < 2 {
|
||||
assert!(event.is_none(), "stationary/cancelled reconnect produced {event:?}");
|
||||
} else {
|
||||
let event = event.unwrap();
|
||||
assert!(matches!(&event, Event::Reconnect { context, relation: id, endpoint: Endpoint::Target, vertex }
|
||||
if *context == canvas.context && *id == relation && *vertex == f.c));
|
||||
let plan = state_edit::plan(&f.project, canvas.context, &event).unwrap();
|
||||
let actual = prepare_command(&f.project, &plan.command).unwrap().project;
|
||||
assert_eq!(plan.selected, relation);
|
||||
assert_eq!(actual.relations[&relation].source(), f.a);
|
||||
assert_eq!(actual.relations[&relation].target(), f.c);
|
||||
assert_eq!(actual.elements, f.project.elements);
|
||||
}
|
||||
assert_eq!(f.project.relations[&relation].target(), f.b);
|
||||
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), snapshot);
|
||||
assert!(state.group.is_none() && state.press.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_singleton_selection_shift_handles_toggle_exact_edge_before_route_tools() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
canvas.selected = relation;
|
||||
let selection = group_selection(&canvas, [Target::Edge(relation)]);
|
||||
canvas.selection = Some(&selection);
|
||||
let baseline = canvas.scene(&State::default()).unwrap();
|
||||
let edge = &baseline.routed.edges[&relation];
|
||||
for world in [edge.pins[0], *edge.points.last().unwrap()] {
|
||||
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());
|
||||
let at = screen(&canvas, point(world));
|
||||
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, Target::Edge(id), Choice::Toggle)))
|
||||
if context == selection_context(canvas.context) && id == relation));
|
||||
assert!(state.group.is_none() && state.press.is_none() && state.bend.is_none());
|
||||
let away = at + Vector::new(35., 20.);
|
||||
motion(&canvas, &mut state, away, away);
|
||||
assert!(input(&canvas, &mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left), away).is_none());
|
||||
assert!(state.bend_preview.is_none());
|
||||
assert_eq!(canvas.scene(&state).unwrap().routed.edges[&relation].points, edge.points);
|
||||
}
|
||||
}
|
||||
|
||||
fn group_selection(canvas: &StateCanvas<'_>, targets: impl IntoIterator<Item = Target>) -> Selection {
|
||||
let mut selection = Selection::new(selection_context(canvas.context).scope);
|
||||
for target in targets { selection.add(target, |_| true); }
|
||||
selection
|
||||
}
|
||||
|
||||
fn group_key(canvas: &StateCanvas<'_>, state: &mut State, key: keyboard::key::Named,
|
||||
modifiers: keyboard::Modifiers) -> Option<Event>
|
||||
{
|
||||
canvas::Program::update(canvas, state, &canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(key), modified_key: keyboard::Key::Named(key),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Delete),
|
||||
location: keyboard::Location::Standard, modifiers, text: None, repeat: false,
|
||||
}), bounds(), mouse::Cursor::Unavailable).and_then(|action| action.into_inner().0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_ordered_press_preview_and_fractional_release_preserve_primary() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let mut canvas = canvas(&f, Tool::Select, zoom);
|
||||
let b = placement(&f, f.b);
|
||||
let c = placement(&f, f.c);
|
||||
let selection = group_selection(&canvas, [Target::Placement(b), Target::Placement(c)]);
|
||||
canvas.selection = Some(&selection);
|
||||
canvas.selected = f.c;
|
||||
let before = selection.snapshot(selection_context(canvas.context)).unwrap();
|
||||
let mut state = State::default();
|
||||
let start = screen(&canvas, header(&canvas, f.b));
|
||||
let end = start + Vector::new(7.25 * zoom, 10.5 * zoom);
|
||||
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() && state.press.is_none());
|
||||
assert_eq!(selection.primary(), Some(&Target::Placement(c)));
|
||||
motion(&canvas, &mut state, end, end);
|
||||
let scene = canvas.scene_with_bounds(&state, Some(bounds())).unwrap();
|
||||
for id in [b, c] {
|
||||
let node = scene.view.nodes.iter().find(|n| n.id == id).unwrap();
|
||||
let original = &f.project.diagrams[&f.diagram].nodes[&id];
|
||||
assert_eq!(node.position, Position { x: original.position.x + 7.25, y: original.position.y + 10.5 });
|
||||
}
|
||||
assert_eq!(scene.routed.edges[&relation].pins, vec![Position { x: 657.5, y: 351.25 }]);
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start),
|
||||
Some(Event::Selection(SelectionEvent::Move(snapshot, Position { x: 7.25, y: 10.5 }))) if snapshot == before));
|
||||
assert_eq!(selection.snapshot(selection_context(canvas.context)).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_stationary_collapses_but_out_and_back_keeps_group() {
|
||||
let f = fixture();
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let a = placement(&f, f.a);
|
||||
let b = placement(&f, f.b);
|
||||
let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
|
||||
canvas.selection = Some(&selection);
|
||||
let start = screen(&canvas, header(&canvas, f.a));
|
||||
for dragged in [false, true] {
|
||||
let mut state = State::default();
|
||||
motion(&canvas, &mut state, start, start);
|
||||
assert!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start).is_none());
|
||||
if dragged {
|
||||
motion(&canvas, &mut state, start + Vector::new(20., 0.), start);
|
||||
motion(&canvas, &mut state, start, start);
|
||||
}
|
||||
let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start);
|
||||
if dragged {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Move(_, Position { x: 0., y: 0. })))));
|
||||
} else {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Placement(id)))) if id == a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_changed_authority_viewport_or_membership_retires_preview_and_release() {
|
||||
let f = fixture();
|
||||
for change in 0..7 {
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let a = placement(&f, f.a);
|
||||
let b = placement(&f, f.b);
|
||||
let selection = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
|
||||
let mut replacement = group_selection(&canvas, [Target::Placement(a), Target::Placement(b)]);
|
||||
replacement.remove(&Target::Placement(a));
|
||||
replacement.add(Target::Placement(a), |_| true);
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
let start = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, start, start);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), start);
|
||||
motion(&canvas, &mut state, start + Vector::new(12., 8.), start);
|
||||
assert!(canvas.group_preview_key(&state, Some(bounds())).is_some());
|
||||
let mut changed_bounds = bounds();
|
||||
match change {
|
||||
0 => canvas.context.revision += 1,
|
||||
1 => canvas.context.epoch += 1,
|
||||
2 => canvas.context.document = Id::new_v4(),
|
||||
3 => canvas.zoom = 1.1,
|
||||
4 => canvas.pan.x += 1.,
|
||||
5 => changed_bounds.x += 1.,
|
||||
_ => canvas.selection = Some(&replacement),
|
||||
}
|
||||
assert!(canvas.group_preview_key(&state, Some(changed_bounds)).is_none());
|
||||
if change < 3 {
|
||||
assert!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()).is_none());
|
||||
}
|
||||
assert!(input_at_bounds(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), start, changed_bounds).is_none());
|
||||
assert!(state.group.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_mixed_route_handle_refuses_drag_and_preserves_stationary_collapse() {
|
||||
let (f, relation) = bend_fixture(true);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b)), Target::Edge(relation)]);
|
||||
canvas.selection = Some(&selection);
|
||||
canvas.selected = relation;
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
let at = screen(&canvas, point(scene.routed.edges[&relation].pins[0]));
|
||||
for dragged in [false, true] {
|
||||
let mut state = State::default();
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.is_some() && state.bend.is_none() && state.press.is_none());
|
||||
if dragged {
|
||||
motion(&canvas, &mut state, at + Vector::new(25., 10.), at);
|
||||
motion(&canvas, &mut state, at, at);
|
||||
}
|
||||
let event = input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), at);
|
||||
if dragged {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
} else {
|
||||
assert!(matches!(event, Some(Event::Selection(SelectionEvent::Collapse(_, Target::Edge(id)))) if id == relation));
|
||||
}
|
||||
assert!(state.bend_preview.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_shift_row_hit_menu_and_reassignment_keep_exact_identity() {
|
||||
let mut f = fixture();
|
||||
let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap();
|
||||
let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work);
|
||||
let internal = f.project.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
|
||||
if let RelationBody::StateTransition { transition_kind, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body {
|
||||
*transition_kind = TransitionKind::Internal;
|
||||
}
|
||||
let a = placement(&f, f.a);
|
||||
for relation in [action, internal] {
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.b))]);
|
||||
canvas.selection = Some(&selection);
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
let node = scene.view.nodes.iter().find(|n| n.id == a).unwrap();
|
||||
let rect = state_action_rows(&f.project, node).into_iter().chain(internal_rows(&f.project, node))
|
||||
.find(|(row, _)| row.id == relation).unwrap().1;
|
||||
assert!(visible_row_target(&f.project, f.diagram, &scene.view, a, relation));
|
||||
let target = Target::StateRow { placement: a, relation };
|
||||
assert_eq!(canvas.hit(&scene, rect.center()).unwrap().target(), target);
|
||||
let mut state = State::default();
|
||||
let modifiers = canvas::Program::update(&canvas, &mut state,
|
||||
&canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(keyboard::Modifiers::SHIFT)),
|
||||
bounds(), mouse::Cursor::Unavailable);
|
||||
assert!(modifiers.is_none());
|
||||
let at = screen(&canvas, rect.center());
|
||||
motion(&canvas, &mut state, at, at);
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at),
|
||||
Some(Event::Selection(SelectionEvent::Choose(_, actual, Choice::Toggle))) if actual == target));
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Right), at),
|
||||
Some(Event::ContextMenu { object, placement: None, selection_target: Some(actual), .. }) if object == relation && actual == target));
|
||||
canvas::Program::update(&canvas, &mut state, &canvas::Event::Window(iced::window::Event::Unfocused), bounds(), mouse::Cursor::Unavailable);
|
||||
assert!(state.modifiers.is_empty());
|
||||
}
|
||||
if let RelationBody::StateAction { source, .. } = &mut f.project.relations.get_mut(&action).unwrap().body { *source = f.b; }
|
||||
if let RelationBody::StateTransition { source, target, .. } = &mut f.project.relations.get_mut(&internal).unwrap().body { *source = f.b; *target = f.b; }
|
||||
let view = projection::project_diagram(&f.project, &f.project.diagrams[&f.diagram]);
|
||||
for relation in [action, internal] {
|
||||
assert!(!visible_row_target(&f.project, f.diagram, &view, a, relation));
|
||||
assert!(visible_row_target(&f.project, f.diagram, &view, placement(&f, f.b), relation));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_delete_and_escape_respect_held_gesture_and_full_set() {
|
||||
let f = fixture();
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(placement(&f, f.a)), Target::Placement(placement(&f, f.b))]);
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
use keyboard::key::Named;
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Hide(_)))));
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::SHIFT), Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
let at = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(group_key(&canvas, &mut state, Named::Delete, keyboard::Modifiers::empty()).is_none());
|
||||
assert!(state.group.is_some());
|
||||
assert!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()).is_none());
|
||||
assert!(state.group.is_none());
|
||||
assert!(matches!(group_key(&canvas, &mut state, Named::Escape, keyboard::Modifiers::empty()), Some(Event::Selection(SelectionEvent::Clear(_)))));
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Middle), at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.is_none() && state.press.is_none() && state.pan_from.is_some());
|
||||
assert!(matches!(motion(&canvas, &mut state, at + Vector::new(20., 10.), at), Some(Event::Pan(_, _))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_entry_anchor_uses_authored_position_with_unsnapped_delta() {
|
||||
let mut f = fixture();
|
||||
let inner = f.project.elements[&f.leaf].parent.unwrap();
|
||||
let entry = f.project.add_element(Kind::EntryPoint, Layer::Sa, "Enter", Some(inner)).unwrap();
|
||||
let original = Position { x: 450.25, y: 410.75 };
|
||||
let entry_node = f.project.place(f.diagram, entry, original);
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(entry_node), Target::Placement(placement(&f, f.leaf))]);
|
||||
canvas.selection = Some(&selection);
|
||||
let scene = canvas.scene(&State::default()).unwrap();
|
||||
let shown = scene.view.nodes.iter().find(|n| n.id == entry_node).unwrap();
|
||||
assert_ne!(shown.position.x, original.x);
|
||||
let at = screen(&canvas, rectangle(shown).center());
|
||||
let mut state = State::default();
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.is_some());
|
||||
let end = at + Vector::new(7.25, 8.5);
|
||||
motion(&canvas, &mut state, end, end);
|
||||
assert!(matches!(input(&canvas, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), end),
|
||||
Some(Event::Selection(SelectionEvent::Move(_, Position { x: 7.25, y: 8.5 })))));
|
||||
assert_eq!(f.project.diagrams[&f.diagram].nodes[&entry_node].position, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_group_row_and_port_mixtures_refuse_complete_set_without_owner_promotion() {
|
||||
let mut f = fixture();
|
||||
let work = f.project.add_element(Kind::Function, Layer::Sa, "Action work", None).unwrap();
|
||||
let action = f.project.relate(RelationKind::StateAction, "Entry", f.a, work);
|
||||
let a = placement(&f, f.a);
|
||||
for other in [Target::StateRow { placement: a, relation: action }, Target::Port { placement: a, port: work }] {
|
||||
let mut canvas = canvas(&f, Tool::Select, 1.);
|
||||
let selection = group_selection(&canvas, [Target::Placement(a), other]);
|
||||
canvas.selection = Some(&selection);
|
||||
let mut state = State::default();
|
||||
assert!(matches!(group_key(&canvas, &mut state, keyboard::key::Named::Delete, keyboard::Modifiers::empty()),
|
||||
Some(Event::Selection(SelectionEvent::Refuse(_, _)))));
|
||||
let at = screen(&canvas, header(&canvas, f.a));
|
||||
motion(&canvas, &mut state, at, at);
|
||||
input(&canvas, &mut state, mouse::Event::ButtonPressed(mouse::Button::Left), at);
|
||||
assert!(state.group.as_ref().is_some_and(|g| g.movement.is_none()));
|
||||
let end = at + Vector::new(12., 8.);
|
||||
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(snapshot, _))) if snapshot.targets().len() == 2));
|
||||
assert!(state.press.is_none() && state.preview.is_none());
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,10 @@ pub fn diagram(app: &App, machine: Id) -> Option<Id> {
|
||||
}
|
||||
|
||||
fn event(e: Event) -> Message {
|
||||
Message::StateDiagram(e)
|
||||
match e {
|
||||
Event::Selection(selection) => Message::DiagramSelection(selection),
|
||||
other => Message::StateDiagram(other),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
@@ -143,8 +146,9 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
.collect::<Vec<_>>();
|
||||
// Keep the optional picker in its own stable slot. Inserting it as a
|
||||
// sibling before the canvas would replace the positional Iced focus tree.
|
||||
let missing_spacing = if missing.is_empty() { 0.0_f32 } else { 8.0 };
|
||||
let mut missing_controls = Column::new();
|
||||
// Reserve the picker height even when all elements are placed. Group Hide
|
||||
// and Undo must not resize the canvas and silently refit its camera.
|
||||
let mut missing_controls = Column::new().height(34);
|
||||
if !missing.is_empty() {
|
||||
let position = Position {
|
||||
x: (48. - state.pan.x) / state.zoom,
|
||||
@@ -169,6 +173,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
diagram: id,
|
||||
context,
|
||||
selected: app.selected,
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
navigation_focus: app
|
||||
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
|
||||
.map(|usage| usage.bounds),
|
||||
@@ -183,6 +188,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
out = out.push(
|
||||
column![
|
||||
missing_controls,
|
||||
selection_controls(app),
|
||||
crate::diagram::focus::wrap(
|
||||
canvas.map(event),
|
||||
context,
|
||||
@@ -191,7 +197,7 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
Message::DiagramTraverse,
|
||||
)
|
||||
]
|
||||
.spacing(missing_spacing)
|
||||
.spacing(8)
|
||||
.height(Fill),
|
||||
);
|
||||
let base: Element<'_, Message> = container(out.spacing(8))
|
||||
@@ -202,6 +208,47 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
|
||||
with_form(app, machine, base)
|
||||
}
|
||||
|
||||
fn selection_controls(app: &App) -> Element<'_, Message> {
|
||||
use crate::app::multiselect::{Event as SelectionEvent, placements};
|
||||
let snapshot = app.multiselect_snapshot();
|
||||
let count = snapshot.as_ref().map_or(0, |s| s.targets().len());
|
||||
let mut controls = row![label(format!("{count} selected"))]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = snapshot.filter(|s| s.targets().len() > 1) {
|
||||
if placements(&snapshot).is_some() {
|
||||
controls = controls.push(action(
|
||||
"Hide selected",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
));
|
||||
}
|
||||
controls = controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(SelectionEvent::Clear(snapshot.context())),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
crate::views::short(app.project().named(app.selected), 18)
|
||||
))
|
||||
.wrapping(iced::widget::text::Wrapping::None),
|
||||
);
|
||||
}
|
||||
let hint = if app.behavior.diagram.tool == Tool::Select {
|
||||
"Shift+click adds or removes from selection"
|
||||
} else {
|
||||
"Use Select / move to select multiple elements"
|
||||
};
|
||||
column![
|
||||
controls,
|
||||
label(hint).wrapping(iced::widget::text::Wrapping::None)
|
||||
]
|
||||
.height(56)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
|
||||
action(
|
||||
"New transition with details…",
|
||||
|
||||
@@ -22,6 +22,9 @@ use std::{
|
||||
sync::Arc,
|
||||
};
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/attachment_tests.rs"]
|
||||
mod attachment_tests;
|
||||
#[cfg(test)]
|
||||
#[path = "diagram/lifecycle_tests.rs"]
|
||||
mod lifecycle_tests;
|
||||
#[cfg(test)]
|
||||
@@ -36,12 +39,17 @@ mod port_labels;
|
||||
pub use port_labels::PortLabels;
|
||||
#[path = "diagram/focus.rs"]
|
||||
pub(crate) mod focus;
|
||||
pub(crate) mod group_gesture;
|
||||
mod multiselect;
|
||||
#[cfg(test)]
|
||||
mod multiselect_tests;
|
||||
#[path = "diagram/work_drag.rs"]
|
||||
mod work_drag;
|
||||
pub use work_drag::WorkFeedback;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct DiagramCanvas<'a> {
|
||||
pub selection: Option<&'a crate::diagram_selection::Selection>,
|
||||
pub allocation: Option<crate::app::allocation_drag::Capability<'a>>,
|
||||
pub project: &'a Project,
|
||||
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
|
||||
@@ -53,12 +61,15 @@ pub struct DiagramCanvas<'a> {
|
||||
pub pan: Vector,
|
||||
pub context: Context,
|
||||
pub selected_bend: Option<usize>,
|
||||
pub selected_attachment: Option<EdgeEnd>,
|
||||
pub editable: bool,
|
||||
pub placing: bool,
|
||||
pub connection: Option<&'a crate::connection_edit::Session>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
group: Option<multiselect::Press>,
|
||||
work: Option<Arc<crate::app::allocation_drag::Press>>,
|
||||
work_cache: RefCell<Option<Arc<Scene>>>,
|
||||
pointer: Option<Point>,
|
||||
@@ -67,6 +78,7 @@ pub struct State {
|
||||
pan_start: Option<(mouse::Button, Point)>,
|
||||
context: Option<Context>,
|
||||
route_drag: Option<Gesture>,
|
||||
attachment_viewport: Option<(f32, Vector, Rectangle)>,
|
||||
connection_press: Option<(Id, Point)>,
|
||||
connection_hover: Option<Point>,
|
||||
port_drag: Option<crate::port_edit::Gesture>,
|
||||
@@ -294,6 +306,11 @@ impl DiagramCanvas<'_> {
|
||||
);
|
||||
request.canvas = Some(self.context);
|
||||
request.creation_point = Some(position(world));
|
||||
if self.attachment_at(&scene, world).is_some() {
|
||||
request.object = self.selected;
|
||||
request.route_point = Some(position(world));
|
||||
return request;
|
||||
}
|
||||
if let Some(edge) = scene.routed.edges.get(&self.selected)
|
||||
&& !edge.custom_suspended
|
||||
&& let Some(index) = edge
|
||||
@@ -371,6 +388,39 @@ impl DiagramCanvas<'_> {
|
||||
})
|
||||
.map(|(id, segment, _)| (id, segment))
|
||||
}
|
||||
fn attachment_at<'b>(
|
||||
&self,
|
||||
scene: &'b Scene,
|
||||
world: Point,
|
||||
) -> Option<(EdgeEnd, &'b ProjectedNode)> {
|
||||
let edge = scene.routed.edges.get(&self.selected)?;
|
||||
if edge.custom_suspended {
|
||||
return None;
|
||||
}
|
||||
[
|
||||
(EdgeEnd::Source, edge.points.first()),
|
||||
(EdgeEnd::Target, edge.points.last()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|(end, at)| {
|
||||
let distance = world.distance(point(*at?));
|
||||
let owner = route_edit::attachment_node(
|
||||
self.project,
|
||||
&self.project.diagrams[&self.diagram],
|
||||
&scene.view,
|
||||
self.selected,
|
||||
end,
|
||||
)?;
|
||||
(distance <= 8. / self.zoom).then_some((end, owner, distance))
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
a.2.total_cmp(&b.2).then_with(|| {
|
||||
(Some(a.0) != self.selected_attachment)
|
||||
.cmp(&(Some(b.0) != self.selected_attachment))
|
||||
})
|
||||
})
|
||||
.map(|(end, owner, _)| (end, owner))
|
||||
}
|
||||
fn hit_label(&self, routed: &RoutedView, p: Point) -> Option<Id> {
|
||||
let p = self.world(p);
|
||||
routed
|
||||
@@ -419,6 +469,7 @@ impl DiagramCanvas<'_> {
|
||||
}
|
||||
fn preview_diagram(&self, state: &State) -> Diagram {
|
||||
let mut diagram = self.project.diagrams[&self.diagram].clone();
|
||||
self.group_preview(state, &mut diagram);
|
||||
if let (Some((id, _, original)), Some(preview)) = (state.drag, state.preview) {
|
||||
let delta = Position {
|
||||
x: preview.x - original.x,
|
||||
@@ -561,6 +612,31 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if let canvas::Event::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
return None;
|
||||
}
|
||||
if matches!(event, canvas::Event::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = iced::keyboard::Modifiers::default();
|
||||
}
|
||||
// An attachment belongs to the boundary geometry captured at press.
|
||||
// Camera/layout changes must not reinterpret its pointer in another space.
|
||||
if matches!(
|
||||
state.route_drag.as_ref().map(|gesture| &gesture.handle),
|
||||
Some(Handle::Attachment { .. })
|
||||
) && (state.attachment_viewport != Some((self.zoom, self.pan, bounds))
|
||||
|| matches!(
|
||||
event,
|
||||
canvas::Event::Window(
|
||||
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
|
||||
) | canvas::Event::Mouse(mouse::Event::CursorLeft)
|
||||
))
|
||||
{
|
||||
state.route_drag = None;
|
||||
state.attachment_viewport = None;
|
||||
state.pointer = None;
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
// A completed work click retires App's allocation epoch before the
|
||||
// retained canvas sees a redraw. Its already-idle press must not eat
|
||||
// the first Delete. A live or not-yet-dispatched press stays guarded.
|
||||
@@ -580,6 +656,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
return Some(action);
|
||||
}
|
||||
if state.context != Some(self.context) {
|
||||
state.group = None;
|
||||
state.work = None;
|
||||
state.pointer = None;
|
||||
state.port_drag = None;
|
||||
@@ -646,6 +723,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
let local = cursor
|
||||
.position()
|
||||
.map(|p| Point::new(p.x - bounds.x, p.y - bounds.y));
|
||||
if let Some(action) = self.group_input(state, event, bounds, cursor) {
|
||||
return Some(action);
|
||||
}
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Right))
|
||||
if cursor.is_over(bounds) =>
|
||||
@@ -840,6 +920,21 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
&self.project.diagrams[&self.diagram],
|
||||
self.selected,
|
||||
);
|
||||
if let Some((end, owner)) = self.attachment_at(&scene, world) {
|
||||
let original = route.attachment(end);
|
||||
self.begin_route(
|
||||
state,
|
||||
world,
|
||||
Handle::Attachment {
|
||||
end,
|
||||
owner: owner.clone(),
|
||||
original,
|
||||
},
|
||||
route,
|
||||
);
|
||||
state.attachment_viewport = Some((self.zoom, self.pan, bounds));
|
||||
return Some(Action::capture());
|
||||
}
|
||||
if let Some(index) = edge
|
||||
.pins
|
||||
.iter()
|
||||
@@ -963,7 +1058,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
if let Some(gesture) = &mut state.route_drag {
|
||||
gesture.move_to(self.world(p), self.zoom);
|
||||
gesture.move_attachment_to(self.world(p), self.zoom);
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let Some((_, start, original)) = state.drag {
|
||||
@@ -1040,17 +1135,43 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
if let Some(mut gesture) = state.route_drag.take() {
|
||||
if matches!(gesture.handle, Handle::Attachment { .. }) {
|
||||
state.attachment_viewport = None;
|
||||
if !cursor.is_over(bounds) {
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
}
|
||||
// `local` uses the ordered pointer position at release,
|
||||
// before any later motion in the same native event batch.
|
||||
if let Some(p) = local {
|
||||
gesture.move_to(self.world(p), self.zoom);
|
||||
gesture.move_attachment_to(self.world(p), self.zoom);
|
||||
}
|
||||
if !gesture.changed {
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if let Handle::Attachment { end, owner, .. } = &gesture.handle {
|
||||
if gesture.route
|
||||
== route_edit::stored_route(
|
||||
&self.project.diagrams[&self.diagram],
|
||||
self.selected,
|
||||
)
|
||||
{
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
return Some(
|
||||
Action::publish(Message::Route(RouteEvent::CommitAttachment(
|
||||
gesture.context,
|
||||
gesture.route,
|
||||
*end,
|
||||
owner.id,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
let bend = match gesture.handle {
|
||||
Handle::Bend(index) => Some(index),
|
||||
Handle::Label { .. } => None,
|
||||
Handle::Attachment { .. } => unreachable!(),
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Message::Route(RouteEvent::Commit(
|
||||
@@ -1094,6 +1215,15 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
canvas::Event::Mouse(mouse::Event::WheelScrolled { delta })
|
||||
if cursor.is_over(bounds) =>
|
||||
{
|
||||
// Cancel before publishing Zoom: a release may arrive in this
|
||||
// same event batch, before App applies the new camera.
|
||||
if matches!(
|
||||
state.route_drag.as_ref().map(|gesture| &gesture.handle),
|
||||
Some(Handle::Attachment { .. })
|
||||
) {
|
||||
state.route_drag = None;
|
||||
state.attachment_viewport = None;
|
||||
}
|
||||
let y = match delta {
|
||||
mouse::ScrollDelta::Lines { y, .. } | mouse::ScrollDelta::Pixels { y, .. } => {
|
||||
*y
|
||||
@@ -1151,7 +1281,17 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
.collect::<BTreeSet<_>>();
|
||||
for n in &view.nodes {
|
||||
if containers.contains(&n.id) {
|
||||
paint_node(&mut f, project, n, self.selected, true);
|
||||
paint_node(
|
||||
&mut f,
|
||||
project,
|
||||
n,
|
||||
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
||||
n.element
|
||||
} else {
|
||||
self.selected
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
let highlighted = highlighted_exchanges(project, self.selected);
|
||||
@@ -1163,7 +1303,9 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
continue;
|
||||
};
|
||||
let end = point(end);
|
||||
let active = edge.id == self.selected || highlighted.contains(&edge.id);
|
||||
let active = edge.id == self.selected
|
||||
|| highlighted.contains(&edge.id)
|
||||
|| self.selection_contains(crate::diagram_usage::Target::Edge(edge.id));
|
||||
let color = if active {
|
||||
style::TEAL
|
||||
} else if routed
|
||||
@@ -1259,17 +1401,42 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
}
|
||||
for n in &view.nodes {
|
||||
if !containers.contains(&n.id) {
|
||||
paint_node(&mut f, project, n, self.selected, false);
|
||||
paint_node(
|
||||
&mut f,
|
||||
project,
|
||||
n,
|
||||
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
||||
n.element
|
||||
} else {
|
||||
self.selected
|
||||
},
|
||||
false,
|
||||
);
|
||||
} else {
|
||||
// Container headers stay readable when relationships traverse
|
||||
// the allocation enclosure. Children remain on top.
|
||||
let mut header = n.clone();
|
||||
header.size.height = 62.;
|
||||
paint_node(&mut f, project, &header, self.selected, true);
|
||||
paint_node(
|
||||
&mut f,
|
||||
project,
|
||||
&header,
|
||||
if self.selection_contains(crate::diagram_usage::Target::Placement(n.id)) {
|
||||
n.element
|
||||
} else {
|
||||
self.selected
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
for port in &view.ports {
|
||||
let color = if self.selected == port.element {
|
||||
let selected = self.selected == port.element
|
||||
|| self.selection_contains(crate::diagram_usage::Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
});
|
||||
let color = if selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::kind_color(
|
||||
@@ -1297,11 +1464,7 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
let mut stroke =
|
||||
Stroke::default()
|
||||
.with_color(color)
|
||||
.with_width(if self.selected == port.element {
|
||||
2.5
|
||||
} else {
|
||||
1.5
|
||||
});
|
||||
.with_width(if selected { 2.5 } else { 1.5 });
|
||||
if glyph == PortGlyph::Legacy {
|
||||
stroke.line_dash = canvas::LineDash {
|
||||
segments: &[3., 2.],
|
||||
@@ -1336,6 +1499,41 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
|
||||
if let Some(edge) = scene.routed.edges.get(&self.selected)
|
||||
&& !edge.custom_suspended
|
||||
{
|
||||
for (end, at) in [
|
||||
(EdgeEnd::Source, edge.points.first()),
|
||||
(EdgeEnd::Target, edge.points.last()),
|
||||
] {
|
||||
if let Some(at) = at
|
||||
&& route_edit::attachment_node(
|
||||
project,
|
||||
&project.diagrams[&self.diagram],
|
||||
view,
|
||||
self.selected,
|
||||
end,
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
let at = point(*at);
|
||||
let handle = Path::rectangle(
|
||||
at - Vector::new(5.5 / self.zoom, 5.5 / self.zoom),
|
||||
Size::new(11. / self.zoom, 11. / self.zoom),
|
||||
);
|
||||
f.fill(
|
||||
&handle,
|
||||
if self.selected_attachment == Some(end) {
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::WHITE
|
||||
},
|
||||
);
|
||||
f.stroke(
|
||||
&handle,
|
||||
Stroke::default()
|
||||
.with_color(style::TEAL)
|
||||
.with_width(1.7 / self.zoom),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (index, pin) in edge.pins.iter().enumerate() {
|
||||
let handle = Path::circle(point(*pin), 5.5 / self.zoom);
|
||||
f.fill(
|
||||
@@ -2033,6 +2231,7 @@ mod tests {
|
||||
y: original.y - 200.,
|
||||
};
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
@@ -2042,6 +2241,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2109,6 +2309,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
context: Context {
|
||||
@@ -2118,6 +2319,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2236,6 +2438,7 @@ mod tests {
|
||||
};
|
||||
let session = Session::new(&p, &view, context, Tool::FunctionalExchange);
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2243,6 +2446,7 @@ mod tests {
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
zoom,
|
||||
pan: Vector::new(20., 30.),
|
||||
editable: true,
|
||||
@@ -2380,6 +2584,8 @@ mod tests {
|
||||
diagram,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 400., y: -100. }],
|
||||
label: None,
|
||||
},
|
||||
@@ -2408,6 +2614,7 @@ mod tests {
|
||||
for zoom in [0.5, 1., 1.5] {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2422,6 +2629,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2485,6 +2693,7 @@ mod tests {
|
||||
fn fast_drags_commit_the_release_cursor_without_a_final_motion_event() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2499,6 +2708,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2583,6 +2793,7 @@ mod tests {
|
||||
fn route_cache_ignores_pan_zoom_and_cancelled_drag_cannot_commit() {
|
||||
let (p, diagram, edge) = route_fixture();
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2597,6 +2808,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2712,6 +2924,7 @@ mod tests {
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
assert!(svg.contains("data-navigation=\"target\""));
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2726,6 +2939,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2767,6 +2981,7 @@ mod tests {
|
||||
offset: Position { x: 0., y: -160. },
|
||||
});
|
||||
let mut canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2781,6 +2996,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2842,6 +3058,7 @@ mod tests {
|
||||
},
|
||||
});
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2856,6 +3073,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
@@ -2876,6 +3094,7 @@ mod tests {
|
||||
routed.edges.insert(duplicate, duplicate_route);
|
||||
let selected = edge.max(duplicate); // UUID ordering must not override selection.
|
||||
let canvas = DiagramCanvas {
|
||||
selection: None,
|
||||
project: &p,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -2890,6 +3109,7 @@ mod tests {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
use super::*;
|
||||
use crate::app::{App, Page};
|
||||
use iced::widget::canvas::Program;
|
||||
|
||||
fn fixture() -> App {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Activity, "Send", 100., 150.);
|
||||
let b = p.add(Kind::Activity, "Receive", 650., 150.);
|
||||
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![Position { x: 450., y: 350. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: Position { x: 5., y: 10. },
|
||||
}),
|
||||
source_attachment: None,
|
||||
target_attachment: Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.6,
|
||||
}),
|
||||
});
|
||||
let mut app = App::with_project(p);
|
||||
app.page = Page::Diagram;
|
||||
app.active_diagram = Some(diagram);
|
||||
let _ = app.update(Message::Select(edge));
|
||||
app
|
||||
}
|
||||
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
allocation: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
navigation_focus: None,
|
||||
zoom,
|
||||
pan: Vector::new(40., 50.),
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: app
|
||||
.selected_attachment
|
||||
.filter(|(c, e, _)| Some(*c) == app.route_context() && *e == app.selected)
|
||||
.map(|(_, _, end)| end),
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
placing: false,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(120., 80.), Size::new(2400., 1800.))
|
||||
}
|
||||
fn screen(c: &DiagramCanvas<'_>, p: Position) -> Point {
|
||||
Point::new(
|
||||
bounds().x + p.x * c.zoom + c.pan.x,
|
||||
bounds().y + p.y * c.zoom + c.pan.y,
|
||||
)
|
||||
}
|
||||
fn send(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: mouse::Event,
|
||||
final_cursor: Point,
|
||||
) -> Option<Message> {
|
||||
c.update(
|
||||
state,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(final_cursor),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn positions(c: &DiagramCanvas<'_>) -> (Point, Point) {
|
||||
let scene = c.scene(&State::default());
|
||||
let start = *scene.routed.edges[&c.selected].points.first().unwrap();
|
||||
let node = route_edit::attachment_node(
|
||||
c.project,
|
||||
&c.project.diagrams[&c.diagram],
|
||||
&scene.view,
|
||||
c.selected,
|
||||
EdgeEnd::Source,
|
||||
)
|
||||
.unwrap();
|
||||
let end = oa_core::routing::attachment_point(
|
||||
c.project,
|
||||
&c.project.diagrams[&c.diagram],
|
||||
node,
|
||||
&EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25,
|
||||
},
|
||||
);
|
||||
(screen(c, start), screen(c, end))
|
||||
}
|
||||
fn drag(app: &App, zoom: f32) -> Message {
|
||||
let c = canvas(app, zoom);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
let messages = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| send(&c, &mut state, e, end))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(messages.len(), 1);
|
||||
messages.into_iter().next().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_event_batches_commit_one_source_history_change_at_three_zooms() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let mut app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let project = app.project().clone();
|
||||
let revision = app.document.revision();
|
||||
let message = drag(&app, zoom);
|
||||
let Message::Route(RouteEvent::CommitAttachment(_, ref route, EdgeEnd::Source, _)) =
|
||||
message
|
||||
else {
|
||||
panic!("attachment commit required")
|
||||
};
|
||||
assert_eq!(
|
||||
route.source_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
let intended = route.clone();
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let mut expected = project;
|
||||
route_edit::with_route(
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&app.active_diagram.unwrap())
|
||||
.unwrap(),
|
||||
intended,
|
||||
);
|
||||
assert_eq!(
|
||||
*app.project(),
|
||||
expected,
|
||||
"only the intended view attachment changes"
|
||||
);
|
||||
let after = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&after, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_stationary_return_and_escape_leave_source_unchanged() {
|
||||
let mut app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let c = canvas(&app, 1.);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
assert!(send(&c, &mut state, event, start).is_none());
|
||||
}
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
] {
|
||||
assert!(send(&c, &mut state, event, end).is_none());
|
||||
}
|
||||
let _ = app.update(Message::Escape);
|
||||
assert!(
|
||||
send(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_camera_layout_focus_and_outside_release_cancel_after_motion() {
|
||||
for cancel in 0..8 {
|
||||
let app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let mut c = canvas(&app, 1.);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
] {
|
||||
assert!(send(&c, &mut state, event, end).is_none());
|
||||
}
|
||||
assert!(state.route_drag.as_ref().is_some_and(|g| g.changed));
|
||||
let mut current_bounds = bounds();
|
||||
let mut release = end;
|
||||
match cancel {
|
||||
0 => c.zoom = 2.,
|
||||
1 => c.pan.x += 20.,
|
||||
2 => current_bounds.x += 20.,
|
||||
3 => current_bounds.height -= 100.,
|
||||
4..=6 => {
|
||||
let event = match cancel {
|
||||
4 => canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
5 => canvas::Event::Window(iced::window::Event::Resized(Size::new(800., 600.))),
|
||||
_ => canvas::Event::Mouse(mouse::Event::CursorLeft),
|
||||
};
|
||||
let message = c
|
||||
.update(
|
||||
&mut state,
|
||||
&event,
|
||||
current_bounds,
|
||||
mouse::Cursor::Available(end),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0);
|
||||
assert!(message.is_none());
|
||||
}
|
||||
_ => {
|
||||
release = Point::new(-100., -100.);
|
||||
assert!(
|
||||
send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: release },
|
||||
release
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
let message = c
|
||||
.update(
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
current_bounds,
|
||||
mouse::Cursor::Available(release),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0);
|
||||
assert!(message.is_none(), "cancellation case {cancel}");
|
||||
assert!(state.route_drag.is_none(), "cancellation case {cancel}");
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_wheel_and_middle_pan_cancel_before_same_batch_left_release() {
|
||||
for wheel in [true, false] {
|
||||
let mut app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let c = canvas(&app, 1.);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
] {
|
||||
assert!(send(&c, &mut state, event, end).is_none());
|
||||
}
|
||||
let camera_message = send(
|
||||
&c,
|
||||
&mut state,
|
||||
if wheel {
|
||||
mouse::Event::WheelScrolled {
|
||||
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
|
||||
}
|
||||
} else {
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle)
|
||||
},
|
||||
end,
|
||||
);
|
||||
assert_eq!(camera_message.is_some(), wheel);
|
||||
assert!(state.route_drag.is_none());
|
||||
// Deliberately retain the same canvas and defer App camera dispatch.
|
||||
assert!(
|
||||
send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
if !wheel {
|
||||
assert!(
|
||||
send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
end
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
if let Some(message) = camera_message {
|
||||
assert!(matches!(message, Message::Zoom(_)));
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_fast_release_uses_release_position_and_stays_in_one_view() {
|
||||
let mut app = fixture();
|
||||
let original = app.active_diagram.unwrap();
|
||||
let _ = app.update(Message::DuplicateDiagram(original));
|
||||
let other = app.active_diagram.unwrap();
|
||||
assert_ne!(original, other);
|
||||
app.active_diagram = Some(original);
|
||||
let edge = app.project().diagrams[&original].routes[0].edge;
|
||||
let _ = app.update(Message::Select(edge));
|
||||
let untouched = app.project().diagrams[&other].clone();
|
||||
let c = canvas(&app, 0.7);
|
||||
let (start, end) = positions(&c);
|
||||
let mut state = State::default();
|
||||
assert!(
|
||||
send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
start
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
let message = send(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
)
|
||||
.expect("release completes drag without final move");
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&original].routes[0].source_attachment,
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.25
|
||||
})
|
||||
);
|
||||
assert_eq!(app.project().diagrams[&other], untouched);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_stale_context_wrong_placement_and_unowned_route_fields_are_rejected() {
|
||||
let mut app = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let message = drag(&app, 1.);
|
||||
let Message::Route(RouteEvent::CommitAttachment(context, route, end, node)) = message else {
|
||||
panic!()
|
||||
};
|
||||
let mut changed = route.clone();
|
||||
changed.waypoints.clear();
|
||||
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
|
||||
context, changed, end, node,
|
||||
)));
|
||||
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
|
||||
context,
|
||||
route.clone(),
|
||||
end,
|
||||
Id::new_v4(),
|
||||
)));
|
||||
let _ = app.update(Message::Escape);
|
||||
let _ = app.update(Message::Route(RouteEvent::CommitAttachment(
|
||||
context, route, end, node,
|
||||
)));
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_reset_preserves_opposite_end_pins_label_and_semantics() {
|
||||
let mut app = fixture();
|
||||
let before = app.project().clone();
|
||||
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
|
||||
let mut expected = before;
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&app.active_diagram.unwrap())
|
||||
.unwrap()
|
||||
.routes[0]
|
||||
.target_attachment = None;
|
||||
assert_eq!(*app.project(), expected);
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Route(RouteEvent::ResetAttachment(EdgeEnd::Target)));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_attachment_handles_honor_explicit_end_choice() {
|
||||
let mut app = fixture();
|
||||
let _ = app.update(Message::Route(RouteEvent::ChooseAttachment(
|
||||
EdgeEnd::Target,
|
||||
)));
|
||||
let c = canvas(&app, 1.);
|
||||
let mut scene = Scene {
|
||||
work: None,
|
||||
view: projection::project_view(c.project, c.diagram),
|
||||
routed: Arc::new(route_diagram(
|
||||
c.project,
|
||||
&c.project.diagrams[&c.diagram],
|
||||
&projection::project_view(c.project, c.diagram),
|
||||
)),
|
||||
};
|
||||
let routed = Arc::make_mut(&mut scene.routed);
|
||||
let edge = routed.edges.get_mut(&c.selected).unwrap();
|
||||
let start = *edge.points.first().unwrap();
|
||||
*edge.points.last_mut().unwrap() = start;
|
||||
assert_eq!(
|
||||
c.attachment_at(&scene, point(start)).unwrap().0,
|
||||
EdgeEnd::Target
|
||||
);
|
||||
let mut source = c;
|
||||
source.selected_attachment = Some(EdgeEnd::Source);
|
||||
assert_eq!(
|
||||
source.attachment_at(&scene, point(start)).unwrap().0,
|
||||
EdgeEnd::Source
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,8 @@ struct Input {
|
||||
suspended: bool,
|
||||
traversing: bool,
|
||||
pointer: Option<Point>,
|
||||
modifiers: iced::keyboard::Modifiers,
|
||||
restore_modifiers: bool,
|
||||
}
|
||||
impl operation::Focusable for Input {
|
||||
fn is_focused(&self) -> bool {
|
||||
@@ -78,7 +80,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
}
|
||||
if state.context.is_none_or(|previous| {
|
||||
(previous.document, previous.diagram, previous.epoch)
|
||||
!= (self.context.document, self.context.diagram, self.context.epoch)
|
||||
!= (
|
||||
self.context.document,
|
||||
self.context.diagram,
|
||||
self.context.epoch,
|
||||
)
|
||||
}) || !self.enabled
|
||||
{
|
||||
state.focused = false;
|
||||
@@ -91,6 +97,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
// canvas's transient gestures without retiring this focus owner.
|
||||
state.pointer = None;
|
||||
tree.children[0] = Tree::new(self.content.as_widget());
|
||||
state.restore_modifiers = true;
|
||||
} else {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
@@ -139,6 +146,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
if let InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(modifiers)) = event {
|
||||
state.modifiers = *modifiers;
|
||||
state.restore_modifiers = false;
|
||||
}
|
||||
if matches!(event, InputEvent::Window(iced::window::Event::Unfocused)) {
|
||||
state.modifiers = iced::keyboard::Modifiers::default();
|
||||
state.restore_modifiers = true;
|
||||
}
|
||||
if let InputEvent::Mouse(mouse::Event::CursorMoved { position }) = event {
|
||||
state.pointer = Some(*position);
|
||||
}
|
||||
@@ -167,7 +182,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
}
|
||||
// Save/export I/O denies commands and acquisition, while real focus
|
||||
// operations and outside clicks can still revoke the retained owner.
|
||||
if self.suspended && matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_)) {
|
||||
let passive_modifiers = matches!(
|
||||
event,
|
||||
InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(_))
|
||||
);
|
||||
if self.suspended
|
||||
&& !passive_modifiers
|
||||
&& matches!(event, InputEvent::Keyboard(_) | InputEvent::Mouse(_))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if let InputEvent::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
@@ -189,13 +211,30 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Owner<'_, Mes
|
||||
return;
|
||||
}
|
||||
if matches!(event, InputEvent::Keyboard(_))
|
||||
&& !passive_modifiers
|
||||
&& (!state.focused || state.traversing || !self.enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if shell.is_event_captured() {
|
||||
if shell.is_event_captured() && !passive_modifiers {
|
||||
return;
|
||||
}
|
||||
if state.restore_modifiers {
|
||||
// Suspending I/O replaces the child to retire pointer gestures.
|
||||
// Held modifiers belong to this longer-lived passive owner, so
|
||||
// restore them before the first admitted event reaches the child.
|
||||
state.restore_modifiers = false;
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
&InputEvent::Keyboard(iced::keyboard::Event::ModifiersChanged(state.modifiers)),
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
|
||||
@@ -0,0 +1,685 @@
|
||||
//! Saved-view pointer geometry. Selection, capability and model admission remain
|
||||
//! with the caller; this module never applies a selection or a model command.
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
use iced::{Point, Rectangle, Vector};
|
||||
use oa_core::{Id, Position};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct Viewport {
|
||||
pub zoom: f32,
|
||||
pub pan: Vector,
|
||||
pub bounds: Rectangle,
|
||||
}
|
||||
|
||||
/// Node displacement policy; route-bend snapping remains outside this helper.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DisplacementPolicy {
|
||||
/// Preserve the ordinary saved-view ten-unit anchor grid.
|
||||
Grid10,
|
||||
/// Preserve State views' fractional authored placement displacement.
|
||||
Unsnapped,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Gesture {
|
||||
snapshot: Snapshot,
|
||||
anchor: Id,
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
displacement: DisplacementPolicy,
|
||||
crossed_threshold: bool,
|
||||
delta: Position,
|
||||
retired: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum Outcome {
|
||||
Click(Id),
|
||||
Move(Snapshot, Position),
|
||||
}
|
||||
|
||||
impl Gesture {
|
||||
pub fn new(
|
||||
snapshot: Snapshot,
|
||||
anchor: Id,
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
) -> Option<Self> {
|
||||
Self::new_with_policy(
|
||||
snapshot,
|
||||
anchor,
|
||||
original,
|
||||
press,
|
||||
viewport,
|
||||
DisplacementPolicy::Grid10,
|
||||
)
|
||||
}
|
||||
|
||||
/// `original` is the authored placement position, not its projected symbol
|
||||
/// position (State entry/exit symbols can be displayed on another boundary).
|
||||
/// Pointer displacement comes from frozen screen geometry and zoom.
|
||||
pub fn new_with_policy(
|
||||
snapshot: Snapshot,
|
||||
anchor: Id,
|
||||
original: Position,
|
||||
press: Point,
|
||||
viewport: Viewport,
|
||||
displacement: DisplacementPolicy,
|
||||
) -> Option<Self> {
|
||||
if !matches!(snapshot.context().scope.surface, Surface::Saved(_))
|
||||
|| !snapshot.targets().contains(&Target::Placement(anchor))
|
||||
|| !snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.all(|target| matches!(target, Target::Placement(_)))
|
||||
|| !viewport.valid()
|
||||
|| !finite_position(original)
|
||||
|| !finite_point(press)
|
||||
|| !viewport.bounds.contains(press)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
snapshot,
|
||||
anchor,
|
||||
original,
|
||||
press,
|
||||
viewport,
|
||||
displacement,
|
||||
crossed_threshold: false,
|
||||
delta: Position { x: 0., y: 0. },
|
||||
retired: false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> &Snapshot {
|
||||
&self.snapshot
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn anchor(&self) -> Id {
|
||||
self.anchor
|
||||
}
|
||||
|
||||
/// Compare before processing each event. On mismatch, the caller must drop
|
||||
/// this gesture, just as on Escape, focus loss or changed model authority.
|
||||
pub fn viewport_matches(&self, viewport: Viewport) -> bool {
|
||||
!self.retired && self.viewport == viewport
|
||||
}
|
||||
|
||||
pub fn crossed_threshold(&self) -> bool {
|
||||
self.crossed_threshold
|
||||
}
|
||||
|
||||
/// Common policy-adjusted world delta for preview and release; zero before a drag.
|
||||
pub fn delta(&self) -> Position {
|
||||
self.delta
|
||||
}
|
||||
|
||||
/// Process an absolute screen position. False permanently retires the
|
||||
/// gesture; the caller must remove its preview and produce no operation.
|
||||
pub fn move_to(&mut self, pointer: Point) -> bool {
|
||||
if self.retired {
|
||||
return false;
|
||||
}
|
||||
if !finite_point(pointer) || !self.viewport.bounds.contains(pointer) {
|
||||
self.retire();
|
||||
return false;
|
||||
}
|
||||
let screen = pointer - self.press;
|
||||
self.crossed_threshold |= screen.x.hypot(screen.y) > 3.;
|
||||
if !self.crossed_threshold {
|
||||
return true;
|
||||
}
|
||||
let unsnapped = Position {
|
||||
x: screen.x / self.viewport.zoom,
|
||||
y: screen.y / self.viewport.zoom,
|
||||
};
|
||||
let desired = Position {
|
||||
x: self.original.x + unsnapped.x,
|
||||
y: self.original.y + unsnapped.y,
|
||||
};
|
||||
if !finite_position(desired) {
|
||||
self.retire();
|
||||
return false;
|
||||
}
|
||||
// Returning to the original position must not align an off-grid anchor
|
||||
// as an accidental edit. It is still a drag, so selection stays grouped.
|
||||
let delta = match self.displacement {
|
||||
// Use the pointer delta directly, avoiding precision loss from
|
||||
// subtracting a large authored original from its desired position.
|
||||
DisplacementPolicy::Unsnapped => unsnapped,
|
||||
DisplacementPolicy::Grid10 if desired == self.original => Position { x: 0., y: 0. },
|
||||
DisplacementPolicy::Grid10 => Position {
|
||||
x: (desired.x / 10.).round() * 10. - self.original.x,
|
||||
y: (desired.y / 10.).round() * 10. - self.original.y,
|
||||
},
|
||||
};
|
||||
if !finite_position(delta) {
|
||||
self.retire();
|
||||
return false;
|
||||
}
|
||||
self.delta = delta;
|
||||
true
|
||||
}
|
||||
|
||||
/// Consume the final pointer too: a release can be the first displaced
|
||||
/// event. The caller must validate snapshot and viewport before acting on
|
||||
/// either outcome. Cancelling is dropping the gesture without calling this.
|
||||
pub fn release(mut self, pointer: Point) -> Option<Outcome> {
|
||||
if !self.move_to(pointer) {
|
||||
return None;
|
||||
}
|
||||
Some(if self.crossed_threshold {
|
||||
Outcome::Move(self.snapshot, self.delta)
|
||||
} else {
|
||||
Outcome::Click(self.anchor)
|
||||
})
|
||||
}
|
||||
|
||||
fn retire(&mut self) {
|
||||
self.retired = true;
|
||||
self.delta = Position { x: 0., y: 0. };
|
||||
}
|
||||
}
|
||||
|
||||
impl Viewport {
|
||||
fn valid(self) -> bool {
|
||||
self.zoom.is_finite()
|
||||
&& self.zoom > 0.
|
||||
&& self.pan.x.is_finite()
|
||||
&& self.pan.y.is_finite()
|
||||
&& self.bounds.x.is_finite()
|
||||
&& self.bounds.y.is_finite()
|
||||
&& self.bounds.width.is_finite()
|
||||
&& self.bounds.height.is_finite()
|
||||
&& self.bounds.width > 0.
|
||||
&& self.bounds.height > 0.
|
||||
&& (self.bounds.x + self.bounds.width).is_finite()
|
||||
&& (self.bounds.y + self.bounds.height).is_finite()
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_point(point: Point) -> bool {
|
||||
point.x.is_finite() && point.y.is_finite()
|
||||
}
|
||||
|
||||
fn finite_position(position: Position) -> bool {
|
||||
position.x.is_finite() && position.y.is_finite()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::diagram_selection::{Context, Scope, Selection};
|
||||
|
||||
fn id(value: u128) -> Id {
|
||||
Id::from_u128(value)
|
||||
}
|
||||
|
||||
fn context() -> Context {
|
||||
Context {
|
||||
scope: Scope {
|
||||
document: id(1),
|
||||
surface: Surface::Saved(id(2)),
|
||||
},
|
||||
revision: 3,
|
||||
epoch: 4,
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(targets: impl IntoIterator<Item = Target>) -> Snapshot {
|
||||
let mut selection = Selection::new(context().scope);
|
||||
for target in targets {
|
||||
selection.add(target, |_| true);
|
||||
}
|
||||
selection.snapshot(context()).unwrap()
|
||||
}
|
||||
|
||||
fn viewport() -> Viewport {
|
||||
Viewport {
|
||||
zoom: 1.,
|
||||
pan: Vector::new(17., -8.),
|
||||
bounds: Rectangle {
|
||||
x: 100.,
|
||||
y: 50.,
|
||||
width: 400.,
|
||||
height: 300.,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn press() -> Point {
|
||||
Point::new(150., 120.)
|
||||
}
|
||||
|
||||
fn gesture(original: Position) -> Gesture {
|
||||
Gesture::new(
|
||||
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
|
||||
id(10),
|
||||
original,
|
||||
press(),
|
||||
viewport(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stationary_or_exact_threshold_release_is_click_without_snapping() {
|
||||
for pointer in [press(), Point::new(153., 120.), Point::new(152., 122.)] {
|
||||
let mut gesture = gesture(Position { x: 13., y: -7. });
|
||||
assert!(gesture.move_to(pointer));
|
||||
assert!(!gesture.crossed_threshold());
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(gesture.release(pointer), Some(Outcome::Click(id(10))));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_latches_and_return_to_offgrid_origin_is_zero_move() {
|
||||
let mut gesture = gesture(Position { x: 13., y: -7. });
|
||||
let captured = gesture.snapshot().clone();
|
||||
assert!(gesture.move_to(Point::new(170., 140.)));
|
||||
assert!(gesture.crossed_threshold());
|
||||
assert!(gesture.move_to(press()));
|
||||
assert!(gesture.crossed_threshold());
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(
|
||||
gesture.release(press()),
|
||||
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_only_displacement_that_snaps_to_zero_still_preserves_group() {
|
||||
let gesture = gesture(Position { x: 20., y: 40. });
|
||||
let captured = gesture.snapshot().clone();
|
||||
assert_eq!(
|
||||
gesture.release(Point::new(154., 120.)),
|
||||
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offgrid_anchor_yields_one_shared_delta_and_release_matches_preview() {
|
||||
let mut gesture = gesture(Position { x: 13., y: -7. });
|
||||
let captured = gesture.snapshot().clone();
|
||||
assert_eq!(gesture.anchor(), id(10));
|
||||
let pointer = Point::new(167., 133.);
|
||||
assert!(gesture.move_to(pointer));
|
||||
// Desired anchor (30,6) snaps to (30,10), hence shared delta (17,17).
|
||||
assert_eq!(gesture.delta(), Position { x: 17., y: 17. });
|
||||
let sibling = Position { x: -6., y: 24. };
|
||||
let translated = Position {
|
||||
x: sibling.x + gesture.delta().x,
|
||||
y: sibling.y + gesture.delta().y,
|
||||
};
|
||||
assert_eq!(translated, Position { x: 11., y: 41. });
|
||||
assert_eq!(
|
||||
gesture.release(pointer),
|
||||
Some(Outcome::Move(captured, Position { x: 17., y: 17. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zoom_scales_world_delta_but_threshold_remains_in_screen_pixels() {
|
||||
let mut gesture = Gesture::new(
|
||||
snapshot([Target::Placement(id(10))]),
|
||||
id(10),
|
||||
Position { x: 20., y: 40. },
|
||||
press(),
|
||||
Viewport {
|
||||
zoom: 2.,
|
||||
..viewport()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(gesture.move_to(Point::new(153., 120.)));
|
||||
assert!(!gesture.crossed_threshold());
|
||||
assert!(gesture.move_to(Point::new(168., 112.)));
|
||||
// Screen (18,-8) means world (9,-4), snapped anchor (30,40).
|
||||
assert_eq!(gesture.delta(), Position { x: 10., y: 0. });
|
||||
assert!(gesture.crossed_threshold());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnapped_fractional_pointer_delta_preserves_authored_offset() {
|
||||
let authored = Position {
|
||||
x: 1024.125,
|
||||
y: -321.5,
|
||||
};
|
||||
let mut gesture = Gesture::new_with_policy(
|
||||
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
|
||||
id(10),
|
||||
authored,
|
||||
press(),
|
||||
Viewport {
|
||||
zoom: 2.,
|
||||
..viewport()
|
||||
},
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)
|
||||
.unwrap();
|
||||
let captured = gesture.snapshot().clone();
|
||||
let pointer = Point::new(164.5, 114.5);
|
||||
assert!(gesture.move_to(pointer));
|
||||
assert_eq!(gesture.delta(), Position { x: 7.25, y: -2.75 });
|
||||
let translated_authored = Position {
|
||||
x: authored.x + gesture.delta().x,
|
||||
y: authored.y + gesture.delta().y,
|
||||
};
|
||||
assert_eq!(
|
||||
translated_authored,
|
||||
Position {
|
||||
x: 1031.375,
|
||||
y: -324.25
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
gesture.release(pointer),
|
||||
Some(Outcome::Move(captured, Position { x: 7.25, y: -2.75 })),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnapped_threshold_and_return_to_origin_preserve_group_lifecycle() {
|
||||
let make = || {
|
||||
Gesture::new_with_policy(
|
||||
snapshot([Target::Placement(id(10)), Target::Placement(id(11))]),
|
||||
id(10),
|
||||
Position { x: 13.25, y: -7.5 },
|
||||
press(),
|
||||
viewport(),
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let mut click = make();
|
||||
assert!(click.move_to(Point::new(153., 120.)));
|
||||
assert!(!click.crossed_threshold());
|
||||
assert_eq!(click.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(
|
||||
click.release(Point::new(153., 120.)),
|
||||
Some(Outcome::Click(id(10)))
|
||||
);
|
||||
let mut drag = make();
|
||||
let captured = drag.snapshot().clone();
|
||||
assert!(drag.move_to(Point::new(157.25, 117.25)));
|
||||
assert_eq!(drag.delta(), Position { x: 7.25, y: -2.75 });
|
||||
assert!(drag.move_to(press()));
|
||||
assert!(drag.crossed_threshold());
|
||||
assert_eq!(drag.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(
|
||||
drag.release(press()),
|
||||
Some(Outcome::Move(captured, Position { x: 0., y: 0. }))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_grid_and_default_constructor_keep_existing_quantization() {
|
||||
let original = Position { x: 13., y: -7. };
|
||||
let mut default = gesture(original);
|
||||
let mut explicit = Gesture::new_with_policy(
|
||||
default.snapshot().clone(),
|
||||
id(10),
|
||||
original,
|
||||
press(),
|
||||
viewport(),
|
||||
DisplacementPolicy::Grid10,
|
||||
)
|
||||
.unwrap();
|
||||
let pointer = Point::new(167., 133.);
|
||||
assert!(default.move_to(pointer));
|
||||
assert!(explicit.move_to(pointer));
|
||||
// Existing ordinary behavior: desired (30,6), grid (30,10).
|
||||
assert_eq!(default.delta(), Position { x: 17., y: 17. });
|
||||
assert_eq!(explicit.delta(), Position { x: 17., y: 17. });
|
||||
assert_eq!(default.release(pointer), explicit.release(pointer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsnapped_release_only_movement_and_invalid_pointer_retirement() {
|
||||
let make = || {
|
||||
Gesture::new_with_policy(
|
||||
snapshot([Target::Placement(id(10))]),
|
||||
id(10),
|
||||
Position {
|
||||
x: 20.125,
|
||||
y: 40.25,
|
||||
},
|
||||
press(),
|
||||
viewport(),
|
||||
DisplacementPolicy::Unsnapped,
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let release_only = make();
|
||||
let captured = release_only.snapshot().clone();
|
||||
assert_eq!(
|
||||
release_only.release(Point::new(154.25, 120.)),
|
||||
Some(Outcome::Move(captured, Position { x: 4.25, y: 0. })),
|
||||
);
|
||||
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
|
||||
let mut gesture = make();
|
||||
assert!(gesture.move_to(Point::new(154.25, 120.)));
|
||||
assert!(!gesture.move_to(invalid));
|
||||
assert!(!gesture.viewport_matches(viewport()));
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert_eq!(gesture.release(press()), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_typed_selection_and_saved_surface_are_required() {
|
||||
for targets in [
|
||||
vec![],
|
||||
vec![Target::Placement(id(11))],
|
||||
vec![Target::Placement(id(10)), Target::Edge(id(20))],
|
||||
vec![
|
||||
Target::Placement(id(10)),
|
||||
Target::Port {
|
||||
port: id(20),
|
||||
placement: id(10),
|
||||
},
|
||||
],
|
||||
] {
|
||||
assert!(
|
||||
Gesture::new(
|
||||
snapshot(targets),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
let other = Context {
|
||||
scope: Scope {
|
||||
surface: Surface::Process(id(2)),
|
||||
..context().scope
|
||||
},
|
||||
..context()
|
||||
};
|
||||
let mut selection = Selection::new(other.scope);
|
||||
selection.add(Target::Placement(id(10)), |_| true);
|
||||
assert!(
|
||||
Gesture::new(
|
||||
selection.snapshot(other).unwrap(),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_frozen_viewport_change_is_detected() {
|
||||
let gesture = gesture(Position { x: 0., y: 0. });
|
||||
assert!(gesture.viewport_matches(viewport()));
|
||||
for changed in [
|
||||
Viewport {
|
||||
zoom: 2.,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
pan: Vector::new(18., -8.),
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
pan: Vector::new(17., -7.),
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
x: 101.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
y: 51.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
width: 401.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
height: 301.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
] {
|
||||
assert!(!gesture.viewport_matches(changed));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_initial_geometry_cannot_start() {
|
||||
let saved = snapshot([Target::Placement(id(10))]);
|
||||
for invalid in [
|
||||
Viewport {
|
||||
zoom: 0.,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
zoom: -1.,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
zoom: f32::NAN,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
zoom: f32::INFINITY,
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
pan: Vector::new(f32::NAN, 0.),
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
width: 0.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
height: -1.,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
Viewport {
|
||||
bounds: Rectangle {
|
||||
x: f32::INFINITY,
|
||||
..viewport().bounds
|
||||
},
|
||||
..viewport()
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
Gesture::new(
|
||||
saved.clone(),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
invalid
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
Gesture::new(
|
||||
saved.clone(),
|
||||
id(10),
|
||||
Position { x: f32::NAN, y: 0. },
|
||||
press(),
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
for invalid in [Point::new(f32::NAN, 120.), Point::new(99., 120.)] {
|
||||
assert!(
|
||||
Gesture::new(
|
||||
saved.clone(),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
invalid,
|
||||
viewport()
|
||||
)
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_pointer_permanently_retires_even_if_pointer_returns() {
|
||||
for invalid in [Point::new(f32::NAN, 120.), Point::new(501., 120.)] {
|
||||
let mut gesture = gesture(Position { x: 20., y: 40. });
|
||||
assert!(gesture.move_to(Point::new(170., 140.)));
|
||||
assert!(!gesture.move_to(invalid));
|
||||
assert_eq!(gesture.delta(), Position { x: 0., y: 0. });
|
||||
assert!(!gesture.move_to(press()));
|
||||
assert!(!gesture.viewport_matches(viewport()));
|
||||
assert_eq!(gesture.release(press()), None);
|
||||
}
|
||||
assert_eq!(
|
||||
gesture(Position { x: 20., y: 40. }).release(Point::new(501., 120.)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_inputs_that_overflow_world_geometry_retire_without_an_operation() {
|
||||
let mut gesture = Gesture::new(
|
||||
snapshot([Target::Placement(id(10))]),
|
||||
id(10),
|
||||
Position { x: 0., y: 0. },
|
||||
press(),
|
||||
Viewport {
|
||||
zoom: f32::MIN_POSITIVE,
|
||||
..viewport()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!gesture.move_to(Point::new(170., 140.)));
|
||||
assert_eq!(gesture.release(press()), None);
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ fn bounds() -> Rectangle {
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -62,6 +63,7 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
use super::group_gesture::{Gesture as GroupGesture, Outcome, Viewport};
|
||||
use super::*;
|
||||
use crate::app::multiselect::{Choice, Event, context};
|
||||
use crate::diagram_selection::Snapshot;
|
||||
use crate::diagram_usage::Target;
|
||||
|
||||
/// Even an unsupported mixed group must retain an ordinary click lifecycle.
|
||||
pub(super) struct Press {
|
||||
snapshot: Snapshot,
|
||||
target: Target,
|
||||
start: Point,
|
||||
viewport: Viewport,
|
||||
movement: Option<GroupGesture>,
|
||||
reason: Option<String>,
|
||||
crossed: bool,
|
||||
retired: bool,
|
||||
}
|
||||
impl Press {
|
||||
fn snapshot(&self) -> &Snapshot {
|
||||
self.movement
|
||||
.as_ref()
|
||||
.map_or(&self.snapshot, GroupGesture::snapshot)
|
||||
}
|
||||
fn viewport_matches(&self, viewport: Viewport) -> bool {
|
||||
!self.retired
|
||||
&& self.viewport == viewport
|
||||
&& self
|
||||
.movement
|
||||
.as_ref()
|
||||
.is_none_or(|movement| movement.viewport_matches(viewport))
|
||||
}
|
||||
fn crossed_threshold(&self) -> bool {
|
||||
self.movement
|
||||
.as_ref()
|
||||
.map_or(self.crossed, GroupGesture::crossed_threshold)
|
||||
}
|
||||
fn delta(&self) -> Position {
|
||||
self.movement
|
||||
.as_ref()
|
||||
.map_or(Position { x: 0., y: 0. }, GroupGesture::delta)
|
||||
}
|
||||
fn move_to(&mut self, pointer: Point) {
|
||||
if self.retired {
|
||||
return;
|
||||
}
|
||||
if !pointer.x.is_finite()
|
||||
|| !pointer.y.is_finite()
|
||||
|| !self.viewport.bounds.contains(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
self.movement = None;
|
||||
return;
|
||||
}
|
||||
self.crossed |= pointer.distance(self.start) > 3.;
|
||||
if let Some(movement) = &mut self.movement
|
||||
&& !movement.move_to(pointer)
|
||||
{
|
||||
self.retired = true;
|
||||
}
|
||||
}
|
||||
fn release(mut self, pointer: Point) -> Option<Event> {
|
||||
self.move_to(pointer);
|
||||
if self.retired {
|
||||
return None;
|
||||
}
|
||||
if !self.crossed {
|
||||
return Some(Event::Collapse(self.snapshot, self.target));
|
||||
}
|
||||
if let Some(reason) = self.reason {
|
||||
return Some(Event::Refuse(self.snapshot, reason));
|
||||
}
|
||||
match self.movement?.release(pointer)? {
|
||||
Outcome::Click(id) => Some(Event::Collapse(self.snapshot, Target::Placement(id))),
|
||||
Outcome::Move(snapshot, delta) => Some(Event::Move(snapshot, delta)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DiagramCanvas<'_> {
|
||||
pub(super) fn selection_contains(&self, target: Target) -> bool {
|
||||
self.selection
|
||||
.is_some_and(|s| s.scope() == context(self.context).scope && s.contains(&target))
|
||||
}
|
||||
|
||||
pub(super) fn group_input(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
let viewport = Viewport {
|
||||
zoom: self.zoom,
|
||||
pan: self.pan,
|
||||
bounds,
|
||||
};
|
||||
if let Some(gesture) = &state.group {
|
||||
let valid = gesture.viewport_matches(viewport)
|
||||
&& self.editable
|
||||
&& self.connection.is_none()
|
||||
&& !self.placing
|
||||
&& self
|
||||
.selection
|
||||
.is_some_and(|s| s.matches(gesture.snapshot(), context(self.context)));
|
||||
let cancelled = matches!(
|
||||
event,
|
||||
canvas::Event::Window(
|
||||
iced::window::Event::Unfocused | iced::window::Event::Resized(_)
|
||||
) | canvas::Event::Mouse(
|
||||
mouse::Event::CursorLeft
|
||||
| mouse::Event::WheelScrolled { .. }
|
||||
| mouse::Event::ButtonPressed(mouse::Button::Right | mouse::Button::Middle)
|
||||
) | canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
);
|
||||
if !valid || cancelled {
|
||||
state.group = None;
|
||||
*state.cache.borrow_mut() = None;
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
}
|
||||
if let Some(gesture) = &mut state.group {
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { position }) => {
|
||||
gesture.move_to(*position);
|
||||
*state.cache.borrow_mut() = None;
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)) => {
|
||||
let gesture = state.group.take().unwrap();
|
||||
*state.cache.borrow_mut() = None;
|
||||
let outcome = cursor
|
||||
.position()
|
||||
.and_then(|pointer| gesture.release(pointer));
|
||||
return Some(match outcome {
|
||||
Some(event) => {
|
||||
Action::publish(Message::DiagramSelection(event)).and_capture()
|
||||
}
|
||||
None => Action::request_redraw().and_capture(),
|
||||
});
|
||||
}
|
||||
canvas::Event::Keyboard(_) => return Some(Action::capture()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let selection = self
|
||||
.selection
|
||||
.filter(|s| s.scope() == context(self.context).scope)?;
|
||||
if state.pan_start.is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.connection.is_some() || self.placing {
|
||||
return None;
|
||||
}
|
||||
if let canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key, modifiers, ..
|
||||
}) = event
|
||||
{
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
if *key == Key::Named(Named::Escape)
|
||||
&& state.drag.is_none()
|
||||
&& state.route_drag.is_none()
|
||||
&& state.port_drag.is_none()
|
||||
&& state.work.is_none()
|
||||
{
|
||||
return Some(
|
||||
Action::publish(Message::DiagramSelection(Event::Clear(context(
|
||||
self.context,
|
||||
))))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() > 1 && *key == Key::Named(Named::Delete) {
|
||||
let snapshot = selection.snapshot(context(self.context))?;
|
||||
let action = if modifiers.is_empty() {
|
||||
Event::Hide(snapshot)
|
||||
} else {
|
||||
Event::Refuse(snapshot, "Use Hide selected to remove placements. Group model-deletion review is not available yet.".into())
|
||||
};
|
||||
return Some(Action::publish(Message::DiagramSelection(action)).and_capture());
|
||||
}
|
||||
}
|
||||
if !matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
) || !cursor.is_over(bounds)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let pointer = cursor.position()?;
|
||||
let local = Point::new(pointer.x - bounds.x, pointer.y - bounds.y);
|
||||
let scene = self.scene(state);
|
||||
let target = if let Some(port) = self.hit_port(&scene.view, local) {
|
||||
Some(Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
})
|
||||
} else if let Some(id) = self.hit(&scene.view, local) {
|
||||
Some(Target::Placement(id))
|
||||
} else {
|
||||
self.hit_label(&scene.routed, local)
|
||||
.or_else(|| self.hit_association_label(&scene.routed, local))
|
||||
.or_else(|| self.hit_edge(&scene.routed, local).map(|hit| hit.0))
|
||||
.map(Target::Edge)
|
||||
}?;
|
||||
if state.modifiers.shift() {
|
||||
state.drag = None;
|
||||
state.preview = None;
|
||||
state.port_drag = None;
|
||||
state.route_drag = None;
|
||||
return Some(
|
||||
Action::publish(Message::DiagramSelection(Event::Choose(
|
||||
context(self.context),
|
||||
target,
|
||||
Choice::Toggle,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if selection.targets().len() <= 1 || !selection.contains(&target) {
|
||||
return None;
|
||||
}
|
||||
let snapshot = selection.snapshot(context(self.context))?;
|
||||
if !self.editable {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
let reason = if crate::app::multiselect::placements(&snapshot).is_none() {
|
||||
Some(
|
||||
"Move requires only placed elements. Remove ports and connections from the selection.",
|
||||
)
|
||||
} else if snapshot
|
||||
.targets()
|
||||
.iter()
|
||||
.any(|t| matches!(t, Target::Placement(id) if self.work_eligible(*id)))
|
||||
{
|
||||
Some("Group allocation drag is not available yet. The selection was retained.")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let movement = if reason.is_none()
|
||||
&& let Target::Placement(anchor) = target
|
||||
{
|
||||
let original = self.project.diagrams[&self.diagram].nodes[&anchor].position;
|
||||
GroupGesture::new(snapshot.clone(), anchor, original, pointer, viewport)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if movement.is_none() && reason.is_none() {
|
||||
return Some(Action::capture());
|
||||
}
|
||||
state.group = Some(Press {
|
||||
snapshot,
|
||||
target,
|
||||
start: pointer,
|
||||
viewport,
|
||||
movement,
|
||||
reason: reason.map(str::to_owned),
|
||||
crossed: false,
|
||||
retired: false,
|
||||
});
|
||||
*state.cache.borrow_mut() = None;
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
|
||||
pub(super) fn group_preview(&self, state: &State, diagram: &mut Diagram) {
|
||||
let Some(gesture) = &state.group else {
|
||||
return;
|
||||
};
|
||||
if !gesture.crossed_threshold() {
|
||||
return;
|
||||
}
|
||||
let Some(ids) = crate::app::multiselect::placements(gesture.snapshot()) else {
|
||||
return;
|
||||
};
|
||||
let Ok(Command::Batch(commands)) = oa_core::diagram_edit::translate_placements(
|
||||
self.project,
|
||||
self.diagram,
|
||||
&ids,
|
||||
gesture.delta(),
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
// Apply only the planner's presentation records. Full model validation
|
||||
// and source/history updates occur once, when App accepts the release.
|
||||
for command in commands {
|
||||
match command {
|
||||
Command::EditNode { node, .. } => {
|
||||
diagram.nodes.insert(node.id, node);
|
||||
}
|
||||
Command::SetEdgeRoute { route, .. } => route_edit::with_route(diagram, route),
|
||||
_ => unreachable!("placement planner returned a non-geometry command"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,758 @@
|
||||
//! Production canvas event regressions for exact multi-selection and group gestures.
|
||||
use super::{DiagramCanvas, State};
|
||||
use crate::app::{
|
||||
Message,
|
||||
multiselect::{Choice, Event as SelectionEvent, context},
|
||||
};
|
||||
use crate::diagram_selection::Selection;
|
||||
use crate::diagram_usage::Target;
|
||||
use iced::widget::canvas::{self, Program};
|
||||
use iced::{Point, Rectangle, Size, Vector, event::Status, keyboard, mouse};
|
||||
use oa_core::*;
|
||||
|
||||
fn position(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(260., 100.), Size::new(2400., 1800.))
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
diagram: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
child: Id,
|
||||
internal: Id,
|
||||
context: crate::route_edit::Context,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
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 c = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "C", None)
|
||||
.unwrap();
|
||||
let child = project
|
||||
.add_element(Kind::Activity, Layer::Oa, "Child", Some(a))
|
||||
.unwrap();
|
||||
let internal = project.relate(RelationKind::Interaction, "Internal", a, b);
|
||||
let external = project.relate(RelationKind::Interaction, "External", b, c);
|
||||
let diagram = project.ensure_diagram(Layer::Oa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let a = project.place(diagram, a, position(100., 200.));
|
||||
let b = project.place(diagram, b, position(500., 200.));
|
||||
let child = project.place(diagram, child, position(120., 280.));
|
||||
project.place(diagram, c, position(900., 200.));
|
||||
let view = project.diagrams.get_mut(&diagram).unwrap();
|
||||
view.nodes.get_mut(&a).unwrap().collapsed = true;
|
||||
view.routes = [
|
||||
(internal, position(400., 320.)),
|
||||
(external, position(800., 340.)),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|(edge, point)| EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![point],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
offset: position(3., -9.),
|
||||
}),
|
||||
})
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
Self {
|
||||
project,
|
||||
diagram,
|
||||
a,
|
||||
b,
|
||||
child,
|
||||
internal,
|
||||
context: crate::route_edit::Context {
|
||||
document: Id::new_v4(),
|
||||
diagram,
|
||||
revision: 7,
|
||||
epoch: 11,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn selection(&self, targets: impl IntoIterator<Item = Target>) -> Selection {
|
||||
let mut selection = Selection::new(context(self.context).scope);
|
||||
for target in targets {
|
||||
assert!(selection.add(target, |_| true));
|
||||
}
|
||||
selection
|
||||
}
|
||||
|
||||
fn pair(&self) -> Selection {
|
||||
self.selection([Target::Placement(self.a), Target::Placement(self.b)])
|
||||
}
|
||||
|
||||
fn canvas<'a>(&'a self, selection: &'a Selection, zoom: f32) -> DiagramCanvas<'a> {
|
||||
let selected = match selection.primary() {
|
||||
Some(Target::Placement(id)) => self.project.diagrams[&self.diagram].nodes[id].element,
|
||||
Some(Target::Edge(id)) => *id,
|
||||
Some(Target::Port { port, .. }) => *port,
|
||||
_ => self.diagram,
|
||||
};
|
||||
DiagramCanvas {
|
||||
selection: Some(selection),
|
||||
project: &self.project,
|
||||
view_index: None,
|
||||
diagram: self.diagram,
|
||||
selected,
|
||||
navigation_focus: None,
|
||||
zoom,
|
||||
pan: Vector::new(20., 30.),
|
||||
context: self.context,
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn screen(&self, placement: Id, zoom: f32) -> Point {
|
||||
let p = self.project.diagrams[&self.diagram].nodes[&placement].position;
|
||||
Point::new(
|
||||
bounds().x + (p.x + 30.) * zoom + 20.,
|
||||
bounds().y + (p.y + 25.) * zoom + 30.,
|
||||
)
|
||||
}
|
||||
|
||||
fn expected_move(&self) -> Diagram {
|
||||
// Independent literal expectations: the folded child moves with A,
|
||||
// only the internal edge's authored world pin translates.
|
||||
let mut expected = self.project.diagrams[&self.diagram].clone();
|
||||
expected.nodes.get_mut(&self.a).unwrap().position = position(140., 260.);
|
||||
expected.nodes.get_mut(&self.b).unwrap().position = position(540., 260.);
|
||||
expected.nodes.get_mut(&self.child).unwrap().position = position(160., 340.);
|
||||
expected.routes[0].waypoints = vec![position(440., 380.)];
|
||||
expected
|
||||
}
|
||||
}
|
||||
|
||||
fn input(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: canvas::Event,
|
||||
viewport: Rectangle,
|
||||
final_cursor: Point,
|
||||
) -> (Option<Message>, Status) {
|
||||
c.update(
|
||||
state,
|
||||
&event,
|
||||
viewport,
|
||||
mouse::Cursor::Available(final_cursor),
|
||||
)
|
||||
.map_or((None, Status::Ignored), |action| {
|
||||
let (message, _, status) = action.into_inner();
|
||||
(message, status)
|
||||
})
|
||||
}
|
||||
|
||||
fn pointer(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: mouse::Event,
|
||||
final_cursor: Point,
|
||||
) -> (Option<Message>, Status) {
|
||||
input(
|
||||
c,
|
||||
state,
|
||||
canvas::Event::Mouse(event),
|
||||
bounds(),
|
||||
final_cursor,
|
||||
)
|
||||
}
|
||||
|
||||
fn start(c: &DiagramCanvas<'_>, state: &mut State, press: Point, final_cursor: Point) {
|
||||
assert!(
|
||||
pointer(
|
||||
c,
|
||||
state,
|
||||
mouse::Event::CursorMoved { position: press },
|
||||
final_cursor
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let (message, status) = pointer(
|
||||
c,
|
||||
state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
final_cursor,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_some());
|
||||
assert!(state.drag.is_none());
|
||||
}
|
||||
|
||||
fn key(named: keyboard::key::Named) -> canvas::Event {
|
||||
canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(named),
|
||||
modified_key: keyboard::Key::Named(named),
|
||||
physical_key: keyboard::key::Physical::Code(if named == keyboard::key::Named::Escape {
|
||||
keyboard::key::Code::Escape
|
||||
} else {
|
||||
keyboard::key::Code::Delete
|
||||
}),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn expect_move(
|
||||
message: Option<Message>,
|
||||
selection: &Selection,
|
||||
current: crate::route_edit::Context,
|
||||
expected: Position,
|
||||
) -> crate::diagram_selection::Snapshot {
|
||||
let Some(Message::DiagramSelection(SelectionEvent::Move(snapshot, delta))) = message else {
|
||||
panic!("expected one captured group Move");
|
||||
};
|
||||
assert_eq!(delta, expected);
|
||||
assert!(selection.matches(&snapshot, context(current)));
|
||||
snapshot
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_press_uses_ordered_hit_not_native_batch_final_cursor() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection([Target::Placement(f.b)]);
|
||||
let before = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let at_a = f.screen(f.a, 1.);
|
||||
let final_cursor = f.screen(f.b, 1.);
|
||||
assert!(
|
||||
input(
|
||||
&c,
|
||||
&mut state,
|
||||
canvas::Event::Keyboard(keyboard::Event::ModifiersChanged(
|
||||
keyboard::Modifiers::SHIFT
|
||||
)),
|
||||
bounds(),
|
||||
final_cursor
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: at_a },
|
||||
final_cursor
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
final_cursor,
|
||||
);
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(
|
||||
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Choose(captured, Target::Placement(id), Choice::Toggle))) if captured == context(f.context) && id == f.a)
|
||||
);
|
||||
assert!(
|
||||
state.group.is_none()
|
||||
&& state.drag.is_none()
|
||||
&& state.route_drag.is_none()
|
||||
&& state.port_drag.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
final_cursor
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
selection.matches(&before, context(f.context)),
|
||||
"canvas emits intent without mutating selection"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn group_canvas_preview_and_release_match_independent_expected_geometry() {
|
||||
for zoom in [0.5, 1., 2.] {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let captured = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, zoom);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, zoom);
|
||||
let end = press + Vector::new(40. * zoom, 60. * zoom);
|
||||
start(&c, &mut state, press, end);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert_eq!(c.preview_diagram(&state), f.expected_move());
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert_eq!(status, Status::Captured);
|
||||
let snapshot = expect_move(message, &selection, f.context, position(40., 60.));
|
||||
assert_eq!(snapshot, captured);
|
||||
assert!(state.group.is_none());
|
||||
let ids = crate::app::multiselect::placements(&snapshot).unwrap();
|
||||
let command =
|
||||
diagram_edit::translate_placements(&f.project, f.diagram, &ids, position(40., 60.))
|
||||
.unwrap();
|
||||
let actual = prepare_command(&f.project, &command).unwrap().project;
|
||||
let mut expected = f.project.clone();
|
||||
expected.diagrams.insert(f.diagram, f.expected_move());
|
||||
assert_eq!(actual, expected);
|
||||
assert!(selection.matches(&captured, context(f.context)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stationary_group_click_does_not_use_motion_after_release_in_same_batch() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(80., 70.);
|
||||
start(&c, &mut state, press, end);
|
||||
let (message, _) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert!(
|
||||
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Collapse(captured, Target::Placement(id)))) if selection.matches(&captured, context(f.context)) && id == f.a)
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_set_keeps_click_lifecycle_and_refuses_only_actual_drag() {
|
||||
for moved in [false, true] {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection([Target::Placement(f.a), Target::Edge(f.internal)]);
|
||||
let captured = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = if moved {
|
||||
press + Vector::new(40., 60.)
|
||||
} else {
|
||||
press + Vector::new(2., 1.)
|
||||
};
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert_eq!(status, Status::Captured);
|
||||
if moved {
|
||||
assert!(
|
||||
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Refuse(snapshot, reason))) if snapshot == captured && reason.contains("only placed elements"))
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
matches!(message, Some(Message::DiagramSelection(SelectionEvent::Collapse(current, Target::Placement(id)))) if current == captured && id == f.a)
|
||||
);
|
||||
}
|
||||
assert!(state.group.is_none());
|
||||
assert!(selection.matches(&captured, context(f.context)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_cancels_live_group_without_clearing_or_publishing_a_model_intent() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let captured = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.expected_move());
|
||||
let (message, status) = input(
|
||||
&c,
|
||||
&mut state,
|
||||
key(keyboard::key::Named::Escape),
|
||||
bounds(),
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_none());
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(selection.matches(&captured, context(f.context)));
|
||||
// A subsequent Escape, after the gesture is idle, may clear selection.
|
||||
assert!(matches!(
|
||||
input(
|
||||
&c,
|
||||
&mut state,
|
||||
key(keyboard::key::Named::Escape),
|
||||
bounds(),
|
||||
end
|
||||
)
|
||||
.0,
|
||||
Some(Message::DiagramSelection(SelectionEvent::Clear(_)))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_is_captured_without_hiding_during_live_group_then_release_moves_once() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let (message, status) = input(
|
||||
&c,
|
||||
&mut state,
|
||||
key(keyboard::key::Named::Delete),
|
||||
bounds(),
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_some());
|
||||
assert_eq!(c.preview_diagram(&state), f.expected_move());
|
||||
expect_move(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
)
|
||||
.0,
|
||||
&selection,
|
||||
f.context,
|
||||
position(40., 60.),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_pan_excludes_group_start_and_delete_even_when_left_is_pressed() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let captured = selection.snapshot(context(f.context)).unwrap();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: press },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_none() && state.drag.is_none());
|
||||
assert!(
|
||||
input(
|
||||
&c,
|
||||
&mut state,
|
||||
key(keyboard::key::Named::Delete),
|
||||
bounds(),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
matches!(pointer(&c, &mut state, mouse::Event::CursorMoved { position: end }, end).0, Some(Message::Pan(delta)) if delta == Vector::new(40., 60.))
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
assert!(selection.matches(&captured, context(f.context)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_viewport_or_context_retires_group_without_a_release_intent() {
|
||||
for change in 0..7 {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
let mut changed = c;
|
||||
let mut viewport = bounds();
|
||||
match change {
|
||||
0 => viewport.x += 1.,
|
||||
1 => viewport.width -= 1.,
|
||||
2 => changed.pan.x += 1.,
|
||||
3 => changed.zoom = 2.,
|
||||
4 => changed.context.epoch += 1,
|
||||
5 => changed.context.revision += 1,
|
||||
_ => changed.context.document = Id::new_v4(),
|
||||
}
|
||||
let (message, _) = input(
|
||||
&changed,
|
||||
&mut state,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
viewport,
|
||||
end,
|
||||
);
|
||||
assert!(
|
||||
message.is_none(),
|
||||
"changed viewport/context {change} must not publish a commit or click"
|
||||
);
|
||||
assert!(state.group.is_none());
|
||||
assert_eq!(
|
||||
changed.preview_diagram(&state),
|
||||
f.project.diagrams[&f.diagram]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_selection_generation_rejects_press_even_after_membership_is_restored() {
|
||||
let f = Fixture::new();
|
||||
let mut selection = f.pair();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
let mut state = State::default();
|
||||
{
|
||||
let c = f.canvas(&selection, 1.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
assert!(selection.remove(&Target::Placement(f.b)));
|
||||
assert!(selection.add(Target::Placement(f.b), |_| true));
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let (message, status) = pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
);
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, Status::Captured);
|
||||
assert!(state.group.is_none());
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_press_without_pointer_history_consumes_release_only_displacement() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
// With no earlier ordered move, the supplied release cursor is authoritative.
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
press
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(state.group.is_some());
|
||||
expect_move(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
)
|
||||
.0,
|
||||
&selection,
|
||||
f.context,
|
||||
position(40., 60.),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn threshold_crossing_then_return_to_origin_emits_zero_move_not_singleton_click() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.pair();
|
||||
let c = f.canvas(&selection, 1.);
|
||||
let mut state = State::default();
|
||||
let press = f.screen(f.a, 1.);
|
||||
let end = press + Vector::new(40., 60.);
|
||||
start(&c, &mut state, press, end);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: press },
|
||||
end
|
||||
)
|
||||
.0
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(c.preview_diagram(&state), f.project.diagrams[&f.diagram]);
|
||||
expect_move(
|
||||
pointer(
|
||||
&c,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
end,
|
||||
)
|
||||
.0,
|
||||
&selection,
|
||||
f.context,
|
||||
position(0., 0.),
|
||||
);
|
||||
assert_eq!(selection.targets().len(), 2);
|
||||
}
|
||||
@@ -61,6 +61,7 @@ fn screen(p: Position, zoom: f32) -> Point {
|
||||
}
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -70,6 +71,7 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: app.connection.as_ref(),
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
selection: None,
|
||||
project,
|
||||
view_index: None,
|
||||
diagram,
|
||||
@@ -16,6 +17,7 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -177,6 +177,7 @@ mod tests {
|
||||
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
selection: None,
|
||||
project: app.project(),
|
||||
view_index: None,
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
@@ -186,6 +187,7 @@ mod tests {
|
||||
pan: Vector::ZERO,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
//! Transient exact-target selection; admission and model operations belong to adapters.
|
||||
use crate::diagram_usage::{Surface, Target};
|
||||
use oa_core::Id;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Scope {
|
||||
/// Document-instance identity, not the persisted project UUID.
|
||||
pub document: Id,
|
||||
pub surface: Surface,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Context {
|
||||
pub scope: Scope,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
|
||||
/// Insertion-ordered unique targets. Membership does not grant edit capability.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Selection {
|
||||
scope: Scope,
|
||||
targets: Vec<Target>,
|
||||
primary: Option<Target>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// Immutable operation input, including the selection lifetime that captured it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Snapshot {
|
||||
context: Context,
|
||||
generation: u64,
|
||||
targets: Vec<Target>,
|
||||
primary: Option<Target>,
|
||||
}
|
||||
|
||||
impl Selection {
|
||||
pub fn new(scope: Scope) -> Self {
|
||||
Self {
|
||||
scope,
|
||||
targets: Vec::new(),
|
||||
primary: None,
|
||||
generation: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scope(&self) -> Scope {
|
||||
self.scope
|
||||
}
|
||||
|
||||
pub fn targets(&self) -> &[Target] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> Option<&Target> {
|
||||
self.primary.as_ref()
|
||||
}
|
||||
|
||||
pub fn contains(&self, target: &Target) -> bool {
|
||||
self.targets.contains(target)
|
||||
}
|
||||
|
||||
/// Replace with one valid target. Rejected admission preserves the old set.
|
||||
pub fn replace(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
if !admitted(&target, valid) || (self.targets.len() == 1 && self.contains(&target)) {
|
||||
return false;
|
||||
}
|
||||
self.targets = vec![target.clone()];
|
||||
self.primary = Some(target);
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Add a new member as primary. Adding an existing exact target is a no-op.
|
||||
pub fn add(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
if !admitted(&target, valid) || self.contains(&target) {
|
||||
return false;
|
||||
}
|
||||
self.targets.push(target.clone());
|
||||
self.primary = Some(target);
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Removal is allowed even if the target is no longer valid in the model.
|
||||
pub fn remove(&mut self, target: &Target) -> bool {
|
||||
let Some(index) = self.targets.iter().position(|member| member == target) else {
|
||||
return false;
|
||||
};
|
||||
self.targets.remove(index);
|
||||
if self.primary.as_ref() == Some(target) {
|
||||
self.primary = self.targets.last().cloned();
|
||||
}
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn toggle(&mut self, target: Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
if self.contains(&target) {
|
||||
self.remove(&target)
|
||||
} else {
|
||||
self.add(target, valid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect another selected member without changing membership or its order.
|
||||
pub fn set_primary(&mut self, target: &Target) -> bool {
|
||||
if !self.contains(target) || self.primary.as_ref() == Some(target) {
|
||||
return false;
|
||||
}
|
||||
self.primary = Some(target.clone());
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) -> bool {
|
||||
if self.targets.is_empty() {
|
||||
return false;
|
||||
}
|
||||
self.targets.clear();
|
||||
self.primary = None;
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Change owner or prune exact targets against the current rendered surface.
|
||||
/// The predicate checks identity/visibility, not eligibility for one action.
|
||||
/// Revision-only changes may retain survivors; no semantic retargeting occurs.
|
||||
pub fn reconcile(&mut self, scope: Scope, valid: impl FnMut(&Target) -> bool) -> bool {
|
||||
if self.scope != scope {
|
||||
self.scope = scope;
|
||||
self.targets.clear();
|
||||
self.primary = None;
|
||||
self.changed();
|
||||
return true;
|
||||
}
|
||||
let previous_len = self.targets.len();
|
||||
self.targets.retain(valid);
|
||||
if self.targets.len() == previous_len {
|
||||
return false;
|
||||
}
|
||||
if !self
|
||||
.primary
|
||||
.as_ref()
|
||||
.is_some_and(|target| self.contains(target))
|
||||
{
|
||||
self.primary = self.targets.last().cloned();
|
||||
}
|
||||
self.changed();
|
||||
true
|
||||
}
|
||||
|
||||
/// Capture input, including an empty selection before a first pointer press.
|
||||
/// Does not grant permission to mutate; adapters still guard drafts, modals,
|
||||
/// capability and geometry. A mismatched owner cannot capture a snapshot.
|
||||
pub fn snapshot(&self, context: Context) -> Option<Snapshot> {
|
||||
if context.scope != self.scope {
|
||||
return None;
|
||||
}
|
||||
Some(Snapshot {
|
||||
context,
|
||||
generation: self.generation,
|
||||
targets: self.targets.clone(),
|
||||
primary: self.primary.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Check before any operation side effect, using the caller's live context.
|
||||
/// Generation rejects remove/re-add and primary-change/restore ABA cycles.
|
||||
pub fn matches(&self, snapshot: &Snapshot, current: Context) -> bool {
|
||||
snapshot.context == current
|
||||
&& current.scope == self.scope
|
||||
&& snapshot.generation == self.generation
|
||||
&& snapshot.targets == self.targets
|
||||
&& self.primary == snapshot.primary
|
||||
}
|
||||
|
||||
fn changed(&mut self) {
|
||||
self.generation = self
|
||||
.generation
|
||||
.checked_add(1)
|
||||
.expect("selection generation exhausted");
|
||||
}
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub fn context(&self) -> Context {
|
||||
self.context
|
||||
}
|
||||
|
||||
pub fn targets(&self) -> &[Target] {
|
||||
&self.targets
|
||||
}
|
||||
|
||||
pub fn primary(&self) -> Option<&Target> {
|
||||
self.primary.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
fn admitted(target: &Target, valid: impl FnOnce(&Target) -> bool) -> bool {
|
||||
!matches!(target, Target::Whole) && valid(target)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::OccurrenceEndpoint;
|
||||
|
||||
fn id(value: u128) -> Id {
|
||||
Id::from_u128(value)
|
||||
}
|
||||
|
||||
fn scope() -> Scope {
|
||||
Scope {
|
||||
document: id(1),
|
||||
surface: Surface::Saved(id(2)),
|
||||
}
|
||||
}
|
||||
|
||||
fn context() -> Context {
|
||||
Context {
|
||||
scope: scope(),
|
||||
revision: 7,
|
||||
epoch: 11,
|
||||
}
|
||||
}
|
||||
|
||||
fn node(value: u128) -> Target {
|
||||
Target::Placement(id(value))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_duplicates_are_noops_but_distinct_visual_identities_survive() {
|
||||
let mut selection = Selection::new(scope());
|
||||
let port_a = Target::Port {
|
||||
port: id(30),
|
||||
placement: id(3),
|
||||
};
|
||||
let port_b = Target::Port {
|
||||
port: id(30),
|
||||
placement: id(4),
|
||||
};
|
||||
assert!(selection.add(node(3), |_| true));
|
||||
assert!(selection.add(port_a.clone(), |_| true));
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert!(!selection.add(node(3), |_| true));
|
||||
assert!(!selection.add(port_a.clone(), |_| true));
|
||||
assert!(selection.matches(&captured, context()));
|
||||
assert!(selection.add(port_b.clone(), |_| true));
|
||||
assert_eq!(selection.targets(), &[node(3), port_a, port_b.clone()]);
|
||||
assert_eq!(selection.primary(), Some(&port_b));
|
||||
assert_eq!(
|
||||
captured.targets(),
|
||||
&[
|
||||
node(3),
|
||||
Target::Port {
|
||||
port: id(30),
|
||||
placement: id(3)
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn primary_removal_uses_latest_survivor_without_reordering() {
|
||||
let mut selection = Selection::new(scope());
|
||||
for value in [3, 4, 5] {
|
||||
selection.add(node(value), |_| true);
|
||||
}
|
||||
assert!(selection.set_primary(&node(3)));
|
||||
assert_eq!(selection.targets(), &[node(3), node(4), node(5)]);
|
||||
assert!(!selection.set_primary(&node(99)));
|
||||
assert!(selection.remove(&node(4)));
|
||||
assert_eq!(selection.primary(), Some(&node(3)));
|
||||
assert!(selection.remove(&node(3)));
|
||||
assert_eq!(selection.primary(), Some(&node(5)));
|
||||
// A now-invalid member remains removable; validity applies to additions.
|
||||
assert!(selection.toggle(node(5), |_| false));
|
||||
assert!(selection.targets().is_empty());
|
||||
assert_eq!(selection.primary(), None);
|
||||
let empty = selection.snapshot(context()).unwrap();
|
||||
assert!(empty.targets().is_empty());
|
||||
assert_eq!(empty.primary(), None);
|
||||
assert!(selection.matches(&empty, context()));
|
||||
selection.add(node(5), |_| true);
|
||||
assert!(!selection.matches(&empty, context()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_and_rejected_targets_cannot_replace_an_existing_selection() {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert!(!selection.replace(Target::Whole, |_| true));
|
||||
assert!(!selection.add(Target::Whole, |_| true));
|
||||
assert!(!selection.toggle(Target::Whole, |_| true));
|
||||
assert!(!selection.replace(node(4), |_| false));
|
||||
assert!(!selection.toggle(node(4), |_| false));
|
||||
assert!(selection.matches(&captured, context()));
|
||||
assert!(selection.replace(node(4), |_| true));
|
||||
assert_eq!(selection.targets(), &[node(4)]);
|
||||
assert_eq!(selection.primary(), Some(&node(4)));
|
||||
assert!(!selection.replace(node(4), |_| true));
|
||||
assert!(selection.clear());
|
||||
assert!(!selection.clear());
|
||||
assert_eq!(selection.primary(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reused_process_occurrences_reconcile_by_full_path_never_definition() {
|
||||
let owner = Scope {
|
||||
surface: Surface::Process(id(2)),
|
||||
..scope()
|
||||
};
|
||||
let mut selection = Selection::new(owner);
|
||||
let occurrence = |path| {
|
||||
Target::ProcessOccurrence(OccurrenceEndpoint {
|
||||
references: vec![id(path), id(50)],
|
||||
occurrence: id(60),
|
||||
})
|
||||
};
|
||||
let first = occurrence(10);
|
||||
let second = occurrence(20);
|
||||
selection.add(first.clone(), |_| true);
|
||||
selection.add(second.clone(), |_| true);
|
||||
assert_eq!(selection.targets(), &[first.clone(), second.clone()]);
|
||||
assert!(selection.reconcile(owner, |target| target == &first));
|
||||
assert_eq!(selection.targets(), std::slice::from_ref(&first));
|
||||
assert_eq!(selection.primary(), Some(&first));
|
||||
// A new call path with the same definition is not a replacement identity.
|
||||
let replacement = occurrence(70);
|
||||
assert!(selection.reconcile(owner, |target| target == &replacement));
|
||||
assert!(selection.targets().is_empty());
|
||||
assert_eq!(selection.primary(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revision_reconciliation_retains_exact_survivors_but_invalidates_operations() {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
selection.add(node(4), |_| true);
|
||||
selection.set_primary(&node(3));
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
let next = Context {
|
||||
revision: 8,
|
||||
..context()
|
||||
};
|
||||
assert!(!selection.reconcile(scope(), |_| true));
|
||||
assert_eq!(selection.targets(), &[node(3), node(4)]);
|
||||
assert_eq!(selection.primary(), Some(&node(3)));
|
||||
assert!(!selection.matches(&captured, next));
|
||||
let accepted = selection.snapshot(next).unwrap();
|
||||
assert!(selection.matches(&accepted, next));
|
||||
assert!(selection.reconcile(scope(), |target| target != &node(4)));
|
||||
assert_eq!(selection.primary(), Some(&node(3)));
|
||||
assert!(!selection.matches(&accepted, next));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changing_document_or_surface_clears_even_identical_target_ids() {
|
||||
for changed in [
|
||||
Scope {
|
||||
document: id(99),
|
||||
..scope()
|
||||
},
|
||||
Scope {
|
||||
surface: Surface::Saved(id(99)),
|
||||
..scope()
|
||||
},
|
||||
Scope {
|
||||
surface: Surface::Scenario(id(2)),
|
||||
..scope()
|
||||
},
|
||||
] {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert!(selection.reconcile(changed, |_| true));
|
||||
assert_eq!(selection.scope(), changed);
|
||||
assert!(selection.targets().is_empty());
|
||||
assert_eq!(selection.primary(), None);
|
||||
assert!(!selection.matches(&captured, context()));
|
||||
assert!(selection.snapshot(context()).is_none());
|
||||
selection.reconcile(scope(), |_| true);
|
||||
selection.add(node(3), |_| true);
|
||||
assert!(!selection.matches(&captured, context()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_epoch_selection_and_primary_cycles_do_not_regain_authority() {
|
||||
let mut selection = Selection::new(scope());
|
||||
selection.add(node(3), |_| true);
|
||||
selection.add(node(4), |_| true);
|
||||
let captured = selection.snapshot(context()).unwrap();
|
||||
assert_eq!(captured.context(), context());
|
||||
assert_eq!(captured.primary(), Some(&node(4)));
|
||||
assert!(!selection.matches(
|
||||
&captured,
|
||||
Context {
|
||||
epoch: 12,
|
||||
..context()
|
||||
}
|
||||
));
|
||||
selection.set_primary(&node(3));
|
||||
selection.set_primary(&node(4));
|
||||
assert!(!selection.matches(&captured, context()));
|
||||
let restored_primary = selection.snapshot(context()).unwrap();
|
||||
selection.remove(&node(4));
|
||||
selection.add(node(4), |_| true);
|
||||
assert_eq!(selection.targets(), captured.targets());
|
||||
assert_eq!(selection.primary(), captured.primary());
|
||||
assert!(!selection.matches(&restored_primary, context()));
|
||||
assert!(selection.matches(&selection.snapshot(context()).unwrap(), context()));
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ fn article(topic: Topic) -> Article {
|
||||
&[
|
||||
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
|
||||
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
|
||||
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
"Select a connection and drag a square endpoint handle along its element boundary to move its attachment in this view without changing what it connects. If handles overlap, right-click the selected connection or use its Actions menu and choose Move source attachment or Move target attachment, then drag the handle. Connections to semantic ports retain their port attachment; move the port instead. Reset source attachment or Reset target attachment restores that end's automatic placement while preserving the other end, pins and label. Reset routing clears all these route overrides in this view. Auto-layout preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -564,7 +564,8 @@ fn article(topic: Topic) -> Article {
|
||||
"Optionally select Create the first state view, then Create and open. The new machine starts without regions or states. For an existing machine, Create diagram places its existing regions and vertices in a saved view; + View creates another independent layout.",
|
||||
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
|
||||
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
|
||||
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
|
||||
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex. To change only where a line touches its existing vertex, choose Adjust source attachment or Adjust target attachment, then drag the highlighted tip along the boundary. Reset source attachment or Reset target attachment restores automatic placement for that end without changing the transition's meaning or other route settings. Escape cancels an attachment drag.",
|
||||
"To shape a selected transition, drag an exposed line segment to add a bend, or drag a square bend handle to move it. Right-click a square and choose Remove bend. The transition's Actions menu also offers Add bend and Clear bends; clearing bends keeps its attachment points and label placement. Bends belong to this saved view. A click alone changes nothing, Escape cancels a drag, and Undo restores the complete edit. Internal transition rows and folded connections do not expose bend editing while their route is hidden.",
|
||||
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
|
||||
],
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ mod data_values;
|
||||
mod data_view;
|
||||
mod diagnostic_input;
|
||||
mod diagram;
|
||||
mod diagram_selection;
|
||||
mod diagram_usage;
|
||||
mod document;
|
||||
mod explorer;
|
||||
@@ -28,7 +29,6 @@ mod recovery;
|
||||
mod route_edit;
|
||||
mod semantic_review;
|
||||
mod source_annotations;
|
||||
mod source_indentation;
|
||||
mod source_navigation;
|
||||
mod source_rename;
|
||||
mod source_tree;
|
||||
|
||||
@@ -12,8 +12,10 @@ pub struct Request {
|
||||
pub anchor: Point,
|
||||
pub object: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub selection_target: Option<Box<crate::diagram_usage::Target>>,
|
||||
pub bend: Option<usize>,
|
||||
pub route_point: Option<Position>,
|
||||
pub route_segment: Option<(usize, Position)>,
|
||||
pub creation_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub source_file: Option<String>,
|
||||
@@ -24,8 +26,10 @@ impl Request {
|
||||
anchor,
|
||||
object,
|
||||
placement: None,
|
||||
selection_target: None,
|
||||
bend: None,
|
||||
route_point: None,
|
||||
route_segment: None,
|
||||
creation_point: None,
|
||||
canvas: None,
|
||||
source_file: None,
|
||||
@@ -53,6 +57,7 @@ pub struct Opened {
|
||||
impl Opened {
|
||||
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
||||
if !app.project().contains(request.object)
|
||||
|| !target_matches(app, &request)
|
||||
|| request
|
||||
.canvas
|
||||
.is_some_and(|c| Some(c) != current_canvas(app))
|
||||
@@ -78,6 +83,7 @@ impl Opened {
|
||||
&& self.layer == app.layer
|
||||
&& self.diagram == current_diagram(app)
|
||||
&& app.project().contains(self.request.object)
|
||||
&& target_matches(app, &self.request)
|
||||
&& self
|
||||
.request
|
||||
.canvas
|
||||
@@ -90,6 +96,32 @@ impl Opened {
|
||||
}
|
||||
}
|
||||
|
||||
fn target_matches(app: &App, request: &Request) -> bool {
|
||||
use crate::diagram_usage::Target;
|
||||
let Some(target) = request.selection_target.as_deref() else {
|
||||
return true;
|
||||
};
|
||||
if request.canvas.is_none() || !app.multiselect_target_visible(target) {
|
||||
return false;
|
||||
}
|
||||
match target {
|
||||
Target::Placement(id) => {
|
||||
request.placement == Some(*id)
|
||||
&& request
|
||||
.canvas
|
||||
.and_then(|context| app.project().diagrams.get(&context.diagram))
|
||||
.and_then(|diagram| diagram.nodes.get(id))
|
||||
.is_some_and(|node| node.element == request.object || node.id == request.object)
|
||||
}
|
||||
Target::Port { port, .. } => request.placement.is_none() && *port == request.object,
|
||||
Target::Edge(id) => request.placement.is_none() && *id == request.object,
|
||||
Target::StateRow { relation, .. } => {
|
||||
request.placement.is_none() && *relation == request.object
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn current_canvas(app: &App) -> Option<route_edit::Context> {
|
||||
if app.page == Page::Behavior {
|
||||
app.state_context()
|
||||
@@ -124,7 +156,22 @@ pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
if let Some(context) = request.canvas
|
||||
if let Some(target) = app.diagram_selection.as_ref().and_then(|s| s.primary()) {
|
||||
request.selection_target = Some(Box::new(target.clone()));
|
||||
request.placement = match target {
|
||||
crate::diagram_usage::Target::Placement(id) => {
|
||||
if let Some(node) = request
|
||||
.canvas
|
||||
.and_then(|c| app.project().diagrams.get(&c.diagram))
|
||||
.and_then(|d| d.nodes.get(id))
|
||||
{
|
||||
request.object = node.element;
|
||||
}
|
||||
Some(*id)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
} else if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
@@ -185,12 +232,112 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
if app.page == Page::Behavior
|
||||
&& let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean)
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if request.canvas.is_some()
|
||||
&& let Some(context) = app.multiselect_context()
|
||||
{
|
||||
use crate::app::multiselect::{Choice, Event as SelectionEvent};
|
||||
use crate::diagram_usage::Target;
|
||||
let view = request
|
||||
.canvas
|
||||
.map(|context| context.diagram)
|
||||
.map(|d| projection::project_view_with_index(p, d, app.document.view_index()));
|
||||
let target = request
|
||||
.selection_target
|
||||
.as_deref()
|
||||
.cloned()
|
||||
.or_else(|| request.placement.map(Target::Placement))
|
||||
.or_else(|| {
|
||||
view.as_ref()?
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == id)
|
||||
.map(|port| Target::Port {
|
||||
port: port.element,
|
||||
placement: port.owner,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
view.as_ref()?
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == id)
|
||||
.then_some(Target::Edge(id))
|
||||
});
|
||||
if let Some(target) = target {
|
||||
let selected = app
|
||||
.diagram_selection
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.contains(&target));
|
||||
entries.push(item(
|
||||
if selected {
|
||||
"Remove from selection"
|
||||
} else {
|
||||
"Add to selection"
|
||||
},
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
if selected {
|
||||
Choice::Remove
|
||||
} else {
|
||||
Choice::Add
|
||||
},
|
||||
)),
|
||||
true,
|
||||
));
|
||||
if let Some(snapshot) = app.multiselect_snapshot().filter(|s| s.targets().len() > 1)
|
||||
&& selected
|
||||
{
|
||||
entries.push(Entry::title(format!(
|
||||
"{} selected",
|
||||
snapshot.targets().len()
|
||||
)));
|
||||
entries.push(item(
|
||||
"Inspect this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target.clone(),
|
||||
Choice::Inspect,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Select only this element",
|
||||
Message::DiagramSelection(SelectionEvent::Choose(
|
||||
context,
|
||||
target,
|
||||
Choice::Replace,
|
||||
)),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Hide selected placements",
|
||||
Message::DiagramSelection(SelectionEvent::Hide(snapshot.clone())),
|
||||
crate::app::multiselect::placements(&snapshot).is_some(),
|
||||
));
|
||||
entries.push(Entry::disabled(
|
||||
"Delete selected from model (not yet available)",
|
||||
));
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
}
|
||||
}
|
||||
if app.page == Page::Behavior
|
||||
&& let Some(mut menu) = crate::behavior::state_menu::popup(app, opened, clean)
|
||||
{
|
||||
if entries.len() > 1 {
|
||||
entries.extend(menu.entries.into_iter().skip(1));
|
||||
menu.entries = entries;
|
||||
}
|
||||
return Some(menu);
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or revert pending edits first"));
|
||||
}
|
||||
@@ -240,6 +387,33 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
.is_none_or(|(s, t)| s == e.source && t == e.target)
|
||||
});
|
||||
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
|
||||
if let Some((diagram, view)) = diagram.zip(view.as_ref()) {
|
||||
for (end, choose, reset) in [
|
||||
(
|
||||
EdgeEnd::Source,
|
||||
"Move source attachment",
|
||||
"Reset source attachment",
|
||||
),
|
||||
(
|
||||
EdgeEnd::Target,
|
||||
"Move target attachment",
|
||||
"Reset target attachment",
|
||||
),
|
||||
] {
|
||||
let eligible = editable
|
||||
&& route_edit::attachment_node(p, diagram, view, id, end).is_some();
|
||||
entries.push(item(
|
||||
choose,
|
||||
Message::Route(route_edit::Event::ChooseAttachment(end)),
|
||||
eligible,
|
||||
));
|
||||
entries.push(item(
|
||||
reset,
|
||||
Message::Route(route_edit::Event::ResetAttachment(end)),
|
||||
eligible && route.is_some_and(|route| route.attachment(end).is_some()),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(point) = request.route_point {
|
||||
entries.push(
|
||||
item(
|
||||
@@ -900,6 +1074,36 @@ mod tests {
|
||||
assert_eq!(app.document.bundle(), &changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_end_choice_is_captured_without_mutating_source_and_stale_menu_is_rejected() {
|
||||
let mut project = Project::empty();
|
||||
let source = project.add(Kind::Activity, "Send", 0., 0.);
|
||||
let target = project.add(Kind::Activity, "Receive", 650., 0.);
|
||||
let edge = project.relate(RelationKind::Interaction, "Report", source, target);
|
||||
let mut app = App::with_project(project);
|
||||
let before = app.document.bundle().clone();
|
||||
let mut request = Request::object(edge, Point::ORIGIN);
|
||||
request.canvas = app.route_context();
|
||||
send(&mut app, Message::Menu(Event::Open(request.clone())));
|
||||
let action = choose(&app, "Move target attachment");
|
||||
send(&mut app, action);
|
||||
assert_eq!(
|
||||
app.selected_attachment,
|
||||
Some((app.route_context().unwrap(), edge, EdgeEnd::Target))
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
request.canvas = app.route_context();
|
||||
send(&mut app, Message::Menu(Event::Open(request)));
|
||||
let stale = choose(&app, "Move source attachment");
|
||||
send(&mut app, Message::Escape);
|
||||
send(&mut app, stale);
|
||||
assert!(
|
||||
app.selected_attachment
|
||||
.is_none_or(|(context, _, _)| Some(context) != app.route_context())
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
|
||||
let mut p = Project::empty();
|
||||
|
||||
@@ -14,6 +14,9 @@ pub struct Context {
|
||||
pub enum Event {
|
||||
SelectBend(Id, usize),
|
||||
Commit(Context, EdgeRoute, Option<usize>),
|
||||
CommitAttachment(Context, EdgeRoute, EdgeEnd, Id),
|
||||
ChooseAttachment(EdgeEnd),
|
||||
ResetAttachment(EdgeEnd),
|
||||
AddBend,
|
||||
AddBendAt(oa_core::Position),
|
||||
RemoveBend,
|
||||
@@ -23,7 +26,14 @@ pub enum Event {
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Handle {
|
||||
Bend(usize),
|
||||
Label { original: Position },
|
||||
Label {
|
||||
original: Position,
|
||||
},
|
||||
Attachment {
|
||||
end: EdgeEnd,
|
||||
owner: projection::ProjectedNode,
|
||||
original: Option<EdgeAttachment>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -58,8 +68,57 @@ impl Gesture {
|
||||
};
|
||||
}
|
||||
}
|
||||
Handle::Attachment { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn move_attachment_to(&mut self, point: Point, zoom: f32) {
|
||||
let Handle::Attachment {
|
||||
end,
|
||||
ref owner,
|
||||
original,
|
||||
} = self.handle
|
||||
else {
|
||||
self.move_to(point, zoom);
|
||||
return;
|
||||
};
|
||||
if point.distance(self.start) * zoom < 3. {
|
||||
self.route.set_attachment(end, original);
|
||||
self.changed = false;
|
||||
return;
|
||||
}
|
||||
self.changed = true;
|
||||
let attachment = attachment_from_point(
|
||||
owner,
|
||||
position(point),
|
||||
self.route.attachment(end).map(|a| a.side),
|
||||
6. / zoom,
|
||||
);
|
||||
self.route.set_attachment(end, Some(attachment));
|
||||
}
|
||||
}
|
||||
|
||||
/// The painted endpoint must still be the actual unported placement, not a folded proxy.
|
||||
pub fn attachment_node<'a>(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
view: &'a projection::ProjectedView,
|
||||
edge: Id,
|
||||
end: EdgeEnd,
|
||||
) -> Option<&'a projection::ProjectedNode> {
|
||||
if semantic_port_endpoint(project, edge, end).is_some() {
|
||||
return None;
|
||||
}
|
||||
let projected = view.edges.iter().find(|e| e.id == edge)?;
|
||||
let actual = projection::edge_endpoint_placements(project, diagram, edge)?;
|
||||
if actual != (projected.source, projected.target) {
|
||||
return None;
|
||||
}
|
||||
let placement = match end {
|
||||
EdgeEnd::Source => projected.source,
|
||||
EdgeEnd::Target => projected.target,
|
||||
};
|
||||
view.nodes.iter().find(|node| node.id == placement)
|
||||
}
|
||||
|
||||
pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
|
||||
@@ -72,10 +131,40 @@ pub fn stored_route(diagram: &Diagram, edge: Id) -> EdgeRoute {
|
||||
edge,
|
||||
waypoints: Vec::new(),
|
||||
label: None,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Insert after the pins preceding the clicked segment along the routed path.
|
||||
pub fn insert_pin_on_segment(
|
||||
route: &mut EdgeRoute,
|
||||
geometry: &RoutedEdge,
|
||||
segment: usize,
|
||||
point: Position,
|
||||
) -> Option<usize> {
|
||||
if route.edge != geometry.id
|
||||
|| geometry.custom_suspended
|
||||
|| route.waypoints != geometry.pins
|
||||
|| route.waypoints.len() >= 128
|
||||
|| !point.x.is_finite()
|
||||
|| !point.y.is_finite()
|
||||
|| geometry
|
||||
.points
|
||||
.get(segment..segment.checked_add(2)?)
|
||||
.is_none()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let index = pin_insertion_index(geometry, segment, point);
|
||||
if index > route.waypoints.len() {
|
||||
return None;
|
||||
}
|
||||
route.waypoints.insert(index, point);
|
||||
Some(index)
|
||||
}
|
||||
|
||||
/// Find a segment when an ordinary caller has only a point, not a captured hit.
|
||||
pub fn insert_pin(route: &mut EdgeRoute, geometry: &RoutedEdge, point: Position) -> usize {
|
||||
let segment = geometry
|
||||
.points
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
//! Source indentation is one workspace edit, with physical line endings intact.
|
||||
use iced::{
|
||||
advanced::{graphics, text},
|
||||
widget::text_editor::{Action, Content, Cursor, Edit, Position},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
const INDENT: &str = " ";
|
||||
|
||||
/// Plain Tab inserts one canonical four-space level at a caret, or prefixes
|
||||
/// every selected line. Shift+Tab removes one leading tab or up to four spaces.
|
||||
/// A selection ending at column zero excludes that final line. All patches are
|
||||
/// local to lines, so CRLF, LF and Unicode bytes outside indentation survive.
|
||||
/// The caller records the resulting buffer once in workspace undo/history.
|
||||
pub fn apply<R>(content: &mut Content<R>, outdent: bool)
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
let raw = content.cursor();
|
||||
let selected = content.selection().is_some_and(|s| !s.is_empty());
|
||||
if !selected && !outdent {
|
||||
content.perform(Action::Edit(Edit::Paste(Arc::new(INDENT.into()))));
|
||||
return;
|
||||
}
|
||||
// Determine affected lines before normalizing Word/Line mode. Those modes
|
||||
// expand columns within their endpoint lines; the copied LF count reveals
|
||||
// whether a zero-column endpoint includes the next line or only its edge.
|
||||
let anchor = raw.selection.unwrap_or(raw.position);
|
||||
let first = raw.position.line.min(anchor.line);
|
||||
let mut last = raw.position.line.max(anchor.line);
|
||||
if selected {
|
||||
let selected_text = content.selection().unwrap();
|
||||
let line_breaks = selected_text.bytes().filter(|b| *b == b'\n').count();
|
||||
// Effective selection starts on the minimum raw endpoint line.
|
||||
last = first + line_breaks;
|
||||
if last > first && selected_text.ends_with('\n') {
|
||||
last -= 1;
|
||||
}
|
||||
}
|
||||
let patches: Vec<_> = (first..=last)
|
||||
.filter_map(|line| {
|
||||
let removed = if outdent {
|
||||
let text = content.line(line)?;
|
||||
if text.text.starts_with('\t') {
|
||||
1
|
||||
} else {
|
||||
text.text.bytes().take(4).take_while(|b| *b == b' ').count()
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
(!outdent || removed > 0).then_some((line, removed))
|
||||
})
|
||||
.collect();
|
||||
if patches.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cursor = crate::source_navigation::selection::capture_for_edit(content);
|
||||
let replacement = Arc::new(if outdent {
|
||||
String::new()
|
||||
} else {
|
||||
INDENT.into()
|
||||
});
|
||||
// Descending order leaves Iced's earliest changed line at the first patch.
|
||||
for &(line, removed) in patches.iter().rev() {
|
||||
content.move_to(Cursor {
|
||||
position: Position { line, column: 0 },
|
||||
selection: Some(Position {
|
||||
line,
|
||||
column: removed,
|
||||
}),
|
||||
});
|
||||
content.perform(Action::Edit(Edit::Paste(replacement.clone())));
|
||||
}
|
||||
let transform = |mut position: Position| {
|
||||
if let Some((_, removed)) = patches.iter().find(|(line, _)| *line == position.line) {
|
||||
position.column = if outdent {
|
||||
position.column.saturating_sub(*removed)
|
||||
} else {
|
||||
position.column + INDENT.len()
|
||||
};
|
||||
}
|
||||
position
|
||||
};
|
||||
content.move_to(Cursor {
|
||||
position: transform(cursor.position),
|
||||
selection: cursor.selection.map(transform),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod keyboard_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,106 +0,0 @@
|
||||
//! Real mounted Iced keyboard dispatch, using the production binding function.
|
||||
use crate::{app::Message, source_navigation::SymbolContext, views::source_tests::FontLayout};
|
||||
use iced::advanced::widget::operation::Focusable;
|
||||
use iced::{
|
||||
Element, Font, Rectangle, Size,
|
||||
advanced::{Layout, Shell, layout, text, widget as core_widget},
|
||||
keyboard::{
|
||||
self, Key, Modifiers,
|
||||
key::{Code, Named, Physical},
|
||||
},
|
||||
widget::{
|
||||
self,
|
||||
text_editor::{Content, State},
|
||||
},
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn mounted_tab_dispatches_only_for_focused_editable_editor_and_plain_shift_variants() {
|
||||
for focused in [false, true] {
|
||||
for editable in [false, true] {
|
||||
for modifiers in [
|
||||
Modifiers::empty(),
|
||||
Modifiers::SHIFT,
|
||||
Modifiers::CTRL,
|
||||
Modifiers::ALT,
|
||||
Modifiers::LOGO,
|
||||
] {
|
||||
let context = SymbolContext {
|
||||
session: oa_core::Id::new_v4(),
|
||||
path: "project.arcadia".into(),
|
||||
};
|
||||
let content = Content::<FontLayout>::with_text("rain");
|
||||
let mut editor = widget::text_editor(&content)
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(iced::Fill)
|
||||
.key_binding(|press| crate::views::source_binding(press, &context, None));
|
||||
if editable {
|
||||
editor = editor.on_action(Message::SourceEdit);
|
||||
}
|
||||
let mut view: Element<'_, Message, iced::Theme, FontLayout> = editor.into();
|
||||
let mut tree = core_widget::Tree::new(view.as_widget());
|
||||
let size = Size::new(600., 240.);
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, size),
|
||||
);
|
||||
if focused {
|
||||
tree.state
|
||||
.downcast_mut::<State<text::highlighter::PlainText>>()
|
||||
.focus();
|
||||
}
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Named(Named::Tab),
|
||||
modified_key: Key::Named(Named::Tab),
|
||||
physical_key: Physical::Code(Code::Tab),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers,
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let mut messages = Vec::new();
|
||||
let captured;
|
||||
{
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
view.as_widget_mut().update(
|
||||
&mut tree,
|
||||
&event,
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Unavailable,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(size),
|
||||
);
|
||||
captured = shell.is_event_captured();
|
||||
}
|
||||
let plain = modifiers.is_empty() || modifiers == Modifiers::SHIFT;
|
||||
let expected = focused && editable && plain;
|
||||
let indentation: Vec<_> = messages
|
||||
.iter()
|
||||
.filter_map(|message| match message {
|
||||
Message::SourceIndent(emitted, outdent) => Some((emitted, outdent)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
indentation.len(),
|
||||
usize::from(expected),
|
||||
"focused={focused}, editable={editable}, modifiers={modifiers:?}"
|
||||
);
|
||||
if expected {
|
||||
assert!(captured, "Tab must not escape to global focus traversal");
|
||||
assert_eq!(indentation[0], (&context, &modifiers.shift()));
|
||||
} else if !focused || !editable {
|
||||
assert!(
|
||||
!captured,
|
||||
"non-editing Tab must remain available to focus traversal"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
use super::*;
|
||||
use iced::widget::text_editor::Motion;
|
||||
|
||||
type Buffer = Content<iced::Renderer>;
|
||||
fn at(line: usize, column: usize) -> Position {
|
||||
Position { line, column }
|
||||
}
|
||||
fn range(start: Position, end: Position, reverse: bool) -> Cursor {
|
||||
Cursor {
|
||||
position: if reverse { start } else { end },
|
||||
selection: Some(if reverse { end } else { start }),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caret_inserts_four_spaces_and_outdent_removes_only_leading_whitespace() {
|
||||
let mut content = Buffer::with_text(" 雨🚀\r\nnext");
|
||||
content.move_to(Cursor {
|
||||
position: at(0, " 雨".len()),
|
||||
selection: None,
|
||||
});
|
||||
apply(&mut content, false);
|
||||
assert_eq!(content.text(), " 雨 🚀\r\nnext");
|
||||
assert_eq!(content.cursor().position, at(0, " 雨 ".len()));
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), "雨 🚀\r\nnext");
|
||||
assert_eq!(content.cursor().position, at(0, "雨 ".len()));
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), "雨 🚀\r\nnext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directional_blocks_preserve_unicode_endings_and_exclude_final_zero_column() {
|
||||
let source = "雨\r\n🚀\nlast\r\n";
|
||||
for reverse in [false, true] {
|
||||
let mut content = Buffer::with_text(source);
|
||||
let original = range(at(0, 0), at(2, 0), reverse);
|
||||
content.move_to(original);
|
||||
apply(&mut content, false);
|
||||
assert_eq!(content.text(), " 雨\r\n 🚀\nlast\r\n");
|
||||
assert_eq!(content.cursor(), range(at(0, 4), at(2, 0), reverse));
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), source);
|
||||
assert_eq!(content.cursor(), original);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdent_handles_tabs_partial_spaces_and_clamps_endpoint_inside_removed_indent() {
|
||||
for reverse in [false, true] {
|
||||
let mut content = Buffer::with_text("\t雨\r\n 🚀\n fin");
|
||||
content.move_to(range(at(0, 0), at(2, 2), reverse));
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), "雨\r\n🚀\nfin");
|
||||
assert_eq!(content.cursor(), range(at(0, 0), at(2, 0), reverse));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_and_line_selections_indent_effective_range_in_both_directions() {
|
||||
for line_mode in [false, true] {
|
||||
for reverse in [false, true] {
|
||||
let mut content = Buffer::with_text("one alpha\r\ntwo beta\nthree gamma");
|
||||
content.move_to(Cursor {
|
||||
position: if reverse { at(2, 8) } else { at(0, 6) },
|
||||
selection: None,
|
||||
});
|
||||
content.perform(if line_mode {
|
||||
Action::SelectLine
|
||||
} else {
|
||||
Action::SelectWord
|
||||
});
|
||||
content.perform(Action::Select(if reverse {
|
||||
Motion::DocumentStart
|
||||
} else {
|
||||
Motion::DocumentEnd
|
||||
}));
|
||||
let selected = content.selection().unwrap();
|
||||
apply(&mut content, false);
|
||||
assert_eq!(
|
||||
content.text(),
|
||||
" one alpha\r\n two beta\n three gamma"
|
||||
);
|
||||
let cursor = content.cursor();
|
||||
assert_eq!(
|
||||
cursor.position.line < cursor.selection.unwrap().line,
|
||||
reverse
|
||||
);
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.text(), "one alpha\r\ntwo beta\nthree gamma");
|
||||
assert_eq!(content.selection(), Some(selected));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_op_outdent_preserves_semantic_word_selection_and_last_empty_line_is_excluded() {
|
||||
let mut content = Buffer::with_text("alpha tail\r\n");
|
||||
content.move_to(Cursor {
|
||||
position: at(0, 2),
|
||||
selection: None,
|
||||
});
|
||||
content.perform(Action::SelectWord);
|
||||
let raw = content.cursor();
|
||||
apply(&mut content, true);
|
||||
assert_eq!(content.cursor(), raw);
|
||||
assert_eq!(content.selection().as_deref(), Some("alpha"));
|
||||
content.perform(Action::SelectAll);
|
||||
apply(&mut content, false);
|
||||
assert_eq!(content.text(), " alpha tail\r\n");
|
||||
assert_eq!(content.cursor().position, at(1, 0));
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
//! Effective selected-byte ranges for committed source departures and edits.
|
||||
//! Effective selected-byte ranges for a committed departure from a source view.
|
||||
//!
|
||||
//! Iced's `Cursor` omits cosmic-text's Word/Line selection kind. Saving its raw
|
||||
//! endpoints can therefore save an empty range for a visibly selected word/line.
|
||||
//! Departure capture requires navigation admission and destination validation,
|
||||
//! immediately before replacing/leaving the live editor. Edit capture requires
|
||||
//! an admitted edit with a nonempty patch plan. Neither is a passive query:
|
||||
//! never use these to inspect pending queries, failed/no-op navigation, or restore
|
||||
//! focus to the same editor.
|
||||
//! Capture only after navigation admission and destination validation succeed,
|
||||
//! immediately before replacing/leaving the live editor. Never use this to inspect
|
||||
//! pending queries, failed/no-op navigation, or restore focus to the same editor.
|
||||
use iced::advanced::{graphics, text};
|
||||
use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position};
|
||||
|
||||
@@ -23,23 +21,6 @@ use iced::widget::text_editor::{Action, Content, Cursor, Motion, Position};
|
||||
/// selection joins editor lines with LF regardless of physical CRLF/LF endings.
|
||||
/// Only the selected text is temporarily copied; retained state is one Cursor.
|
||||
pub fn capture<R>(content: &mut Content<R>) -> Cursor
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
effective_range(content)
|
||||
}
|
||||
|
||||
/// Capture immediately before a committed indentation edit. Like departure,
|
||||
/// this converts Word/Line selections to their effective directional byte range.
|
||||
/// The caller restores the transformed range after editing the same Content.
|
||||
pub fn capture_for_edit<R>(content: &mut Content<R>) -> Cursor
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
effective_range(content)
|
||||
}
|
||||
|
||||
fn effective_range<R>(content: &mut Content<R>) -> Cursor
|
||||
where
|
||||
R: text::Renderer<Editor = graphics::text::Editor>,
|
||||
{
|
||||
|
||||
@@ -329,6 +329,8 @@ fn reopening_canvas_for_a_modal_preserves_viewport_after_route_edits() {
|
||||
diagram,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 400., y: 400. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.5,
|
||||
|
||||
@@ -112,6 +112,8 @@ mod tests {
|
||||
});
|
||||
view.routes.push(EdgeRoute {
|
||||
edge: exchange,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 420., y: 440. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.4,
|
||||
|
||||
@@ -455,6 +455,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.align_y(iced::Center);
|
||||
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
|
||||
let program = DiagramCanvas {
|
||||
selection: app.diagram_selection.as_ref(),
|
||||
allocation: Some(app.allocation_capability()),
|
||||
project: app.project(),
|
||||
view_index: Some(app.document.view_index()),
|
||||
@@ -466,6 +467,12 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_attachment: app
|
||||
.selected_attachment
|
||||
.filter(|(context, edge, _)| {
|
||||
Some(*context) == app.route_context() && *edge == app.selected
|
||||
})
|
||||
.map(|(_, _, end)| end),
|
||||
selected_bend: app
|
||||
.selected_bend
|
||||
.filter(|(edge, _)| *edge == app.selected)
|
||||
@@ -602,6 +609,37 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
)
|
||||
.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 =
|
||||
row![label(format!("{count} selected")).wrapping(widget::text::Wrapping::None)]
|
||||
.spacing(7)
|
||||
.align_y(iced::Center)
|
||||
.height(32);
|
||||
if let Some(snapshot) = selection.as_ref().filter(|s| s.targets().len() > 1) {
|
||||
if crate::app::multiselect::placements(snapshot).is_some() {
|
||||
selection_controls = selection_controls.push(action(
|
||||
"Hide selected",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Hide(
|
||||
snapshot.clone(),
|
||||
)),
|
||||
));
|
||||
}
|
||||
selection_controls = selection_controls
|
||||
.push(action(
|
||||
"Clear selection",
|
||||
Message::DiagramSelection(crate::app::multiselect::Event::Clear(
|
||||
snapshot.context(),
|
||||
)),
|
||||
))
|
||||
.push(
|
||||
label(format!(
|
||||
"Properties edit only {}",
|
||||
short(app.project().named(app.selected), 36)
|
||||
))
|
||||
.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 {
|
||||
@@ -614,16 +652,37 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
} 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 {
|
||||
"Right-click for actions · drag to arrange · scroll to zoom"
|
||||
"Shift-click to add/remove · right-click for actions · drag to arrange · scroll to zoom"
|
||||
};
|
||||
// Status is outside the measured canvas. Clipping prevents long text
|
||||
// from painting over work headers or the dedicated destination slot.
|
||||
tools = tools.push(container(label(hint)).height(30).width(Fill).clip(true));
|
||||
// Selection changes must not resize the measured canvas or refit its
|
||||
// camera. Reserve both rows even when no group actions are available.
|
||||
tools = tools.push(
|
||||
container(
|
||||
column![
|
||||
selection_controls,
|
||||
label(hint).wrapping(widget::text::Wrapping::None)
|
||||
]
|
||||
.spacing(3),
|
||||
)
|
||||
.height(56)
|
||||
.width(Fill)
|
||||
.clip(true),
|
||||
);
|
||||
}
|
||||
column![container(tools).padding(14), canvas].into()
|
||||
}
|
||||
@@ -1178,15 +1237,6 @@ pub(crate) fn source_binding(
|
||||
use crate::source_navigation::{SymbolEvent, query::Action};
|
||||
use iced::keyboard::{Key, key::Named};
|
||||
use widget::text_editor::{Binding, Status};
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.key == Key::Named(Named::Tab)
|
||||
&& (press.modifiers.is_empty() || press.modifiers == iced::keyboard::Modifiers::SHIFT)
|
||||
{
|
||||
return Some(Binding::Custom(Message::SourceIndent(
|
||||
context.clone(),
|
||||
press.modifiers.shift(),
|
||||
)));
|
||||
}
|
||||
if matches!(press.status, Status::Focused { .. })
|
||||
&& press.modifiers.is_empty()
|
||||
&& press.key == Key::Named(Named::F2)
|
||||
|
||||
@@ -234,6 +234,8 @@ fn basic_transition_reconcile_keeps_existing_selected_view_placement_and_route_e
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: trace,
|
||||
source_attachment: None,
|
||||
target_attachment: None,
|
||||
waypoints: vec![Position { x: 301., y: 507. }],
|
||||
label: Some(EdgeLabel {
|
||||
fraction: 0.35,
|
||||
|
||||
@@ -12,6 +12,8 @@ use tokio::{sync::oneshot, task::JoinHandle};
|
||||
mod communication_limits;
|
||||
#[path = "http/data_contracts.rs"]
|
||||
mod data_contracts;
|
||||
#[path = "http/edge_attachments.rs"]
|
||||
mod edge_attachments;
|
||||
#[path = "http/port_anchors.rs"]
|
||||
mod port_anchors;
|
||||
#[path = "http/semantic_transitions.rs"]
|
||||
|
||||
@@ -0,0 +1,344 @@
|
||||
use super::*;
|
||||
use oa_core::{
|
||||
EdgeAttachment, PortAnchorSide,
|
||||
source_project::{ProjectLimits, SourceBundle, compile_bundle},
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn source_bundle() -> SourceBundle {
|
||||
compile_bundle(&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "// project rationale\narcadia 7; project attachments \"Attachments\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
|
||||
("model.arcadia".into(), "// semantic ownership\noa_activity first \"First\" {}\noa_activity second \"Second\" {}\ninteraction signal \"Signal\" { source @first; target @second; }\n".into()),
|
||||
("views.arcadia".into(), "// view rationale\ndiagram view \"View\" { routes [{ edge @signal; waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; }]; }\ndiagram other \"Other\" { routes [{ edge @signal; }]; }\n".into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap().bundle
|
||||
}
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
async fn import(server: &TestServer) -> StoredProject {
|
||||
let response = server
|
||||
.client
|
||||
.post(server.url("/projects/import-source-project"))
|
||||
.json(&source_bundle())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let value: Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED, "{value}");
|
||||
server
|
||||
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn transact(
|
||||
server: &TestServer,
|
||||
current: &StoredProject,
|
||||
commands: Value,
|
||||
dry_run: bool,
|
||||
) -> reqwest::Response {
|
||||
server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&json!({"commands":commands,"dry_run":dry_run}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_pair(current: &StoredProject) {
|
||||
let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, current.model);
|
||||
assert_eq!(compiled.bundle, current.source_project);
|
||||
}
|
||||
|
||||
async fn assert_unchanged(server: &TestServer, expected: &StoredProject) {
|
||||
let actual = server.get(expected.resource_id).await;
|
||||
assert_eq!(actual.etag(), expected.etag());
|
||||
assert_eq!(
|
||||
serde_json::to_value(actual).unwrap(),
|
||||
serde_json::to_value(expected).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edge_attachments_http_dry_run_etags_source_parity_and_restart() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let original = import(&server).await;
|
||||
let diagram = id(&original.model, "view");
|
||||
let mut expected = original.model.clone();
|
||||
let route = &mut expected.diagrams.get_mut(&diagram).unwrap().routes[0];
|
||||
route.source_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.25,
|
||||
});
|
||||
route.target_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Bottom,
|
||||
offset: 1.0,
|
||||
});
|
||||
let commands = json!([Command::SetEdgeRoute {
|
||||
diagram,
|
||||
route: route.clone()
|
||||
}]);
|
||||
let preview = transact(&server, &original, commands.clone(), true).await;
|
||||
assert_eq!(preview.status(), StatusCode::OK);
|
||||
let preview: Value = preview.json().await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap();
|
||||
assert_eq!(
|
||||
compile_bundle(&proposed, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
assert_unchanged(&server, &original).await;
|
||||
|
||||
let applied = transact(&server, &original, commands.clone(), false).await;
|
||||
assert_eq!(applied.status(), StatusCode::OK);
|
||||
let mut current = server.get(original.resource_id).await;
|
||||
assert_eq!(applied.headers()["etag"], current.etag());
|
||||
assert_eq!(current.revision, original.revision + 1);
|
||||
assert_eq!(
|
||||
current.model, expected,
|
||||
"only the two attachment fields in this view change"
|
||||
);
|
||||
assert_eq!(current.source_project, proposed);
|
||||
assert_pair(¤t);
|
||||
for path in ["project.arcadia", "model.arcadia"] {
|
||||
assert_eq!(
|
||||
current.source_project.files[path],
|
||||
original.source_project.files[path]
|
||||
);
|
||||
}
|
||||
assert!(current.source_project.files["views.arcadia"].contains("// view rationale"));
|
||||
let noop = transact(&server, ¤t, commands.clone(), false).await;
|
||||
assert_eq!(noop.status(), StatusCode::OK);
|
||||
assert_eq!(noop.headers()["etag"], current.etag());
|
||||
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
|
||||
let stale = json!([
|
||||
Command::Rename(id(¤t.model, "first"), "Must not persist".into()),
|
||||
commands[0].clone()
|
||||
]);
|
||||
assert_eq!(
|
||||
transact(&server, &original, stale, false).await.status(),
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
assert_unchanged(&server, ¤t).await;
|
||||
|
||||
let mut edited = current.source_project.clone();
|
||||
let text = edited.files.get_mut("views.arcadia").unwrap();
|
||||
assert_eq!(text.matches("offset 0.25;").count(), 1);
|
||||
*text = text.replace("offset 0.25;", "offset 0.375;");
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
|
||||
.source_attachment
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.offset = 0.375;
|
||||
let response = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&edited)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
current = server.get(current.resource_id).await;
|
||||
assert_eq!(current.model, expected);
|
||||
assert_eq!(current.source_project, edited);
|
||||
assert_pair(¤t);
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(source.status(), StatusCode::OK);
|
||||
assert_eq!(source.headers()["etag"], current.etag());
|
||||
assert_eq!(
|
||||
oa_core::language::parse(&source.text().await.unwrap()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let bundle = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{}/source-project", current.resource_id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(bundle.status(), StatusCode::OK);
|
||||
assert_eq!(bundle.headers()["etag"], current.etag());
|
||||
assert_eq!(
|
||||
bundle.json::<SourceBundle>().await.unwrap(),
|
||||
current.source_project
|
||||
);
|
||||
server.stop().await;
|
||||
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
assert_unchanged(&server, ¤t).await;
|
||||
let mut route = expected.diagrams[&diagram].routes[0].clone();
|
||||
route.source_attachment = None;
|
||||
let reset = json!([Command::SetEdgeRoute {
|
||||
diagram,
|
||||
route: route.clone()
|
||||
}]);
|
||||
assert_eq!(
|
||||
transact(&server, ¤t, reset.clone(), false)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
expected.diagrams.get_mut(&diagram).unwrap().routes[0] = route;
|
||||
let reset_state = server.get(current.resource_id).await;
|
||||
assert_eq!(
|
||||
reset_state.model, expected,
|
||||
"reset preserves pins, label, opposite end, other view and semantics"
|
||||
);
|
||||
assert_pair(&reset_state);
|
||||
let noop = transact(&server, &reset_state, reset, false).await;
|
||||
assert_eq!(noop.status(), StatusCode::OK);
|
||||
assert_eq!(noop.headers()["etag"], reset_state.etag());
|
||||
assert_eq!(noop.json::<Value>().await.unwrap()["changed"], false);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edge_attachments_http_malformed_and_invalid_offsets_reject_atomically() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let current = import(&server).await;
|
||||
let diagram = id(¤t.model, "view");
|
||||
let first = id(¤t.model, "first");
|
||||
for (bad, status) in [
|
||||
(json!({}), StatusCode::BAD_REQUEST),
|
||||
(json!({"side":"Top"}), StatusCode::BAD_REQUEST),
|
||||
(json!({"offset":0.5}), StatusCode::BAD_REQUEST),
|
||||
(
|
||||
json!({"side":"Diagonal","offset":0.5}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(json!({"side":"Top","offset":null}), StatusCode::BAD_REQUEST),
|
||||
(
|
||||
json!({"side":"Top","offset":"NaN"}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
json!({"side":"Top","offset":0.5,"unknown":true}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
json!({"side":"Top","offset":-0.01}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"side":"Top","offset":1.01}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
] {
|
||||
for field in ["source_attachment", "target_attachment"] {
|
||||
let mut route =
|
||||
serde_json::to_value(¤t.model.diagrams[&diagram].routes[0]).unwrap();
|
||||
route[field] = bad.clone();
|
||||
let commands = json!([Command::Rename(first, "Must roll back".into()), {"SetEdgeRoute":{"diagram":diagram,"route":route}}]);
|
||||
let response = transact(&server, ¤t, commands, false).await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
status,
|
||||
"{field} {bad}: {}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
assert_unchanged(&server, ¤t).await;
|
||||
}
|
||||
}
|
||||
let mut invalid = current.model.clone();
|
||||
invalid.diagrams.get_mut(&diagram).unwrap().routes[0].target_attachment =
|
||||
Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 2.0,
|
||||
});
|
||||
let response = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{}/model", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&invalid)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_unchanged(&server, ¤t).await;
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn edge_attachments_served_openapi_matches_required_nested_records_and_optional_ends() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let response = server
|
||||
.client
|
||||
.get(server.url("/openapi.json"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let schema: Value = response.json().await.unwrap();
|
||||
let schemas = &schema["components"]["schemas"];
|
||||
let attachment = &schemas["EdgeAttachment"];
|
||||
assert_eq!(attachment["required"], json!(["side", "offset"]));
|
||||
assert_eq!(attachment["additionalProperties"], false);
|
||||
assert_eq!(
|
||||
attachment["properties"]["side"]["$ref"],
|
||||
"#/components/schemas/PortAnchorSide"
|
||||
);
|
||||
assert_eq!(attachment["properties"]["offset"]["minimum"], 0);
|
||||
assert_eq!(attachment["properties"]["offset"]["maximum"], 1);
|
||||
for field in ["source_attachment", "target_attachment"] {
|
||||
assert!(
|
||||
!schemas["EdgeRoute"]["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!(field))
|
||||
);
|
||||
let optional = &schemas["EdgeRoute"]["properties"][field];
|
||||
assert_eq!(optional["default"], Value::Null);
|
||||
assert_eq!(
|
||||
optional["oneOf"],
|
||||
json!([{"$ref":"#/components/schemas/EdgeAttachment"},{"type":"null"}])
|
||||
);
|
||||
}
|
||||
let compiled = compile_bundle(&source_bundle(), ProjectLimits::default()).unwrap();
|
||||
let diagram = id(&compiled.decoded.project, "view");
|
||||
let mut route = compiled.decoded.project.diagrams[&diagram].routes[0].clone();
|
||||
let omitted = serde_json::to_value(&route).unwrap();
|
||||
assert!(omitted.get("source_attachment").is_none());
|
||||
assert!(omitted.get("target_attachment").is_none());
|
||||
route.target_attachment = Some(EdgeAttachment {
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.0,
|
||||
});
|
||||
let encoded = serde_json::to_value(Command::SetEdgeRoute { diagram, route }).unwrap();
|
||||
assert_eq!(
|
||||
encoded["SetEdgeRoute"]["route"]["target_attachment"],
|
||||
json!({"side":"Right","offset":0.0})
|
||||
);
|
||||
server.stop().await;
|
||||
}
|
||||
+28
-1
@@ -2,7 +2,7 @@
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="en" lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="generator" content="Docutils 0.22.2: https://docutils.sourceforge.io/" />
|
||||
<meta name="generator" content="Docutils 0.23: https://docutils.sourceforge.io/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Arcadia Studio model REST API</title>
|
||||
<style type="text/css">
|
||||
@@ -947,6 +947,33 @@ authored files and revision. JSON/source imports may omit <span class="docutils
|
||||
it defaults to <span class="docutils literal">[]</span> and empty lists are omitted on export. The normative
|
||||
diagram semantics and source spelling are in <span class="docutils literal">language.rst</span> section 5.</p>
|
||||
</section>
|
||||
<section id="diagram-connection-attachments">
|
||||
<h2>Diagram connection attachments</h2>
|
||||
<p><span class="docutils literal">EdgeRoute.source_attachment</span> and <span class="docutils literal">target_attachment</span> are optional
|
||||
<span class="docutils literal"><span class="pre">{"side":"Bottom","offset":0.25}</span></span> records. Side and a finite offset in
|
||||
<span class="docutils literal">[0,1]</span> are required within a supplied record; unknown fields are rejected.
|
||||
Absence/null means automatic placement. Omitted fields in older schema-3
|
||||
models retain that meaning. The normal transaction endpoint accepts:</p>
|
||||
<pre class="literal-block">{"commands":[{"SetEdgeRoute":{
|
||||
"diagram":"<diagram UUID>",
|
||||
"route":{"edge":"<relation or trace UUID>",
|
||||
"source_attachment":{"side":"Bottom","offset":0.25},
|
||||
"waypoints":[],"label":null}
|
||||
}}]}</pre>
|
||||
<p><span class="docutils literal">SetEdgeRoute</span> replaces the complete route entry. Clients changing one end
|
||||
must retain its other attachment, bends and label; omit/null only the intended
|
||||
attachment to restore that end's automatic position. <span class="docutils literal">RemoveEdgeRoute</span> resets
|
||||
all presentation for that edge. Both support ETag checking, dry runs, atomic
|
||||
validation and source-project writeback. Invalid offsets reject the whole
|
||||
transaction without changing files or revision.</p>
|
||||
<p>These are per-view presentation preferences, not a semantic reconnect. Fractions
|
||||
follow the projected shape, including moves/resizes; semantic reconnect retains
|
||||
them as preferences of the same edge-end slots. Real semantic ports take
|
||||
precedence, and promoted folded endpoints suspend authored routing. The complete
|
||||
boundary mapping, lifecycle and generated-view limitations are normative in
|
||||
<span class="docutils literal">language.rst</span> section 5. GET model/source, PUT source, desktop and transactions
|
||||
share this representation; no separate attachment endpoint exists.</p>
|
||||
</section>
|
||||
<section id="contextual-process-and-chain-topology">
|
||||
<h2>Contextual process and chain topology</h2>
|
||||
<p>Operational processes and system functional chains can combine work
|
||||
|
||||
@@ -329,6 +329,37 @@ authored files and revision. JSON/source imports may omit ``port_anchors``;
|
||||
it defaults to ``[]`` and empty lists are omitted on export. The normative
|
||||
diagram semantics and source spelling are in ``language.rst`` section 5.
|
||||
|
||||
Diagram connection attachments
|
||||
-------------------------------
|
||||
|
||||
``EdgeRoute.source_attachment`` and ``target_attachment`` are optional
|
||||
``{"side":"Bottom","offset":0.25}`` records. Side and a finite offset in
|
||||
``[0,1]`` are required within a supplied record; unknown fields are rejected.
|
||||
Absence/null means automatic placement. Omitted fields in older schema-3
|
||||
models retain that meaning. The normal transaction endpoint accepts::
|
||||
|
||||
{"commands":[{"SetEdgeRoute":{
|
||||
"diagram":"<diagram UUID>",
|
||||
"route":{"edge":"<relation or trace UUID>",
|
||||
"source_attachment":{"side":"Bottom","offset":0.25},
|
||||
"waypoints":[],"label":null}
|
||||
}}]}
|
||||
|
||||
``SetEdgeRoute`` replaces the complete route entry. Clients changing one end
|
||||
must retain its other attachment, bends and label; omit/null only the intended
|
||||
attachment to restore that end's automatic position. ``RemoveEdgeRoute`` resets
|
||||
all presentation for that edge. Both support ETag checking, dry runs, atomic
|
||||
validation and source-project writeback. Invalid offsets reject the whole
|
||||
transaction without changing files or revision.
|
||||
|
||||
These are per-view presentation preferences, not a semantic reconnect. Fractions
|
||||
follow the projected shape, including moves/resizes; semantic reconnect retains
|
||||
them as preferences of the same edge-end slots. Real semantic ports take
|
||||
precedence, and promoted folded endpoints suspend authored routing. The complete
|
||||
boundary mapping, lifecycle and generated-view limitations are normative in
|
||||
``language.rst`` section 5. GET model/source, PUT source, desktop and transactions
|
||||
share this representation; no separate attachment endpoint exists.
|
||||
|
||||
Contextual process and chain topology
|
||||
-------------------------------------
|
||||
|
||||
|
||||
+45
-2
@@ -1785,14 +1785,16 @@ the Reparent command. Moving, resizing, arranging, or folding an owner retains
|
||||
its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
|
||||
<span class="docutils literal">port_anchors</span>; their explicit migration supplies an empty list.</p>
|
||||
<p>Each <span class="docutils literal">routes</span> entry is an EdgeRoute record with a required <span class="docutils literal">edge</span> reference
|
||||
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), and <span class="docutils literal">label</span>
|
||||
(default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
|
||||
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), <span class="docutils literal">label</span>
|
||||
(default <span class="docutils literal">none</span>), and <span class="docutils literal">source_attachment</span> / <span class="docutils literal">target_attachment</span> (each
|
||||
default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
|
||||
at most once in a diagram's route list; different diagrams MAY store independent
|
||||
routes for the same edge. List order and explicit empty entries are retained by
|
||||
source and JSON round trips, although list order has no routing meaning. Edges
|
||||
need not currently be visible in their diagram.</p>
|
||||
<pre class="code text literal-block"><code>routes [
|
||||
{ edge @report_exchange;
|
||||
source_attachment { side Bottom; offset 0.25; };
|
||||
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
|
||||
label { fraction 0.55; offset { x 0; y -16; }; };
|
||||
}
|
||||
@@ -1809,6 +1811,47 @@ are structural errors. A pinned point that obstructs routing remains stored;
|
||||
geometric routing difficulty is reported separately from structural validity.
|
||||
Each EdgeRoute counts as one record against <span class="docutils literal">ParseLimits.max_records</span>; pinned
|
||||
points are bounded by the per-route limit and aggregate input byte limits.</p>
|
||||
<p>An attachment is an EdgeAttachment record with required <span class="docutils literal">side</span> (Left, Right,
|
||||
Top, Bottom) and required finite binary32 <span class="docutils literal">offset</span> from zero through one.
|
||||
Unknown fields, omitted nested fields and out-of-range offsets are structural
|
||||
errors (<span class="docutils literal">invalid_attachment_offset</span> for the latter). Attachments are bounded
|
||||
inline values, at most two per route, not additional graph objects or records.
|
||||
JSON uses the same field names, enum strings and number values. Omitted or null
|
||||
attachments mean automatic positioning; canonical compact source and JSON omit
|
||||
absent attachments. Earlier schema-3 input hydrates both fields as absent.</p>
|
||||
<p>The end names designate stored source/target slots, even for undirected edges.
|
||||
These values MUST NOT change semantic endpoints, ports, ownership, type,
|
||||
orientation or identity. Each preference belongs to that edge end in that view:
|
||||
another view remains independent. It follows the current endpoint's projected
|
||||
boundary under movement, resize or semantic reconnect; reconnect preserves the
|
||||
preference, as it preserves authored bends. The fraction does not identify the
|
||||
previous semantic endpoint. Deletion/reidentification of the edge follows the
|
||||
existing whole-route rules; moving, hiding or replacing its placement retains
|
||||
the preference. Removing one attachment restores that end's automatic position
|
||||
without changing bends, labels or the opposite attachment.</p>
|
||||
<p>For Left/Right, offset gives the Y fraction of the complete projected bounding
|
||||
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
|
||||
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
|
||||
their width/height; Regions and fork/join bars are rectangles. State circles use
|
||||
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
|
||||
<span class="docutils literal">t = 2 * offset - 1</span> and <span class="docutils literal">D</span> be width for Left/Right or height for Top/Bottom.
|
||||
The inset from the selected bounding side is <span class="docutils literal">D/2 * (1 - <span class="pre">sqrt(1-t*t))</span></span> for an
|
||||
ellipse and <span class="docutils literal">D/2 * abs(t)</span> for a diamond. A Terminate X uses the outermost
|
||||
painted-stroke intersection, inset <span class="docutils literal">D * min(offset, <span class="pre">1-offset)</span></span>. Zero and one
|
||||
are valid; different side/fraction pairs can describe the same painted point.
|
||||
Routing leads extend in the selected cardinal direction beyond the bounding box
|
||||
before turning. Source/API fractions retain their precision; pointer editing
|
||||
snaps fractions to hundredths without changing the stored semantic record.</p>
|
||||
<p>An actual semantic Port remains authoritative, including when projection hides
|
||||
its port marker. An edge attachment on that end is retained but dormant; use
|
||||
the port's own placement anchor to move a port. When folding promotes an endpoint
|
||||
to an ancestor, all authored route data is suspended and resumes on expansion.
|
||||
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
|
||||
tips retain their positions with an external loop and a routing warning; the
|
||||
editor must offer an explicit way to choose either end even when handles overlap.
|
||||
Internal state transitions have no external connection. Process's generated
|
||||
topology and Scenario's lifeline/activation/time-derived tips are not consumers
|
||||
of these diagram route fields; this feature does not redefine their semantics.</p>
|
||||
<p><span class="docutils literal">SetEdgeRoute { diagram, route }</span> upserts an entry by edge identity, retaining
|
||||
the order and contents of unrelated entries. <span class="docutils literal">RemoveEdgeRoute { diagram, edge }</span>
|
||||
removes it; removing an absent entry is a no-op for an existing diagram and edge.
|
||||
|
||||
+49
-2
@@ -1167,8 +1167,9 @@ its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
|
||||
``port_anchors``; their explicit migration supplies an empty list.
|
||||
|
||||
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
|
||||
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
|
||||
(default ``none``). Route records have no separate UUID or alias. An edge occurs
|
||||
to an existing Relation or Trace, ``waypoints`` (default ``[]``), ``label``
|
||||
(default ``none``), and ``source_attachment`` / ``target_attachment`` (each
|
||||
default ``none``). Route records have no separate UUID or alias. An edge occurs
|
||||
at most once in a diagram's route list; different diagrams MAY store independent
|
||||
routes for the same edge. List order and explicit empty entries are retained by
|
||||
source and JSON round trips, although list order has no routing meaning. Edges
|
||||
@@ -1178,6 +1179,7 @@ need not currently be visible in their diagram.
|
||||
|
||||
routes [
|
||||
{ edge @report_exchange;
|
||||
source_attachment { side Bottom; offset 0.25; };
|
||||
waypoints [{ x 420; y 260; }, { x 690; y 260; }];
|
||||
label { fraction 0.55; offset { x 0; y -16; }; };
|
||||
}
|
||||
@@ -1196,6 +1198,51 @@ geometric routing difficulty is reported separately from structural validity.
|
||||
Each EdgeRoute counts as one record against ``ParseLimits.max_records``; pinned
|
||||
points are bounded by the per-route limit and aggregate input byte limits.
|
||||
|
||||
An attachment is an EdgeAttachment record with required ``side`` (Left, Right,
|
||||
Top, Bottom) and required finite binary32 ``offset`` from zero through one.
|
||||
Unknown fields, omitted nested fields and out-of-range offsets are structural
|
||||
errors (``invalid_attachment_offset`` for the latter). Attachments are bounded
|
||||
inline values, at most two per route, not additional graph objects or records.
|
||||
JSON uses the same field names, enum strings and number values. Omitted or null
|
||||
attachments mean automatic positioning; canonical compact source and JSON omit
|
||||
absent attachments. Earlier schema-3 input hydrates both fields as absent.
|
||||
|
||||
The end names designate stored source/target slots, even for undirected edges.
|
||||
These values MUST NOT change semantic endpoints, ports, ownership, type,
|
||||
orientation or identity. Each preference belongs to that edge end in that view:
|
||||
another view remains independent. It follows the current endpoint's projected
|
||||
boundary under movement, resize or semantic reconnect; reconnect preserves the
|
||||
preference, as it preserves authored bends. The fraction does not identify the
|
||||
previous semantic endpoint. Deletion/reidentification of the edge follows the
|
||||
existing whole-route rules; moving, hiding or replacing its placement retains
|
||||
the preference. Removing one attachment restores that end's automatic position
|
||||
without changing bends, labels or the opposite attachment.
|
||||
|
||||
For Left/Right, offset gives the Y fraction of the complete projected bounding
|
||||
box; for Top/Bottom it gives the X fraction. The attachment intersects the painted
|
||||
shape along that cardinal scan line. Rounded cards use radius 10, limited by half
|
||||
their width/height; Regions and fork/join bars are rectangles. State circles use
|
||||
their ellipse boundary and Choice uses its diamond boundary. More precisely, let
|
||||
``t = 2 * offset - 1`` and ``D`` be width for Left/Right or height for Top/Bottom.
|
||||
The inset from the selected bounding side is ``D/2 * (1 - sqrt(1-t*t))`` for an
|
||||
ellipse and ``D/2 * abs(t)`` for a diamond. A Terminate X uses the outermost
|
||||
painted-stroke intersection, inset ``D * min(offset, 1-offset)``. Zero and one
|
||||
are valid; different side/fraction pairs can describe the same painted point.
|
||||
Routing leads extend in the selected cardinal direction beyond the bounding box
|
||||
before turning. Source/API fractions retain their precision; pointer editing
|
||||
snaps fractions to hundredths without changing the stored semantic record.
|
||||
|
||||
An actual semantic Port remains authoritative, including when projection hides
|
||||
its port marker. An edge attachment on that end is retained but dormant; use
|
||||
the port's own placement anchor to move a port. When folding promotes an endpoint
|
||||
to an ancestor, all authored route data is suspended and resumes on expansion.
|
||||
Geometric difficulty MUST NOT discard preferences. Coincident authored self-loop
|
||||
tips retain their positions with an external loop and a routing warning; the
|
||||
editor must offer an explicit way to choose either end even when handles overlap.
|
||||
Internal state transitions have no external connection. Process's generated
|
||||
topology and Scenario's lifeline/activation/time-derived tips are not consumers
|
||||
of these diagram route fields; this feature does not redefine their semantics.
|
||||
|
||||
``SetEdgeRoute { diagram, route }`` upserts an entry by edge identity, retaining
|
||||
the order and contents of unrelated entries. ``RemoveEdgeRoute { diagram, edge }``
|
||||
removes it; removing an absent entry is a no-op for an existing diagram and edge.
|
||||
|
||||
+45
-1
@@ -2137,15 +2137,59 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EdgeAttachment": {
|
||||
"type": "object",
|
||||
"description": "Per-view preference on one stored connection end, not a semantic endpoint. Offset is finite and measured along the complete projected bounding-box side. Real semantic ports take precedence; hidden/promoted geometry retains the preference. Reconnect preserves the preference on its source/target slot.",
|
||||
"properties": {
|
||||
"side": {
|
||||
"$ref": "#/components/schemas/PortAnchorSide"
|
||||
},
|
||||
"offset": {
|
||||
"type": "number",
|
||||
"minimum": 0,
|
||||
"maximum": 1,
|
||||
"description": "Left/Right: top-to-bottom fraction. Top/Bottom: left-to-right fraction. Shape-aware geometry derives the actual contour point."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"side",
|
||||
"offset"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"EdgeRoute": {
|
||||
"type": "object",
|
||||
"description": "One diagram's authored route for an existing Relation or Trace, including currently hidden edges. No separate identity. Only pinned points are stored; explicit empty routes are retained.",
|
||||
"description": "One diagram's authored presentation for an existing Relation or Trace, including currently hidden edges. No separate identity. Stores optional endpoint preferences, pinned points and label placement; explicit empty routes are retained. Removing the route resets all authored presentation.",
|
||||
"properties": {
|
||||
"edge": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"description": "Existing Relation or Trace identity, never a node placement."
|
||||
},
|
||||
"source_attachment": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EdgeAttachment"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
|
||||
},
|
||||
"target_attachment": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/EdgeAttachment"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Absent or null means automatic. Omitted when serializing automatic presentation. Resetting this field preserves the other end, pins and label."
|
||||
},
|
||||
"waypoints": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
+7
-365
@@ -1,64 +1,5 @@
|
||||
# Architectural and product decisions
|
||||
|
||||
## Private mixed syntax boundary — 2026-09-23
|
||||
|
||||
Proceed with the [reviewed syntax-only slice](analysis-mixed-parser-review-20260923.md)
|
||||
on tested private source `10d7f334`, retaining current root `195ddbb` as an
|
||||
ordinary-compatibility reference. Direct comparison found the relevant ordinary
|
||||
syntax/include machinery equal or equivalent; whole-file drift chiefly concerns
|
||||
semantic/editor behavior. A full root merge is not a prerequisite for this
|
||||
private experiment, and neither whole language/updater file may replace the
|
||||
other during eventual integration.
|
||||
|
||||
One opaque complete-bundle result borrows the actual original files and owns
|
||||
the shared budget, canonical file table, ordinary occurrence positions and typed
|
||||
unresolved syntax. It supplies no admitted Project, Index or edit authority.
|
||||
Explicit IDs are a staging restriction; missing IDs reject before generation.
|
||||
Retain duplicate syntax occurrences until semantic uniqueness is checked.
|
||||
Ordinary signed numbers require byte adjacency and complete value spans;
|
||||
typed subtraction retains its separate grammar. Bound aggregate syntax storage
|
||||
without silently reinterpreting caller model-record or ordered-step limits.
|
||||
Propagate phase-check failures even where the Budget does not latch them.
|
||||
|
||||
The [proposal](analysis-mixed-parser-proposal-20260923.md) and review define the
|
||||
independent fixtures and rejected shortcuts. Same-pass unresolved syntax is a
|
||||
dependency of later mixed resolution/index/composition, not public source8
|
||||
activation or a substitute for whole-output admission. Source implementation,
|
||||
fresh review and runtime checks remain required.
|
||||
|
||||
## Private source composition decision — 2026-09-23
|
||||
|
||||
Adopt the opaque validated original-span rename batch as the next private
|
||||
analysis adapter step, with the [independent review's clarifications](analysis-original-span-review-20260923.md).
|
||||
The [original proposal](analysis-original-span-proposal-20260922.md) compares
|
||||
this against replacing both ordinary and typed edit representations. The narrow
|
||||
adapter preserves current ordinary compatibility and reduces migration risk;
|
||||
a shared edit AST remains a candidate if structural composition demonstrates a
|
||||
need. Neither sequentially applying producers with stale offsets nor canonical
|
||||
rewriting is acceptable for authored-source preservation.
|
||||
|
||||
The immediate patch exports complete borrowed rename records and splits
|
||||
ownership validation from standalone final-output size preflight. It may defer
|
||||
only hypothetical typed-only output sizing; active inherited budget, plan/name
|
||||
storage, qualified namespace, source equality and complete occurrence checks
|
||||
remain mandatory. Standalone rename/apply semantics remain intact. This is
|
||||
private staging, not a complete parse-session or public source8 boundary.
|
||||
|
||||
Before production composition, one checked parsed-bundle witness must bind the
|
||||
complete source/entry/file map, ordinary model, typed index, profile and complete
|
||||
target intent. Ordinary syntax-kind replacement currently emits same-start
|
||||
insertion/deletion pairs; explicitly normalize such owned operations with exact
|
||||
trivia oracles before admitting them, keeping cross-producer collisions errors.
|
||||
Gate supported changes by a precise complete-model diff. Compile the actual
|
||||
emitted full bundle, use normal admission, and compare the complete intended
|
||||
Project before installation. Exact untouched bytes are an independent gate.
|
||||
Subtree edits, parameter lifecycle, repaired deletion, reidentity, relocation,
|
||||
missing-ID stabilization, migration/history and checked-save remain required
|
||||
delivery work; passing the private rename slice cannot close those requirements.
|
||||
|
||||
The source-pinned proposal/review retain their original local provenance labels.
|
||||
The accepted underlying repair contract is [r2 at analysis source 1b1c3e3](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/1b1c3e3abb6fc3d0934c9dbfbd21fddc8ae4f594/harness/analysis-source8-20260913/analysis-source8-repair-design-r2-20260913.md).
|
||||
|
||||
## Current continuation decisions — 2026-09-06
|
||||
|
||||
- Saving under another source entry basename is destination bookkeeping, not an engineering edit. Adopt an exact current save receipt without adding Undo or clearing Redo; rebase uniform entry names across retained history after collision and compilation checks. Stale receipts cannot redirect newer raw source. Native discovery and eleven focused regression results are in release-save-as-runtime-packaging.md; updated native verification remains required.
|
||||
@@ -516,310 +457,11 @@ not new random identities assigned on each parse. Public source8 integration mus
|
||||
thread the same accounting through the outer project and included files; this
|
||||
private checkpoint does not itself admit a complete version8 source bundle.
|
||||
|
||||
## Complete mixed Project before typed editing index — 2026-09-23
|
||||
## Connection-end presentation preferences — 2026-09-22
|
||||
|
||||
Choose the [private Project-first contract](analysis-mixed-project-contract-20260923.md)
|
||||
as the next semantic dependency, conditional on acceptance of its frozen syntax
|
||||
prerequisite. Reuse ordinary construction and typed lowering once, then check the
|
||||
complete Project and retain original syntax, the global lowering-site arena and
|
||||
its file ownership for later indexing. Including the typed index now would also
|
||||
change source-view/updater compatibility and several simultaneous allocation
|
||||
phases, while still leaving ordinary occurrence editing incomplete. This split
|
||||
preserves a concrete no-rescan/no-relower continuation and isolates model
|
||||
correctness from later edit authority; it does not narrow the required parity goal.
|
||||
|
||||
The [independent critique](reviews/mixed-project-contract-20260923.md) requires a
|
||||
full hand-authored retained-site oracle and a single admission result preserving
|
||||
static usage and every warning. Existing raw architectural diagnostic production
|
||||
is explicitly outside preallocation accounting; new mapped/retained storage must
|
||||
be reserved before copying or retention. Revisit the split if actual ownership
|
||||
work shows sites cannot be retained without duplicated models or repeated parsing.
|
||||
Public source8, mixed editing, checked-save and root reconciliation remain later
|
||||
dependencies, not capabilities delivered by this design decision.
|
||||
|
||||
## 2026-09-24 — preserve mixed bundle authority through index construction
|
||||
|
||||
Adopt the [mixed typed-index contract](analysis-mixed-index-contract-20260924.md):
|
||||
consume the sealed complete-Project witness into a distinct read-only owner and
|
||||
reuse private structural/token verification, with aggregate retained-ledger
|
||||
accounting. A copied verifier would duplicate correction work; reparsing into
|
||||
the old numeric-source Index would discard bundle/profile authority. The
|
||||
[independent review](analysis-mixed-index-review-20260924.md) resolved storage
|
||||
domain and completeness-proof ambiguities before adoption. This is a bounded
|
||||
implementation decision, not runtime acceptance or full editing parity. Revisit
|
||||
if a concrete verifier invariant prevents reuse or multiple retained owners
|
||||
become necessary; no generic public indexing framework is justified now.
|
||||
|
||||
## 2026-09-24 — index ordinary authored names before rename composition
|
||||
|
||||
Adopt the [ordinary occurrence successor](analysis-ordinary-index-contract-20260924.md)
|
||||
after source mapping and independent critique. Retain the accepted typed owner
|
||||
and add a physical-token-keyed ordinary map from sealed original positions.
|
||||
This isolates complete coverage/ownership from later edit intent and avoids a
|
||||
new structural path arena or parser. A fused streaming rename planner could
|
||||
save map storage, but would combine two unverified boundaries; revisit after
|
||||
coverage is established and a real consumer demonstrates the benefit.
|
||||
The contract explicitly handles synthetic brace-containing roots, complete
|
||||
reference extents and the existing ordinary depth policy. It grants read-only
|
||||
name-occurrence authority only. Rename composition and complete editing parity
|
||||
remain separate required capabilities.
|
||||
|
||||
## 2026-09-24 — gate mixed rename intent against the complete desired model
|
||||
|
||||
Adopt the [mixed rename planning contract](analysis-mixed-rename-plan-contract-20260924.md)
|
||||
after source mapping and independent criticism. Consume the complete authored
|
||||
owner, immutably borrow desired Project, and retain only changed-name and physical
|
||||
edit maps. Exhaustive shallow alias exceptions avoid Project clones, generic
|
||||
diff machinery and duplicate legacy batches. Final namespaces are checked
|
||||
simultaneously, so swaps work and collisions with unchanged names reject.
|
||||
|
||||
Existing whole-model PartialEq semantics define non-name equivalence, including
|
||||
signed-zero equivalence across geometry, scalar and historical carriers. Original
|
||||
non-name source bytes remain exact. The allocating iterative Expr comparator
|
||||
requires a conservative E scratch reservation; independent type-graph and
|
||||
frontier analysis bounds E by the retained original expression arena, including
|
||||
all four root families. Revisit if new Expr carriers appear or conservative
|
||||
headroom demonstrably blocks useful cases.
|
||||
|
||||
Deliberate later output parsing must preserve exact inherited per-snapshot policy
|
||||
with separately reported new usage and aggregate live-storage accounting. An
|
||||
independent fresh ledger must not hide retained output or old state. Execution
|
||||
ownership/readmission seams remain separate required implementation; this
|
||||
decision does not label a read-only plan as accepted rewritten source.
|
||||
|
||||
## 2026-09-24 — carry output storage through actual verification
|
||||
|
||||
The [execution source map](analysis-mixed-rename-execution-20260924/RENAME-EXECUTION-SOURCE-MAP.md)
|
||||
identifies the next boundary: output buffers already coexist with parsing, so
|
||||
adding a retained-output baseline only at resolution checks too late. Preserve
|
||||
the exact per-snapshot Limits and ProjectLimits, explicitly drop consumed old
|
||||
auxiliary state, and carry surviving output storage through verification.
|
||||
The final comparison must include aliases and parameter names; the planning
|
||||
gate deliberately omits them and cannot establish accepted rewritten output.
|
||||
|
||||
We considered authenticating exact output bytes, then reserving a conservative
|
||||
parser envelope derived from the unchanged syntax topology. The
|
||||
[independent feasibility investigation](analysis-mixed-rename-execution-20260924/RENAME-EXECUTION-ENVELOPE-FEASIBILITY.md)
|
||||
found useful partial bounds but no complete safe, useful aggregate formula.
|
||||
Finishing it would duplicate parser lifecycle knowledge across expression
|
||||
tasks, name sets, projection buffers, string decoding and include traversal.
|
||||
Select explicit carried storage accounting at actual growth/handoff seams for
|
||||
the next bounded design instead. Revisit an envelope only if a small, stable
|
||||
source-derived proof demonstrates useful caps; do not use unexplained byte or
|
||||
node multipliers. This chooses a direction, not an implemented execution API.
|
||||
|
||||
The [domain audit](analysis-mixed-rename-execution-20260924/CARRIED-STORAGE-DOMAIN-AUDIT.md)
|
||||
keeps the declared metric explicit: logical auxiliary slots/text are distinct
|
||||
from model/serde/raw-validator heap, allocator capacity and process RSS. The
|
||||
exploratory allocation inventory is not a requirement to meter all heap usage.
|
||||
The successor contract must reconcile that domain before selecting growth
|
||||
hooks or claiming an aggregate pre-allocation guarantee.
|
||||
|
||||
## 2026-09-25 — preserve parser compatibility while carrying live storage
|
||||
|
||||
Adopt the [private carried-phase prerequisite](analysis-carried-phase-contract-20260925.md).
|
||||
Keep legacy parser checkpoints unchanged; an explicit carried path checks the
|
||||
combined syntax and retained storage at the declared phase boundaries, including
|
||||
when its carried baseline is zero. An opaque guard binds the actual bundle,
|
||||
exact policy and retained owner through parsing and resolution. Seed the existing
|
||||
resolver ledger once, without charging live storage as new parse work.
|
||||
|
||||
New continuation constructors remain fixture-only until the actual output
|
||||
producer and consumed-owner transition exist. The narrow owning fixture contains
|
||||
only the bundle, so its carried count is F map entries, zero lower occurrences
|
||||
and all source/key/entry UTF-8 bytes. This is a phase-accounting prerequisite,
|
||||
not fulfillment or waiver of stronger parser producer timing requirements for
|
||||
final output acceptance. It grants no editing authority.
|
||||
|
||||
Runtime review of the first carried-phase candidate exposed a stack overflow in
|
||||
an unchanged legacy 128 KiB control during numeric parsing. Remove duplicated
|
||||
policy from the retained guard: the consumed input transfers exact policy into
|
||||
the Budget and syntax limits, while a small guard retains the backing owner.
|
||||
This changes representation without new heap storage or weaker tests. Prefer
|
||||
this to boxing policy or introducing an external policy-owner lifetime. The
|
||||
debugger identifies the active parsing path; it does not prove a sole cause or
|
||||
establish that the representation repair passes before it is actually tested.
|
||||
|
||||
## 2026-09-25 — separate owned emission from accepted rewritten output
|
||||
|
||||
The [emission contract](analysis-mixed-emission-contract-20260925.md) consumes
|
||||
the validated plan. A no-op returns its original owner; changed emission reserves
|
||||
the complete output map and UTF-8 storage while the old plan is live, then copies
|
||||
each file once in original edit coordinates. It drops old auxiliary ownership
|
||||
before constructing the private pending result. Exact policies and the desired
|
||||
whole Project survive, but no parse, adoption, save or public editing authority
|
||||
is granted. Reject any borrowed carried owner on the changed path: dropping a
|
||||
guard cannot free the externally owned allocation. A future owning transition
|
||||
must consume that allocation or account for its continued lifetime explicitly.
|
||||
|
||||
The [source-bound Windows evidence](experiments/analysis-mixed-emission-20260925/README.md)
|
||||
preserves an initial full-run stack abort and the successful repair. Split the
|
||||
existing iterative binding driver's prefix and continuation operations rather
|
||||
than changing arithmetic, increasing stack sizes, boxing state or weakening
|
||||
tests. Independent review establishes mechanical equivalence; actual rebuilt
|
||||
prologues and unchanged runtime controls support the bounded stack reduction.
|
||||
This does not close the older, separately unrerun full-pipeline setup limitation.
|
||||
|
||||
Final acceptance still requires parsing and resolving actual output bytes,
|
||||
complete Project equality under justified scratch bounds, explicit parser
|
||||
producer timing and an owner retaining the accepted bundle. Original expression
|
||||
counts alone do not bound an arbitrary faulty output. Logical auxiliary usage
|
||||
continues to exclude the established model/serde/validator and allocator domains;
|
||||
no process-wide memory guarantee follows from this private milestone.
|
||||
|
||||
## 2026-09-25 — reserve named syntax text at its producer
|
||||
|
||||
The [text producer contract](analysis-text-producer-contract-20260925.md) selects
|
||||
a separate private route bound to actual carried authority. Reserve raw quoted
|
||||
payload P before the existing decoder, then transfer decoded D and release P-D.
|
||||
This avoids a duplicate decoder while deliberately allowing conservative
|
||||
escaped-input refusal on the new route. Preserve legacy/phase-only diagnostics.
|
||||
Count simultaneous key/reference copies and all consumed UUID spellings; drop
|
||||
temporary strings before refund. Borrow the ordinary declaration-kind word.
|
||||
Keep small scalar state, transfer retained text once into the resolver ledger,
|
||||
and independently reconcile it against the existing syntax inventory.
|
||||
|
||||
The [verified Windows packet](experiments/analysis-text-producer-20260925/README.md)
|
||||
records six new passes, unchanged prior outcomes, initial lint repair and the
|
||||
single framework-hint difference in one inherited failure log. Preserve the
|
||||
original checker refusal and narrow named-byte amendment instead of weakening
|
||||
general comparison. Compiler prologue observations and existing stack controls
|
||||
bound evidence only; this adds no universal heap or stack guarantee.
|
||||
Entry/metadata growth timing and the remaining scratch inventory require their
|
||||
own explicit decision before actual emitted-output acceptance can be enabled.
|
||||
|
||||
## 2026-09-25 — strengthen the existing retained-entry metric at named producers
|
||||
|
||||
The [entry producer contract](analysis-entry-producer-contract-20260925.md)
|
||||
preserves U's declared grammar events and the conservative M inventory. A new
|
||||
explicit route checks C+U+M before selected growth; old routes retain their
|
||||
timing. Prefer grammar scalar counts plus a prepaid projector quota to another
|
||||
unmetered AST-counting frontier. Seven-slot declaration envelopes already include
|
||||
synthetic bodies; actual nested fields/values contribute separately. Quota
|
||||
consumption neither recharges nor refunds M, and independent lengths/metadata
|
||||
must reconcile. No saturating correction may create a successful witness.
|
||||
|
||||
[Windows evidence](experiments/analysis-entry-producer-20260925/README.md)
|
||||
preserves both precompile latch fixes, the syntax-cap versus counting-scratch
|
||||
evaluator correction and the initial test-only Debug-bound compile failure.
|
||||
All six new tests pass with unchanged inherited dispositions and strict Clippy.
|
||||
Additional scalar state changes compiler frames, so old stack controls and
|
||||
actual prologues remain evidence requirements; a logical slot is not a native
|
||||
stack byte. Remaining independently bounded parser scratch requires an explicit
|
||||
scope decision, not an inferred universal heap-metering requirement.
|
||||
|
||||
## 2026-09-25 — accept actual emitted bytes into one owning result
|
||||
|
||||
The [acceptance contract](analysis-mixed-acceptance-contract-20260925.md) selects
|
||||
a shared borrow of the complete sealed PendingRename as carried authority,
|
||||
rather than bare counters, duplicated policy or a new heap wrapper. Compile-time
|
||||
covariance permits the short verification borrow. Use both selected parser
|
||||
producer modes, complete resolution/admission and full actual-left Project
|
||||
PartialEq; reserve the actual expression arena count, not original provenance.
|
||||
|
||||
Move the actual model and mapped warnings with the accepted source. This preserves
|
||||
diagnostic/source identity without reparsing or substituting the desired model.
|
||||
Warnings already occupy the resolver ledger: exhaustively count their surviving
|
||||
inventory without charging twice, verify final O+W as a bounded subset while all
|
||||
verification owners remain live, then drop syntax/guards and move the bundle.
|
||||
No mutable extraction, history installation or repeated carried edit is implied.
|
||||
|
||||
Retain independently bounded traversal/delimiter/Pratt/duplicate-name scratch as
|
||||
an explicit separate domain. File/source-byte bounds do not tightly bound copied
|
||||
path bytes. This selected logical accounting is not universal heap accounting.
|
||||
The [finite Windows evidence](experiments/analysis-output-acceptance-20260925/README.md)
|
||||
checks exact inherited limits, real returned models/warnings, lifetime and stack
|
||||
behavior. Preserve the initial two incorrect empty-warning expectations and their
|
||||
independent full-warning correction. A raw build-target path spelling difference
|
||||
requires only a named exact-pair checker amendment, not weakened failure or source
|
||||
comparison. All earlier outcomes remain preserved; public integration is next.
|
||||
|
||||
## 2026-09-25 — restore ordinary schema8 compatibility before mixed adoption
|
||||
|
||||
Current formatters already emit schema8, while the ordinary reader previously
|
||||
rejected it. Admit that version through the existing ordinary grammar and model
|
||||
validation, preserving migration, complete source indexes, identity stabilization,
|
||||
updates and checked saves. Keep typed and long analysis-constructor refusals;
|
||||
ordinary acceptance does not qualify missing typed formatting or mixed editing.
|
||||
The [contract and independent comparison](ordinary-source8-compatibility-contract-20260925.md)
|
||||
reject replacing compile_bundle with an incomplete read-only index.
|
||||
|
||||
[Windows evidence](experiments/ordinary-source8-20260925/README.md) exercises the
|
||||
previously blocked nested/transaction workflows, complete ordinary OA/SA and
|
||||
historical trace round trips, relevant integration tests and actual read-only CLI
|
||||
commands. Preserve the initial example-trace assumption failure, four obsolete
|
||||
private header-rejection assertions and the historical JSON oracle's missing
|
||||
empty definitions catalog. Correct their expectations explicitly while retaining
|
||||
whole-model and typed-refusal checks; two test renames have exact inventory maps.
|
||||
|
||||
Reuse the source-identical core, other integration and CLI evidence after the last
|
||||
three-line migration-test correction. Verify the entire successor diff and every
|
||||
other declared input, rerun the affected target plus all-target Clippy/format, and
|
||||
identify each tested code version. No Linux, GUI or release qualification follows
|
||||
from these core/CLI results. Publication remains blocked by server disk exhaustion.
|
||||
|
||||
## 2026-09-25 — expose complete mixed validation through a borrowed read-only result
|
||||
|
||||
Select `language::check_mixed_bundle` with an opaque non-Clone result borrowing
|
||||
the caller's complete explicit-ID SourceBundle. Retain the real resolved owner
|
||||
and return immutable Project/warning views; do not substitute a bare model or an
|
||||
incomplete editable index. The [contract](analysis-public-read-contract-20260925.md)
|
||||
records alternatives, policy and bounds. Fresh input selects both existing
|
||||
producer modes with zero carried ownership, followed by complete resolution and
|
||||
admission under the exact caller policy. Internal entry ceilings remain separate
|
||||
from final-model record limits.
|
||||
|
||||
Promote only the required parser/resolver dependency cone to production; private
|
||||
index/edit machinery stays test-only. Normal-library tests are required because
|
||||
the production Parser layout changes even when ordinary callers select no mixed
|
||||
mode. That build exposed a missed semantic-row initializer, fixed at its source;
|
||||
two actual compiler warnings received narrow cfg/import corrections.
|
||||
|
||||
[Windows evidence](experiments/analysis-public-read-20260925/README.md) at bab6f96
|
||||
checks complete independently authored OA/SA models, exact warnings and refusals,
|
||||
source conservation, constructor policy, source lifetimes, prior workflows and
|
||||
actual ordinary CLI behavior. All 299 core and 75 selected integration cases pass.
|
||||
Rustdoc's two compile-fail runtime names append a suffix absent from its listing:
|
||||
preserve the recorder refusal and reconcile exactly those two names against the
|
||||
successful three-case raw run. No generic name normalization or rerun is needed.
|
||||
|
||||
This developer-facing API does not enable mixed file discovery in the CLI or
|
||||
graphical editing. Add those through explicit subsequent contracts, retaining
|
||||
filesystem safety and complete model/index ownership. Do not route a nominal
|
||||
mixed check through the ordinary loader, which currently rejects typed syntax
|
||||
and constructs editable compilation. Source/evidence were subsequently published
|
||||
after server storage recovery (issue7 comment1333); no release or Linux
|
||||
qualification follows.
|
||||
|
||||
## 2026-09-25 — separate mixed CLI discovery from editable ordinary loading
|
||||
|
||||
Expose `arcadia check-mixed ROOT [--json]` through a closed read-only dispatch and
|
||||
opaque mixed source snapshot. Reuse the real parser and existing disk checks,
|
||||
but do not construct or expose an ordinary editable index for typed input.
|
||||
The [contract](analysis-mixed-cli-contract-20260925.md) specifies explicit
|
||||
identities, source8, report fields, original locations and exit behavior.
|
||||
|
||||
Share one discovery Budget/ModelCounts across included files and reserve the
|
||||
file metadata before parsing. Retain producer counters conservatively after
|
||||
each temporary syntax unit drops. This is cumulative discovery reservation,
|
||||
not a claim about live allocations. Drop that phase before complete semantic
|
||||
validation under a fresh budget with the original caller policy. Clamp the
|
||||
source-byte ceiling before disk reads. Stop on the first located mixed parsing
|
||||
failure so a span-only budget latch cannot be attributed to a later sibling;
|
||||
retain final filesystem and pending-journal checks.
|
||||
|
||||
[Evidence](experiments/analysis-mixed-cli-20260925/README.md) at f10490b1 checks
|
||||
whole independent OA/SA models, exact reports and bytes, cumulative limits,
|
||||
ordinary migrations/saves and finite stack controls. New public disk and CLI
|
||||
tests passed initially; two old CLI assertions still expected schema7. Correct
|
||||
only their exact migration/header expectations, retaining the immutable fixture,
|
||||
all40 identities and full-text/model/fixed-point checks. Alias the test process
|
||||
Command import to remove the observed warning. Preserve those original outcomes.
|
||||
|
||||
Independent review also found the actual alarm include_str fixture missing
|
||||
from the inherited recorder's input recipe. Add that precise path before final
|
||||
gates (567 inputs); keep the six initial 566-input receipts and their omission
|
||||
caveat. Final source-bound checks pass 302 core,98 integrations,3 docs, strict
|
||||
core/CLI Clippy and5 direct CLI runs. Formatting remains the same inherited
|
||||
failure. Publication recovered without changing the existing release; mixed
|
||||
editing, application adoption and Linux qualification remain separate work.
|
||||
Issue #33 uses optional side/fraction attachments on each view's `EdgeRoute`,
|
||||
with semantic Port precedence and shared shape-aware routing. Visual attachment
|
||||
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.
|
||||
|
||||
+19
-378
@@ -1,233 +1,20 @@
|
||||
# Published Windows preview and branch checkpoint — 2026-09-24 UTC
|
||||
# Verified branch checkpoint — 2026-09-13 UTC
|
||||
|
||||
The published preview below identifies the combined delivery. Earlier
|
||||
contribution checkpoints retain their original source and acceptance scope.
|
||||
This describes Codex-956atf's contribution, not a combined release.
|
||||
[PROJECT_GOAL.md](../PROJECT_GOAL.md) remains authoritative. The complete OA/SA
|
||||
application is unfinished; published previews and finite prototypes are intermediate.
|
||||
|
||||
**The current prerelease is Windows-only. Linux/WSL builds and checks remain
|
||||
excluded by the user's explicit request, including after networking recovers.**
|
||||
**Linux/WSL builds and checks remain deferred until the user explicitly reports
|
||||
networking resolved.** No guest setup, diagnosis, restart or cache transfer is
|
||||
active. The VPN remains untouched.
|
||||
|
||||
Published [integrated Windows preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.integrated.20260924):
|
||||
**v0.2.0-preview.integrated.20260924**, release7, prerelease=true, draft=false.
|
||||
The tag, exact release body and all seven attachment identities were read back
|
||||
after publication. The 34,168,661-byte Studio ZIP has SHA256
|
||||
`5554e32533e3cb2c33fb4143a5a0902733d5a8d47577303cb2842d268e7bc76d`;
|
||||
the release includes its checksum, two companion kits/checksums and VALIDATION.md.
|
||||
[Release notes](prerelease-integrated-20260924-release-notes.md) and
|
||||
[final validation](prerelease-integrated-20260924-validation.md) identify exact
|
||||
commands, evidence, launch steps, known limits and the two machines' bounded
|
||||
packaged trials. Full application and broad human-checklist acceptance remain
|
||||
unfinished; this development-profile preview is ready for human verification.
|
||||
|
||||
The published Windows source is
|
||||
**715036e132677dd2bc2fb37ed316d93dc9987aec** on
|
||||
`codex/prerelease-integrated-956atf-20260924`. Its
|
||||
[source handoff](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/715036e132677dd2bc2fb37ed316d93dc9987aec/harness/prerelease-integrated-source-20260924.md)
|
||||
identifies the integrated contributions, reviewed event correction and exact
|
||||
test paths. Combined runtime/package acceptance is separate from the
|
||||
contribution-specific evidence below. [Issue37](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/37)
|
||||
owns current build and release coordination.
|
||||
The Route helper qualification and Allocation Text Apply test import corrections
|
||||
are included; both earlier compile failures occurred before tests ran. The candidate's 66 package
|
||||
documents/examples pass [closure and source-identity verification](reviews/prerelease37-windows-documents-20260924.md).
|
||||
The separate e68a839 Route contribution's raw test/lint evidence passed an
|
||||
[independent evidence review](reviews/prerelease37-route-evidence-20260924.md);
|
||||
its results do not establish combined-candidate execution.
|
||||
The full Windows test run at `8ddfadf4` failed a State picker fixture whose
|
||||
canvas-relative coordinates hit the added selection toolbar. The current
|
||||
source includes peer `a9cd364e`'s measured picker slot and capture assertion;
|
||||
the [independent source review](reviews/prerelease37-state-picker-20260924.md)
|
||||
retains its bounded assumptions. [Reviewed targeted evidence](reviews/prerelease37-state-picker-evidence-20260924.md)
|
||||
at `a9cd364e` passes formatting and all26 State tests, including the original
|
||||
failure. The corrected combined [test-evidence review](reviews/prerelease37-final04-tests-20260924.md)
|
||||
at `715036e` verifies 2,200 passed, eight ignored and zero failed across all
|
||||
2,208 names in 61 native harnesses, including all 29 required cases. The
|
||||
[complete gate review](reviews/prerelease37-final04-complete-20260924.md) verifies
|
||||
all 3,709 inputs against immutable Git bytes, strict workspace Clippy and the
|
||||
individual Studio/server builds. The supplemental combined build also passes;
|
||||
its differing artifacts are not the selected package inputs. Packaged acceptance
|
||||
remains separate. The [downloaded package](reviews/prerelease37-downloaded-package-20260924.md)
|
||||
passes exact archive/product/document checks. [Server evidence](reviews/prerelease37-package-server-20260924.md)
|
||||
verifies 16 requests and exact model/source/revision/ETag persistence across an
|
||||
owned forced restart; the initial legacy-schema fixture expectation failure is
|
||||
preserved. [Packaged GUI evidence](reviews/prerelease37-native-gui-20260924.md),
|
||||
[separate-machine QA](reviews/prerelease37-independent-qa-20260924.md) and the
|
||||
[parent visual cross-check](reviews/prerelease37-parent-visual-20260924.md) pass
|
||||
their bounded reviewed scopes. State label congestion, explicit transition-form
|
||||
Cancel and the preserved launcher refusals remain described in final validation.
|
||||
|
||||
## Published read-only mixed source CLI — 2026-09-25
|
||||
|
||||
Source **f10490b13bb3332ff60c2372d554e276f6f44959** is pushed and ref-verified
|
||||
on `codex/analysis-mixed-cli-956atf-20260925`. `arcadia check-mixed ROOT [--json]`
|
||||
discovers explicit-ID schema8 files with the existing filesystem safeguards,
|
||||
then validates the complete ordinary and typed model. Reports preserve original
|
||||
source locations; the command does not change files. Ordinary CLI commands retain
|
||||
their separate migration and editing behavior.
|
||||
|
||||
The [contract](analysis-mixed-cli-contract-20260925.md) and
|
||||
[Windows evidence](experiments/analysis-mixed-cli-20260925/README.md) identify
|
||||
302 core passes, 98 integration passes across eleven suites, three doctests,
|
||||
strict core/CLI Clippy and five direct CLI trials. Exact source bytes, report
|
||||
contents, unchanged file inventories and eleven prior small-stack controls were
|
||||
independently reconciled. The original two stale schema7 test expectations and
|
||||
import warning remain in the evidence, alongside their test-only repairs.
|
||||
The final 567-input recipe includes the alarm fixture omitted by the initial
|
||||
566-input recipe; the initial omission is explicitly retained as a limitation.
|
||||
Formatting still reports the identical pre-existing differences.
|
||||
|
||||
Discovery and final validation have separate bounded budgets; cumulative
|
||||
discovery reservations are conservative and do not establish an all-heap bound.
|
||||
This checkpoint supplies no mixed editing/save/history, graphical adoption,
|
||||
atomic filesystem snapshot or universal depth128 guarantee. The existing
|
||||
Windows prerelease is unchanged, and Linux remains deferred. Live continuation
|
||||
and the user's requested pause belong in issue7, not this checkpoint.
|
||||
|
||||
## Published public mixed source8 read API — 2026-09-25
|
||||
|
||||
Source **bab6f96a5b05b37f2b5d67a430aed0f67d783e73** on
|
||||
`codex/analysis-public-read-956atf-20260925` exposes
|
||||
`language::check_mixed_bundle(&SourceBundle, ProjectLimits)`. The result borrows
|
||||
the complete explicit-ID input and exposes only its admitted Project and located
|
||||
warnings. It retains the existing syntax, storage and admission evidence; no
|
||||
editable index, save permission or CLI discovery is implied.
|
||||
|
||||
The [contract](analysis-public-read-contract-20260925.md) and
|
||||
[Windows evidence](experiments/analysis-public-read-20260925/README.md) record
|
||||
299 core passes, ten new normal-library integration passes, 65 existing ordinary
|
||||
integration passes, three documentation cases, strict core Clippy and actual
|
||||
ordinary CLI check/rename-preview success at this exact source. All 298 prior
|
||||
core outcomes and the named small-stack controls pass. Format retains identical
|
||||
pre-existing differences. The first missing production initializer, subsequent
|
||||
warning cleanup and exact rustdoc recorder-name reconciliation are preserved.
|
||||
|
||||
Source and evidence were published after server storage recovery; all five
|
||||
backlog source refs and evidence ref `f810fb40` were read back successfully.
|
||||
[Issue7 handoff](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/7#issuecomment-1333)
|
||||
confirms publication and identifies the separately pending mixed CLI acceptance.
|
||||
The released preview is unchanged. Mixed CLI discovery is supplied above; graphical/editor adoption,
|
||||
typed saving/history and the broader application remain unfinished. Existing
|
||||
depth qualification and full-pipeline small-stack limitations remain; Linux/WSL
|
||||
checks remain user-deferred.
|
||||
|
||||
## Published ordinary source8 compatibility — 2026-09-25
|
||||
|
||||
Source **7b06b8ea1b7ef902ee0b3d0ec2971a9a7a1f1acd** on
|
||||
`codex/ordinary-source8-956atf-20260925` restores public ordinary parsing of the
|
||||
schema8 files already emitted by current formatters. Versions3–7 still migrate;
|
||||
typed analysis declarations remain explicitly unsupported by ordinary APIs.
|
||||
The [contract](ordinary-source8-compatibility-contract-20260925.md) and
|
||||
[Windows evidence](experiments/ordinary-source8-20260925/README.md) distinguish
|
||||
this compatibility repair from full mixed-source editing.
|
||||
|
||||
The unchanged implementation at **0c07f53ab517626f536f79dbaa2885a587bf1db7**
|
||||
passes all298 core tests, including the16 formerly header-blocked workflows,
|
||||
and60 selected integration tests. Actual CLI check and cross-file rename preview
|
||||
pass with exact reported changes and unchanged input files. The successor only
|
||||
repairs a historical JSON expectation and its note; its five migration tests and
|
||||
strict core all-target Clippy pass. Format retains identical pre-existing
|
||||
differences. Evidence preserves all three failed evaluator stages, two explicit
|
||||
test-name mappings and exact component-version reuse. Source and evidence are
|
||||
published after server recovery; the published preview is unchanged.
|
||||
|
||||
## Earlier private parser and output acceptance checkpoints — 2026-09-25
|
||||
|
||||
Source **edf096cd33350865ecfc3c65ec266a6c0d3c4000** on
|
||||
`codex/analysis-mixed-acceptance-956atf-20260925` connects real emitted output to
|
||||
combined parser producers, full model admission/equality and an owning result
|
||||
containing the actual bundle, model and mapped warnings.
|
||||
[Windows evidence](experiments/analysis-output-acceptance-20260925/README.md)
|
||||
records eleven new passes, strict core Clippy and all 281 prior outcomes preserved:
|
||||
the full 292-name run gives 276 passes/16 inherited failures. All 16 failure bodies remain
|
||||
identical after numeric thread-identifier normalization; format is unchanged.
|
||||
Ten inherited stack controls and new scoped 128 KiB verification/drop pass. Initial
|
||||
nine-pass/two-failure warning-oracle corrections and the exact build-target path
|
||||
spelling exception are preserved. Source and evidence are published. Public
|
||||
typed read validation is supplied above; installation/history and repeated
|
||||
carried editing remain incomplete.
|
||||
Ordinary source8 compatibility is supplied by the later checkpoint above.
|
||||
|
||||
The earlier **29417130c71f70c4eaefe8ed4c879efba78eb72c** on
|
||||
`codex/analysis-entry-producers-956atf-20260925` adds the reviewed
|
||||
[retained-entry producer route](analysis-entry-producer-contract-20260925.md).
|
||||
Its [Windows evidence](experiments/analysis-entry-producer-20260925/README.md)
|
||||
passes six new cases and strict core Clippy. Full library remains failed at
|
||||
265 passed/16 inherited failed, preserving all275 prior outcomes, all16 failure
|
||||
bodies (numeric thread identifiers only normalized) and all ten stack controls. Formatting is
|
||||
unchanged. Initial test-only compilation failure is preserved. This source and
|
||||
packet are published after server storage recovery.
|
||||
Actual output verification/accepted ownership is supplied by the later checkpoint
|
||||
above; public editing remains open.
|
||||
|
||||
Source **97a8703adc75467a7f8e75f5c6cb00ca542b707c** on
|
||||
`codex/analysis-text-producers-956atf-20260925` adds the separate private
|
||||
[syntax text producer route](analysis-text-producer-contract-20260925.md).
|
||||
Its [source-bound Windows evidence](experiments/analysis-text-producer-20260925/README.md)
|
||||
passes six new cases and strict core Clippy. Full library remains failed at
|
||||
259 passed /16 inherited failed; all269 prior outcomes and all ten stack controls
|
||||
are preserved. Format remains the same inherited failure. Failure output is
|
||||
15 exact blocks plus one precisely documented missing framework hint.
|
||||
|
||||
This source and evidence are published after server storage recovery, with
|
||||
remote evidence commit `f810fb40` verified alongside the source branch;
|
||||
emission source `8d52dab9` is published separately. This checkpoint does not
|
||||
change the integrated preview. The later acceptance contract explicitly selects
|
||||
the remaining scratch domain and implements actual output verification/ownership;
|
||||
its exclusions and public-integration limits remain material.
|
||||
|
||||
## Earlier local contribution checkpoint and evidence
|
||||
## Current integrated candidate and evidence
|
||||
|
||||
Branch `codex/behavior-authoring-20260910`, published as `codex/release1-956atf`.
|
||||
Integrated code checkpoint **4ffff0d9d94bd1875e6b316b84ac11e5c5c9657c** combines
|
||||
the reviewed ordinary/Text contribution **3f9e6a18af96b5cd10940da4093be8da28b9c835**
|
||||
with source-editor Tab/Shift+Tab indentation. Windows desktop verification passes
|
||||
1,188 tests with six existing ignores; all 1,194 names and nine targeted
|
||||
indentation cases are accounted for. Strict desktop Clippy and matching GUI
|
||||
build pass on 711 unchanged inputs. The closed native trials cover caret and
|
||||
forward/reverse block indentation, text undo/redo, empty drafts, continued caret
|
||||
typing and Apply/Save/reopen. The saved 577 bytes exactly preserve the baseline's
|
||||
mixed line endings and Unicode while indenting its 13 physical lines. The first
|
||||
trial restored the baseline and is not a save oracle. Find's Tab does not advance
|
||||
focus; this inherited limitation is distinct from the verified editor boundary.
|
||||
This is Windows contribution acceptance, not complete editor accessibility,
|
||||
public source8, a combined peer union, or a packaged release.
|
||||
[Exact gates, original native captures and independent review](experiments/text-indentation-4ffff0d-20260923/README.md)
|
||||
retain both the failed initial compilation and the interrupted first trial.
|
||||
|
||||
Earlier code checkpoint **8abc144cf233c64e43c6bee5033c22ee818e2d14** preserves
|
||||
the production inputs of **feab2020d8e5b6bfdb59c86ab4aee161617b8b6f**. Its Windows
|
||||
desktop suite passes 1,096 tests with three existing manual ignores; all 1,099
|
||||
names are admitted. Targeted navigation/rename checks, strict desktop Clippy and
|
||||
the matching development GUI build pass. The closed native trial verifies
|
||||
Details-to-Preview scroll restoration, explicit reset boundaries, and exact
|
||||
six-file preservation after Cancel and Save. The
|
||||
[portable evidence](experiments/rename-scroll-feab202-20260913/README.md), published
|
||||
at **f95d0310482565e91220a97e13fc756f27f9d590**, retains the initial failing test,
|
||||
20 original frames and independent review. Its committed bytes and verifier were
|
||||
rechecked on September 22. This narrow trial does not establish Text integration,
|
||||
complete rename acceptance or release readiness.
|
||||
|
||||
Earlier production source follows; each result retains its original code scope.
|
||||
Verified production source **44095f78c28b79d6d085863dc928adba8e24d955** includes
|
||||
Find focus, departure-selection and scoped Escape corrections. Its Windows
|
||||
desktop suite passes1095 with3 existing manual ignores; core passes471 with1
|
||||
manual ignore; server passes56. All three strict Clippy scopes and the matching
|
||||
development GUI/server build pass. [Exact source, commands and raw evidence](release-navigation-440-windows-20260913.md).
|
||||
The integrated documentation checkpoint **3c22fe2629fc242714d32805790262fa2c4e33b9**
|
||||
preserves those production inputs and includes the updated AGENTS instructions.
|
||||
|
||||
The actual440 native trial closed normally with79 notes,78 images and14 complete
|
||||
saved-model/source comparisons. Rename/Undo/Redo/reopen, scoped selection,
|
||||
placement/state targeting and sampled OA/SA lifecycle/topology navigation are
|
||||
verified within the [closed trial's explicit scope](experiments/navigation-native-440-20260913/REPORT.md).
|
||||
It found a same-page Details-to-Preview scroll-loss defect; initial keyboard
|
||||
Delete after chooser navigation also required an explicit canvas click. Neither
|
||||
is hidden by passing saved-byte checks. The scroll defect is corrected and
|
||||
verified by the separate feab trial above; the initial chooser-to-canvas keyboard
|
||||
focus friction remains a limitation of the 440 trial. This remains a development
|
||||
checkpoint, not a combined release. [Integration issue27](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/27)
|
||||
owns current verification, correction and release coordination.
|
||||
Current source **c28abcf13a834585a9954f7e54175d47c0a0460b** adds Find focus and
|
||||
departure-selection corrections. Combined execution and native acceptance are
|
||||
not yet established; [integration issue27](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/27)
|
||||
holds current verification and release coordination.
|
||||
Earlier source **e1240295bd114d23d3f67a69e6b38bdb2e25bb0d** combines the canvas
|
||||
contribution with source/diagram navigation and reviewed alias rename. Its1082-test
|
||||
listing,17 cross-feature tests,4 mounted preview-scroll tests and strict all-target
|
||||
@@ -283,162 +70,16 @@ failures and corrections. Earlier authoring and allocation checkpoints remain in
|
||||
|
||||
## Isolated contributions and material limitations
|
||||
|
||||
Combined peer candidate **e34ba27e26ede3ab55f276acc250d92b2e0c3204**, published on
|
||||
`codex/peer-union-956atf-20260923`, composes current Text/Tab with saved Scenario,
|
||||
final inspector actions, attachments, State bends and the transition footer.
|
||||
The literal Windows workspace suite passes 2,005 tests with zero failures and
|
||||
eight named ignores; all 2,013 listed names across 60 executables and three
|
||||
doctest sections are accounted for. Strict workspace/all-target Clippy and the
|
||||
matching native/external-oracle builds pass on 760 unchanged inputs. The
|
||||
[portable gate evidence](experiments/peer-union-e34-windows-20260923/README.md)
|
||||
binds three rebuilt executables through fresh listings and preserves the failed
|
||||
historical f82 desktop run (1,337 passed, three failed, seven ignored), the
|
||||
missing-DLL listing attempt, and capture limitations. Its verifier passes the
|
||||
positive case and rejects all 12 semantic mutations. The preceding
|
||||
[partial gate archive](experiments/peer-union-windows-20260923/README.md) retains
|
||||
earlier Scenario and lint failures. The separate
|
||||
[native checkpoint](experiments/peer-union-e34-native-20260924/README.md) records
|
||||
33 successful whole-model/source checkpoints, the retained failed G2 pointer
|
||||
attempt and its recovery, and five transition checks through fresh reopen and
|
||||
unchanged reapply. The first trace identity and all resulting source-bundle bytes
|
||||
remain exact. Independent review checks 245 frame pins and inspects 38 keyframes;
|
||||
17 transition-oracle controls meet their expected outcomes. The original
|
||||
interrupted process and later normal-close observations remain distinct. Dirty
|
||||
middle-button pan, held-drag Escape and short-window Scenario Apply are unobserved.
|
||||
Public format and release acceptance remain incomplete; accepted root production
|
||||
remains4ffff0d. The newer prerelease candidate and live coordination are in
|
||||
[issue37](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/37).
|
||||
|
||||
Analysis/core source checkpoint **a2a28f8d23a46ba1696b6a8410d4d3b5c1808f49**,
|
||||
Analysis/core source checkpoint **b2c970b4673e062e47eadd12906d2316c4b1a7b3**,
|
||||
publication branch `codex/analysis-core-956atf-20260911`, is not integrated into
|
||||
production. Its private staged typed parser/lowerer has142 listed tests,
|
||||
34 passing typed cases,1 passing raw-token case and strict all-target core Clippy
|
||||
on531 unchanged inputs. The complete library run remains126 passed/16 failed,
|
||||
with all16 failures at the unsupported public source8 header. The original
|
||||
fixture error and its test-only correction remain in [exact S2b evidence](release-analysis-source-s2b-20260913.md).
|
||||
Published private writer **f1d79df4979121335ed7b4ce4da84b2fca8a9ef4** has
|
||||
[portable S2c evidence](experiments/analysis-source-s2c-windows-20260913/README.md):
|
||||
152 listed, 44 typed and one token test passed, strict Clippy passed; the full
|
||||
library result remains 136 passed / 16 failed at unsupported public source8.
|
||||
Published source **1b1c3e3abb6fc3d0934c9dbfbd21fddc8ae4f594** adds a private
|
||||
qualified source index/rename plan and the independently reviewed cleanup for
|
||||
the demonstrated depth129 rejected-tree stack overflow. Its
|
||||
[S3 evidence](experiments/analysis-source-s3-windows-20260922/README.md), published
|
||||
at **345a42e4658f6e0e8e01ef64a2df8f4e28a8945a**, records 167 listed tests,
|
||||
59 typed passes (nine index/five update), one token pass, three clean stack
|
||||
controls and strict Clippy on 538 unchanged inputs. The original overflow and
|
||||
dirty diagnostic/probe results are retained. No current full-core pass is
|
||||
claimed. Public source8 parser, writer, source-update and persistence composition
|
||||
remain incomplete; these branches are not integrated production analysis.
|
||||
|
||||
Published private source **10d7f3340d5cd61a656dc0bcf7c143f8897c5551** adds
|
||||
complete borrowed original-span export for later edit composition. Its
|
||||
[Windows evidence](experiments/analysis-original-span-20260923/README.md) records
|
||||
173 listed tests (all prior 167 retained), 65 typed passes including all 11
|
||||
updater cases, and strict core all-target Clippy on the same 538 committed
|
||||
inputs. Review caught and corrected an intermediate standalone budget
|
||||
regression. This is an isolated export adapter; the later syntax checkpoint
|
||||
below supplies mixed-bundle ownership, while output compilation and public
|
||||
source8 remain incomplete.
|
||||
|
||||
Published private mixed-syntax source
|
||||
**a578ac98a06772a60bec00ae287e65650680d119** retains the complete source bundle,
|
||||
original positions and exact caller policy, using shared ordinary/typed token
|
||||
consumption with explicit identities and aggregate file/byte admission. Its
|
||||
[Windows evidence](experiments/analysis-mixed-syntax-20260923/README.md) records
|
||||
194 listed tests, all prior 173 retained, 20 focused mixed passes and 85 typed
|
||||
passes within the actual full result: **178 passed / 16 failed**. All 152
|
||||
historical statuses are unchanged and all 42 later tests pass. Strict core
|
||||
all-target Clippy passes on 541 unchanged committed inputs. Formatting remains
|
||||
failed in six inherited, unchanged files. Original failed gates and the host
|
||||
clock discontinuity are retained; a fresh actual direct listing binds the final
|
||||
focused/full executable after Cargo rebuilt it. This syntax witness does not
|
||||
resolve or admit a complete mixed Project, activate public source8 or implement
|
||||
editing composition. The accepted
|
||||
[complete-Project contract](analysis-mixed-project-contract-20260923.md) defines
|
||||
the semantic boundary implemented by the separate private checkpoint below.
|
||||
|
||||
Published private complete-Project source
|
||||
**aaec78fcc9b273d1485497126392afdd1fb2e54e** retains the original syntax, bundle,
|
||||
budgets and limits while resolving/admitting the complete ordinary/typed model.
|
||||
The next [mixed typed-index contract](analysis-mixed-index-contract-20260924.md)
|
||||
is accepted after source review and resolved accounting/completeness findings.
|
||||
Private typed-index source **93eb6117f95353d25d594d59780018f197c51672** implements
|
||||
the consumed witness and shared verifier. Its
|
||||
[Windows evidence and independent oracle review](experiments/analysis-mixed-index-20260924/README.md)
|
||||
verify strict core Clippy, all seven new cases and the complete 216-name inventory
|
||||
on 545 unchanged inputs. Full library remains **200 passed / 16 failed**,
|
||||
preserving all 209 baseline statuses; formatting still has the identical eight
|
||||
inherited blocks. Original lint and depth-expectation test failures are preserved.
|
||||
The [source review](analysis-mixed-index-20260924/source-review.md) records three
|
||||
repaired diagnostic findings; its reviewed production hashes are unchanged.
|
||||
The new owner keeps legacy updater authority separate. Complete mixed editing
|
||||
and public source8 remain required.
|
||||
The [ordinary occurrence successor contract](analysis-ordinary-index-contract-20260924.md)
|
||||
uses retained parser positions and physical token keys, with a separate consumed
|
||||
read-only owner. Private source **ee500908e3d77182ddd54e12524f9b6c6eb43b60** now
|
||||
implements that dependency. Its [Windows evidence and independent review](experiments/analysis-ordinary-index-20260924/README.md)
|
||||
verify all13 new cases, strict core Clippy and the229-name inventory on549
|
||||
unchanged inputs. Full library remains **213 passed /16 failed**, with all216
|
||||
prior outcomes and failure bodies unchanged; formatting retains the identical
|
||||
eight inherited blocks. Initial9-pass/4-fail fixture evidence and exact test-only
|
||||
repairs are preserved. Both occurrence families now retain complete original
|
||||
authority, but no rename composition or public edit capability follows yet.
|
||||
The [whole-target rename planning contract](analysis-mixed-rename-plan-contract-20260924.md)
|
||||
is implemented privately at **18073a6f2eabce83fc83997f094e331eec835a16**.
|
||||
Its [Windows evidence and independent review](experiments/analysis-mixed-rename-plan-20260924/README.md)
|
||||
verify all 17 new tests and strict core Clippy on 551 unchanged inputs. The full
|
||||
246-name run remains **230 passed / 16 failed**, preserving all prior outcomes;
|
||||
formatting retains identical inherited failures. Complete non-name comparison,
|
||||
simultaneous namespaces, exact merged edits, inherited caps and combined sizing
|
||||
are verified. The scoped planner comparison/drop test passes on 128 KiB;
|
||||
separate full-pipeline parsing/setup overflows remain explicitly unresolved.
|
||||
The [carried-phase prerequisite](analysis-carried-phase-contract-20260925.md)
|
||||
is implemented privately at **fa7a980418ef99d772e67c66c28dfa7b5181f875**.
|
||||
Its [Windows evidence](experiments/analysis-carried-phase-20260925/README.md)
|
||||
records 12 new passes, strict core Clippy, 553 unchanged inputs and a completed
|
||||
258-name full run with **242 passed / 16 failed**. Exact policy, retained bundle
|
||||
ownership and aggregate phase accounting survive parsing and resolution;
|
||||
legacy checkpoints remain unchanged. An initial full-run stack regression was
|
||||
corrected by retaining a smaller guard, with the existing 128 KiB controls
|
||||
unchanged and passing. The earlier separate setup overflow remains unresolved.
|
||||
At that checkpoint, continuation constructors are fixture-only. The subsequent
|
||||
[private output-emission contract](analysis-mixed-emission-contract-20260925.md)
|
||||
is implemented at **8d52dab97c92cc31baae9f20d8c324b55818aa5d** on
|
||||
`codex/analysis-mixed-emission-956atf-20260925`.
|
||||
Its [Windows evidence](experiments/analysis-mixed-emission-20260925/README.md)
|
||||
records eleven new passes and a completed 269-name full run with **253 passed /
|
||||
16 unchanged failed**, strict core all-target Clippy and identical inherited
|
||||
formatting failures on 555 unchanged inputs. The consuming no-op preserves the
|
||||
original owner; changed output reserves all owned source storage before copying
|
||||
and remains private and unverified. Borrowed carried owners cannot use the changed
|
||||
path. An initial stack abort is preserved; a mechanical parser split reduces
|
||||
the observed numeric-prefix prologue reservations by 13,032 bytes, and the
|
||||
unchanged overflowing control passes the final suite and three fresh processes.
|
||||
Accepted editing and stronger parser allocation-timing obligations remain open.
|
||||
[DECISIONS.md](DECISIONS.md) records the scope and rejected
|
||||
alternatives; live ownership and acceptance remain in issue7.
|
||||
The preceding Project [Windows evidence](experiments/analysis-mixed-project-20260924/README.md)
|
||||
distinguishes runtime source **71cadfa0cd0e112caa29588d2494753301f3cb6c** from
|
||||
the formatting-only aaec successor. Strict core all-target Clippy, actual listing
|
||||
209, Project 13/13, shared validation 2/2 and four exact public compatibility
|
||||
cases pass on 544 unchanged inputs. Full lib remains **193 passed / 16 failed**,
|
||||
preserving all prior statuses and exact unsupported-public-source8 diagnostics.
|
||||
Successor formatting remains failed on eight inherited blocks in five unchanged
|
||||
files; no aaec runtime rerun is claimed. The parent-reviewed portable verifier
|
||||
and all 15 finite controls pass, with independent raw-output/pin readback.
|
||||
This private source is not integrated into the application prerelease. Public
|
||||
source8, mixed edit execution, checked output and UI parity remain
|
||||
unfinished.
|
||||
|
||||
The [reviewed S1/S7 assessment](issue28-s1-s7-assessment-956atf-20260923.md)
|
||||
distinguishes static analysis/case overlays from contextual inheritance,
|
||||
fixed/default bindings and executable trade studies with durable result
|
||||
provenance. Strengthened fixture and parity criteria remain proposals, not
|
||||
implemented evaluation or a replacement metamodel decision.
|
||||
Earlier [S2a evidence](release-analysis-source-s2a-20260913.md) retains its
|
||||
129-test113/16 library result and separate7/9 language integration result,
|
||||
including the old V2 oracle that omitted the empty definitions catalog.
|
||||
production. It adds source-save decode/equality containment and a private staged
|
||||
expression/declaration parser, then repairs the frozen source3 lowering envelope.
|
||||
Its129-test library run has113 passes and16 failures, all at the unsupported
|
||||
source8 header. All22 expression/declaration/token cases and8 bridge/save guards
|
||||
pass; strict all-target Clippy passes on524 unchanged inputs. The separate language
|
||||
integration target has7 passes and9 failures:8 unsupported headers and an old V2
|
||||
oracle that omits the new empty definitions catalog. [Exact S2a evidence](release-analysis-source-s2a-20260913.md)
|
||||
preserves every failure and the independent structural comparison.
|
||||
[Earlier bridge evidence](release-analysis-source3-bridge-20260913.md) remains scoped
|
||||
to6216cff.
|
||||
The earlier2c515cf full library run had98 passes and16 failures:14 rejected the
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
# Private carried-baseline phase-check contract
|
||||
|
||||
2026-09-25. **Selected private prerequisite; implementation and runtime acceptance remain pending.** Source inspected: `18073a6f2eabce83fc83997f094e331eec835a16`. This contract builds on [the domain audit](analysis-mixed-rename-execution-20260924/CARRIED-STORAGE-DOMAIN-AUDIT.md) and the accepted mixed-parser, mixed-Project, typed-index, ordinary-index and mixed-rename-plan contracts. It neither implements a parser nor grants rewritten-output acceptance.
|
||||
|
||||
## 1. Deliverable and nonclaims
|
||||
|
||||
Introduce one private way to parse a deliberate complete snapshot under the **exact inherited Limits and ProjectLimits**, while carrying a constructor-controlled live auxiliary baseline through existing parser phase checks and subsequent resolution. The resulting parsed/resolved witnesses keep that baseline; no separately passing fresh ledger may hide it.
|
||||
|
||||
The metric remains the existing logical triple `(entry_slots, lower_occurrence_slots, owned_in_scope_text_bytes)`. This dependency preserves current parser timing limits: it is **not before-every-allocation admission**, a complete parser-transient inventory, a raw model/serde/validator heap meter, an allocator-capacity/RSS bound, or a stack guarantee. Existing grammar entry checks, raw token/byte/Exact accounting and model/static policies remain intact.
|
||||
|
||||
This is independently useful because any later owned-output verifier needs exact policy and nonzero live-storage participation before it can safely compose existing parser/resolver phases. It is insufficient on its own to accept rewritten output. Emission reservations, actual-byte verification, whole-intent equality, returned ownership and installation/history remain separate successor requirements.
|
||||
|
||||
## 2. Ownership and construction authority
|
||||
|
||||
The production continuation is private and constructible only from an owning or borrow-bound retained-state witness with a verified ledger and exact policy. A scalar Slots argument alone is not authority. It contains the inherited policy and the scalar carried live Slots. It must not be Clone/Copy or expose a production constructor accepting arbitrary Limits, replacement Budget or arbitrary Slots. A scalar Slots type can remain Copy internally; the authority wrapping it cannot be independently duplicated through its API.
|
||||
|
||||
Selected scope: no production constructor or old-owner/emitter transition is delivered in this prerequisite. Both borrowed and retained construction remain in fixture-only helpers; the production-shaped parser/guard machinery is exercised without granting fabricated output authority. Test-only construction binds a real retained output-shaped fixture owner and its actual bundle; it demonstrates arithmetic and propagation, not an implemented emitter transition. A bare Slots argument plus an unrelated later bundle is forbidden even for the proposed production shape. The minimal concrete ownership seam is specified in §5.
|
||||
For actual later use, the transition must distinguish:
|
||||
|
||||
- **Old auxiliary state dropped:** consume and finish dropping old syntax/index/maps/warnings/plan-owned auxiliary data before constructing the verification continuation. Surviving output buffers/map/path keys/entry and any additional named auxiliaries supply C.
|
||||
- **Old auxiliary state retained:** C includes the still-live old baseline plus all surviving new output/auxiliary ownership. A mere change of phase cannot release old reservations.
|
||||
|
||||
The desired Project remains an external immutable borrow with the established model-allocation exclusion. An emitted SourceBundle must have a stable external owner throughout any syntax/result borrowing it. Do not return an owning bundle alongside a witness that borrows that same field. A future callback/scoped verifier or drop-syntax-then-own-output result requires its own accepted design.
|
||||
|
||||
The parser consumes the continuation and borrows the actual complete SourceBundle. Entry, canonical paths, exact file set/bytes and normal include/reachability authority still come from that bundle and the normal traversal. No replacement numeric file table, caller-supplied tokens/model, loose index or partial file subset is accepted. Failure returns diagnostics, no reusable continuation or partial witness; its external output owner remains responsible for dropping its own buffers.
|
||||
|
||||
## 3. Policy and disjoint counters
|
||||
|
||||
Let:
|
||||
|
||||
- C = live carried Slots inherited from the owning transition; fixed for this parse/resolution attempt unless a documented owned allocation is actually released.
|
||||
- U = current snapshot `Budget.usage.entries`.
|
||||
- M = current existing `ParsedMixedSyntax.metadata` subtotal.
|
||||
- T = current snapshot retained syntax text counted by the existing bounded syntax-accounting walk at resolution.
|
||||
- A = subsequent existing resolver/catalog/lower/diagnostic reservations and named accounting scratch.
|
||||
|
||||
Create one **new-snapshot** Budget from the exact captured Limits. Preserve all Limits fields, including tightened bytes/tokens/entries/references/frames/numeric_units, and the complete captured ProjectLimits. Do not reconstruct defaults from only caller bytes/references, silently widen, or replace the old live witness's Budget. Work Usage and numeric usage start at zero for this deliberate snapshot and accumulate normally across all included files.
|
||||
|
||||
C is live storage, not parse work: do not add it to bytes/tokens/entries/references Usage, Exact work, model records or deepest_frame. New source bytes are charged once during scanning even if those same bytes are also carried owned output text: these are distinct work and storage dimensions. Original input/output parse work reports remain distinct, never reset/subtracted to pretend the reparse did no work.
|
||||
|
||||
All arithmetic composing C with phase totals must use checked addition and refuse overflow. The entry ceiling is Limits.entries; occurrence ceiling remains the existing entries-plus-references ceiling; text ceiling remains Limits.bytes. Aggregate output byte size remains a separate source-size check. A failure from nonlatching lowering/index phase checks must propagate immediately; later `Budget::active()` is not a substitute.
|
||||
|
||||
## 4. Exact phase checks and temporal limits
|
||||
|
||||
Use the current syntax charge table without adding speculative containers. In particular M begins with file references and later adds the current `tokens.len()`-based metadata component, value-end subtotal, ordinary position slots and lexical-owner count (`mixed.rs:277–280,321–330`). The module header describes the token-count component as token-end metadata and tokens themselves as token-bounded; preserve the existing one component, **do not introduce a second tokens.len() charge** by relabeling it. Dropped value-end subtotal may remain the inherited conservative overestimate.
|
||||
|
||||
Required ordering/checkpoints for the explicit carried route (legacy wrappers retain their existing checks):
|
||||
|
||||
1. **Continuation admission:** after policy validation/Budget construction and before broad traversal allocation, check all three C dimensions against the exact inherited ceilings. Report overflow/cap failure safely at entry/default span.
|
||||
2. **Complete input size precheck:** retain current checked sum of all source file lengths and `index_bytes(total_source_bytes)` (`mixed.rs:269–276`). Do not sum borrowed input bytes into C.text again merely because they are being parsed. In the output-owned case their ownership is already in C.
|
||||
3. **Initial file metadata:** preserve the current file-slot precheck before `Traversal::new` (`:277–284`), replacing its independent entry check with `C.entries + M` (U is zero here).
|
||||
4. **Existing scan/grammar checks:** preserve current raw token, byte, syntax-entry, reference, depth and Exact event checks exactly. Do not add a new check on every allocation or change legacy parser semantics. C does not change these work counters.
|
||||
5. **Per-file preprojection metadata checkpoint:** after the parsed unit and existing `ordinary_position_slots` count, construct prospective M with checked arithmetic, then check **C.entries + U + M** before position projection (`:319–340`). This is the new combined aggregate phase check. It includes all completed prior files and current grammar work. It is not a proof that current-unit ASTs, tokens or counting scratch were never temporarily allocated before refusal.
|
||||
6. **Successful complete-syntax boundary:** after traversal succeeds and before returning the witness, recheck C plus final U+M, and C's other dimensions. Preserve exact carried C and scalar M in the sealed syntax result. No second reservation is created by a noncharging check.
|
||||
7. **Resolver transition, exactly once:** start its Ledger with C, then run the existing syntax-accounting admission once: reserve U+M and walk retained structures to reserve T and accounting-work slots. Continue all existing catalog, lowering, mapped-diagnostic and index reservations from that same ledger. Final resolver baseline is C+the normal resolved baseline, with the same existing releases. Do not add parser checks as reservations and charge U/M again; do not seed zero; do not run two syntax walks merely to recreate authority.
|
||||
|
||||
Parser-owned syntax Strings and selected parser temporaries retain their **existing timing**: retained text is admitted by the bounded walk after parsing, before resolver-dependent allocations. Traversal internals, position delimiter/end scratch and parser counting Vecs do not suddenly become individually Ledger-metered. They retain existing independent file/token/depth controls; no universal auxiliary-preallocation claim may be made. Accounting::syntax's own counting work remains explicitly reserved before its growth.
|
||||
|
||||
The resolver already declares model/serde and raw architecture-validator allocations outside this metric (`accounting.rs:1,72–79`; Project contract §3). Keep these exclusions. Produced raw Issues are admitted into charged retained diagnostics before destination retention; output/source-key Strings newly introduced by a later emitter are in scope and cannot use the exclusions to disappear.
|
||||
|
||||
## 5. Smallest concrete Rust ownership seam
|
||||
|
||||
Selected private types (names may follow repository conventions):
|
||||
|
||||
```rust
|
||||
struct SnapshotPolicy { limits: Limits, project_limits: ProjectLimits }
|
||||
|
||||
// Owns the actual bundle represented by its verified retained slots. Fields
|
||||
// private; no production nonzero constructor until the emitter transition.
|
||||
struct RetainedSnapshot {
|
||||
bundle: SourceBundle,
|
||||
policy: SnapshotPolicy,
|
||||
retained: Slots,
|
||||
}
|
||||
|
||||
enum SnapshotBacking<'s> {
|
||||
// External caller-owned input, exactly like the existing parser domain;
|
||||
// its source bytes are borrowed, so C is zero. Not an emitted-owned result.
|
||||
Borrowed { bundle: &'s SourceBundle, policy: SnapshotPolicy },
|
||||
Retained(&'s RetainedSnapshot),
|
||||
}
|
||||
|
||||
// Not Clone/Copy. Methods derive bundle, exact policy and C from the backing;
|
||||
// no independently supplied bundle or arbitrary C at parse invocation.
|
||||
struct CarriedInput<'s> { backing: SnapshotBacking<'s> }
|
||||
|
||||
fn parse_carried(input: CarriedInput<'_>) -> Result<ParsedMixedSyntax<'_>, Diagnostics>;
|
||||
```
|
||||
|
||||
`RetainedSnapshot` keeps the output map, source Strings, path keys and entry alive; retained counts correspond to that exact owner. It must not have a public or generally available `new(bundle, slots, policy)`. For this prerequisite, `cfg(test)` builds a real retained fixture bundle and counts its F entries and actual source/key/entry UTF-8 bytes internally. Its policy comes from the tested existing immutable witness/profile, not a widened replacement. Production support for emission must later reserve these allocations before copying and install verified counts through its sole transition constructor. No arbitrary other live old-owner allocations may be claimed by this minimal retained shape; if the future design keeps old auxiliaries, add an explicit guard for them then.
|
||||
|
||||
The borrowed variant supplies a fixture-only carried C=0 route without pretending an owned output is free. It captures exact policy through a narrow internal read-only policy snapshot of an existing sealed witness. There is no independent replacement profile at `parse_carried` invocation. Its bundle is fixed in the input. Test-only tighter-profile construction remains explicitly test-only. The two backing variants describe ownership, not a numeric-zero branch controlling checks.
|
||||
|
||||
Consume `CarriedInput<'s>` at parser entry, transferring its exact policy into the new Budget and existing syntax ProjectLimits field. Retain only a small `SnapshotGuard<'s>` distinguishing borrowed zero carry from a reference to the allocation-owning RetainedSnapshot. Do not duplicate the full policy inside every later syntax/resolved/index/plan value. The current bundle reference is derived from the same consumed input. This uses the existing bundle lifetime, so no self-reference or extra generic lifetime is needed. In retained mode both references borrow the external stable snapshot; the syntax cannot outlive it. Keep the small guard inside syntax when resolve consumes syntax and places it in `ResolvedMixedProject`. Read-only methods expose no way to remove or replace the guard. Baseline extraction for resolution is an internal scalar read, not a second independently transferable authority.
|
||||
|
||||
Use one shared parse body with an explicit legacy-versus-carried option. Existing parse wrappers select legacy and retain their old checkpoint timing. The carried entry always runs combined checks, including its borrowed C=0 variant. Do not select behavior with `C == 0`. A narrow resolver initialization obtains C from the guard (zero for legacy), seeds Ledger once, and invokes existing `accounting::syntax` once; existing resolved ownership already retains syntax and therefore the guard. No separate wrapper around every later index phase is necessary.
|
||||
|
||||
The current Budget keeps Limits private. Add only an internal read-only exact-policy snapshot seam if needed, not mutation or resetting of a live Budget. New snapshot Usage remains independent. Do not implement carry by subtracting C from Limits globally; that would conflate storage with source/token/reference/depth/numeric work. Do not duplicate grammar, include budgets, scans, model state or generic allocation infrastructure.
|
||||
|
||||
This representation deliberately postpones an owning accepted-output result and emitter. An actual emitted bundle must live in `RetainedSnapshot` (or an equally explicit future sealed owner) before verification borrows it. Parsing/resolution failures drop the temporary guard/witness; the surrounding owner then drops output normally. No accepted output or installation authority escapes merely because a carried parse/resolve succeeded.
|
||||
## 6. Observable acceptance through real paths
|
||||
|
||||
Except explicitly labeled arithmetic/fault-injection controls below, cases must use real `parse_with_profile`-equivalent parsing and `resolve`, not synthetic parser outputs or a separately fabricated accepted Project. Expected arithmetic and affected phase/location are independently authored. Do not mutate a live admitted owner to tighten its Budget.
|
||||
|
||||
1. **Zero-C compatibility:** accepted existing OA/SA, multiple canonical files, Unicode/CRLF, ordinary positions, warnings, Project, original bundle borrowing and parse/numeric Usage remain identical through full resolution/index. Compare opt-in carried C=0 successful outcomes with the original entry, while separately verifying the original path preserves its old refusal timing. Current ordinary callers preserve exact policy and prior phase behavior. Run existing syntax/project/index/rename tests and retain inherited failure dispositions. The earlier-refusal qualification below must be explicit.
|
||||
2. **Carried retention:** a small admitted bundle with independently hand-counted baseline C yields the same complete Project, warnings, syntax work/numeric usage and canonical file keys as zero C. Resolved live Slots differ by exactly C in each domain; lower temporary releases never release C. This output-shaped owner has C.occurrences = 0; occurrence overflow/cap checks may be fault-injection or direct arithmetic tests, not claims of a real retained occurrence owner.
|
||||
3. **Initial checkpoint:** C entries plus all-file references exactly fit/fail at cap/cap-minus-one before traversal; C text/occurrences each independently fit/fail without relabeling source bytes or references. Use realistic retained fixture storage for positive cases. Dedicated arithmetic-overflow tests may use test-only max values.
|
||||
4. **Combined C+U+M checkpoint:** author a real minimal/multi-file fixture where each independent C, U and M fits but the sum fails. Cap-minus-one refuses at the intended preprojection or final syntax boundary, with original current-file diagnostics if that checkpoint has a justified file. Exact cap returns syntax; resolve may need more headroom and must not be claimed passed under that smaller cap. For a combined parser+resolver positive, choose the cap from the measured/hand-counted maximum of **all** phases.
|
||||
5. **Resolver carry:** choose a fixture/cap pair where parsing passes and the carried syntax/catalog/lower/warning aggregate crosses the cap in resolve, while the zero-C version fits. Assert the actual resource refusal and original file/span policy. Separately show a successful full path retains C once, not twice.
|
||||
6. **Text timing/exclusion honesty:** parse a quoted-string/ordinary-position-copy fixture, then require resolve's C.text+T to fail/succeed at its existing text-accounting boundary. Do not report that fixture as rejecting before decoding/copying the syntax Strings. Verify raw Issue exclusion and retained warning admission remain as before.
|
||||
7. **Overflow:** exercise checked C+file metadata, C+U+M, text and resolver initial composition overflow through private test construction and the real call entry. Overflow must return a small safe diagnostic, never wrap/saturate into success or return a partial witness. Distinguish overflow from ordinary cap failure if an earlier C dimension already exceeds the hard ceiling; direct checked-add helper tests may be needed for unreachable-under-valid-policy arithmetic, and must be labeled as such rather than claiming an impossible accepted C.
|
||||
8. **Policy fidelity:** tightened independent bytes/tokens/entries/references/frames/numeric_units and caller model/file/include/depth policies are carried unchanged; a second-snapshot normal-looking input must still obey every tightened limit. Usage is new-snapshot work, not previous Usage+C. No default profile fallback.
|
||||
9. **Failure/ownership lifecycle:** consumed continuation cannot be reused; borrowed bundle/intent remain unchanged; failure exposes only diagnostics. Tests holding a real external baseline allocation prove it remains alive through parse/resolve and drops normally afterward. Future emitter ownership is not simulated as accepted production provenance.
|
||||
|
||||
Existing admission/index small-stack cases remain separate regression tests. This slice promises no new universal stack bound, and preserves the documented environment-sensitive upstream parsing observation.
|
||||
|
||||
## 7. Required explicit clarifications / possible revisions
|
||||
|
||||
**Temporal wording:** parser proposal “bound ordinary temporary containers/references before allocation” and Project contract “before allocating or copying an in-scope representation” must not be cited as proof that this phase-only seam admits every parser allocation before growth. Existing code admits retained syntax text later and has independent parser transient controls. Adoption should explicitly state: this prerequisite carries the surviving baseline through current logical **phase checks**; it neither closes nor waives any stronger before-growth obligations for the final execution contract. If parent interprets the earlier broad wording as an unconditional requirement on every counted parser producer now, that interpretation and this intentionally phase-only deliverable conflict. Parent must explicitly amend/scope the predecessor requirement or defer acceptance of this limited prerequisite; implementation may not silently weaken it.
|
||||
|
||||
**Zero baseline and earlier refusal — selected compatibility rule:** keep the original path and phase checks unchanged, and enforce combined checks only through the explicit carried path, including a legitimate zero C. That carried C=0 path can reject a syntax-only witness earlier where original syntax succeeded but original resolve rejected U+M. Document and test this intended difference. Successful complete outcomes and Usage must agree. A numeric-zero bypass would violate the opt-in carried contract, while globally imposing U+M on legacy syntax would violate the chosen compatibility scope. No claim of identical parser-only acceptance across these two deliberately distinct private paths is made.
|
||||
**Final execution acceptance:** the rename contract requires a surviving aggregate baseline during actual-output parse/resolve and whole-Project equality, but that requirement alone does not approve an emitter or resolve all parser temporal policy. This prerequisite can be accepted independently while the output producer, before-growth decision, E equality reservation, old-owner drop boundary and owning accepted-result design remain unfinished. No release claim of accepted mixed editing follows.
|
||||
|
||||
## 8. Exit criterion
|
||||
|
||||
A reviewed private continuation plus real zero/nonzero-baseline parser/resolver tests establish exact policy retention, disjoint work/live dimensions, aggregate checks at the listed boundaries, once-only resolver transfer and safe failure ownership. Evidence must name the source pin, counted domain, actual refusal phases and remaining timing/exclusion limits. Broader rewritten-output acceptance stays blocked on its own explicit contract and tests, not silently considered satisfied by this prerequisite.
|
||||
|
||||
## Parent scope decision
|
||||
|
||||
Accept this as a limited phase-accounting prerequisite. The existing entry path retains its checkpoints unchanged; only the explicit carried path performs combined checks, even at zero C. This neither satisfies nor waives stronger predecessor before-growth requirements for final output acceptance. The final execution contract must still resolve those requirements before editing authority is enabled. No production entry can yet create a carried continuation: fixture-only construction tests exact-policy capture and propagation, not an implemented consuming old-owner transition. The retained positive fixture counts exactly F map entries, zero lower occurrences, and all actual source/key/entry UTF-8 bytes. Nonzero occurrence or impossible arithmetic cases are explicitly fault injection, never successful owner provenance.
|
||||
@@ -1,102 +0,0 @@
|
||||
# Private retained-entry producer prerequisite
|
||||
|
||||
Baseline `97a8703adc75467a7f8e75f5c6cb00ca542b707c`. This selected contract
|
||||
strengthens the timing of the existing logical entry metric. It does not grant
|
||||
accepted-output, public source8 or save/history authority. Source/evidence remain
|
||||
local until the Gitea storage publication blocker is resolved.
|
||||
|
||||
## Metric and authority
|
||||
|
||||
Add one explicit fixture route enabling both text and entry producers, using the
|
||||
existing opaque CarriedInput and exact inherited policy. Preserve legacy,
|
||||
carried-phase and text-only routes and their diagnostic timing. C comes from the
|
||||
actual carried guard, including an explicitly selected zero guard.
|
||||
|
||||
U remains exactly Budget.usage.entries, the existing cumulative syntax events.
|
||||
M remains F supplied file records + one significant-token-end subtotal + unique
|
||||
ordinary value-end keys + ordinary lexical-owner occurrences + projected slots.
|
||||
Projected slots remain seven per ordinary declaration (including its synthetic
|
||||
body and conservative header envelope), one per field, one per actual Ast value.
|
||||
Node headers also retain the seven envelope. Dropped token/value-end structures
|
||||
keep their conservative subtotal through syntax return. Usage never includes C,
|
||||
M or quota consumption, and significant tokens do not gain a second M charge.
|
||||
|
||||
Before selected growth check checked `C.entries + U + M + delta` against the
|
||||
exact entry ceiling. A failed check changes no U/M total. Use only small scalar
|
||||
optional Budget state; do not duplicate policy or create another owning wrapper.
|
||||
Preserve current byte/guard checks, then atomically validate/install C and M=F
|
||||
before Traversal construction. F is transferred, not charged again, when file
|
||||
records move between containers.
|
||||
|
||||
## Producer and reconciliation rules
|
||||
|
||||
- Budget::entry preserves its existing syntax-work check first; in the new mode
|
||||
check the prospective aggregate before incrementing U or returning to callers.
|
||||
Existing typed/ordinary event sites remain unchanged.
|
||||
- Encapsulate value-end key-presence check, reservation and insertion in one State
|
||||
helper. Charge only a new key. Encapsulate lexical-owner reservation and push
|
||||
likewise; charge occurrences, not distinct UUIDs. Hide unchecked mutable
|
||||
metadata access from the ordinary parser.
|
||||
- Count pending projection with checked scalar grammar hooks: seven per ordinary
|
||||
declaration, one per successfully parsed field and one per successful value.
|
||||
Nested values/fields naturally contribute. Project/include/description do not
|
||||
call these hooks; duplicate fields fail the entire parse. The synthetic body
|
||||
is already in seven, and adds no value event.
|
||||
- At projection boundary atomically reserve token count plus pending projection
|
||||
into M before ends/header/field/value construction. The stronger route must
|
||||
not call the old unmetered borrowed-work Vec counting pass. Other routes keep
|
||||
that existing path.
|
||||
- A scalar prepaid quota is consumed before projector growth: seven before a
|
||||
declaration/header envelope, one before each field, one before each actual
|
||||
value (before recursive children). It neither recharges nor refunds M. Require
|
||||
zero after projection. Under/over quota is an internal invariant failure,
|
||||
never a successful witness with corrected/subtracted counts.
|
||||
- After each file compare reserved map/owner counts with actual lengths and
|
||||
M delta with checked token/owner/map/projected inventory. At syntax completion
|
||||
compare total M with the independently accumulated metadata field. Keep the
|
||||
existing once-only C then U+M resolver ledger transfer; text reconciliation
|
||||
remains unchanged. Never seed a combined total and add syntax a second time.
|
||||
|
||||
## Rejection behavior
|
||||
|
||||
New aggregate ceiling refusal uses `Source8 lowering entry limit exceeded.`
|
||||
Overflow uses `Source8 producer entry size overflow.` Both latch in the new
|
||||
entry mode, leave totals unchanged and propagate immediately. Legacy phase
|
||||
checks keep their existing nonlatching semantics. U-only failure retains its
|
||||
existing precedence and message. Invariant mismatches return an explicit error;
|
||||
they cannot silently repair a count or produce an accepted result.
|
||||
|
||||
Attribute U to its existing event span; owner to the original declaration start;
|
||||
value-end to the completed Ast's start/end; file and complete projection
|
||||
reservation to Span::default in the current file. Grammar counter overflow uses
|
||||
its corresponding declaration/field/value span. Ordinary diagnostic transport
|
||||
continues to use the existing one-byte start representation. Tests distinguish
|
||||
these actual seam diagnostics from later phase or resolver refusal.
|
||||
|
||||
## Acceptance and exclusions
|
||||
|
||||
Real E: C1/U1/M11 gives syntax13/12; full resolver17/16 is a separate boundary.
|
||||
Real three-file chain: C3/U3/M19 gives syntax25/24, separate resolver29/28.
|
||||
Use sufficiently large unchanged text policies (at least E146 and chain358),
|
||||
so earlier text limits cannot preempt the entry assertion. First ordinary owner
|
||||
refuses at prefix cap4; first description value-end refuses at prefix cap7.
|
||||
Neither next integer is claimed to admit the complete document. Review the
|
||||
reserve-plus-mutate helpers and test unchanged collections on refusal directly.
|
||||
|
||||
Independently author a nested record/array/reference/historical UUID fixture and
|
||||
node/element envelopes, with literal counts and spans fixed before runtime.
|
||||
Cover repeated map keys, quota underflow/leftover, overflow and latched refusal
|
||||
as labeled internal controls. Verify actual-owner and explicit C0 routes,
|
||||
whole OA/SA and multi-file Project/Usage/numeric parity against text-only, source
|
||||
conservation, once-only transfer and unchanged old caps/errors. Run new focused
|
||||
cases, full existing disposition, strict core Clippy and format comparison;
|
||||
inspect changed Budget/State/projector prologues and all old small-stack controls.
|
||||
Do not enlarge stacks, loosen tests or add a heap wrapper to hide regressions.
|
||||
|
||||
This retained metric does not bound allocator capacity/node overhead, file-table
|
||||
physical transfer overlap, significant-token storage beyond its existing token
|
||||
bound, traversal/open-declaration/delimiter/Pratt work stacks, duplicate-name
|
||||
scratch, numeric/decoder internals, model/validator heap or native stack. Those
|
||||
named scratch domains need a later explicit finite decision. Actual emitted-byte
|
||||
verification, justified whole-Project equality work and accepted bundle ownership
|
||||
also remain open. No claim of before-every-allocation or all-heap accounting.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Independent critique of retained entry producer prerequisite
|
||||
|
||||
Reviewed ENTRY-PROPOSAL.md against local immutable HEAD `97a8703adc75467a7f8e75f5c6cb00ca542b707c`. No code, compiler, runtime, network or publication action. References below are repository-relative at that source. This is a design review, not implementation acceptance.
|
||||
|
||||
## Disposition
|
||||
|
||||
The proposed scalar grammar count plus prepaid projection quota is a reasonable bounded next prerequisite. It preserves the established logical U/M domain without pretending to bound every allocation. I found no inherent accounting contradiction or need for a parser rewrite. Before implementation, settle the new rejection contract and explicitly distinguish prepaid quota consumption from live accounting. Broader output verification and owner transition remain open.
|
||||
|
||||
## Required precision before implementation
|
||||
|
||||
1. Specify the new aggregate error message, attribution and poison behavior. Existing `Budget::entry` first checks `active`, checks the syntax-only bound, latches a failure, then increments U (`budget.rs:170–206`). Existing metadata phase checks are nonlatching (`mixed.rs:1–17`, `budget.rs:238`). The new mode must run the existing syntax-work check first, then checked aggregate arithmetic, and commit U only after both succeed. Define whether aggregate failure latches, and require immediate propagation consistently; do not inadvertently change legacy error precedence. An overflow must not mutate U/M or masquerade as a limit comparison.
|
||||
|
||||
2. Initialize and validate `C.entries + F` before installing the entry state or constructing Traversal. C comes from the actual guard, including an explicitly present zero guard. The existing source also checks source bytes before the file reservation (`mixed.rs:300–329`): retain that ordering unless the new route's contract intentionally says otherwise. Policy caps in entry tests need generous text/byte/token limits so this earlier validation cannot preempt the claimed entry seam.
|
||||
|
||||
3. Define projection quota as consumption of an already reserved logical subtotal, not another live charge. At the boundary reserve `tokens.len + pending_projection` atomically once; every producer consumes quota before its representation growth, and completion requires zero. Consumption must never reduce Budget M or charge M again. Seven per declaration covers the synthetic body and conservative header envelope, even though node headers have only four actual roles (`language.rs:1121–1168`). No release of M follows temporary ends/value-map destruction: the established metric conservatively retains those subtotals through the witness (`mixed.rs:42–47`, `368–429`).
|
||||
|
||||
4. Make the attribution of new owner/value-map/pending overflow checks explicit. The owner seam occurs after consuming the opening brace (`language.rs:1693–1702`), so a generic current-token error would point into the body, not the declaration start. Pass the selected declaration span deliberately. The value-end seam has the completed Ast offset and end (`1357–1375`); choose and test that source span, including the ordinary one-byte diagnostic transport. The proposal currently promises the original declaration span but does not fully define all new diagnostics.
|
||||
|
||||
## Coverage and lifecycle review
|
||||
|
||||
U is cumulative syntax work, not a count of simultaneously live allocated objects. Keep exactly the old calls: ordinary project/declaration/include/description/field/value events; typed declaration/field/type/dimension/override and expression/operator/call-argument events. Existing `syntax.rs:60–63` excludes grouping from a new entry, and `169–176` charges binary operators before their deferred construction. The new check belongs in the common Budget seam, not additional Vec pushes. Existing Pratt/open-declaration work stacks stay outside this selected metric.
|
||||
|
||||
Successful grammar-derived projection counts match the old walk's shape: seven per ordinary declaration, one per successful field and one per successful value. `language.rs:1294–1343` recursively routes nested object fields through field(), and `1357–1375` routes every child Ast through value(); `mixed.rs:440–465` counts precisely those same retained fields/values. Duplicate fields cause whole-parse failure after insertion (`language.rs:1307`, `1589`) and cannot create a successful replacement that needs a projection refund. Project description and includes bypass field/value and correctly do not contribute projected slots. Incrementing only after successful grammar completion avoids presenting a partial failed parse as a reconciled witness.
|
||||
|
||||
The value-end helper should check key presence, reserve only a new key, then perform insertion in the same private operation. Real successful source generally supplies distinct Ast offsets; a repeated key is a useful labeled internal replacement control, not a claim about ordinary grammar provenance. The owner helper counts declaration occurrences, including repeated IDs, before the Vec push. Final comparisons against actual lengths are needed because merely carrying counters alongside unchecked public fields would not establish producer coverage.
|
||||
|
||||
F is initialized once for every supplied file. The later BTreeMap-to-Vec transfer (`mixed.rs:410–436`) moves those same logical records. It is not another F, and this policy does not claim to bound transient physical container overlap. Similarly, the resolver reconstructs a distinct ledger from C and existing U+M; it must not seed the combined subtotal and add syntax again. Keep existing file/path/model validation and resolver equality checks, rather than inventing a second accepted witness authority.
|
||||
|
||||
## Finite oracle assessment
|
||||
|
||||
The existing independently authored E and three-file fixtures support the proposed totals:
|
||||
|
||||
| Fixture | C | U | M | Syntax total | Separate resolver peak |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| E | 1 | 1 | 11 = F1 + token ends10 | 13 | 17 |
|
||||
| Three-file chain | 3 | 3 | 19 = F3 + token ends16 | 25 | 29 |
|
||||
|
||||
Use syntax-only 13/12 and25/24 boundaries, then separate full-resolution17/16 and29/28 boundaries. The text-producer route must have a sufficient actual inherited byte cap; E146 remains the known complete text control. Scanner raw token work31 in the chain must not become M16. Exact chain refusal file/span requires the literal traversal fixture, not aggregate arithmetic alone.
|
||||
|
||||
The owner cap4 control is sound as a prefix: C1 + F1 + project U1 + declaration U1 =4 immediately before the first owner M reservation. The first ordinary description-value cap7 is also a prefix: C1 + F1 + U4 + owner1 =7 before its value-end insertion. Neither cap5 nor cap8 proves a complete successful syntax; the following token/projection subtotal is larger. Timing must be shown by a finite observer or directly reviewed reserve-plus-mutate operation, with a roomy positive instance of the same real fixture. A final error alone cannot prove that no map/Vec insertion happened first.
|
||||
|
||||
For the additional nested fixture, author the literal source and expected U, distinct value offsets, owner count, significant tokens and projection subtotal before writing test expectations. Include both a nested Ast Record and the synthetic declaration body so they cannot be conflated; retain node's seven-envelope distinction. Quota underflow/leftover and arithmetic overflow controls are internal fault injections, not complete-source acceptance evidence. Whole OA/SA/project and Usage parity at ample limits provides the positive semantic control; no generic output-derived counting oracle is needed.
|
||||
|
||||
## Simpler alternatives and boundaries
|
||||
|
||||
Keeping the old borrowed-work Vec count and adding a live scratch allowance would touch fewer grammar sites, but introduces a new peak metric and policy, precisely the expansion this slice avoids. A recursion-based counting pass adds another depth-sensitive walk and is worse after the existing stack investigation. The proposed scalar counter is the simplest option that both eliminates that extra counting frontier on the new path and keeps U/M unchanged.
|
||||
|
||||
Quota could technically be omitted after trusting the grammar count, but then a future projector branch could silently escape the prepaid inventory. A single narrow scalar consumption callback is worthwhile as an internal coverage invariant; do not turn it into a generic visitor or a second retained ledger. Ensure value quota is consumed before recursive children and header envelope before header collection; field quota can precede its recursive value construction. Text and quota callbacks may require a carefully scoped common borrower, not a second Budget copy.
|
||||
|
||||
The existing delimiter Vec at `language.rs:1033–1040`, Pratt stacks, traversal structures and declaration open stack remain named excluded scratch. Significant tokens remain covered by their existing token bound; token-end M is reserved before ends construction, not falsely billed as two N-sized containers. Those exclusions are compatible with a retained-inventory prerequisite but forbid all-heap, physical peak or complete before-allocation claims. Measure actual Budget/State/projector frames and retain all existing small-stack controls without changes; a few scalar fields are not evidence of harmless stack cost.
|
||||
|
||||
Recommended next step: implement only the explicit fixture route, common prospective U check, private metadata helpers and grammar counter/prepaid quota with these finite cases. Retain legacy, carried-phase and text-only behavior verbatim. This produces earlier enforcement of the existing entry metric, not accepted output or closure of broader scratch obligations.
|
||||
|
||||
Parent's subsequent selected clarification resolves the diagnostic ambiguity if adopted explicitly: aggregate refusals reuse the lowering-entry-limit message at the actual producer span and latch only in the new mode; checked overflow has a distinct source-producer-entry-size-overflow error. Nested ordinary declarations do not undermine the successful-count argument: `language.rs:1540–1600,1693–1723` keeps declarations as separate flat occurrences with their own fields and seven-slot envelopes. The open stack supplies lexical ownership, not a second declaration or projected synthetic body.
|
||||
@@ -1,59 +0,0 @@
|
||||
# Existing retained U/M producer timing — proposal
|
||||
|
||||
Source `97a8703adc75467a7f8e75f5c6cb00ca542b707c`; 2026-09-25. Original read-only proposal, selected by the sibling entry-producer contract. Source references are repository-relative at the pinned commit; original local worktree links are transcribed as plain paths for relocation. No build or runtime result is inferred. This is a next prerequisite for the existing private output-verification path, not an accepted-output API or all-heap accounting.
|
||||
|
||||
## Selected-domain invariant
|
||||
|
||||
U remains exactly Budget.usage.entries, the existing cumulative syntax-event charge; do not relabel every allocated object or Vec push as a new U event. M remains exactly the existing conservative subtotal: F complete-file slots + significant-token-end count + unique ordinary value_ends entries + lexical-owner entries + ordinary projection slots. Its dropped value-end/token-end portions remain represented through the syntax witness as before.
|
||||
C is the actual retained guard's immutable Slots, never Usage. In a new explicit entry-producing mode, every selected U/M growth must first check checked `C.entries + U + M + delta <= exact Limits.entries`. A refused reservation installs no new U/M total. Existing occurrence and numeric work semantics do not change; text-producing checks continue independently against the same policy.
|
||||
The final U/M inventory must equal the existing inventory for successful input. This strengthens timing, not the metric. Token storage still uses the token bound: M has ONE token-end component, not separate charges for significant tokens and ends. Capacity, container node overhead and native stack do not become entries.
|
||||
|
||||
## Compatibility and concrete owner
|
||||
|
||||
Keep legacy, carried-phase and text-only routes unchanged, including their phase timing/diagnostics. Add a fixture-only route enabling text plus entry producers from the existing opaque CarriedInput; no arbitrary baseline/profile constructor and no numeric-zero bypass. Existing text-only E146/145 and phase-only tests retain their meanings.
|
||||
A small optional Budget entry state can hold only carried-entry baseline and current M; U is read from existing Usage, never duplicated. Derive C directly from the guard at initialization. Initialize M=F before Traversal::new using the existing file check. No full policy copy, owner enum, new heap container or String wrapper is needed. Measure generated frames after implementation rather than spending the repaired stack margin by assumption.
|
||||
Use Budget::entry as the common prospective-U seam: preserve the old syntax-work check/error behavior, then perform the new aggregate check before incrementing Usage or letting the caller create its associated representation. These checks must not reset work, reinterpret references as occurrences, or add U again at final handoff.
|
||||
A per-file scalar pending_projection count belongs in mixed State, where ordinary grammar callbacks already meet the shared Budget. It counts future projection slots without reserving them prematurely. Keeping this scalar distinct from live M avoids charging output that has not been produced yet.
|
||||
|
||||
## Concrete seams and ownership
|
||||
|
||||
| Existing component | Producer seam | Required action / reconciliation |
|
||||
| --- | --- | --- |
|
||||
| U ordinary events | `crates/oa-core/src/language.rs`:1192 syntax_entry; field1321, value1357, project1513, include1613, description1637, declaration1657 | All route through State::entry/Budget::entry before their current grammar event. Preserve exact event counts and caller order. No charge for every open-stack push or every Rust enum. |
|
||||
| U typed events | `crates/oa-core/src/language/analysis_source/declarations.rs`:261,290,476,542,611; `crates/oa-core/src/language/analysis_source/syntax.rs`:61,169,242 | Existing pre-event calls cover declaration/field/type/dimension/override and Pratt expression/operator/argument events. Grouping mutates an existing expression and does not invent an entry. Deferred node construction uses its already reserved event. Keep the repaired iterative driver unchanged. |
|
||||
| F file records | `crates/oa-core/src/language/analysis_source/mixed.rs`:314–329 | Initial complete-file reservation stays before traversal. Map-to-canonical-Vec consumption transfers the same logical file records; do not add another F merely for the new container. |
|
||||
| Ordinary value-end map | language.rs:1373, after successful Ast construction but before map insertion | A State::remember_value_end helper checks whether the offset is new, reserves one M only for a new key, then inserts. Ast already has its U event. Replacement updates do not add a slot. Per-file reserved count must equal actual map.len before its conservative subtotal is retained/dropped. |
|
||||
| Lexical owners | language.rs:1700, before push and ordinary declaration insertion | A State::push_lexical_owner helper reserves one M before its current Vec push. Count occurrences, not unique IDs. Per-file delta must equal Vec.len. Typed roots/project header do not gain fabricated ordinary-owner entries. |
|
||||
| Token-end metadata | language.rs:1032 ends collection; mixed.rs:368+ preprojection boundary | Reserve exactly tokens.len once before ends creation, alongside projection below. No need to charge M during lex::tokens significant-token push: that producer belongs to the existing token-bound-only domain, and ends does not yet exist. |
|
||||
| Ordinary projected slots | language.rs:1042–1168 Positions and per-declaration construction | Pre-reserve pending_projection once before any projected declaration/header/field/value allocation. Preserve seven logical slots per ordinary declaration/body/header envelope, one per field key, one per Ast-projected value; keep the node-header envelope conservative rather than revising it. |
|
||||
|
||||
Direct map/Vec growth should remain inside the same narrow State helper as its reserve; expose no independent unchecked mutable metadata operation for the new path. The shared ordinary Parser invokes these helpers only through its existing optional mixed State. Public grammar need not acquire a new state owner.
|
||||
|
||||
## Avoid the current counting-work Vec on the new path
|
||||
|
||||
The current `crates/oa-core/src/language/analysis_source/mixed.rs`:440+ is a postparse saturating tally with an unmetered borrowed-work Vec. Calling it before a projection reservation does not establish before-growth accounting for that Vec. Do not silently charge its scratch under the retained projection subtotal or call saturation checked arithmetic.
|
||||
Recommended replacement ONLY for the new route: increment checked pending_projection by7 when an ordinary declaration is created, by1 for every successful ordinary field(), and by1 for every successful ordinary value(). Nested objects/arrays are visited naturally by those existing calls; each child value and nested key contributes exactly once. Project/include/description grammar does not call field/value in that path and must not enter this projection counter. Failed whole parses need no successful-witness reconciliation.
|
||||
At the projection boundary, reserve pending_projection + tokens.len into M once. Maintain a scalar projection quota consumed by the actual projected producer:7 per declaration,1 per field,1 per EditValue. Check quota subtraction before the corresponding production and require zero at completion. This compares the independent grammar-derived inventory with actual projection consumption without a second AST walk or an extra collection. Static header roles are included in the existing7 envelope; do not also subtract their individual count.
|
||||
Legacy/carried-phase/text-only retain ordinary_position_slots and their prior behavior. The stronger path's quota hook can be an additional narrow optional callback/scalar in the existing shared projector; do not add a generic visitor framework or clone ASTs. The source review must compare grammar counting order and projection consumption coverage, including synthetic body Record handling.
|
||||
The synthetic per-declaration body Record is already part of7 and is NOT a second value() contribution. Ast::Object below a field is an actual value and consumes1 plus its field entries; Ast::Array consumes1 plus items. Atom/Reference/HistoricalUuid each consume1. This distinction needs explicit tests.
|
||||
|
||||
## Transfer and final checks
|
||||
|
||||
After each file, compare its reserved value-end/owner counts with actual lengths and its consumed projection quota with zero. Compute the expected M increment from those lengths, tokens.len and the reconciled projection count; compare against the producer M delta instead of adding it again. Initial F is checked separately once.
|
||||
At final syntax return assert Budget's M equals ParsedMixedSyntax.metadata and the file inventory's checked total. Keep Usage.entries as the original U. The existing resolver still seeds C once and reserves U+M once in its distinct ledger; this is transfer/reconstruction, not an additional simultaneously retained allocation. Do not seed C+U+M and then call the unchanged additive syntax path again. Text T transfer/reconciliation remains as implemented.
|
||||
No new occurrence storage is allocated in this slice; C.occurrences continues its inherited checks. If a future producer introduces an auxiliary container, it is not covered by these retained counters unless explicitly inventoried.
|
||||
|
||||
## Finite acceptance oracles and limits
|
||||
|
||||
1. Empty E: C.entries1, U1, M11 (F1 + ten token ends), final syntax13; cap13 parses,12 refuses before token-end/projection creation, full resolver peak17 remains distinct. Real inherited text policy must be sufficiently large (producer E146) so text cannot preempt the entry control.
|
||||
2. Existing three-file chain: C.entries3, U3, M19 (F3 + sixteen token ends), final25; syntax25/24 and resolver29/28 remain separate cases. Verify actual file/checkpoint attribution and no double-add at transfer. Scanner raw-token work is31, not M's sixteen significant tokens.
|
||||
3. Minimal ordinary empty activity: before its first lexical-owner push C1+F1+U2=4; entry cap4 can distinguish the new owner reservation refusal. It is a negative timing control, NOT a claim cap5 admits the complete syntax. Use actual guard ownership, generous text/token limits and original declaration span.
|
||||
4. First ordinary `description ""` value: C1+F1+U4+one lexical owner=7 immediately before its new value-end entry; cap7 refuses before map insertion. Again cap8 only fits that prefix, not the later complete metadata. A bounded test observer or separately reviewed reserve-plus-insert helper must establish ordering; a final retained count alone cannot.
|
||||
5. Independently hand-count flat fields, nested record/array/reference/HistoricalUuid and node versus element headers: exact grammar projection counter equals old retained inventory, and actual projection quota ends at0. Invalid under/over quota and repeated map-key controls are labeled internal fault injection, not real source provenance. No AST cloning or output-derived expected model.
|
||||
6. Whole real OA/SA/multi-file outcomes and Usage/numeric parity against text-only at ample limits; explicit C0 new mode; old route cap/error behavior unchanged. Work Usage counts must not absorb C, M or projected quota events.
|
||||
7. Preserve all old 128 KiB/deep/wide controls and measure changed Budget/State/projector frames. No stack/test-size relaxation or RUST_MIN_STACK. Repeated failure must not leak a partial witness or new source authority.
|
||||
|
||||
## Finite domain decision still required
|
||||
|
||||
The projector also uses a delimiter Vec at language.rs:1033–1040; traversal and ordinary open-declaration coordination likewise remain existing scratch, not retained M. The proposed contract can honestly cover U/M while leaving those named scratch producers open. It cannot claim the single N token-end reservation accounts two simultaneous N-sized containers. If the parent requires these scratch entries now, choose an explicit additional live W, reserve before each push/release on pop, and derive new peak oracles; that is a separate finite metric addition.
|
||||
Comparison: keep the old count walk and add bounded W for its work frontier (smaller initial grammar edits, but expands scratch policy and peak caps), versus grammar scalar + producer quota (more named seams, no new frontier/domain). Recommend the latter for this narrowly retained-inventory prerequisite. Broader scratch, final actual-output equality and accepted-owner transition remain out of scope. No additional task is proposed or started here.
|
||||
@@ -1,151 +0,0 @@
|
||||
# Private actual-output rename acceptance
|
||||
|
||||
Baseline `29417130c71f70c4eaefe8ed4c879efba78eb72c`. Select the complete pending
|
||||
owner borrow, actual-output equality bound and moved model/warning outcome after
|
||||
independent design, domain and ownership critiques. This connects the verified
|
||||
emitter and named parser producers; it is no longer another fixture-only
|
||||
continuation. The surrounding mixed implementation remains private experimental
|
||||
source. Public source8, installation/history and release integration are separate.
|
||||
|
||||
## Authority and result
|
||||
|
||||
Add a consuming verification operation on opaque PendingRename. Its only normal
|
||||
constructor remains validated consumed emission. No production constructor may
|
||||
accept an arbitrary bundle/model/Slots/profile tuple. Add CarriedInput::from_pending
|
||||
accepting a shared reference to that complete pending owner; derive bundle,
|
||||
exact Limits/ProjectLimits and retained O from the same owner. Both backing and
|
||||
guard retain a complete pending reference, not just numeric counters. Keep enum
|
||||
variants private and expose only narrow immutable pending views to carried code.
|
||||
|
||||
Prefer `&'s PendingRename<'s>` inside the existing single-lifetime backing, using
|
||||
the immutable desired reference's covariance to shorten a longer desired borrow
|
||||
to the verification borrow. Actual Rust compilation must establish that relation.
|
||||
Do not thread a second lifetime through every parser owner, duplicate full policy
|
||||
inside guards, introduce a heap wrapper or permit mutation while the guard lives.
|
||||
The existing fixture constructors and old parser entry points keep their behavior.
|
||||
The new continuation is the sole emitter-connected entry into the combined text
|
||||
and retained-entry producer route, including actual zero metadata if applicable.
|
||||
|
||||
On success return a non-self-referential owning AcceptedRename with the actual
|
||||
SourceBundle, actual resolved Project, mapped warnings, exact inherited policies
|
||||
and final retained Slots. Preserve scalar verification Usage and numeric work
|
||||
separately from original-snapshot work; no cumulative reinterpretation. The
|
||||
accepted type has private fields and read-only accessors, with no unchecked or
|
||||
mutable constructor, borrowed syntax or borrowed desired model. Source, desired
|
||||
and pending may be dropped before the accepted result is inspected.
|
||||
|
||||
This result does not itself save files, edit history, install a model or enable
|
||||
repeated carried-source emission. Later adoption must jointly preserve its
|
||||
graph/source/diagnostic relationship; no separate mutable extraction is added.
|
||||
No-op remains the existing explicit unchanged-owner branch.
|
||||
|
||||
## Actual verification and equality
|
||||
|
||||
Parse the actual complete pending bundle under its exact policy using both named
|
||||
producer modes. Propagate its located parse diagnostics. Consume the resulting
|
||||
syntax through the existing resolver and complete model admission, preserving O
|
||||
through all parser/resolver stages. Never default policy, substitute intended
|
||||
tokens/model, drop untouched files, re-emit bytes for comparison or reset carry.
|
||||
|
||||
After actual model admission reserve `E_actual = resolved.expressions.len()`
|
||||
entry slots against that resolved owner's full live ledger, which includes O,
|
||||
syntax and mapped warnings. Compare the entire actual Project with desired using
|
||||
its established PartialEq, including aliases, parameter names, all maps and
|
||||
historical payloads. Do not use rename::same_project, which exempts names. Release
|
||||
the equality frontier after comparison. A mismatch returns a small static
|
||||
invalid_source diagnostic at entry/default span0..0, not a formatted model.
|
||||
|
||||
The independently reviewed bound follows every reachable actual Expr root:
|
||||
calculation body, property base, case overrides and check predicate. Each actual
|
||||
node has a source expression-site entry. Iterative Expr equality's live pairs
|
||||
correspond to distinct nodes of its actual left operand; list/call shape checks
|
||||
precede child queueing, and no nested equality frontier remains live. Whole-model
|
||||
comparisons visit roots sequentially. E_actual0 cannot invoke an allocating Expr
|
||||
comparison from a nonexistent actual root. Existing signed-zero PartialEq remains
|
||||
semantic equality, not bitwise equality. Revisit the proof when payload types or
|
||||
lowering/equality implementations change. Original pending E is provenance only;
|
||||
the new implementation must use the current resolved arena for its reservation.
|
||||
|
||||
## Mapped warnings and ownership transfer
|
||||
|
||||
Retain the mapped warnings by moving them, together with the actual resolved
|
||||
Project, after successful equality. The model remains in its existing excluded
|
||||
model-allocation domain and has already passed admission. Mapped warnings remain
|
||||
in the selected auxiliary domain. Do not clone a model, warning, path or vector.
|
||||
|
||||
Compute warning inventory W with a checked borrowed loop over the actual records:
|
||||
one entry per warning, zero occurrences, and UTF-8 lengths of path, code, optional
|
||||
field and message. Require current mapper invariants: every related vector empty
|
||||
and no Error severity. Unexpected shapes fail before producing accepted authority;
|
||||
do not silently ignore newly introduced fields/related locations. The diagnostic
|
||||
field accounting should be exhaustive enough that schema changes demand review.
|
||||
|
||||
W already participates in the full resolver ledger. Do not charge it twice during
|
||||
equality or transfer. Compute final O+W with checked arithmetic, verify it is a
|
||||
componentwise subset of the still-live ledger, and check its exact inherited
|
||||
ceilings while Budget exists. This is a destination-inventory check, not an early
|
||||
release: keep full coexistence accounting until all temporary owners actually
|
||||
drop. Move Project/warnings to locals, drop all verification syntax/guard,
|
||||
expression/occurrence/catalog owners, then move the pending bundle/policy into
|
||||
AcceptedRename. Rust must enforce that no pending borrow survives that move.
|
||||
Final retained counts exclude released syntax and equality scratch but include
|
||||
all surviving mapped warnings. Failure returns only diagnostics and destroys
|
||||
the consumed pending output and partial verification state.
|
||||
|
||||
## Explicit remaining scratch decision
|
||||
|
||||
Choose the existing logical retained domain plus the named decode/equality
|
||||
temporaries; do not expand it into universal parser/all-heap accounting. Existing
|
||||
traversal, delimiter, ordinary-open, Pratt and duplicate-name scratch retain their
|
||||
independent file/edge/token/grammar controls and are excluded from carried W.
|
||||
Significant-token storage retains its independent token ceiling; M counts the
|
||||
separate token-end representation once. No new output-verification collection
|
||||
may be hidden under an old exclusion.
|
||||
|
||||
File count/source-text limits do not tightly bound copied traversal path bytes.
|
||||
Path folding/canonicalization and include target/location copies can occur before
|
||||
edge refusal; retain that known limitation explicitly. Open-declaration depth is
|
||||
bounded by declaration events, not typed-value depth. Pratt frames are not native
|
||||
stack bytes. Decoder/numeric internals, raw model/validator allocation, allocator
|
||||
capacity/overhead and native stack remain their established separate domains.
|
||||
Do not claim aggregate admission before every allocation or process-memory bounds.
|
||||
This resolves the earlier scratch-domain choice without waiving selected retained
|
||||
producer timing, actual output admission, equality or returned ownership.
|
||||
|
||||
## Finite acceptance
|
||||
|
||||
Use unchanged inherited profiles from original parse/index/plan through emission
|
||||
and verification. Never tighten an admitted owner to manufacture a late failure.
|
||||
Minimal project p→next emits81 bytes, O=(1,0,105), T11. Entries17 and bytes152
|
||||
admit the full pipeline; bytes151 reaches output UUID decode at37..38, line2
|
||||
column27, after scanner peak141 and emission peak114. Entry16 fails emission
|
||||
first and is not a later verifier cap. Assert the entire independent expected
|
||||
Project/bundle, empty warnings, final O, exact policy and separate snapshot Usage.
|
||||
|
||||
Retain a real warning-bearing rename with an independently authored full
|
||||
diagnostic and exact final O+W. Distinguish final retention counts from greater
|
||||
earlier peaks; do not invent a final-only cap-minus-one boundary. Reuse emitter
|
||||
OA/SA/cross-file/Unicode/CRLF/swap byte oracles and whole expected models where
|
||||
valid, checking returned warning paths/spans against the actual returned bytes.
|
||||
The [empty-name warning oracle](analysis-mixed-acceptance-design-20260925/WARNING-ORACLE.md)
|
||||
uses unchanged entries18 and bytes161/160: old emission peaks159 bytes, actual
|
||||
output warning mapping161, and accepted O+W=(2 entries,0 occurrences,157 text).
|
||||
Its negative warning-map diagnostic retains the full project-header11..69 span,
|
||||
unlike the parser's one-byte token-start transport. These are expectations to
|
||||
verify, not results inferred from source arithmetic.
|
||||
|
||||
Equal-byte test-only corruption can cover wrong-but-admissible alias/name,
|
||||
malformed source and model-invalid output while preserving O. A length-changing
|
||||
corruption must first be rejected by an explicitly selected inventory check or
|
||||
use independently verified actual-O fixture provenance, never stale counts.
|
||||
Include greater actual expression count than old E, wide/deep comparison,
|
||||
target-only mismatch and signed-zero cases. Label internal inventory/shape faults
|
||||
as such; do not relabel them as authentic emitted histories. Check true owning
|
||||
lifetime by returning success out of a scope that drops original input/desired.
|
||||
|
||||
Preserve all previous tests, failure bodies and format disposition; run focused
|
||||
new cases, full core library, strict all-target Clippy and format comparison.
|
||||
Exercise meaningful scoped128KiB verify/drop cases with prerequisites prepared
|
||||
outside the worker, preserve all old stack controls and inspect changed
|
||||
prologues. Do not enlarge stacks, loosen policies or rerun failures until lucky.
|
||||
Older separately unrerun full-pipeline setup limitations remain distinct.
|
||||
@@ -1,136 +0,0 @@
|
||||
# Independent actual-output acceptance oracle — proposal only
|
||||
|
||||
Source inspected: `a629ae01d49bb3cb482a047eb41832ad4246a5c6`. Derived from literal
|
||||
bytes and existing source, not parser/emitter execution. Only string lengths and
|
||||
literal substring offsets were evaluated. Future verification API/domain/equality
|
||||
contract is not yet selected; all runtime acceptance remains pending.
|
||||
|
||||
## Minimal real rename, one unchanged inherited policy
|
||||
|
||||
Entry/key `main.arcadia` has 12 ASCII bytes. Original exact source (final LF):
|
||||
|
||||
```text
|
||||
arcadia 8;
|
||||
project p "Project" id "00000000-0000-0000-0000-000000000001" {
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
Desired differs only in project alias `p` -> `next`. Expected complete emitted
|
||||
bundle is exactly one file under the same entry/key, containing (final LF):
|
||||
|
||||
```text
|
||||
arcadia 8;
|
||||
project next "Project" id "00000000-0000-0000-0000-000000000001" {
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
Original source is 78 bytes; output is 81. The sole edit is original [19,20),
|
||||
replacement `next` (four bytes); output alias is [19,23), display text [25,32),
|
||||
UUID quoted token [37,75). Output retained O=(1 entry, 0 occurrences, 105 text),
|
||||
where 105=81 source+12 map key+12 entry bytes. No output key/path is omitted.
|
||||
|
||||
Complete expected Project: schema_version=8; id=00000000-0000-0000-0000-000000000001;
|
||||
alias="next"; name="Project"; description=""; definitions, elements, relations,
|
||||
diagrams, artifact_links and traces are all empty maps. Expected warnings empty.
|
||||
This asserts every model field, not just the changed alias. E_original=E_actual=0.
|
||||
|
||||
Select original Limits entries=17, bytes=152 (or 151 negative), all other fields
|
||||
unchanged from an admissible roomy source8 profile; keep the same ProjectLimits
|
||||
through original parse/index/plan/emission/verification. Never tighten live policy.
|
||||
|
||||
| Stage | Logical entries | Logical text | Derivation |
|
||||
| --- | ---: | ---: | --- |
|
||||
| Original syntax retained |12|8|U1 + M11; alias1+name7 |
|
||||
| Original resolver peak |16|10|Project catalog +4 entries, two alias copies |
|
||||
| Original typed index retained |13|9|One global name; no typed headers/expressions |
|
||||
| Original ordinary index retained |14|9|One authored project header |
|
||||
| Rename plan peak/retained |16|9|R1 name target + namespace Q1 (then dropped), M1 edit |
|
||||
| Emission overlap |17|114|Plan16/9 + output O1/105 |
|
||||
| Output strong syntax retained |13|116|C1 + U1 + M11; C105 + T11 |
|
||||
| Output resolver peak |17|124|Syntax13 + project catalog4; two four-byte alias copies |
|
||||
| Output resolved retained/equality |13|116|Catalog dropped; E_actual0 equality scratch |
|
||||
| Owning accepted bundle after verification owners drop |1|105|Only actual output O remains |
|
||||
|
||||
Original indexing/transient peaks do not exceed the original resolver/plan/emission
|
||||
entry ceiling17 for this empty model; prior emitted-overlap source tests explicitly
|
||||
use entries17 and bytes114 through the real predecessor path. The new proposed
|
||||
152/151 byte profiles are looser than that already authored predecessor boundary.
|
||||
No new verification allocation occurs while the old authored owner remains live.
|
||||
|
||||
## Exact new byte boundary and timing
|
||||
|
||||
Scanner validation of the output UUID reserves raw payload36 before decoding.
|
||||
No grammar text exists during that file scan: C105+W36=141 (display payload7 is
|
||||
smaller). Grammar first retains alias4 and display name7, hence T11. Its UUID
|
||||
consumer reserves P36 while T11 remains live: 105+11+36=152, before the existing
|
||||
serde decode. Valid decode D36 transfers then UUID parsing drops/refunds it;
|
||||
final T11 and W0. Project has no projected ordinary values or textual keys.
|
||||
|
||||
Therefore 152 is the full-pipeline positive byte cap and 151 isolates the new
|
||||
actual-output grammar producer refusal, after predecessors/emission and scanner
|
||||
have fit. Expected source path main.arcadia, code invalid_source, message
|
||||
`Source8 index/output byte limit exceeded.`, ordinary transported span [37,38),
|
||||
line2 column27, no related locations. Raw quoted extent is [37,75), but the
|
||||
ordinary error transport intentionally retains its one-byte start representation.
|
||||
Do not expect this refusal at the former unverified-emission114 boundary.
|
||||
|
||||
Entry cap17 permits the whole pipeline, but cap16 refuses **emission first**:
|
||||
plan16 plus O1 cannot fit. Thus it cannot demonstrate a later verification/resolver
|
||||
entry refusal. Equality reserves zero here; no equality-specific minus-one case
|
||||
exists. A different justified fixture is needed for any proposed late entry gate.
|
||||
|
||||
## Equal-byte corruption controls (not genuine emitted results)
|
||||
|
||||
Start each control from a fresh legitimately emitted pending owner under roomy
|
||||
unchanged inherited limits. Mutate only in the private fixture harness. Equal UTF-8
|
||||
length preserves O numerically; production must not expose mutation/constructors.
|
||||
|
||||
- Output alias [19,23): `next` -> `nope`. Valid alias and otherwise empty model;
|
||||
parse/admission should pass, complete Project equality must reject.
|
||||
- Output name [25,32): `Project` -> `Changed`. Valid decoded display name, same
|
||||
seven bytes; parse/admission should pass, non-name equality must reject.
|
||||
- Version byte [8,9): `8` -> `?`. Equal bytes, malformed source; scanner/parser
|
||||
must reject before model equality. Do not claim a successful wrong-model case.
|
||||
|
||||
Each failure must drop pending verification state, return no accepted owner, and
|
||||
leave original source and desired unchanged. Do not recursively format models.
|
||||
The new verifier's semantic-mismatch message is deliberately unspecified here.
|
||||
|
||||
## Actual expression count larger than original, unchanged O
|
||||
|
||||
Use the same project wrapper with this exact single body line:
|
||||
|
||||
```text
|
||||
calculation c "C" id "00000000-0000-0000-0000-000000000002" { result Number; body 10000; }
|
||||
```
|
||||
|
||||
Original source168 bytes; a genuine project p->next rename emits171 bytes, O=(1,0,195).
|
||||
Desired includes the unchanged Number calculation c, same ID/name, no parameters,
|
||||
and literal10000 expression. Original arena E=1. On a fresh pending corruption
|
||||
fixture replace output expression [160,165) `10000` with `1 + 2` (five bytes).
|
||||
The bytes still describe a Number calculation, now binary Add with two numeric
|
||||
children: actual raw-expression arena E_actual=3. Bundle lengths/keys/entry/O
|
||||
remain unchanged; complete equality must reject its different body. Use roomy
|
||||
entries/bytes4096 from the original parse, not a policy replacement after emission.
|
||||
|
||||
The equality reservation must derive 3 from the freshly resolved actual output,
|
||||
not trust pending's old scalar1. This fixture proves provenance/count selection;
|
||||
its unequal root variants may short-circuit comparison and do not prove a frontier
|
||||
actually exceeding1. Lowering already reserves five entries per actual node,
|
||||
while equality requests only E_actual. No isolated equality-cap-minus-one refusal
|
||||
is claimed; the earlier lowering peak may dominate. A meaningful additional
|
||||
wide/deep positive and target-shape mismatch must be justified independently.
|
||||
|
||||
## Source basis and limits
|
||||
|
||||
At the pinned source, `rename/emission.rs:6` defines the sealed pending fields;
|
||||
its output_slots counts all source/key/entry bytes, and emit_changed_unverified
|
||||
reserves old plan+O before copies and drops old source before constructing pending.
|
||||
`rename/emission/tests.rs` already pins literal NEXT and predecessor17/114.
|
||||
`mixed/project.rs:245`/`:315` reserve the project alias/owner/table catalog; catalog
|
||||
storage releases before returning resolved state. `mixed/project/accounting.rs`
|
||||
seeds syntax and lower scratch independently; LowerStorage::all uses5N+2A.
|
||||
`budget.rs::quoted_text` and raw_tokens StringEvent implement the P/D timing above.
|
||||
These are logical storage derivations, not allocator/RSS or runtime test claims.
|
||||
@@ -1,85 +0,0 @@
|
||||
# Actual emitted-output acceptance — working proposal
|
||||
|
||||
2026-09-25, parent read-only design based on source97a8703 and the entry-producer
|
||||
contract; entry implementation has not yet passed gates. This is neither selected
|
||||
contract nor permission to activate public source8. No next implementation claim.
|
||||
|
||||
The useful next outcome is a single owning accepted output after parsing the
|
||||
actual complete emitted bytes with inherited policy and carried output storage,
|
||||
resolving/admitting them and comparing the entire Project to desired intent.
|
||||
Do not insert another unneeded fixture-only milestone after the retained producer
|
||||
requirements pass. Remaining scratch-domain precision still needs explicit
|
||||
decision and independent critique; see DOMAIN-DECISION-CRITIQUE.md when ready.
|
||||
|
||||
## Narrow authority/lifetime candidate
|
||||
|
||||
Current private emission::PendingRename contains actual SourceBundle, exact
|
||||
Limits/ProjectLimits, reserved output Slots, original E and immutable desired
|
||||
Project borrow. Its only non-test constructor is consumed emission. Existing
|
||||
carried::CarriedInput currently accepts borrowed or fixture RetainedSnapshot.
|
||||
|
||||
Candidate A: add a named CarriedInput::from_pending(&PendingRename), with access
|
||||
only through read-only sealed pending views. It must derive all bundle, exact
|
||||
policy and retained slots from that same pending owner, accepting no loose
|
||||
bundle/profile/Slots. Backing can store the existing bundle/policy plus a shared
|
||||
reference to the pending retained Slots; guard needs only that reference after
|
||||
policy transfers to Budget. The function's borrow lifetime keeps the entire
|
||||
pending owner unavailable for mutation or consumption during verification. This
|
||||
avoids introducing desired's second lifetime into every parser owner. Prove this
|
||||
with actual Rust type checks; do not assume a reference to counters alone is
|
||||
sufficient authority if construction can be separated from the owner.
|
||||
|
||||
Candidate B: move owned snapshot storage into a shared RetainedSnapshot wrapper
|
||||
used by PendingRename, then reference it directly. This may simplify backing but
|
||||
must avoid an unrestricted new(bundle,slots,policy) constructor or unnecessarily
|
||||
coupling parser construction to emitter internals. Compare actual code size and
|
||||
representation before choosing. No generic trait/callback allocator framework.
|
||||
|
||||
A consuming PendingRename::verify method borrows itself for the strong combined
|
||||
text/entry parse; every result remains local until success. Consume syntax through
|
||||
project::resolve under the same carried guard, so complete model admission is
|
||||
the existing one. After equality and bounded validation, drop resolved syntax,
|
||||
model/catalogs and guards explicitly, then move SourceBundle into a narrow
|
||||
owning accepted result. Failure returns located diagnostics and drops pending
|
||||
output; no usable partial bundle or accepted token escapes. An owned accepted
|
||||
bundle is not a self-referential parsed witness. Repeated retained-source editing
|
||||
and actual filesystem/history installation remain distinct work.
|
||||
|
||||
## Whole equality and scratch
|
||||
|
||||
Reuse complete Project PartialEq, including established signed-zero semantics;
|
||||
do not reuse rename::same_project, which intentionally exempts names. The
|
||||
comparator's left operand must be the freshly resolved actual output model.
|
||||
Use that resolver's actual raw-expression-site count E_actual as the conservative
|
||||
equality frontier bound, reserving it against its live ledger before comparison
|
||||
and releasing after equality returns. Original E remains provenance only and
|
||||
cannot bound faulty emitted output. Existing planner proof covers Expr's iterative
|
||||
frontier only when every actual left-model Expr root is represented in the source
|
||||
expression arena; re-audit this reachability for full Project equality (including
|
||||
opaque historical carriers) before selecting E_actual. Original scalar count is
|
||||
not proof of actual output topology. No additional counting traversal or cloning
|
||||
desired model should be introduced without its own bound.
|
||||
|
||||
Reject malformed bytes, model-invalid bytes, admissible-but-wrong aliases and
|
||||
admissible-but-wrong non-name semantics. Error reporting must not recursively
|
||||
format arbitrary models. Exact input/output policies survive; new parser Usage
|
||||
is separate snapshot work. Output C remains live through syntax, resolver and
|
||||
equality and must never be reset to zero or added twice.
|
||||
|
||||
## Required finite acceptance before implementation
|
||||
|
||||
Reuse published emitter exact OA/SA/all-file/Unicode/CRLF maps as independent
|
||||
byte expectations. Construct at least one actual emitted owner whose inherited
|
||||
profile admits original parse/index/plan/emission and then passes verification,
|
||||
plus cap-minus-one refusal at the intended later stage; do not tighten a live
|
||||
owner after admission. Fake/mutated pending bytes are labeled corruption controls.
|
||||
Original empty/project rename may have a new combined producer peak greater
|
||||
than the emitter's former114-byte oracle: recompute the whole pipeline honestly.
|
||||
|
||||
Test wrong-but-admissible output, malformed output, exact policies, complete
|
||||
source/target immutability and scope/lifetime of returned ownership. Wide/deep
|
||||
actual output versus target-only shape mismatch must establish equality's chosen
|
||||
frontier bound. New tests do not replace existing public/legacy/fmt dispositions,
|
||||
stack controls or the separately unrerun older full-pipeline setup limitation.
|
||||
Full parsed-output acceptance remains private until a separate public integration
|
||||
contract reconciles all16 schema8 failures and complete graphical/source behavior.
|
||||
@@ -1,29 +0,0 @@
|
||||
# Independent actual-output acceptance critique
|
||||
|
||||
2026-09-25. Design-only readback of ACCEPTANCE-PROPOSAL.md SHA256 `b59ca190e3cde85611158542ccf86e67c210b853b09a1fd0354daf57d4304509`, the ownership source map and domain critique, against local source `29417130c71f70c4eaefe8ed4c879efba78eb72c`. Entry runtime acceptance remains separate. No source edits, compiler, parser or network execution.
|
||||
|
||||
The proposed actual-byte parse/resolve/full-equality boundary is the useful next outcome. No further fixture-only intermediary is intrinsically required once the selected prerequisites pass. Prefer Candidate A as refined by OWNERSHIP-SOURCE-MAP: retain a complete immutable PendingRename reference, with lifetime shortening through its covariant desired Project borrow. This is simpler and stronger to inspect than projecting authority down to a bare Slots reference, and avoids Candidate B's unnecessary rearrangement of existing owning fields. Actual type checks must establish the proposed coercion and borrow scope; this review does not claim compilation.
|
||||
|
||||
## Ownership and accounting decisions to retain
|
||||
|
||||
Pending's only production constructor is consumed emission (`mixed/project/ordinary_index/rename/emission.rs:6` and its concluding construction). Its bundle, policy and retained Slots are private; emission reserves complete O before copies and drops the old indexed owner before Pending escapes. A from_pending constructor must accept the complete pending reference only and derive all views from that object. Keep guard variants private, add no arbitrary production tuple constructor, and do not copy the full policy into the guard. Existing output keys, entry and all files remain C through the new parse and resolver.
|
||||
|
||||
Put verification in a consuming Pending method. A local scope can borrow pending, use the strong text+entry route, consume syntax through existing project::resolve, reserve equality scratch on that resolved owner's live ledger, compare, then drop the resolved owner and all borrowing guards. Only afterward move the actual SourceBundle into a non-self-referential accepted owner. Returning borrowed syntax alongside it, resetting C to zero, defaulting policies or exposing a mutable Pending bundle would defeat this seam. None is needed. No-op remains the separately consuming original-owner branch.
|
||||
|
||||
Choose the accepted result's retained contents explicitly. If only bundle/policy/accepted authority survives, drop resolver warnings with verification storage. If warnings are retained or returned alongside it, their mapped strings/path copies and destination slots survive too and need an explicit transfer; final live storage is no longer O alone. `mixed/project.rs` currently reserves every mapped warning before pushing it (around lines 588–643). Do not silently clone warnings or equate excluded raw validator Issues with those counted mapped diagnostics. A small entry/default-span mismatch diagnostic is sufficient; recursive formatting of whole models is unnecessary.
|
||||
|
||||
## Actual-expression equality bound
|
||||
|
||||
Use `resolved_actual.project().eq(desired)` under reserved `E_actual = resolved_actual.expressions.len()`, not the old pending scalar or rename::same_project. All current Project-to-Expr roots remain CalculationDefinition.body (`analysis/types.rs:85–94`), EngineeringProperty.base, AnalysisCase.overrides and AnalysisCheck.predicate (`model.rs:667–682`). Historical Trace/SemanticBaseline/FacetReview payloads are closed and contain ordinary strings/IDs/LifelineBinding rather than analysis Expr (`semantic/types.rs:84–174`, `model.rs:526,1632–1650`). The model/type/equality/lowering files are unchanged from the previously reviewed `18073a6` proof.
|
||||
|
||||
`lower.rs:687–699,755–846` routes each real binding through expression lowering, emits one ExpressionSite per visited node, and schedules all child variants; mixed resolution retains this arena while merging model definitions/elements. `analysis/expression_lifecycle.rs:170–270` keeps one iterative pair frontier, checks list/call shape before child insertion and never invokes another Expr equality with its frontier live. Pair members correspond to distinct nodes of the actual left tree, so every simultaneously live frontier is bounded by its actual tree size, hence E_actual. Sequential whole-Project field/container comparisons do not require summing all separate equality Vecs. E_actual may conservatively reject a comparison that would short-circuit; do not promise unconditional success with no phase headroom.
|
||||
|
||||
Make E_actual=0 explicit: derived Binding/parent variant checks reject target-only Expression before entering Expr::eq; no allocating equality call has a zero-node actual Expr. Keep whole-model PartialEq, including +0/-0 equivalence across geometry, ordinary and historical real carriers. Equality establishes semantic PartialEq, not bitwise target identity. Renew this reachability proof if Project payload types or expression production change.
|
||||
|
||||
## Corruption controls and finite gates
|
||||
|
||||
Corrupted Pending tests must preserve their claimed live-storage provenance. Equal-byte replacements can change valid semantics, syntax or expression topology while retaining the authenticated O. Length-changing test mutations must either be rejected by an explicitly selected inventory invariant or construct an independently verified actual-O fixture under the inherited policy; keeping stale O after growing the bundle is not evidence of correct carried accounting. Especially test faulty actual output with more expression nodes than original E to demonstrate why E_actual is used.
|
||||
|
||||
Use one unchanged inherited profile for original parse/index/plan/emission and verification. Recompute the combined original and new-snapshot peaks; a syntax-only cap need not admit the independent counting walk, resolver catalogs or final equality. Require a real full-pipeline positive and a cap-minus-one that genuinely reaches the intended late stage, plus wrong-but-admissible names and non-name data, malformed/model-invalid output, E=0 target-only damage, actual wide/deep equality and signed zero. Drop original inputs and desired after success and inspect the independently owning returned bytes. Failed verification must yield only diagnostics and drop Pending, without exposing a usable partial bundle.
|
||||
|
||||
The domain critique's explicit independent bounds for named remaining scratch are compatible with this outcome; they must be selected and documented, not described as aggregate carried reservations. Existing bounds/exclusions do not exempt new output ownership, equality frontier or retained mapped diagnostics. Native verifier/drop frames require concrete measurements and existing stack controls; earlier separately unrerun setup failures are not converted to success by this design. Public source8, repeated retained editing and filesystem/history adoption remain separate contracts.
|
||||
@@ -1,55 +0,0 @@
|
||||
# Accepted owner contents: independent critique
|
||||
|
||||
Read-only inspection of the current resolver/warning mapper and owned diagnostic schemas, with unchanged relevant behavior from97a8703 through2941713. No source modification, build or runtime claim. This recommends a contract choice; parent owns selection.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Prefer an opaque owning result containing the verified SourceBundle, the **actual freshly resolved Project**, mapped warnings, exact inherited policy and its final retained Slots. This avoids throwing away already admitted graph data and actionable diagnostics immediately before a later coherent source/graph adoption step. It remains a narrow non-self-referential value: Project and SourceDiagnostic own their contents and do not borrow syntax. No history/install operation, public source8 activation or repeated-edit constructor follows automatically.
|
||||
|
||||
Bundle/policy only is a valid smaller proof artifact, but requires a later parse/admit step to recover the graph and warnings for adoption. It is preferable only if the deliberate next consumer needs exclusively bytes and no diagnostics/model. That does not appear to match the currently described editing goal. Moving existing values after verification is smaller than introducing another cache, clone or deferred parse protocol.
|
||||
|
||||
The accepted model must be the resolver's actual output model, not a clone/move of desired intent merely because equality passed. Whole equality is the gate, not a replacement constructor. Existing model/serde/raw-validator heap exclusions permit retaining that already admitted Project without adding its internal maps/strings to logical auxiliary W; call this an explicit inherited domain, not a claim that the Project occupies no memory.
|
||||
|
||||
## Exact retained-warning inventory
|
||||
|
||||
`mixed/project.rs:594–649` reserves each mapped diagnostic before its path copy/destination push. The reservation is one entry, zero occurrences, and byte lengths of path + code + optional field + message. Severity/object UUID/span/line/column are scalar; no copied text is hidden there. `source_project.rs:80–84` defines `SourceDiagnostic { path, diagnostic, related }`; mapper always creates empty related. The return at project.rs:651 rejects any Error-severity issue before returning a resolved success.
|
||||
|
||||
For the current successful producer, compute without allocations:
|
||||
|
||||
```
|
||||
W.entries = warnings.len()
|
||||
W.occurrences = 0
|
||||
W.text = checked sum over warnings of
|
||||
path.len + diagnostic.code.len + optional field.len + message.len
|
||||
```
|
||||
|
||||
Use the same Slots checked arithmetic as the mapper, not saturating sums or a numeric total copied from a previous phase. Require every related vector empty and every severity non-Error under this sealed extraction route. An unexpected shape is an invariant refusal before returning the accepted owner, never silently ignored. If later mapper changes introduce related locations, update the named inventory (entries and path bytes) and tests together; this choice does not authorize flattening/dropping them.
|
||||
|
||||
A checked borrowed loop over current warnings allocates no extra frontier or String. Do not construct a secondary warnings Vec, clone diagnostics, format the Project, or rerun validation to count them. Current storage ledger already contains these warning reservations while syntax and equality are live; **do not reserve W a second time there**. Reconstruction for final transfer is distinct from additive growth.
|
||||
|
||||
## Safe transition and accounting
|
||||
|
||||
Recommended logical sequence:
|
||||
|
||||
1. Resolve the actual complete output with carried O under exact inherited policy; keep resolver and warnings live.
|
||||
2. Reserve the selected actual-model equality scratch against the full live ledger, compare the entire actual Project with desired, and release E. Retaining warnings means they must already participate in that equality-time aggregate, as they currently do in resolver storage.
|
||||
3. Compute W from the current warning records and checked final `O + W`. Validate that final total under the resolver's original Budget while it still exists. It should be a subset of the successfully held full storage; a componentwise subset check is a useful internal invariant, not a substitute for correct ownership inventory. If checking fails, return diagnostics with the existing failure escape and drop output, never a usable partial owner.
|
||||
4. Destructure/move actual Project and warnings into local owners, explicitly drop all remaining syntax/index/expression/occurrence/catalog/guard state, then move pending SourceBundle/policy into the accepted owner. Rust must establish that the guard borrowing pending is gone before its bundle moves. No copied warning path/model is necessary and no self-reference remains.
|
||||
|
||||
Final retained entries are O.entries + warning count; text is O.text + W.text; occurrences are O.occurrences (normally zero for the emitted bundle) plus zero. Do not carry released syntax U/M/T, equality E or resolver catalogs as live accepted storage. Conversely, do not return O alone when warnings remain owned. Model and original desired borrow do not become new logical slots merely by this ownership transition.
|
||||
|
||||
An independently computed final subset check can happen before physically dropping other objects because it is checking the eventual destination inventory, not claiming those drops already happened. The full resolver ledger must continue to cover coexistence until actual drops. An implementation that resets that live ledger to O+W early and subsequently allocates against its artificial headroom would be incorrect.
|
||||
|
||||
All accepted-result fields should remain private with read-only accessors. No public unchecked bundle+Project+warnings+Slots constructor, mutable bundle/model access, or from-desired shortcut. Any later consuming adoption method needs its own invariant about jointly installing graph/source/warnings. Read-only accepted ownership itself does not authorize saving/history mutation.
|
||||
|
||||
## Finite evaluators and caveats
|
||||
|
||||
Use a real emitted output that resolves with at least one stable mapped warning. Independently derive warning entry/text bytes from its literal expected path/code/field/message; assert warning content/location unchanged, actual Project equality and exact final O+W. A warning-free fixture alone cannot detect the dropped-W bug. Include multiple warnings or an optional field only if needed to distinguish summation from one-record handling; do not expand into a full validator redesign.
|
||||
|
||||
Keep the equality-time warning reservation and final retention tests separate from output verification caps: final O+W is usually below the already admitted resolver/equality peak, so a purported final-only cap-minus-one failure may be impossible through a real earlier-admitted owner. Use a checked-inventory fault control for impossible overflow/related-shape defects and label it accordingly; retain a real profile unchanged through actual pipeline positives/refusals. Do not lower the policy after admission to fabricate provenance.
|
||||
|
||||
Warnings should stay actionable after syntax drops: source path, span, Unicode line/column and optional field must still refer to the verified returned bytes. Moving them preserves the mapper's positions; rewriting output after extraction would invalidate that relation. An independently matching desired Project alone cannot establish diagnostic-to-byte identity.
|
||||
|
||||
Optionally retain already available scalar new-snapshot Usage/static admission metadata if a concrete caller needs it, but it is not necessary to retain expression arenas or whole syntax just to avoid losing those scalars. Original work and verification work must remain separately identified. No new arbitrary constructor is justified by exposing counters.
|
||||
|
||||
I see no correctness blocker in moving actual Project plus warnings with explicit O+W transfer. The material obligations are exact warning inventory, no duplicate charge, full-ledger coexistence through equality/drop, and sealed immutable construction. This recommendation leaves actual equality-frontier proof and output-admission behavior to their separate accepted contract/review.
|
||||
@@ -1,48 +0,0 @@
|
||||
# Remaining parser scratch: bounded independent critique
|
||||
|
||||
Read-only source basis97a8703adc75467a7f8e75f5c6cb00ca542b707c. Entry-producer work is pending and is not assumed accepted or executed here. This is a recommendation for the parent to decide, not an accepted domain change or runtime finding.
|
||||
|
||||
## Conclusion and obligation
|
||||
|
||||
Yes: an output-acceptance contract can explicitly preserve existing independent file/token/grammar/numeric bounds for named remaining parser scratch instead of adding all that scratch to the carried aggregate. This is consistent with the bounded logical domain, provided the contract accurately names its timing and exclusions. It must not claim one global physical memory ceiling, aggregate before-every-allocation, or that an independent bound is a reservation against the surviving output owner.
|
||||
|
||||
The user need is reliable graphical/text/API editing and persistence. A physical heap meter is not a user-owned requirement identified in the domain audit. The accepted rename-plan contract's mandatory successor *does* require complete actual emitted output, exact inherited policy, surviving-output accounting, actual parse/resolve/admission, whole desired-Project equality with E scratch, documented old-owner transition, and no usable failed result. These are not waived by choosing a smaller scratch domain. That contract does not name every parser coordination stack as a new carried-ledger allocation.
|
||||
|
||||
The earlier `CARRIED-STORAGE-DOMAIN-AUDIT.md` calls itself a design audit, not an accepted contract. Its recommended expansion seams and question about traversal/delimiter scratch must not silently become user requirements. In particular, its suggested significant-token push charge is not the currently accepted metric: the entry contract explicitly retains token storage under the token bound, with one token-end M subtotal reserved before ends construction. The newer text contract explicitly names its retained String producers and temporary decode/result strings. Neither permits hiding a newly introduced in-scope owned output/path/equality structure as old scratch.
|
||||
|
||||
## What existing source actually bounds
|
||||
|
||||
All source references below are repository-relative at97a8703.
|
||||
|
||||
| Scratch | Existing guard or structural bound | Remaining limitation |
|
||||
| --- | --- | --- |
|
||||
| Folded path table and traversal paths | `source_project/traversal.rs:22–87` checks file count and source-text sum before its folded table; validates every supplied path. Active/visited unique paths are related to file count; pending includes to include-edge count. | File-count bounds are not total path-byte bounds. Folded Unicode path copies and canonicalization allocate outside aggregate T. `include` at149–178 resolves/copies target and location before its edge-limit refusal; it is false to describe every such temporary as guarded before growth. |
|
||||
| Traversal queue/children/active stack | Same file/edge/depth policy; `next` checks depth before visited insertion; `finish_file` transfers pending children into stack (`89–186`). | Repeated include targets still consume edges and path copies. Entry count and path text can coexist with output C without debiting its aggregate ceiling. A deep/wide graph is not bounded solely by visited-file count. |
|
||||
| Delimiter stack | `language.rs:1032–1040`: at most one push per opening delimiter in the already scanned significant-token sequence. Ends array is the selected M subtotal. | Delimiter stack is another collection, not prepaid by the same N ends reservation. Token guard is a separate finite bound; no aggregate C+M charge for delimiter slots exists. |
|
||||
| Ordinary open declarations | `language.rs:1539–1723`: stack carries indices into the flat declaration vector. Existing declaration events/model count checks precede each declaration and its open push. | Maximum live depth is structurally at most declarations. Do not claim this vector is controlled by typed-value depth64 or by the typed Pratt frame budget; those are different controls. |
|
||||
| Pratt coordination | `analysis_source/syntax.rs:18–45,60–63,154–264`: tasks/values/sequence coordination are iterative; expression/operator/argument events, raw tokens and relevant grammar-frame checks constrain growth. | Not every Vec push has its own reserve, and grammar frame count is not native stack bytes. Completed subtree values remain owned while siblings parse. Existing small-stack successes are finite evidence, not a universal stack guarantee. |
|
||||
| Typed duplicate-name/ID sets | `declarations.rs:284–307,604–624` and `syntax.rs:238–249`: entries track already charged declarations/arguments/overrides. Named string clones derive from parsed reference/name text. | These clones are explicitly excluded scratch, not retained T or scanner/grammar decode W. Work count bounds their number, not independently their total copied bytes; original text/source bounds provide an additional constraint. No existing aggregate reservation covers their overlap. |
|
||||
|
||||
These are finite structural relationships, not exact peak formulas. With unlimited/wide configured policies, they may be practically large. UTF-8 case folding, path normalization, repeated path copies, large sequence coordination and native enum sizes are reasons not to present the logical ceiling as a process-memory bound.
|
||||
|
||||
## Choices
|
||||
|
||||
1. **Retain named independent bounds, explicitly excluded from carried W.** Smallest next step. Complete selected retained T/U/M hooks and reconciliation; retain exact existing traversal/token/grammar checks and refusal semantics; finish owning actual-output admission/equality. Document the table above in the acceptance contract. This is my recommendation for the currently stated editing objective.
|
||||
|
||||
2. **Add selected scratch W, starting with a demonstrated problem.** Useful if a specific unbounded or disproportionate copy threatens the intended deployment, or if the parent selects a stronger aggregate resource promise. For example, traversal path storage would require canonicalization/folded-path temporary and queue ownership decisions, with path-byte rather than just file-slot accounting. A delimiter-only W is simpler but proves nothing about the other excluded structures. Each addition needs before-growth placement, release/transfer rules and a literal peak oracle; it is not a harmless extension to current caps.
|
||||
|
||||
3. **Universal parser/all-heap containment.** Much larger and unsupported as the automatic next prerequisite. Allocator capacity, model/serde/validator and numeric internals are already excluded by accepted contracts. Instrumenting only parser stacks would still not establish this promise.
|
||||
|
||||
Do not choose option1 while retaining prose from option2 that says all parser auxiliary storage is charged against C. Conversely, do not block actual-output correctness on proving an unrequested universal bound.
|
||||
|
||||
## Concrete evidence needs without scope expansion
|
||||
|
||||
Before relying on the existing bounds, source review must establish each structural relationship and no new output-verification helper bypasses its existing checks. Reuse existing limit controls where they already cover duplicate include edges, depth, token exhaustion, declaration nesting and wide/deep expressions. A missing relevant control is a reason for one finite diagnostic test, not a generic scratch allocator framework.
|
||||
|
||||
The clearest risk deserving a specific design note is traversal path text: source-byte sum excludes map keys, and include canonicalization occurs before edge refusal. The accepted owned-output C already counts all emitted path keys and entry text, but that does not account folded/path queue copies. Either explicitly retain this limitation, or select a separate path-specific strengthening. Do not claim file/source limits alone provide a tight path-byte peak. No measured failure in this review requires instrumentation immediately.
|
||||
|
||||
New accepted-output lifetime/equality scratch is different: it is explicitly mandated and cannot be excluded as parser coordination. Preserve old B during emission, carry actual O after the documented old-owner drop, include new verification syntax/storage against O, reserve E against the correct verification live total, and drop temporary verification before returning a non-self-referential owning accepted result. An excluded raw desired/model borrow does not justify losing in-scope path/output copies. Retained mapped diagnostics continue to require their existing reservations even though transient refusal diagnostics have an escape.
|
||||
|
||||
Native stack risk remains empirical. Preserve existing128KiB/deep/wide controls and inspect changed prologues if new state/hooks alter frames. A stack failure is not repaired by putting vector slots in W; neither a phase check nor a token bound proves Rust stack headroom. Do not relax tests to support a stronger claim.
|
||||
|
||||
Suggested precise acceptance wording: “The verifier checks the declared retained syntax and auxiliary domain, plus the named temporary decode and equality domains, against the exact inherited aggregate policy including surviving output. Existing traversal, delimiter, ordinary-open, Pratt and duplicate-name scratch retain their independent file/edge/token/grammar bounds and are excluded from that aggregate metric. This is not a physical-memory or before-every-allocation guarantee.” Actual source/evidence must support the selected hooks before that wording is claimed implemented.
|
||||
@@ -1,97 +0,0 @@
|
||||
# Pending output ownership source map — proposal only
|
||||
|
||||
Source inspected: `a629ae01d49bb3cb482a047eb41832ad4246a5c6`; no compilation,
|
||||
source modification or acceptance claim. This refines only the lifetime seam in
|
||||
[ACCEPTANCE-PROPOSAL.md](ACCEPTANCE-PROPOSAL.md), not equality/scratch policy.
|
||||
Paths below are relative to `.local/as3batch` at that commit.
|
||||
|
||||
## Existing authority
|
||||
|
||||
`crates/oa-core/src/language/analysis_source/mixed/project/ordinary_index/rename/emission.rs:6`
|
||||
owns PendingRename's bundle, exact Limits/ProjectLimits, retained Slots, original
|
||||
expression count and immutable desired Project reference. Its constructor at the
|
||||
end of `emit_changed_unverified` follows output reservation and explicit old-owner
|
||||
drop; no public bundle/slots constructor exists. Those private fields currently
|
||||
prevent sibling carried code from reading authority.
|
||||
|
||||
`.../mixed/carried.rs:40` consumes CarriedInput into a bundle reference, one policy
|
||||
value and a slim guard. `:63` derives C from that guard; `:116` selects both producer
|
||||
modes. `.../mixed.rs:39` stores the guard alongside the borrowed bundle and Budget;
|
||||
`:308` is the shared parser. Consumed resolution at `.../mixed/project.rs:453`
|
||||
retains that syntax. None of these owners currently needs desired's lifetime.
|
||||
|
||||
## Recommend Candidate A with a complete pending reference
|
||||
|
||||
The smallest variant avoids projecting authority down to a bare `&Slots` at all:
|
||||
|
||||
```rust
|
||||
// Private variants in the existing enums; no new allocating wrapper.
|
||||
SnapshotBacking::Pending(&'s PendingRename<'s>)
|
||||
GuardBacking::Pending(&'s PendingRename<'s>)
|
||||
|
||||
impl<'s> CarriedInput<'s> {
|
||||
pub(super) fn from_pending<'t: 's>(p: &'s PendingRename<'t>) -> Self {
|
||||
Self { backing: SnapshotBacking::Pending(p) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is a proposed Rust shape, not a compiler-tested patch. PendingRename is
|
||||
covariant in its sole lifetime: the only use is `desired: &'t Project`, and all
|
||||
other fields own data without that lifetime. Consequently the longer desired
|
||||
borrow can be shortened to the pending borrow `'s` when storing `p`. The original
|
||||
pending value retains its original type/lifetime; no data moves or self-reference
|
||||
is created. Parser, resolved and indexed owner types keep their single existing
|
||||
`'s`. Immutable `&PendingRename` itself prevents mutation, consumption or dropping
|
||||
its bundle while syntax/guard remain usable. No trait object or PhantomData proof
|
||||
is needed, and future invariant lifetime-bearing fields would require revisiting
|
||||
this coercion rather than silently bypassing it.
|
||||
|
||||
Add narrowly visible immutable Pending methods `bundle() -> &SourceBundle`,
|
||||
`policy() -> (Limits, ProjectLimits)` and `retained() -> Slots`, restricted to
|
||||
`pub(in crate::language::analysis_source::mixed)`. CarriedInput accepts only the
|
||||
complete pending reference, never those components as independent arguments.
|
||||
`into_parts` derives bundle/policy from its stored pending and transfers the same
|
||||
reference into GuardBacking::Pending; `slots()` calls that pending's retained
|
||||
method. Policy is copied once into the existing preparse transfer value and then
|
||||
Budget/syntax limits; the guard contains only one pointer, not another policy.
|
||||
Pending necessarily retains its original immutable policy as owner provenance.
|
||||
No recomputation of C, tuple authentication or runtime identity comparison helps.
|
||||
|
||||
## Candidate B and visibility
|
||||
|
||||
Embedding an owned RetainedSnapshot in Pending would remove the cross-reference
|
||||
between carried and emission modules and also avoid desired lifetime covariance.
|
||||
It adds no necessary heap allocation, but replaces existing pending fields,
|
||||
changes emission construction and existing direct inspection tests, and requires
|
||||
a controlled production snapshot constructor or wider sibling field authority.
|
||||
That larger migration has no present ownership benefit over the complete pointer.
|
||||
Prefer B only if later genuinely shared owning snapshot behavior justifies it.
|
||||
|
||||
The module paths are already visible: `ordinary_index.rs:433` exposes rename to
|
||||
analysis_source, and `rename.rs:2` exposes emission likewise. Pending type itself
|
||||
has that visibility. Keep carried private to mixed; descendants can name its
|
||||
module. The needed changes are just its from_pending entry point and a named
|
||||
strong parse entry with visibility up to mixed, plus the three immutable pending
|
||||
views. Keep SnapshotBacking/GuardBacking variants and fields private to carried;
|
||||
do not export SnapshotPolicy or add arbitrary production RetainedSnapshot inputs.
|
||||
|
||||
## Consuming verification and finite type checks
|
||||
|
||||
Implement verify in emission, where it can consume self and eventually move its
|
||||
private bundle. A local block calls the strong parser with from_pending(&self),
|
||||
consumes syntax into resolve, performs separately specified bounded equality,
|
||||
then drops resolved owner/guard before leaving that block. Only afterward move
|
||||
self.bundle into an owning accepted result. No parsed witness or desired borrow
|
||||
escapes, so accepted output has no lifetime parameter or self-reference. Failure
|
||||
returns diagnostics and drops self; successful no-op remains the separate old
|
||||
owner branch. This note does not select accepted-result extraction/installation.
|
||||
|
||||
Compiler acceptance must demonstrate: long-lived desired + local pending can
|
||||
produce a short scoped syntax; resolved owner cannot outlive pending; moving or
|
||||
mutating pending while the guard is subsequently used is rejected; dropping all
|
||||
verification borrows permits moving the bundle into the accepted owner. Positive
|
||||
runtime tests should drop original input and desired after acceptance and still
|
||||
inspect returned output. Preserve exact inherited policies and C once throughout;
|
||||
no new carrying-mode bypass or policy mutation. Any compile-fail examples should
|
||||
be narrow external snippets/appropriate harness, not runtime unsafe tricks.
|
||||
@@ -1,60 +0,0 @@
|
||||
# Independent unnamed-project warning oracle
|
||||
|
||||
Authored from existing mapper/catalog/index/emission logic at2941713 (relevant behavior unchanged from97a8703), not parser or runtime output. This is a prospective acceptance evaluator, not a passing execution claim. Literal-only Python checked lengths, hashes and offsets; no compiler/parser/binary ran.
|
||||
|
||||
## Exact original and output
|
||||
|
||||
Single file/entry `main.arcadia` (12 UTF-8 bytes each). Original71-byte source, including final LF:
|
||||
|
||||
```text
|
||||
arcadia 8;
|
||||
project p "" id "00000000-0000-0000-0000-000000000001" {
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
Original SHA256 `68b9215285d622c77b46df105839f65c40fb76f2ba7c55a6a9cbbc1fc4576146`. Rename only project aliasp→next; physical original alias span19..20. Output74-byte literal:
|
||||
|
||||
```text
|
||||
arcadia 8;
|
||||
project next "" id "00000000-0000-0000-0000-000000000001" {
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
Output SHA256 `dbde19f06d6bde26c16b4672dcc43036dec693fb04ebc23de76e4faa369dd81f`. O=(entries1, occurrences0, text98), since74+12+12=98. Exact inherited entries18 and bytes161 are the proposed unchanged positive policy; bytes160 is the proposed late-refusal sibling, with all other limits roomy and unchanged throughout each pipeline. Original parsing must be the supported legacy uncarried route because changed emission rejects carried syntax.
|
||||
|
||||
## Complete expected Project and warning
|
||||
|
||||
Actual accepted Project must have schema_version8, UUID00000000-0000-0000-0000-000000000001, alias`next`, name`""`, description`""`, and all six maps empty: definitions, elements, relations, diagrams, artifact_links, traces. Original Project differs only in alias`p`. No expressions or lowered occurrences exist, so E_actual=0 for equality.
|
||||
|
||||
Exactly one mapped diagnostic is expected:
|
||||
|
||||
- path`main.arcadia`; related empty;
|
||||
- severityWarning; code`unnamed`; object_idSome(ID1); fieldSome(`name`);
|
||||
- message`Give the project a descriptive name.` (36 bytes);
|
||||
- output span11..69, line2, column1.
|
||||
|
||||
`validation.rs:325–333` emits that warning for empty trimmed project name. `project.rs:666+` falls back to the project header for the name field; empty name does not manufacture a field span. Header start11 is unchanged, opening brace69 ends the output header. Original warning instead spans11..66. The returned warning must be the output warning, not one moved from original admission.
|
||||
|
||||
Current mapper `project.rs:594–649` makes W=(entries1, occurrences0, text59): path12 + code7 + field4 + message36. Scalar span/UUID/severity/line/column add no string bytes. Final accepted O+W=(2,0,157). Model payload remains the established excluded domain; this is not its physical memory size.
|
||||
|
||||
## Verification peak, with catalog lifetime
|
||||
|
||||
Output U1/M11/T4: one project syntax event, F1+ten significant tokens, aliasnext4 and empty name/description. Work bytes74, raw tokens19, references0, numeric units0. Combined retained syntax is entries13/text102=O98+T4. Scanner temporary36 fits134; grammar project UUID temporary36 overlaps alias4, requiring138. No ordinary positions, value maps or typed syntax exist.
|
||||
|
||||
Catalog creation reserves alias/owner2 entries with alias4 text, then table2 entries with another alias4 (`project.rs:240–325`). Its peak is entries17/text110. `project.rs:521–524` drops aliases before lowering; `557–560` drops tables before warning mapping. Thus neither extra alias copy remains at the warning reservation. The owner table remains one entry, no text, through mapping, and drops afterward (`655`).
|
||||
|
||||
Immediately before warning mapping: entries14=syntax13+owner1, text102. The mapped warning reserve adds1/59, reaching entries15/text161. At bytes160 the exact refusal is `Source8 index/output byte limit exceeded.`, codeinvalid_source, pathmain.arcadia, span11..69, line2column1, no related diagnostics. This typed mapper failure preserves the full header span; it is not the ordinary parser's one-byte transport. Bytes161 admits this reservation. Owner table then drops, leaving resolver entries14/text161 =O + U/M/T + W. EqualityE0 adds nothing. After syntax drops, retained accepted inventory is entries2/text157.
|
||||
|
||||
The final O+W check is not itself the161-byte peak and must not be called the site of160 refusal. It rechecks the smaller eventual retained subset. The intended negative occurs before warning destination allocation while original output-owned C is still live.
|
||||
|
||||
## Original predecessor clearance under the same policy
|
||||
|
||||
Original uncarried syntax has U1/M11/T1. Its name is empty; original mapped warning has the same59-byte string inventory. Catalog peaks at syntax12+4=16 entries and text3; at warning mapping it retains syntax12+owner1+warning1=14 entries/text60. Owner release leaves13 entries/text60. Typed index adds one retained global-name entry/text1, giving14/text61. Ordinary authored index adds one header occurrence entry; its one-global reverse map is temporary, so final authored state15/text61. No expression/occurrence/field work adds another slot.
|
||||
|
||||
Plan stores one changed-name entry and one physical edit entry; replacements borrow desired and add no owned text. Its final state is17 entries/text61. Collision-check one-element namespace occurs before the edit exists, so it does not exceed17. E0 adds no equality scratch. Old emitted-output overlap is17+O1=18 entries and61+98=159 text. Both bytes160 and161 therefore clear old parser/resolver/index/plan/emission; source/output byte lengths71/74 also fit. Emission drops old owner/warning before verification, so the old59 warning bytes do not coexist with the new warning map.
|
||||
|
||||
This derivation agrees with the existing named empty emission envelope after removing seven old name bytes and adding one59-byte/one-entry original warning. It is nevertheless an independently traceable source calculation, not a runtime result. Do not introduce a default policy, a newly tightened pending owner, or a borrowed numeric baseline to make the negative pass earlier stages.
|
||||
|
||||
Expected real end-to-end distinction: identical entries18 and other policies, bytes161 succeeds with exact output/Project/warning/O+W; bytes160 reaches actual-output warning mapping and refuses there. If runtime rejects earlier, investigate actual source/lifetime/count differences rather than changing the oracle from observed output. Additional copy/reservation introduced by the future acceptance implementation must be disclosed and independently justified before claiming this cap.
|
||||
@@ -1,121 +0,0 @@
|
||||
# Mixed source8 command-line checking
|
||||
|
||||
Selected next outcome in the already-owned textual-language scope: an engineer
|
||||
can run `arcadia check-mixed ROOT [--json]` on an explicit entry file and receive
|
||||
located results for its complete included mixed model, without changing files.
|
||||
Baseline source is public-read checkpoint bab6f96a. This contract is local-only
|
||||
while Gitea writes/publication remain unavailable. The published preview is frozen.
|
||||
|
||||
## User-visible behavior
|
||||
|
||||
Keep existing check/lint/stabilize/rename behavior and the ordinary library
|
||||
`prepare` contract. Add one explicit check-mixed spelling, rejecting --write,
|
||||
unknown/repeated options and invalid argument counts. Route it separately from
|
||||
editable preparation; do not construct a fake Prepared or SourceIndex. Use a
|
||||
closed command-dispatch enum rather than an ambiguous combination of mode flags.
|
||||
|
||||
The existing Report envelope remains version1. Operation is check-mixed; success
|
||||
is checked/exit0, with advisory warnings permitted. Changes is empty, written is
|
||||
false, migration is null, identities_to_stabilize is zero. All locations refer to
|
||||
captured original input. Missing explicit UUIDs, unsupported versions, malformed
|
||||
syntax and semantic invalidity are errors, not implicit stabilization or migration.
|
||||
Retain actual warning order and authored object identities from the admitted model.
|
||||
|
||||
Use the existing exit conventions: source/semantic rejection1, arguments2,
|
||||
disk/path/journal/output failure3. Match existing treatment of loader diagnostics
|
||||
and disk error variants, including its source-limit distinctions; document/test
|
||||
the actual relevant precedence instead of assuming every limit uses one phase.
|
||||
Human output/help must explain the explicit-ID schema8 read-only contract and
|
||||
make the check result clear without implying proposed edits.
|
||||
|
||||
## Source discovery and ownership
|
||||
|
||||
Add `source_files::load_mixed_source(entry, limits)` returning an opaque
|
||||
MixedSourceSnapshot with immutable bundle() and diagnostics() views. It captures
|
||||
original bytes only. It confers no save authority, editable compilation, recovery
|
||||
or promise of an atomic snapshot against another process. A caller must handle
|
||||
discovery diagnostics before treating the bundle as complete. CLI owns this
|
||||
snapshot, invokes check_mixed_bundle on its borrowed bundle only after discovery
|
||||
succeeds, and copies diagnostics into the owned report before dropping the result.
|
||||
|
||||
Reuse the existing private disk discovery loop and path/read/physical-alias/journal
|
||||
checks through a closed ordinary/mixed selector. The ordinary route remains
|
||||
unchanged, including overlays, detached buffers, expectations and final compilation.
|
||||
The new fresh mixed route has no overlays and skips ordinary compile_bundle.
|
||||
Consume its private DiskProject storage into bundle/diagnostics and discard unused
|
||||
expectations/root/reachability after the checks; avoid cloning reachable_bundle or
|
||||
exporting DiskProject as mixed save authority.
|
||||
|
||||
Before disk reads, clamp the mixed source-byte allowance to min(caller,64MiB),
|
||||
matching the parser's hard ceiling; preserve all other caller restrictions.
|
||||
Keep canonical root-relative paths, UTF-8, regular-file/link rules, case and
|
||||
physical-alias detection, aggregate files/bytes/edges/depth and pending journals.
|
||||
Pending transactions remain an error and are never recovered implicitly.
|
||||
Resolve includes with the existing resolver. Parse each successfully loaded path
|
||||
once in existing deterministic traversal order; count repeated authored edges.
|
||||
Complete semantic checking still detects cycles and cross-file errors.
|
||||
|
||||
Inspect includes by the real shared mixed Parser::unit grammar, including typed
|
||||
declarations, explicit IDs and long-form refusals. Centralize the minimal parser
|
||||
setup/operation needed by discovery and complete parsing; preserve the latter's
|
||||
grammar, producer hooks, policy, retained witnesses and stack behavior. No regex
|
||||
scanner, second grammar or complete-checker call on partial bundles is permitted.
|
||||
Return no partial include list after a unit syntax failure.
|
||||
|
||||
## Bounded discovery phase
|
||||
|
||||
Choose one shared discovery Budget and ModelCounts for the entire discovery pass,
|
||||
derived by the existing mixed source profile. Enable existing text and entry
|
||||
producer checks once with zero initial reservation. Byte/token/reference/numeric
|
||||
work and syntax/model counts accumulate across files; do not restart allowances
|
||||
per file. Charge each inspected file before lexing/parsing and keep the existing
|
||||
prospective syntax/value/owner producer checks. Tokens keep their existing
|
||||
pre-scan bound; disk/path/traversal tables retain their separate filesystem limits.
|
||||
Do not allocate edit positions or a resolved/indexed model merely for discovery.
|
||||
|
||||
Completed unit syntax is dropped after moving its include information. Its
|
||||
remaining producer charges deliberately remain reserved until this phase ends:
|
||||
these counters are a conservative cumulative admission bound, not a claim of
|
||||
exact live storage or a verifier-owned carry. Ordinary in-parser temporary and
|
||||
UUID/key releases remain unchanged. No unit-drop refund mechanism or new generic
|
||||
allocation framework is needed. Shared grammar and once-per-path order consume
|
||||
the same syntax/work events as full parsing, with fewer later projections; tests
|
||||
must discriminate cumulative accounting from accidental per-file resets.
|
||||
|
||||
A syntax/budget failure is terminal for mixed parsing: record its original located
|
||||
diagnostic, do not reuse a poisoned span-only Budget for another file, and do not
|
||||
invoke final model checking on incomplete discovery. Still perform required final
|
||||
filesystem and journal checks. Ordinary aggregation remains unchanged.
|
||||
|
||||
After successful discovery, drop the discovery budget/scratch and run the existing
|
||||
complete checker with a fresh profile and the original caller policy. These are
|
||||
two explicitly bounded passes, not a claim of one shared whole-command numeric
|
||||
allowance or all-heap accounting. Discovery grants no semantic admission witness.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Real executable and library cases load mixed OA/SA multi-file fixtures with
|
||||
cross-file/scoped typed references and nested ordinary records. Independently
|
||||
construct complete expected models for those same bytes; check actual complete
|
||||
JSON/human diagnostics and status rather than inventing a model field in Report.
|
||||
- Prove exact source/directory preservation on success, warning and refusal,
|
||||
including Unicode/CRLF and comment/string text resembling includes or aliases.
|
||||
Exercise missing UUID classes and wrong-kind/unresolved typed references.
|
||||
- Test missing/cyclic includes, root/path escape, case and physical aliases,
|
||||
invalid UTF-8/nonregular files, pending journal, and selected exact file/byte/
|
||||
depth/edge boundaries. Platform-specific checks are claimed only when run.
|
||||
- Observe the actual shared discovery constructor/counters: multi-file cumulative
|
||||
work, retained conservative reservations after dropping a unit, zero temporary
|
||||
balance after successful units, exact caller/profile capture and first-error
|
||||
location with an unparsed later sibling. Use real small boundary controls with
|
||||
positive cases; test-only tightening must not become public policy.
|
||||
- All old core and relevant filesystem/source/stack tests remain passing. Run
|
||||
existing ordinary CLI tests plus new mixed CLI tests, actual CLI commands,
|
||||
strict core/CLI Clippy, doctests and formatting disposition. Bind gates to a
|
||||
reviewed clean source commit and preserve initial failures without weakening
|
||||
model, bytes, locations or resource expectations.
|
||||
|
||||
Independent source/runtime review follows implementation. Parent owns integration,
|
||||
compiler execution and acceptance. Core discovery/parser and CLI/report/tests/docs
|
||||
may be delegated with disjoint files and the interfaces above. Linux remains
|
||||
user-deferred; no GUI/editor/save/history or release integration is implied.
|
||||
@@ -1,48 +0,0 @@
|
||||
# Read-only mixed CLI: independent critique
|
||||
|
||||
Research only against `bab6f96a5b05b37f2b5d67a430aed0f67d783e73`; no builds, application execution, production edits or remote actions. Reviewed implementation map SHA256 `cb611919a1d6b6463f5e7054922d40457734305656e0dfff9dd4a1388a2c4ed7`. References below are relative to that source. This selects a bounded next outcome, not acceptance of an implementation.
|
||||
|
||||
## Recommendation
|
||||
|
||||
Implement explicit `arcadia check-mixed ROOT [--json]`: discover actual included files safely, then invoke the existing complete borrowed checker on captured original bytes. Require schema8 and explicit UUIDs, including ordinary declarations in this command. Return diagnostics through the existing report envelope, with no changes, migration, stabilization or write. This delivers useful command-line validation for the textual-model/linting goal in `PROJECT_GOAL.md:64–69,96`, while leaving editable model adoption separate.
|
||||
|
||||
`check --mixed` is also coherent, but offers no semantic advantage and requires more option-combination rules. Choose one spelling. Automatic replacement of `check` is unsafe: existing ordinary drafts intentionally accept omitted IDs and older versions. Schema8 alone cannot select between those policies. A manifest of every file is a simpler parser-free discovery interface, but transfers routine include enumeration to users and is not an equivalent root-file workflow. Full compile/index adoption is substantially larger and unnecessary here.
|
||||
|
||||
## Simpler correct discovery policy
|
||||
|
||||
Prefer **one shared discovery Budget and ModelCounts, with conservative completed-unit reservations retained until discovery ends**. This is simpler than the map's proposed exact release/reconciliation seam. Do not describe its counters as exact live storage, an admitted syntax witness, or carry into the final verifier. Ordinary and public in-memory checker accounting must remain unchanged.
|
||||
|
||||
The source supports this narrower policy:
|
||||
|
||||
- `analysis_source/mixed.rs:390–422` performs lexing and the shared `Parser::unit` using one Budget/ModelCounts. `lex.rs:15–68` charges bytes, scanner events including trivia, and scanner temporary string work. State shares model counts and typed numeric parsing rather than creating a fresh per-file allowance.
|
||||
- Full parsing retains each completed unit, tokens and lexical owners, and additionally constructs positions (`mixed.rs:425–511`). Dropping the discovery unit does not itself decrement Budget. Leaving its final charges reserved therefore remains an upper bound on live producer-domain storage; it does not create dangling ownership or an authority witness. Keep normal existing temporary/UUID/provisional-key releases within the parser unchanged.
|
||||
- For the same successfully parsed files and depth-first order, discovery uses the same unit grammar, U/token/reference/numeric events and transient string decoding as full parsing. Omitting position construction removes nonnegative metadata/text charges. Initial or incremental discovery file-slot reservations must be no greater than the full parser's F file slots, and must not charge already-visited paths again. Under these conditions, retaining completed-unit charges causes **no additional producer-cap refusal relative to the corresponding successful full syntax parse**, which retains those units anyway. This conditional argument does not promise identical failure precedence for invalid input or disk discovery.
|
||||
- Both existing walkers push includes in reverse for source-order DFS (`source_files.rs:511–517`, `source_project/traversal.rs:179–183`). Preserve that order and once-per-path inspection. Repeated authored edges still count; repeated targets do not get another syntax budget.
|
||||
|
||||
Name the phases explicitly: one bounded discovery syntax pass, followed by one independently bounded complete verification pass. Derive both profiles from the same original caller policy; do not expose arbitrary production profiles. In particular, numeric work is capped per named pass, not falsely advertised as one 50,000,000-unit command-wide allowance. Discovery Budget, parser state and all ordinary/typed/token owners must be dropped before full verification starts. Only loader-owned sources, bounded include/path bookkeeping and diagnostics survive. Do not retain an unnecessary parsed model or clone a complete syntax witness.
|
||||
|
||||
This reasoning covers the existing logical producer domains, not all Rust allocations, path bookkeeping, model heap, allocator capacity or RSS. Resolved include targets/locations belong to the existing bounded loader domain; do not call their transfer a producer-ledger reconciliation. Discovery never invokes the resolver or its exact syntax-inventory check. No new refund mechanism is needed merely to make already-freed units disappear from a conservative reservation.
|
||||
|
||||
An alternative fresh unit budget with explicit remaining aggregate work caps could also be correct, but requires transferring numeric/event usage and separating remaining work from per-unit storage limits. That is more machinery than retaining reservations. Fresh unrestricted per-file budgets are rejected: they silently multiply numeric and other work ceilings by file count.
|
||||
|
||||
**Concrete failure obligation:** existing discovery continues after `inspect_includes` failures (`source_files.rs:519–521`), whereas `Budget::active` (`budget.rs:228`) returns a latched Error containing a Span but no filename. Reusing that failed budget for a sibling would risk reporting the old span against the sibling. Select terminal mixed syntax/work failure: keep its first located diagnostic and perform no further unit parses using that budget. Still perform the selected final filesystem/journal checks. Preserve ordinary-path aggregation unchanged. Specify first-failure behavior rather than attempting to claim diagnostic identity with complete in-memory validation.
|
||||
|
||||
## Disk ownership and safety
|
||||
|
||||
Reuse the existing logical-path, root-containment, regular-file, bounded-read, case/physical-alias and journal checks (`source_files.rs:111–325,536–549`). Before any read, enforce aggregate effective source bytes `min(caller.model.max_source_bytes, 64 MiB)`; checking only the caller's possibly unlimited ceiling and rejecting later in the parser is insufficient. Pass the original ProjectLimits to final verification, without silently widening or replacing them.
|
||||
|
||||
For a fresh load, consume the discovered DiskProject into an opaque read-only `{ bundle, diagnostics }` after final checks, dropping expectations/root/reachability. Every stored source was discovered from the entry; no overlay/detached-input preservation is needed. Avoid `reachable_bundle()` (`source_files.rs:59–72`), which clones all source strings. The existing temporary raw expectation bytes and UTF-8 strings are two bounded copies, not one accounted allocation; document reuse honestly. A smaller fresh-only storage owner may be a later optimization, not a prerequisite for this milestone.
|
||||
|
||||
Keep reserved journal names, pending-save refusal and no implicit recovery. Existing path checks do not establish an atomic multi-file snapshot or defeat every concurrent replacement race (`source_files.rs:177`); promise validation of captured bytes and no writes by this command. Do not broaden platform claims beyond actual tests. Fail discovery before invoking the complete checker on missing/invalid buffers.
|
||||
|
||||
Route this command before ordinary CLI `prepare` (`oa-cli/src/lib.rs:213–310`), which calls ordinary lint/ID analysis/compilation even for Check. An Arguments command branch is sufficient; do not add a fake SourceIndex, editable Prepared value, save conversion or mixed write option.
|
||||
|
||||
## Falsifiable acceptance
|
||||
|
||||
1. Real executable OA/SA multi-file fixtures: typed/scoped cross-file references, ordinary nesting, escaped includes, comments containing `include`, Unicode and mixed CRLF. Compare complete diagnostics/report bytes and independently authored complete models for the same captured literals. Hash files and directory entries before/after; reject `--write` and unknown options.
|
||||
2. Discovery-budget tests: two files each individually below a tightened private numeric/event cap but jointly over it; repeated target inspected once; repeated edges counted; one shared ModelCounts limit across files; exact cap/cap-minus-one boundaries. Test-only profile tightening must not become caller-controlled public authority. Compare discovery producer reservations with the full parser at equivalent successful prefixes/fixtures, including quoted provisional releases and omitted projection charges.
|
||||
3. Failure attribution: first file triggers a latched budget refusal while a sibling remains queued; assert the original file/span once, no sibling reinterpretation and no final checker invocation. Include missing IDs in project, ordinary declaration, typed definition and parameter, plus unresolved/wrong-kind references reaching final verification.
|
||||
4. Existing filesystem invariants: bounded aggregate reads even with unlimited caller bytes; include depth/edges/files, invalid UTF-8, nonregular files, escapes/reserved names, case/physical aliases, cycles and pending journals. Preserve ordinary loader/CLI results. Pin mixed diagnostic precedence where discovery differs; platform-specific link/FIFO cases remain conditional.
|
||||
5. Verify actual normal-library/CLI boundaries and relevant finite stack controls after helper extraction. No universal headroom or full authoring claim follows from these checks.
|
||||
|
||||
No design blocker remains if the contract adopts the conservative reservation meaning, terminal failure rule, effective pre-read byte cap, and separate fresh final verifier above. The implementation must demonstrate those conditions; this report does not waive existing parser limits or substitute discovery success for complete semantic admission.
|
||||
@@ -1,164 +0,0 @@
|
||||
# Mixed CLI read-only check: implementation map
|
||||
|
||||
Design only against public-read branch bab6f96a (parent reports accepted gates).
|
||||
No source edits, compiler or remote actions. Paths/lines refer to this source.
|
||||
|
||||
## Recommended user outcome
|
||||
|
||||
Add explicit `arcadia check-mixed ROOT [--json]`, requiring the public checker's
|
||||
schema8 and explicit UUIDs. Preserve check/lint/stabilize/rename-alias behavior.
|
||||
Use the existing report envelope with operation check-mixed, status checked or
|
||||
invalid, no changes, written=false, migration=null and identities_to_stabilize=0.
|
||||
The operation validates captured original bytes; it does not stabilize, export,
|
||||
format, index or save. Help must state this distinction and reject --write.
|
||||
|
||||
The command choice and discovery architecture are separate decisions. Merely
|
||||
adding a command enum variant does not make the existing loader support typed
|
||||
syntax. A real parser-consistent discovery seam is the prerequisite below.
|
||||
|
||||
## Current blockers and reusable code
|
||||
|
||||
- oa-cli/src/lib.rs:213 prepare loads source_files::load_project, then ordinary
|
||||
lint/analyze/compile (234–310). Operation::Check still returns Prepared holding
|
||||
CompiledSourceProject and a disk snapshot; it is not a read-only mixed route.
|
||||
- main.rs:76 dispatches all accepted arguments through prepare and optional write.
|
||||
Add a separate report-returning mixed-check branch before that branch. Do not
|
||||
forge Prepared or an empty SourceIndex. An explicit Arguments command enum can
|
||||
separate read-check from existing editable Operation, or use a distinct
|
||||
check_mixed flag/variant handled before prepare; avoid illegal write combinations.
|
||||
- source_files.rs:329 load_project creates a DiskProject then discover_missing.
|
||||
Discovery's DFS reads bounded bytes and tracks originals/expectations (389–494),
|
||||
but495 calls ordinary inspect_includes and529 runs ordinary compile_bundle.
|
||||
- source_project.rs:207–268 inspect_includes validates paths and calls ordinary
|
||||
language::parse_unit, so it rejects valid short typed declarations. Filtering
|
||||
that diagnostic cannot reveal the missing include graph safely.
|
||||
- mixed.rs:390–422 already lexes and invokes the shared Parser::unit with actual
|
||||
mixed State and explicit IDs, producing ordinary.includes plus typed syntax.
|
||||
This exact unit grammar can inspect includes without resolving global references.
|
||||
Extract/reuse this narrow unit operation instead of a regex, guessed brace
|
||||
scanner, second quote decoder or calling the complete checker on partial bundles.
|
||||
- source_files.rs:104–216 root_entry/check_logical/disk_path/read_bounded already
|
||||
enforce root containment, canonical logical names, reserved journal namespace,
|
||||
link/regular-file restrictions and bounded reads.240 check_snapshot and293
|
||||
check_physical_aliases preserve aggregate and physical-alias checks. Reuse these
|
||||
functions in one core discovery engine; do not replicate their security logic in CLI.
|
||||
|
||||
## Minimal safe ownership shape
|
||||
|
||||
Introduce a named read-only discovery entry in source_files, with a small private
|
||||
closed selector OrdinaryEditable / MixedRead for shared discovery dispatch.
|
||||
Existing public discover_missing/load_project select OrdinaryEditable and retain
|
||||
all current completion/lint behavior. MixedRead selects mixed include inspection
|
||||
and skips ordinary compile/stabilization; it still performs all disk/path/journal
|
||||
checks. No generic caller-provided parser/loader callbacks or new public framework.
|
||||
|
||||
Prefer an opaque owning `MixedSourceSnapshot` exposing only bundle() and
|
||||
load_diagnostics(), or a narrowly scoped `load_mixed_source` result owning those
|
||||
values. Do not advertise it as a save snapshot or provide conversion into editable
|
||||
CompiledSourceProject. Internally reusing DiskProject's captured bytes is possible,
|
||||
but returning DiskProject publicly would unnecessarily expose save/discovery
|
||||
operations and expectations. Keep original raw-byte expectations private if reused.
|
||||
This design adds a small wrapper, not a clone of every buffer for an extra model.
|
||||
|
||||
CLI owns the snapshot, borrows its complete reachable SourceBundle while calling
|
||||
language::check_mixed_bundle, maps warnings/errors into the existing Report, then
|
||||
drops CheckedMixed before dropping source. Report owns mapped diagnostic strings;
|
||||
no borrowed checker escapes and no self-referential source/result struct is needed.
|
||||
Report has no Project field: complete model evidence comes from independent core
|
||||
and CLI-library tests of those same literals, not an invented JSON assertion.
|
||||
|
||||
## Discovery parser work and limits: resolve before implementing
|
||||
|
||||
Discovery is an extra syntax pass; final borrowed checker remains the sole complete
|
||||
semantic admission. Its existing budgets must be fresh for that full pass; do not
|
||||
subtract discovery work from final counts or represent a successful partial scan
|
||||
as an admitted model. Re-reading/reparsing captured strings is explicit bounded work,
|
||||
not a claim that the checker itself reparses or that disk is reread between phases.
|
||||
|
||||
The mixed unit helper needs a genuine Budget and ModelCounts. A naïve new budget
|
||||
per file silently multiplies allowed discovery numeric/token/reference work by the
|
||||
file count; a single shared budget with all retained-text totals left charged after
|
||||
units are dropped misrepresents live storage. Therefore contract must choose a
|
||||
small explicit **discovery-phase work policy** and lifetime:
|
||||
|
||||
- Recommended: one shared discovery work budget across all captured file parses,
|
||||
cumulative bytes/tokens/references/numeric/syntax-event work, with per-unit syntax
|
||||
owners dropped after copying only include locations. Reuse Parser::unit and its
|
||||
resource checks. Name and check temporary per-unit metadata/text ownership before
|
||||
growth if selecting producer mode; reconcile/release it on unit destruction.
|
||||
This is a real small producer-lifetime seam to specify, not existing free behavior.
|
||||
- Simpler alternative: per-unit bounded inspection with a documented aggregate
|
||||
upper bound of max_files times its cap. It is finite but can be far larger than
|
||||
the selected checker work profile; do not choose it silently or describe it as
|
||||
inheriting one whole-project numeric budget. Parent may reject that cost policy.
|
||||
- Do not retain all parsed witnesses then clone/reparse the complete bundle merely
|
||||
to avoid accounting a dropped unit. That grows live state and complicates source
|
||||
lifetimes for no consumer benefit. Do not stream-load into a borrowed BTreeMap
|
||||
while previously parsed units retain references into it; ordinary Rust borrowing
|
||||
disallows this without additional ownership infrastructure, which is unnecessary.
|
||||
|
||||
The implementation contract should pin the selected discovery bound/diagnostic
|
||||
order and helper's drop/refund rules before source edits. This is the main remaining
|
||||
bounded design question, not an argument for full editable-source adoption.
|
||||
|
||||
## Filesystem and diagnostic contract
|
||||
|
||||
Preserve existing root/file checks, UTF-8 rejection, aggregate byte/file/edge/depth
|
||||
limits, case/physical alias refusals and pending-journal detection. Root depth0;
|
||||
include paths resolved by existing resolve_include; count repeated authored edges.
|
||||
Final complete check still rejects cycles/semantic failures using its exact original
|
||||
coordinates. Discovery diagnostics prevent invoking checker with missing/invalid
|
||||
buffers. Return exit3 for disk/path/pending-journal failures under existing CLI
|
||||
mapping; exit1 for source/semantic rejection,0 for valid with advisory warnings,
|
||||
2 for invalid arguments. Pin mixed loader's diagnostic precedence in tests rather
|
||||
than assuming it equals in-memory checker's earlier/later limit branch.
|
||||
|
||||
Do not suppress pending_save simply because operation is read-only: existing CLI
|
||||
refuses it, and the captured files may represent interrupted save state. No journal
|
||||
recovery is implicit. Neither old nor new read path promises atomic multi-file
|
||||
snapshot/CAS against concurrent external replacement (source_files.rs:177 comment).
|
||||
Promise unchanged bytes by this command and validation of captured bytes, not
|
||||
that the disk still equals that snapshot after a concurrent editor writes.
|
||||
|
||||
## Compared/rejected options
|
||||
|
||||
- Automatically replace ordinary check with mixed: breaks existing omitted-ID and
|
||||
migration behavior. Schema8 alone does not distinguish ordinary drafts from the
|
||||
stricter explicit-ID mixed contract. Avoid heuristic detection/fallback on errors.
|
||||
- `check --mixed`: coherent alternative explicit spelling, but adds an option whose
|
||||
invalid combinations must be excluded for mutations. Dedicated check-mixed makes
|
||||
the limited outcome easier to explain; select one spelling, not both initially.
|
||||
- Explicit all-files CLI manifest/bundle: can reuse checker immediately, but forces
|
||||
users to enumerate includes and introduces another input/argument format. It is
|
||||
useful only if a real pipeline already supplies bundles; not the recommended
|
||||
ordinary filesystem workflow and not a substitute for safe include loading.
|
||||
- Full compile_bundle adoption: requires typed stabilization/index/update/formatter
|
||||
and application consumers; none is necessary for this read-only outcome.
|
||||
|
||||
## Finite acceptance and scoped files
|
||||
|
||||
Core changes: source_files shared discovery selector + read-only snapshot;
|
||||
language mixed unit inspection helper and narrow reexport for core-only use;
|
||||
source_project include-location conversion reuse where appropriate. CLI lib
|
||||
arguments/report path + main dispatch, docs/source-cli.md/help, focused tests.
|
||||
No changes to updater/save/formatter or public CheckedMixed authority.
|
||||
|
||||
Test actual executable on independently authored OA/SA three-file fixtures with
|
||||
cross-file typed/scoped references and ordinary nesting. Exact complete JSON
|
||||
warning/error reports, raw original byte hashes/directory listing unchanged after
|
||||
each command, including CRLF/Unicode and comments mentioning include/aliases.
|
||||
Core tests compare independently authored full models for those same bytes.
|
||||
|
||||
Exercise missing/cyclic include, invalid path/root escape, case/physical aliases,
|
||||
nonregular/invalid-UTF8 file, pending journal and exact aggregate/depth/edge limits.
|
||||
Only claim platform-specific link/FIFO tests where cfg permits; Linux remains
|
||||
user-deferred. Exercise missing IDs in all four classes and typed unresolved/wrong
|
||||
kind errors. Verify --write rejection and all existing ordinary CLI commands remain
|
||||
unchanged. No fake success for an empty include graph or wrong command dispatch.
|
||||
Add unit-hook counter/lifetime evidence for the chosen discovery work policy and
|
||||
repeat relevant existing filesystem/stack regressions after shared-loop factoring.
|
||||
|
||||
Parent decisions needed: command spelling; explicit discovery work/storage policy
|
||||
above; whether reuse of private DiskProject storage under opaque wrapper is worth
|
||||
its duplicated captured bytes versus a smaller read-only buffer owner. No source
|
||||
or compiler work is authorized by this proposal.
|
||||
@@ -1,128 +0,0 @@
|
||||
# Private mixed rename emission prerequisite
|
||||
|
||||
2026-09-25. Selected private prerequisite following bounded design and independent
|
||||
ownership/oracle review; implementation and runtime acceptance pending. Source baseline:
|
||||
`fa7a980418ef99d772e67c66c28dfa7b5181f875`. This implements the output construction
|
||||
and old-owner lifetime portion of the [rename successor](analysis-mixed-rename-plan-contract-20260924.md#mandatory-successor-accepted-rewritten-output).
|
||||
It does not implement accepted output, public source8, persistence or history.
|
||||
The [carried parser prerequisite](analysis-carried-phase-contract-20260925.md)
|
||||
and its remaining before-growth limitations retain their stated scope.
|
||||
The [producer inventory](analysis-mixed-emission-design-20260925/NEXT-PRODUCER-HOOKS.md),
|
||||
[independent critique](analysis-mixed-emission-design-20260925/NEXT-PRODUCER-CRITIQUE.md),
|
||||
[transition proposal](analysis-mixed-emission-design-20260925/EMISSION-TRANSITION-PROPOSAL.md)
|
||||
and [contract review with ownership correction](analysis-mixed-emission-design-20260925/EMISSION-CONTRACT-REVIEW.md)
|
||||
record candidates and rationale. This selected contract supersedes conflicting
|
||||
earlier proposal choices, including the owning enum and runtime byte guard.
|
||||
|
||||
## Ownership and API
|
||||
|
||||
Consume the opaque `RenamePlan` and borrow a complete current `SourceBundle`.
|
||||
Revalidate current entry, complete file set, paths and all bytes against the
|
||||
original snapshot before any copying, including for no-op. Reuse the planner's
|
||||
complete physical edit validation; no external edit list, Project or policy may
|
||||
replace its authority.
|
||||
|
||||
If no names changed, return the original `AuthoredMixedProject` unchanged, with
|
||||
its actual bundle, policy, work usage, warnings and storage. Allocate no output.
|
||||
The changed result owns a complete pending bundle, the exact inherited `Limits`
|
||||
and `ProjectLimits`, its verified output storage total O, the desired Project
|
||||
borrow and scalar original expression-site count E. It retains no old syntax,
|
||||
catalog, warning, index or plan map. Original caller-owned source and desired
|
||||
Project remain untouched. This does not claim those external borrows are freed.
|
||||
|
||||
Keep pending fields private. Expose no bundle extraction, mutation, installation,
|
||||
history, save or accepted-result conversion. Tests may inspect fields. No general
|
||||
constructor accepting arbitrary bundle/Slots/policy is authorized. The only
|
||||
construction path consumes the actual plan after checked emission. Reuse or add
|
||||
a sealed carried-snapshot connection only if it accepts this actual pending
|
||||
owner, never loose parts; invoking parser verification remains outside this slice.
|
||||
|
||||
For changed emission in this prerequisite require original legacy syntax
|
||||
(`carried == None`). Reject an explicit carried source before output reservation,
|
||||
even its borrowed-zero mode. Dropping a source witness only drops its guard; an
|
||||
external RetainedSnapshot would remain alive, so claiming only O survived would
|
||||
hide C. Future repeated owned editing needs an explicitly consuming owner
|
||||
transition or surviving C guard. No-op may preserve any source and its guard
|
||||
unchanged, because no old reservation is discarded. This narrow provenance
|
||||
restriction is intentional, not a numeric-zero shortcut or a final editing API.
|
||||
|
||||
Select two explicit consuming methods:
|
||||
`into_unchanged(self, current) -> Result<AuthoredMixedProject<'s>, Failure>` and
|
||||
`emit_changed_unverified(self, current) -> Result<PendingRename<'t>, Failure>`.
|
||||
Both validate current snapshot and complete edits and enforce their branch;
|
||||
calling the wrong branch refuses. Callers can select using the existing immutable
|
||||
`changed_names().is_empty()` observation. A branch cannot change after that read.
|
||||
This separates source and desired lifetimes without boxing, a large common
|
||||
owning result enum, generic callback machinery or lint exemptions. Do not
|
||||
duplicate full policy inside old syntax owners. Keep all existing stack controls.
|
||||
|
||||
## Storage and construction
|
||||
|
||||
Let B be the existing plan owner's live ledger, which already includes both the
|
||||
R changed-name entries and M physical-edit entries. Do not add R+M twice.
|
||||
Compute the complete final file byte sum S using the existing checked original
|
||||
coordinate sizing walk, including untouched files. Then compute
|
||||
|
||||
```text
|
||||
O.entries = original complete file count F
|
||||
O.occurrences = 0
|
||||
O.text = S + sum(canonical key UTF-8 byte lengths) + entry UTF-8 byte length
|
||||
peak during emission = B + O
|
||||
```
|
||||
|
||||
Every addition/subtraction must be checked. Reserve all O against the original
|
||||
ledger and exact inherited policy before constructing any output String, copied
|
||||
key, entry or map element. Reserving the complete monotone output up front covers
|
||||
its partial construction; capacity/allocator overhead remain outside the logical
|
||||
metric. Original sources and desired replacement aliases are borrowed during
|
||||
copying. No lengths map, copied edit list, temporary complete file or second
|
||||
output buffer is needed. Any additional owned container requires explicit scope
|
||||
and reservation before growth. Source-size shrinkage does not release old B.
|
||||
|
||||
Walk canonical files and the sorted edit stream. For each file, append original
|
||||
untouched ranges and the exact qualified target's desired alias in original byte
|
||||
coordinates, then the tail. Produce one String per file, including untouched
|
||||
files; preserve complete paths and entry. Avoid reparsing, decoding, UUID creation,
|
||||
normalization and global textual replacement. Validate original UTF-8 boundaries
|
||||
and nonoverlap through the existing complete edit validator before construction.
|
||||
Check the constructed length against independently computed size before ownership
|
||||
transition. Check final complete file count and byte total without a second copy.
|
||||
|
||||
Capture policy and E as scalars; destroy old plan maps and the complete old source
|
||||
owner explicitly before returning the pending owner. O is then the only retained
|
||||
in-scope owner from this operation. Failure yields existing located diagnostics
|
||||
and drops partial output; no partial owner escapes. Preserve failure ordering:
|
||||
stale snapshot, invalid edit map/size, output storage reservation, construction
|
||||
consistency; changed carried-source refusal precedes output reservation.
|
||||
Arithmetic fault injection must be labeled as such, not successful
|
||||
real-world owner provenance.
|
||||
|
||||
## Verification and remaining boundary
|
||||
|
||||
Use independent literal original-coordinate maps and expected full bytes for OA
|
||||
and SA, simultaneous global/scoped swaps, repeated/reordered labels, cross-file
|
||||
references, ordinary categories, Unicode/CRLF and comments. Verify copied untouched
|
||||
files and exact entry/keys. No-op must preserve original owner data/pointers, not
|
||||
an equivalent reconstructed owner. Corrupt current touched/untouched data, paths,
|
||||
entry and file set, and corrupt plan edit locations/targets through test-only
|
||||
access; require no result. Reuse valid planner tests where they cover the same
|
||||
validator rather than duplicate all cases.
|
||||
|
||||
Independently hand-count a small real fixture's B and O. Choose exact entry/text
|
||||
caps that admit all predecessor phases and refuse cap-minus-one at emission.
|
||||
Do not mutate an admitted live ledger or widen policy to manufacture this test.
|
||||
Assert final O, original exact policies, desired borrow and E. Include combined
|
||||
grow/shrink and unchanged-file sizing. Small-stack emission/drop checks prepare
|
||||
parser prerequisites on the normal thread, then move the complete consumed plan
|
||||
into the worker; preserve all prior full-pipeline controls and limitations.
|
||||
|
||||
Independent byte fixtures test the emitter; the pending type does not assert
|
||||
runtime semantic authentication. A duplicate streaming emitter/comparator is not
|
||||
required just to return this unverified internal state. Final acceptance still
|
||||
requires actual-output parse/resolve/admission under the exact policy and carried
|
||||
O, strict whole-Project equality with E scratch reserved, a resolved temporal
|
||||
parser accounting contract, and an owning accepted result after temporary syntax
|
||||
drops. Those requirements cannot be substituted by length checks or byte tests.
|
||||
Original E is provenance metadata, not a demonstrated bound on arbitrary faulty
|
||||
emitted syntax. The later verifier must justify its equality bound against both
|
||||
actual operands and cannot rely on an unperformed runtime topology check.
|
||||
@@ -1 +0,0 @@
|
||||
*.md -text
|
||||
@@ -1,43 +0,0 @@
|
||||
# Independent emission contract review
|
||||
|
||||
Reviewed `harness/analysis-mixed-emission-contract-20260925.md` against the current `fa7a980418ef99d772e67c66c28dfa7b5181f875` planner/guard shapes, accepted rename successor and carried-phase scope. Read-only; no contract/source edits, builds or network activity.
|
||||
|
||||
Disposition: **no blocking design finding; suitable for the bounded private implementation described.** This review accepts its unverified prerequisite boundary, not final edited-output acceptance.
|
||||
|
||||
## Runtime byte guard decision
|
||||
|
||||
The selected omission is reasonable. My earlier NEXT-PRODUCER-CRITIQUE recommended a streaming guard partly to establish authenticated topology for a potential later parser envelope. This draft does not grant that authority. An opaque unverified pending owner does not need a second runtime comparator merely to prove the same emission operation again. Independently authored complete byte fixtures discriminate emitter defects without installing another production algorithm.
|
||||
|
||||
A runtime guard becomes worthwhile if a later phase relies on unchanged token/AST/include topology **before** parsing actual bytes, or promises runtime preservation of all non-name bytes independently of emitter correctness. In that case it should consume actual output by matching borrowed original/replacement chunks and prefixes, rather than reconstructing output through the same append loop/helper; comparison must separately cover entry, file set, keys and untouched files. Whole-Project equality alone does not establish lexical preservation of comments/formatting. None of those stronger claims is made by this pending type, so the guard can remain deferred.
|
||||
|
||||
## Ownership and sizing
|
||||
|
||||
The contract correctly requires stale complete-snapshot and complete-edit validation before any output copies, including no-op. No-op returns the original authored owner and allocates no output. Changed output derives solely from the consumed sealed plan, borrows aliases while copying, retains exact policy and desired borrow, and offers no extraction/mutation/save/adoption escape.
|
||||
|
||||
O is correctly F entries plus every final source byte, copied canonical key and the distinct entry-path bytes, with zero lower occurrences. B already contains R+M. Reserving B+O before construction covers monotone partial output without double-charging plan maps or prematurely crediting shrinkage. Repeating a scalar sizing walk is preferable to an extra lengths map. Per-file length, complete count and total consistency checks detect size-construction disagreement without implying byte authentication.
|
||||
|
||||
The named old-owner drop is essential: capturing output/policy/desired/E does not itself release old syntax/index/warnings/maps. Implementer must make that destruction precede return and preserve the original external source/desired borrows. Failure diagnostics retain the existing bounded rejection exception; no partial pending owner escapes.
|
||||
|
||||
The proposed callback or nested-result shape is appropriately left for actual type review. It should not evade accounting with an otherwise unnecessary Box, duplicate the full policy inside old syntax or change expectations merely to satisfy enum-size lint. Compiler/Clippy and the inherited stack controls remain necessary evidence.
|
||||
|
||||
## Useful acceptance boundaries
|
||||
|
||||
The independent full-byte fixtures, combined growth/shrink, copied untouched files and no-op ownership assertions test the actual new behavior. Reusing the existing complete edit validator and its corruption evidence avoids duplicating the entire planner test matrix. New B+O exact-cap tests must admit predecessor phases under the same profile; final O assertions must follow actual old-owner destruction rather than scalar subtraction alone.
|
||||
|
||||
Normal-thread setup followed by scoped small-stack emission/drop isolates the new operation without falsely promising that upstream parsing fits every worker stack. Existing full-pipeline controls and prior abort limitations remain separate.
|
||||
|
||||
## One explicit future-use caution
|
||||
|
||||
Original E is valid provenance metadata, but without runtime byte authentication it is **not automatically a bound on arbitrary actual output topology**. A faulty emitter may produce admissible-but-wrong bytes with a different structure. A future topology-based parser envelope or whole-Project comparison must prove its scratch bound for that case, derive a safe bound from admitted actual output/intent, or reject structural mismatch before exceeding the reserved bound. Merely retaining E from the original owner is not that proof.
|
||||
|
||||
This is not a blocker to pending emission: the draft defers parser verification, temporal accounting and strict whole-intent equality. Preserve that deferral in code/API/docs; do not wire a future accepted-result constructor while relying only on this review, output lengths or test fixtures. No broader parser scratch expansion is required to implement the selected private milestone.
|
||||
|
||||
## Qualification after carried-lifetime finding
|
||||
|
||||
The original no-blocker disposition missed a material ownership edge identified by the other oracle reviewer. A source parsed through a retained carried guard borrows an external RetainedSnapshot. Destroying its syntax/index/plan drops only the guard, not that external owner or C. Returning a changed pending result with only O would therefore discard live-storage accounting authority without ending the corresponding ownership. My original disposition is insufficient without the following amendment.
|
||||
|
||||
Reread the amended contract: **changed emission requires `syntax.carried == None`; any Some variant, including borrowed-zero mode, is refused before O reservation.** This is an explicit provenance boundary, not `slots == 0`. No-op remains permitted for any source only because it returns the same authored owner and guard without releasing the original baseline. Complete-current-snapshot and edit validation still precede copying/no-op as specified.
|
||||
|
||||
With this restriction, the pending operation can honestly retain only O after the consumed legacy syntax/index/plan owner dies. Future repeated edits of owned output require consuming the actual external output owner or retaining its guard and counting surviving C; merely borrowing and later dropping a source witness is inadequate. Runtime tests should distinguish changed legacy success, changed borrowed-zero refusal, changed retained refusal and guarded no-op preservation, with adequate caps so unrelated resource failures do not hide this rule.
|
||||
|
||||
The amendment resolves the identified edge for this prerequisite; the qualified disposition is suitable for bounded implementation. I also verified the contract now explicitly states that original E does not bound arbitrary faulty emitted syntax and requires a justified bound against both future equality operands.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user