Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3aa80de89c | ||
|
|
79cc9d5551 | ||
|
|
3f9e6a18af | ||
|
|
7836a93edb | ||
|
|
f714a25bbc | ||
|
|
cb941367d7 | ||
|
|
96b756bece | ||
|
|
e624d5b9c4 | ||
|
|
d7c065991d | ||
|
|
136ed6e586 | ||
|
|
46124ce76c | ||
|
|
554cfbd9fe | ||
|
|
92ff99eb54 | ||
|
|
16904398db | ||
|
|
74542ae2c1 | ||
|
|
feab2020d8 | ||
|
|
bebf9f594c | ||
|
|
022d8cda2a | ||
|
|
357da24542 | ||
|
|
44095f78c2 | ||
|
|
0ecdadbb9c | ||
|
|
c28abcf13a | ||
|
|
0d8c144355 | ||
|
|
7f2947d651 | ||
|
|
3412d4f369 | ||
|
|
5e62d88c4f | ||
|
|
13f7e6b0d1 | ||
|
|
29525d7823 | ||
|
|
0f002b4254 | ||
|
|
3ac74825f8 | ||
|
|
64fea0a505 | ||
|
|
e1240295bd | ||
|
|
659b7a655a | ||
|
|
a17ca5d066 | ||
|
|
95e0e12716 | ||
|
|
dd39e1fdc1 | ||
|
|
f555193172 | ||
|
|
e44f07565c | ||
|
|
a357b22778 | ||
|
|
05b613ea19 | ||
|
|
afa74a3059 | ||
|
|
200aa90689 | ||
|
|
2c6ededc51 | ||
|
|
bbd3b0e843 | ||
|
|
8c3e7acd14 | ||
|
|
26ca4a4c81 | ||
|
|
aadc6396d3 | ||
|
|
e0685b8e52 | ||
|
|
d61f1f3877 | ||
|
|
419c78c071 | ||
|
|
83f53c7c6e | ||
|
|
40154983cc | ||
|
|
8112dea7a3 | ||
|
|
e2c027e54c | ||
|
|
b25065c442 | ||
|
|
956a584132 | ||
|
|
e57c390d49 | ||
|
|
24bb24839b | ||
|
|
f7d84faf30 | ||
|
|
260d773852 | ||
|
|
42511f0f8a | ||
|
|
c3c2d01453 | ||
|
|
4c387ccd64 | ||
|
|
cb46ef4054 | ||
|
|
2d9e9c1849 | ||
|
|
12b2237ee9 | ||
|
|
49a4cb17b1 | ||
|
|
28e21a0ae8 | ||
|
|
dc1506f2e8 | ||
|
|
5b174a9fa6 | ||
|
|
0d6f677329 | ||
|
|
60ea483430 | ||
|
|
0c38980c70 | ||
|
|
f1bd5149db | ||
|
|
3840ca2965 | ||
|
|
5ce798b713 | ||
|
|
1b3c401a19 | ||
|
|
76209cc186 | ||
|
|
5a7018117d | ||
|
|
85173a3c95 | ||
|
|
d9b3f0dc91 | ||
|
|
94b5eecb1c | ||
|
|
6b9cc77ece | ||
|
|
bc8bc4f5a1 | ||
|
|
f52d81c1b9 | ||
|
|
97c91cc06c | ||
|
|
0bd7886608 | ||
|
|
563772dc62 | ||
|
|
eefdedaf47 | ||
|
|
bc88acd932 | ||
|
|
17d56c06f7 | ||
|
|
bcb7057ab2 | ||
|
|
36eff7225a | ||
|
|
dd38c46a4b | ||
|
|
a31b40d0f8 | ||
|
|
88c17a448f | ||
|
|
abffa4b3f0 | ||
|
|
82fd0acf64 | ||
|
|
ff0175488a | ||
|
|
06ec962129 | ||
|
|
6969c825a8 | ||
|
|
638d06c4e3 | ||
|
|
41fa5d530c | ||
|
|
828c50c491 | ||
|
|
f8a6a5264a | ||
|
|
b4c8fd7102 | ||
|
|
e7491fb00a | ||
|
|
bef1ea33e4 | ||
|
|
3c9e6eeb64 | ||
|
|
80f2588214 | ||
|
|
8895dd2e0f | ||
|
|
8d55d2d2eb | ||
|
|
03d20ec296 | ||
|
|
c0e66f94e3 | ||
|
|
7f081ad457 | ||
|
|
c7ed894900 | ||
|
|
98427b9620 | ||
|
|
cab01c3d7f | ||
|
|
d8bdbd9e48 | ||
|
|
7d14bd0dd1 | ||
|
|
dc0f2e12f3 | ||
|
|
bc311bc076 | ||
|
|
a34558d3be | ||
|
|
2cc4d115d0 | ||
|
|
1e9f0c2a97 | ||
|
|
1516949567 | ||
|
|
c4e1580bb0 |
+52
-47
@@ -22,65 +22,70 @@ harness/history/2026-09-06-ledgers-before-consolidation/** -text
|
||||
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
|
||||
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/history/2026-09-11-ytr83r-integration/** -text whitespace=cr-at-eol
|
||||
crates/oa-desktop/src/recovery/fixtures/*.json -text
|
||||
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/source7-native/** -text
|
||||
harness/fixtures/basic-editing/** -text
|
||||
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
|
||||
harness/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/refined-contracts-review-20260911/** -text whitespace=cr-at-eol
|
||||
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
|
||||
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
|
||||
harness/fixtures/scenario-lifecycle-native/**/*.arcadia -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/lifecycle-native-ytr83r-20260911/*.log -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-oa-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-native-ui-sa-20260911/** -text -whitespace
|
||||
harness/experiments/wsl-postboot-ytr83r-20260911/** -text -whitespace
|
||||
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
# Preserve checksum-bound font notices verbatim through Git round trips.
|
||||
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
|
||||
scripts/windows-notices/Fira-OFL.txt -text
|
||||
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
|
||||
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
|
||||
harness/experiments/behavior-creation-native-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/** -text whitespace=cr-at-eol
|
||||
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
|
||||
harness/release-scenario-native-9e8r6t-20260911.md -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-numeric-gate-20260911/** -text
|
||||
harness/experiments/chooser-correction-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/chooser-lint-gate-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/history/2026-09-11-behavior-wiki-before-refresh.md -text whitespace=cr-at-eol
|
||||
# Preserve the independently fingerprinted historical review verbatim.
|
||||
harness/release-behavior-canvas-phase1-review-20260911.md -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-3c9e6ee-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/scenario-native-3c9e6ee-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-codec-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/history/2026-09-11-ledgers-before-gitea-migration/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-union-windows-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-a31b40d-windows-native-20260911/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/allocation-d9b3f0d-windows-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/allocation-3840ca2-pause-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/diagram-removal-windows-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/diagram-resize-foundation-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/delete-native-f7-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-editing-followup-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/resize-port-policy-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-83f53c7-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/nested-lifecycle-union-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-d61f1f3-20260912/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/lifecycle-fixture-preflight-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/palette-readability-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/release-analysis-next-acceptance-20260913.md -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/runtime-notices-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-checked-save-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/canvas-native-dd39e1f-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/navigation-palette-e124029-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/navigation-native-preparation-e124029-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-source-s0-s1-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/experiments/analysis-source3-bridge-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
harness/scenario-analysis-version-integration-review-20260913.md -text whitespace=cr-at-eol
|
||||
harness/scenario-analysis-version-integration-inputs-20260913.json -text whitespace=cr-at-eol
|
||||
harness/experiments/analysis-source-s2a-windows-20260913/** -text whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
|
||||
|
||||
harness/experiments/lifecycle-header-overflow-before-20260911/** -text -whitespace
|
||||
harness/history/2026-09-11-ytr83r-record-migration/STATE-before.md -text -whitespace
|
||||
|
||||
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
|
||||
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
|
||||
|
||||
harness/experiments/contextual-lifecycle-integration-ytr83r-20260911/** -text -whitespace
|
||||
harness/experiments/windows-runtime-321897f-20260911/** -text -whitespace
|
||||
harness/child-work-native-contract.rs -text whitespace=cr-at-eol
|
||||
harness/fixtures/child-work-native/** -text whitespace=cr-at-eol
|
||||
harness/experiments/windows-gates-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-oa-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/packaged-server-865483b-20260911/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-union-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/windows-gates-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/native-child-continuation-865483b-20260911/** -text -whitespace
|
||||
harness/experiments/lifecycle-header-correction-native-865483b-20260911/** -text -whitespace
|
||||
harness/release-native-child-continuation-review-20260911.md -text -whitespace
|
||||
|
||||
harness/experiments/packaged-gui-close-865483b-20260911/** -text -whitespace
|
||||
harness/release-grapheme-shortening-review-20260911.md -text -whitespace
|
||||
harness/experiments/staged-source-native-a57c334-20260911/** -text -whitespace
|
||||
harness/experiments/grapheme-windows-cb91525-20260911/** -text -whitespace
|
||||
harness/experiments/canvas-native-close-a57c334-20260911/** -text -whitespace
|
||||
harness/release-staged-native-a57-independent-review-20260911.md -text -whitespace
|
||||
harness/release-saved-destination-independent-review-20260912.md -text -whitespace
|
||||
harness/experiments/repair-union-windows-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-camera-a57c334-20260912/** -text -whitespace
|
||||
harness/release-camera-a57-independent-review-20260912.md -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-package-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-native-33e30c1-20260912/** -text -whitespace
|
||||
harness/experiments/canvas-repair-server-33e30c1-20260912/** -text -whitespace
|
||||
|
||||
harness/experiments/canvas-repair-publication-20260912/** -text -whitespace
|
||||
# Preserve the pinned Iced dependency bytes and upstream manifest.
|
||||
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
|
||||
|
||||
Generated
+22
-2
@@ -1567,8 +1567,6 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "iced_widget"
|
||||
version = "0.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1596afa0d3109c2618e8bc12bae6c11d3064df8f95c42dfce570397dbe957ab"
|
||||
dependencies = [
|
||||
"iced_renderer",
|
||||
"log",
|
||||
@@ -2107,6 +2105,25 @@ dependencies = [
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-integer"
|
||||
version = "0.1.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -2153,6 +2170,9 @@ name = "oa-core"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
"same-file",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
[workspace]
|
||||
members = ["crates/oa-core", "crates/oa-desktop", "crates/oa-server", "crates/oa-cli"]
|
||||
exclude = ["crates/vendor/iced_widget"]
|
||||
resolver = "2"
|
||||
|
||||
[patch.crates-io]
|
||||
iced_widget = { path = "crates/vendor/iced_widget" }
|
||||
|
||||
[workspace.package]
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
|
||||
@@ -222,8 +222,8 @@ pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Repor
|
||||
Box::new(report.clone())
|
||||
})?;
|
||||
let raw = snapshot.reachable_bundle();
|
||||
// The loader may include warnings from stabilized compilation. Only its
|
||||
// discovery/IO diagnostics are authoritative on the original bytes.
|
||||
// Keep discovery/IO failures that lint cannot recover from the bundle alone.
|
||||
// Recompute source diagnostics below, against the same original bytes.
|
||||
let mut diagnostics: Vec<_> = snapshot
|
||||
.diagnostics
|
||||
.iter()
|
||||
|
||||
@@ -12,6 +12,9 @@ uuid.workspace = true
|
||||
thiserror.workspace = true
|
||||
tempfile = "3"
|
||||
same-file = "1"
|
||||
num-bigint = "0.5.1"
|
||||
num-integer = "0.1.47"
|
||||
num-traits = "0.2.19"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
|
||||
(p, diagram, exchanges, categories, contexts)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
|
||||
//!
|
||||
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
|
||||
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
|
||||
//! expression/call/property/list/target visits and retain the same logical walk on
|
||||
//! cache hits. Stored AST size is not a dynamic execution limit.
|
||||
//!
|
||||
//! Approximate engineering functions/irrational unit scales require a subsequent
|
||||
//! explicit numeric profile; rationals do not implement degree/radian conversion
|
||||
//! or transcendental mathematics.
|
||||
|
||||
mod dimension;
|
||||
mod exact;
|
||||
mod meter;
|
||||
mod quantity;
|
||||
|
||||
pub use dimension::{Dimension, DimensionAxis};
|
||||
pub use exact::{Exact, ExactWire};
|
||||
pub use meter::{
|
||||
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
|
||||
WorkSite, WorkUsage,
|
||||
};
|
||||
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Profile identity includes the accounting rules, not crate-internal timing.
|
||||
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
|
||||
pub const MAX_COMPONENT_BITS: u64 = 4096;
|
||||
/// Cross products require 8192 bits; their sum can require one additional bit.
|
||||
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
|
||||
pub const MAX_COMPONENT_DIGITS: usize = 1234;
|
||||
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
|
||||
pub const MAX_INTEGER_POWER: i8 = 16;
|
||||
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
|
||||
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumberComponent {
|
||||
Numerator,
|
||||
Denominator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericLimit {
|
||||
#[error("request work exhausted: {0}")]
|
||||
Work(WorkExhaustion),
|
||||
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
|
||||
Bits {
|
||||
component: NumberComponent,
|
||||
bits: u64,
|
||||
limit: u64,
|
||||
stage: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
pub enum NumericError {
|
||||
#[error("numeric limit: {0}")]
|
||||
Limit(NumericLimit),
|
||||
#[error("invalid numeric literal")]
|
||||
InvalidLiteral,
|
||||
#[error("numeric literal exceeds its digit or byte limit")]
|
||||
LiteralTooLong,
|
||||
#[error("decimal exponent is outside -128 through 128")]
|
||||
DecimalExponent,
|
||||
#[error("wire rational is not in canonical reduced form")]
|
||||
NonCanonicalWire,
|
||||
#[error("rational denominator is zero")]
|
||||
ZeroDenominator,
|
||||
#[error("division by zero")]
|
||||
DivisionByZero,
|
||||
#[error("zero to the power zero is undefined in this profile")]
|
||||
ZeroPowerZero,
|
||||
#[error("integer power is outside -16 through 16")]
|
||||
IntegerPower,
|
||||
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
|
||||
DimensionExponent { axis: DimensionAxis, value: i32 },
|
||||
#[error("unit scale must be strictly positive")]
|
||||
UnitScale,
|
||||
#[error("a linear unit must have zero offset")]
|
||||
LinearOffset,
|
||||
#[error("an affine point must have a distinct linear difference kind")]
|
||||
DifferenceKind,
|
||||
#[error("numeric kinds or dimensions are incompatible")]
|
||||
IncompatibleTypes,
|
||||
#[error("operation is not defined for affine points")]
|
||||
AffineOperation,
|
||||
#[error("AsKind requires an anonymous matching dimension and a linear target")]
|
||||
KindAssertion,
|
||||
#[error("request work limits exceed the provisional reference ceilings")]
|
||||
InvalidWorkLimits,
|
||||
}
|
||||
|
||||
impl NumericError {
|
||||
pub fn is_terminal_work_exhaustion(&self) -> bool {
|
||||
matches!(self, Self::Limit(NumericLimit::Work(_)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,107 @@
|
||||
use super::NumericError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DimensionAxis {
|
||||
Length,
|
||||
Mass,
|
||||
Time,
|
||||
ElectricCurrent,
|
||||
Temperature,
|
||||
Amount,
|
||||
LuminousIntensity,
|
||||
}
|
||||
|
||||
impl DimensionAxis {
|
||||
pub const ALL: [Self; 7] = [
|
||||
Self::Length,
|
||||
Self::Mass,
|
||||
Self::Time,
|
||||
Self::ElectricCurrent,
|
||||
Self::Temperature,
|
||||
Self::Amount,
|
||||
Self::LuminousIntensity,
|
||||
];
|
||||
}
|
||||
|
||||
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
|
||||
/// This is a dimension, never a nominal quantity-kind compatibility proof.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
|
||||
pub struct Dimension([i16; 7]);
|
||||
|
||||
impl Dimension {
|
||||
pub const ZERO: Self = Self([0; 7]);
|
||||
|
||||
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
|
||||
for (index, value) in exponents.iter().copied().enumerate() {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value: i32::from(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self(exponents))
|
||||
}
|
||||
|
||||
pub fn exponents(self) -> [i16; 7] {
|
||||
self.0
|
||||
}
|
||||
|
||||
pub fn is_dimensionless(self) -> bool {
|
||||
self == Self::ZERO
|
||||
}
|
||||
|
||||
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, 1)
|
||||
}
|
||||
|
||||
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
|
||||
self.combine(rhs, -1)
|
||||
}
|
||||
|
||||
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
|
||||
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
|
||||
let mut output = [0; 7];
|
||||
for (index, value) in self.0.iter().enumerate() {
|
||||
output[index] =
|
||||
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
|
||||
}
|
||||
Ok(Self(output))
|
||||
}
|
||||
|
||||
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
|
||||
if !(-32..=32).contains(&value) {
|
||||
return Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::ALL[index],
|
||||
value,
|
||||
});
|
||||
}
|
||||
Ok(value as i16)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<[i16; 7]> for Dimension {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
|
||||
Self::new(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Dimension> for [i16; 7] {
|
||||
fn from(value: Dimension) -> Self {
|
||||
value.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,469 @@
|
||||
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
|
||||
use super::{
|
||||
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
|
||||
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
|
||||
NumericOperation, WorkLimits, WorkMeter,
|
||||
};
|
||||
use num_bigint::BigInt;
|
||||
use num_integer::Integer;
|
||||
use num_traits::{One, Signed, Zero};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// The interchange representation deliberately does not use num-bigint's serde
|
||||
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
|
||||
/// Its derived decoder allocates String fields before numeric validation; a
|
||||
/// transport/document adapter must bound the encoded envelope and decoded text.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExactWire {
|
||||
pub numerator: String,
|
||||
pub denominator: String,
|
||||
}
|
||||
|
||||
/// Reduced rational with positive denominator and <=4096-bit components.
|
||||
///
|
||||
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
|
||||
/// of already normalized bounded values, not an expression comparison API.
|
||||
/// Standalone serde admission bounds the subsequent BigInt validation, not the
|
||||
/// preceding String decoding. Document adapters must bound encoded/decoded input
|
||||
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "ExactWire", into = "ExactWire")]
|
||||
pub struct Exact {
|
||||
numerator: BigInt,
|
||||
denominator: BigInt,
|
||||
}
|
||||
|
||||
impl Exact {
|
||||
pub fn from_integer(value: i64) -> Self {
|
||||
Self {
|
||||
numerator: BigInt::from(value),
|
||||
denominator: BigInt::one(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.numerator.is_zero()
|
||||
}
|
||||
|
||||
pub fn is_positive(&self) -> bool {
|
||||
self.numerator.is_positive()
|
||||
}
|
||||
|
||||
pub fn component_bits(&self) -> (u64, u64) {
|
||||
(self.numerator.bits(), self.denominator.bits())
|
||||
}
|
||||
|
||||
pub fn to_wire(&self) -> ExactWire {
|
||||
ExactWire {
|
||||
numerator: self.numerator.to_string(),
|
||||
denominator: self.denominator.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical *literal* spelling, not a generic expression formatter.
|
||||
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
|
||||
/// belongs to the future expression parser's UnaryNegate node instead.
|
||||
pub fn canonical_literal(&self) -> String {
|
||||
if self.denominator.is_one() && !self.numerator.is_negative() {
|
||||
self.numerator.to_string()
|
||||
} else {
|
||||
format!("ratio({}, {})", self.numerator, self.denominator)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let n_digits = wire_digits(&wire.numerator, true)?;
|
||||
let d_digits = wire_digits(&wire.denominator, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseWire,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let numerator = parse_integer(&wire.numerator)?;
|
||||
let denominator = parse_integer(&wire.denominator)?;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if !numerator.gcd(&denominator).is_one() {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
|
||||
/// It does not parse expressions, unary operators, units, calls or names.
|
||||
/// Source ratios/decimals may reduce; wire values must already be canonical.
|
||||
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if source.len() > MAX_LITERAL_BYTES {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let source = trim_ascii(source);
|
||||
if let Some(body) = source.strip_prefix("ratio(") {
|
||||
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
|
||||
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
|
||||
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
|
||||
}
|
||||
Self::parse_decimal(source, meter)
|
||||
}
|
||||
|
||||
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let n_digits = source_integer_digits(n, true)?;
|
||||
let d_digits = source_integer_digits(d, false)?;
|
||||
let n_bound = decimal_bits(n_digits.len());
|
||||
let d_bound = decimal_bits(d_digits.len());
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(n_digits.len(), n_bound)
|
||||
+ parse_cost(d_digits.len(), d_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
|
||||
}
|
||||
|
||||
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let mut parts = source.split(['e', 'E']);
|
||||
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
|
||||
let exponent = match parts.next() {
|
||||
Some(text) => parse_exponent(text)?,
|
||||
None => 0,
|
||||
};
|
||||
if parts.next().is_some() {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let (whole, fraction) = match mantissa.split_once('.') {
|
||||
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
|
||||
Some(_) => return Err(NumericError::InvalidLiteral),
|
||||
None => (mantissa, ""),
|
||||
};
|
||||
source_integer_digits(whole, false)?;
|
||||
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let digit_count = whole.len() + fraction.len();
|
||||
if digit_count > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
let adjustment = exponent - fraction.len() as i32;
|
||||
let power = adjustment.unsigned_abs();
|
||||
let mantissa_bound = decimal_bits(digit_count);
|
||||
let scale_bound = decimal_bits(power as usize + 1);
|
||||
let (n_bound, d_bound) = if adjustment >= 0 {
|
||||
(mantissa_bound + scale_bound, 1)
|
||||
} else {
|
||||
(mantissa_bound, scale_bound)
|
||||
};
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
// Fixed logical allowance for constructing 10^power; independent of
|
||||
// num-bigint's selected multiplication/exponentiation implementation.
|
||||
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
|
||||
meter.numeric(
|
||||
NumericOperation::ParseLiteral,
|
||||
parse_cost(digit_count, mantissa_bound)
|
||||
+ power_steps * product_cost(scale_bound, scale_bound)
|
||||
+ product_cost(mantissa_bound, scale_bound)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let digits = format!("{whole}{fraction}");
|
||||
let mut numerator = parse_integer(&digits)?;
|
||||
let scale = BigInt::from(10_u8).pow(power);
|
||||
let denominator = if adjustment >= 0 {
|
||||
numerator *= scale;
|
||||
BigInt::one()
|
||||
} else {
|
||||
scale
|
||||
};
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, false, meter)
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
self.add_or_subtract(rhs, true, meter)
|
||||
}
|
||||
|
||||
fn add_or_subtract(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
subtract: bool,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
let left_bound = a + d;
|
||||
let right_bound = c + b;
|
||||
let n_bound = left_bound.max(right_bound) + 1;
|
||||
let d_bound = b + d;
|
||||
Self::check_estimates(n_bound, d_bound)?;
|
||||
meter.numeric(
|
||||
if subtract {
|
||||
NumericOperation::Subtract
|
||||
} else {
|
||||
NumericOperation::Add
|
||||
},
|
||||
product_cost(a, d)
|
||||
+ product_cost(c, b)
|
||||
+ sum_cost(left_bound, right_bound)
|
||||
+ product_cost(b, d)
|
||||
+ normalization_cost(n_bound, d_bound),
|
||||
)?;
|
||||
let left = &self.numerator * &rhs.denominator;
|
||||
let right = &rhs.numerator * &self.denominator;
|
||||
let numerator = if subtract { left - right } else { left + right };
|
||||
let denominator = &self.denominator * &rhs.denominator;
|
||||
Self::normalize_precharged(numerator, denominator)
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + c, b + d)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Multiply,
|
||||
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
|
||||
)?;
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.numerator,
|
||||
&self.denominator * &rhs.denominator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates(a + d, b + c)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Divide,
|
||||
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
|
||||
)?;
|
||||
if rhs.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Self::normalize_precharged(
|
||||
&self.numerator * &rhs.denominator,
|
||||
&self.denominator * &rhs.numerator,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
let (a, b) = self.component_bits();
|
||||
let (c, d) = rhs.component_bits();
|
||||
Self::check_estimates((a + d).max(c + b), 1)?;
|
||||
meter.numeric(
|
||||
NumericOperation::Compare,
|
||||
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
|
||||
)?;
|
||||
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: -&self.numerator,
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
|
||||
Ok(Self {
|
||||
numerator: self.numerator.abs(),
|
||||
denominator: self.denominator.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
|
||||
if self.is_zero() {
|
||||
return Err(NumericError::DivisionByZero);
|
||||
}
|
||||
Ok(Self {
|
||||
numerator: if self.numerator.is_negative() {
|
||||
-&self.denominator
|
||||
} else {
|
||||
self.denominator.clone()
|
||||
},
|
||||
denominator: self.numerator.abs(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
|
||||
/// Each reduced stage obeys the same component bound; no large power is
|
||||
/// allocated and subsequently discarded after discovering it is oversized.
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.numeric(NumericOperation::Power, self.copy_cost())?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.is_zero() && exponent == 0 {
|
||||
return Err(NumericError::ZeroPowerZero);
|
||||
}
|
||||
let factor = if exponent < 0 {
|
||||
self.reciprocal(meter)?
|
||||
} else {
|
||||
self.clone()
|
||||
};
|
||||
let mut value = Self::from_integer(1);
|
||||
for _ in 0..exponent.unsigned_abs() {
|
||||
value = value.multiply(&factor, meter)?;
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub(super) fn copy_cost(&self) -> u64 {
|
||||
let (n, d) = self.component_bits();
|
||||
1 + words(n) + words(d)
|
||||
}
|
||||
|
||||
fn normalize_precharged(
|
||||
mut numerator: BigInt,
|
||||
mut denominator: BigInt,
|
||||
) -> Result<Self, NumericError> {
|
||||
Self::check_bits(
|
||||
&numerator,
|
||||
&denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"intermediate",
|
||||
)?;
|
||||
if denominator.is_zero() {
|
||||
return Err(NumericError::ZeroDenominator);
|
||||
}
|
||||
if denominator.is_negative() {
|
||||
numerator = -numerator;
|
||||
denominator = -denominator;
|
||||
}
|
||||
let gcd = numerator.gcd(&denominator);
|
||||
numerator /= &gcd;
|
||||
denominator /= gcd;
|
||||
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
|
||||
Ok(Self {
|
||||
numerator,
|
||||
denominator,
|
||||
})
|
||||
}
|
||||
|
||||
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(
|
||||
n,
|
||||
NumberComponent::Numerator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)?;
|
||||
Self::check_bit_count(
|
||||
d,
|
||||
NumberComponent::Denominator,
|
||||
MAX_INTERMEDIATE_BITS,
|
||||
"predicted",
|
||||
)
|
||||
}
|
||||
|
||||
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
|
||||
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
|
||||
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
|
||||
}
|
||||
|
||||
fn check_bit_count(
|
||||
bits: u64,
|
||||
component: NumberComponent,
|
||||
limit: u64,
|
||||
stage: &str,
|
||||
) -> Result<(), NumericError> {
|
||||
if bits > limit {
|
||||
return Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component,
|
||||
bits,
|
||||
limit,
|
||||
stage: stage.to_owned(),
|
||||
}));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<ExactWire> for Exact {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
|
||||
// BigInt validation is bounded after ExactWire strings have decoded.
|
||||
// The adapter owns envelope/text/aggregate admission bounds and must not
|
||||
// reset evaluation meters.
|
||||
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Exact> for ExactWire {
|
||||
fn from(value: Exact) -> Self {
|
||||
value.to_wire()
|
||||
}
|
||||
}
|
||||
|
||||
fn trim_ascii(text: &str) -> &str {
|
||||
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
|
||||
}
|
||||
|
||||
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = if signed {
|
||||
text.strip_prefix('-').unwrap_or(text)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
if digits.len() > MAX_COMPONENT_DIGITS {
|
||||
return Err(NumericError::LiteralTooLong);
|
||||
}
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
|
||||
let digits = source_integer_digits(text, signed).map_err(|error| match error {
|
||||
NumericError::LiteralTooLong => error,
|
||||
_ => NumericError::NonCanonicalWire,
|
||||
})?;
|
||||
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
|
||||
return Err(NumericError::NonCanonicalWire);
|
||||
}
|
||||
Ok(digits)
|
||||
}
|
||||
|
||||
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
|
||||
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
|
||||
}
|
||||
|
||||
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
|
||||
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
|
||||
(true, digits)
|
||||
} else {
|
||||
(false, text.strip_prefix('+').unwrap_or(text))
|
||||
};
|
||||
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return Err(NumericError::InvalidLiteral);
|
||||
}
|
||||
let mut value = 0_i32;
|
||||
for digit in digits.bytes() {
|
||||
value = value * 10 + i32::from(digit - b'0');
|
||||
if value > MAX_DECIMAL_EXPONENT {
|
||||
return Err(NumericError::DecimalExponent);
|
||||
}
|
||||
}
|
||||
Ok(if negative { -value } else { value })
|
||||
}
|
||||
|
||||
fn decimal_bits(digits: usize) -> u64 {
|
||||
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
|
||||
(digits as u64 * 3322).div_ceil(1000).max(1)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
[
|
||||
{
|
||||
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
}
|
||||
},
|
||||
"name": "ten_to_300"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
|
||||
"numerator": "1"
|
||||
}
|
||||
},
|
||||
"name": "one_over_two_to_4095"
|
||||
},
|
||||
{
|
||||
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
|
||||
"expression": {
|
||||
"Number": {
|
||||
"denominator": "1",
|
||||
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
|
||||
}
|
||||
},
|
||||
"name": "negative_4096_bit_bound"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,240 @@
|
||||
use super::{NumericError, NumericLimit};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use thiserror::Error;
|
||||
|
||||
/// Every event is charged once per dynamic occurrence, before entering it.
|
||||
///
|
||||
/// A Call expression therefore charges Expression and CalculationEntry; a
|
||||
/// Property read charges Expression and PropertyBinding. List element visits
|
||||
/// are additional to evaluation of their expressions. Only executed branches
|
||||
/// are charged. Targets are sorted by category (properties, then checks), then
|
||||
/// stable UUID bytes, by the integrating evaluator.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum EvaluationStep {
|
||||
Target,
|
||||
Expression,
|
||||
CalculationEntry,
|
||||
PropertyBinding,
|
||||
ListElement,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NumericOperation {
|
||||
ParseWire,
|
||||
ParseLiteral,
|
||||
Add,
|
||||
Subtract,
|
||||
Multiply,
|
||||
Divide,
|
||||
Compare,
|
||||
Negate,
|
||||
Absolute,
|
||||
Reciprocal,
|
||||
Power,
|
||||
KindAssertion,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkResource {
|
||||
DynamicEvents,
|
||||
NumericUnits,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum WorkSite {
|
||||
Evaluation(EvaluationStep),
|
||||
Numeric(NumericOperation),
|
||||
}
|
||||
|
||||
/// Failed reservations consume no partial charge. The first failure is latched.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
|
||||
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
|
||||
pub struct WorkExhaustion {
|
||||
pub resource: WorkResource,
|
||||
pub used: u64,
|
||||
pub requested: u64,
|
||||
pub limit: u64,
|
||||
pub site: WorkSite,
|
||||
}
|
||||
|
||||
/// Provisional calibration ceilings, not measured execution-time guarantees.
|
||||
///
|
||||
/// Effective limits, profile identity, capture, targets and context must all be
|
||||
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
|
||||
pub struct WorkLimits {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct WorkLimitsWire {
|
||||
dynamic_events: u64,
|
||||
numeric_units: u64,
|
||||
}
|
||||
|
||||
impl WorkLimits {
|
||||
pub const REFERENCE: Self = Self {
|
||||
dynamic_events: 1_000_000,
|
||||
numeric_units: 50_000_000,
|
||||
};
|
||||
|
||||
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
|
||||
if dynamic_events > Self::REFERENCE.dynamic_events
|
||||
|| numeric_units > Self::REFERENCE.numeric_units
|
||||
{
|
||||
return Err(NumericError::InvalidWorkLimits);
|
||||
}
|
||||
Ok(Self {
|
||||
dynamic_events,
|
||||
numeric_units,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dynamic_events(self) -> u64 {
|
||||
self.dynamic_events
|
||||
}
|
||||
|
||||
pub fn numeric_units(self) -> u64 {
|
||||
self.numeric_units
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<WorkLimitsWire> for WorkLimits {
|
||||
type Error = NumericError;
|
||||
|
||||
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
|
||||
Self::new(value.dynamic_events, value.numeric_units)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkLimits> for WorkLimitsWire {
|
||||
fn from(value: WorkLimits) -> Self {
|
||||
Self {
|
||||
dynamic_events: value.dynamic_events,
|
||||
numeric_units: value.numeric_units,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkUsage {
|
||||
pub dynamic_events: u64,
|
||||
pub numeric_units: u64,
|
||||
}
|
||||
|
||||
/// Intentionally neither Clone nor Default: pass the same mutable meter through
|
||||
/// every target, numeric operation and expanded call in one evaluation request.
|
||||
///
|
||||
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
|
||||
/// mark already collected causes as incomplete. Replaying an aggregate cache
|
||||
/// cost is insufficient if it changes the exact exhaustion location; initially
|
||||
/// retain logical traversal even when numerical values come from a cache.
|
||||
#[derive(Debug)]
|
||||
pub struct WorkMeter {
|
||||
limits: WorkLimits,
|
||||
usage: WorkUsage,
|
||||
exhausted: Option<WorkExhaustion>,
|
||||
}
|
||||
|
||||
impl WorkMeter {
|
||||
pub fn new(limits: WorkLimits) -> Self {
|
||||
Self {
|
||||
limits,
|
||||
usage: WorkUsage::default(),
|
||||
exhausted: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn limits(&self) -> WorkLimits {
|
||||
self.limits
|
||||
}
|
||||
|
||||
pub fn usage(&self) -> WorkUsage {
|
||||
self.usage
|
||||
}
|
||||
|
||||
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
|
||||
self.exhausted.as_ref()
|
||||
}
|
||||
|
||||
pub fn ensure_active(&self) -> Result<(), NumericError> {
|
||||
match &self.exhausted {
|
||||
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
|
||||
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
|
||||
}
|
||||
|
||||
pub(super) fn numeric(
|
||||
&mut self,
|
||||
operation: NumericOperation,
|
||||
units: u64,
|
||||
) -> Result<(), NumericError> {
|
||||
self.reserve(
|
||||
WorkResource::NumericUnits,
|
||||
units,
|
||||
WorkSite::Numeric(operation),
|
||||
)
|
||||
}
|
||||
|
||||
fn reserve(
|
||||
&mut self,
|
||||
resource: WorkResource,
|
||||
requested: u64,
|
||||
site: WorkSite,
|
||||
) -> Result<(), NumericError> {
|
||||
self.ensure_active()?;
|
||||
let (used, limit) = match resource {
|
||||
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
|
||||
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
|
||||
};
|
||||
// Subtraction is safe: only successful reservations update usage.
|
||||
if requested > limit - used {
|
||||
let failure = WorkExhaustion {
|
||||
resource,
|
||||
used,
|
||||
requested,
|
||||
limit,
|
||||
site,
|
||||
};
|
||||
self.exhausted = Some(failure.clone());
|
||||
return Err(NumericError::Limit(NumericLimit::Work(failure)));
|
||||
}
|
||||
match resource {
|
||||
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
|
||||
WorkResource::NumericUnits => self.usage.numeric_units += requested,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Logical 64-bit words are platform independent. These are deterministic
|
||||
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
|
||||
pub(super) fn words(bits: u64) -> u64 {
|
||||
bits.max(1).div_ceil(64)
|
||||
}
|
||||
|
||||
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits) * words(b_bits)
|
||||
}
|
||||
|
||||
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
|
||||
1 + words(a_bits.max(b_bits))
|
||||
}
|
||||
|
||||
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
|
||||
let width = words(n_bits.max(d_bits));
|
||||
// 64*w² for GCD plus 2*w² for the two quotient operations.
|
||||
1 + 66 * width * width
|
||||
}
|
||||
|
||||
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
|
||||
1 + digits as u64 * words(estimated_bits)
|
||||
}
|
||||
@@ -0,0 +1,378 @@
|
||||
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
|
||||
use std::cmp::Ordering;
|
||||
|
||||
/// K is supplied by the common resolver's verified nominal compatibility
|
||||
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
|
||||
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
|
||||
/// Exact definition references and pins remain the caller's provenance.
|
||||
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
|
||||
/// comparing an unbounded rooted contract graph is resolver admission work and
|
||||
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
|
||||
/// not enforce this resource contract.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedKind<K> {
|
||||
key: K,
|
||||
dimension: Dimension,
|
||||
difference: Option<K>,
|
||||
}
|
||||
|
||||
impl<K> ResolvedKind<K> {
|
||||
pub fn linear(key: K, dimension: Dimension) -> Self {
|
||||
Self {
|
||||
key,
|
||||
dimension,
|
||||
difference: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn key(&self) -> &K {
|
||||
&self.key
|
||||
}
|
||||
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
self.dimension
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
self.difference.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> ResolvedKind<K> {
|
||||
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
|
||||
if difference.is_affine() || key == difference.key {
|
||||
return Err(NumericError::DifferenceKind);
|
||||
}
|
||||
Ok(Self {
|
||||
key,
|
||||
dimension: difference.dimension,
|
||||
difference: Some(difference.key.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn difference_kind(&self) -> Option<Self> {
|
||||
self.difference
|
||||
.as_ref()
|
||||
.map(|key| Self::linear(key.clone(), self.dimension))
|
||||
}
|
||||
}
|
||||
|
||||
/// Canonical coordinate = magnitude * scale + offset.
|
||||
/// Unit symbols and display preferences are outside this numeric representation.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Unit<K> {
|
||||
kind: ResolvedKind<K>,
|
||||
scale: Exact,
|
||||
offset: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> Unit<K> {
|
||||
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
|
||||
if !scale.is_positive() {
|
||||
return Err(NumericError::UnitScale);
|
||||
}
|
||||
if !kind.is_affine() && !offset.is_zero() {
|
||||
return Err(NumericError::LinearOffset);
|
||||
}
|
||||
Ok(Self {
|
||||
kind,
|
||||
scale,
|
||||
offset,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> &ResolvedKind<K> {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
pub fn scale(&self) -> &Exact {
|
||||
&self.scale
|
||||
}
|
||||
|
||||
pub fn offset(&self) -> &Exact {
|
||||
&self.offset
|
||||
}
|
||||
|
||||
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
|
||||
/// A mathematically cancelling wider intermediate still reports NumericLimit;
|
||||
/// replacing this path with a fused operation requires a new work profile.
|
||||
pub fn to_canonical(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
magnitude
|
||||
.multiply(&self.scale, meter)?
|
||||
.add(&self.offset, meter)
|
||||
}
|
||||
|
||||
/// Fixed subtract-then-divide path; never rounds a coordinate.
|
||||
pub fn from_canonical(
|
||||
&self,
|
||||
coordinate: &Exact,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
coordinate
|
||||
.subtract(&self.offset, meter)?
|
||||
.divide(&self.scale, meter)
|
||||
}
|
||||
|
||||
pub fn convert(
|
||||
&self,
|
||||
magnitude: &Exact,
|
||||
target: &Self,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.kind != target.kind {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal numerical types. Boolean/list/check semantics belong to the common
|
||||
/// expression evaluator. Anonymous zero dimension normalizes to Number.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NumericType<K> {
|
||||
Number,
|
||||
Named(ResolvedKind<K>),
|
||||
Anonymous(Dimension),
|
||||
}
|
||||
|
||||
impl<K> NumericType<K> {
|
||||
pub fn dimension(&self) -> Dimension {
|
||||
match self {
|
||||
Self::Number => Dimension::ZERO,
|
||||
Self::Named(kind) => kind.dimension,
|
||||
Self::Anonymous(dimension) => *dimension,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_affine(&self) -> bool {
|
||||
matches!(self, Self::Named(kind) if kind.is_affine())
|
||||
}
|
||||
|
||||
fn anonymous(dimension: Dimension) -> Self {
|
||||
if dimension.is_dimensionless() {
|
||||
Self::Number
|
||||
} else {
|
||||
Self::Anonymous(dimension)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(self) -> Self {
|
||||
match self {
|
||||
Self::Anonymous(dimension) => Self::anonymous(dimension),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A canonical rational coordinate with an internal numerical type.
|
||||
///
|
||||
/// Derived Eq compares stored representation. Formal equality/ordering must call
|
||||
/// compare(), which rejects mismatched nominal kinds instead of treating them
|
||||
/// as a false predicate. Public property output must still check its declared
|
||||
/// type and anchor the result to that declaration's exact definition reference.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NumericValue<K> {
|
||||
value_type: NumericType<K>,
|
||||
coordinate: Exact,
|
||||
}
|
||||
|
||||
impl<K: Clone + Eq> NumericValue<K> {
|
||||
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
|
||||
Self {
|
||||
value_type: value_type.normalized(),
|
||||
coordinate,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn number(coordinate: Exact) -> Self {
|
||||
Self::new(NumericType::Number, coordinate)
|
||||
}
|
||||
|
||||
pub fn from_unit(
|
||||
magnitude: &Exact,
|
||||
unit: &Unit<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(unit.kind.clone()),
|
||||
unit.to_canonical(magnitude, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn value_type(&self) -> &NumericType<K> {
|
||||
&self.value_type
|
||||
}
|
||||
|
||||
pub fn coordinate(&self) -> &Exact {
|
||||
&self.coordinate
|
||||
}
|
||||
|
||||
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
unit.from_canonical(&self.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.add(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.additive_type(rhs, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.subtract(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn additive_type(&self, rhs: &Self, subtract: bool) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::{Anonymous, Named, Number};
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(Anonymous(left), Anonymous(right)) if left == right => Ok(Anonymous(*left)),
|
||||
(Named(left), Named(right)) => match (&left.difference, &right.difference) {
|
||||
(None, None) if left == right => Ok(Named(left.clone())),
|
||||
(Some(difference), Some(_)) if subtract && left == right => Ok(Named(
|
||||
ResolvedKind::linear(difference.clone(), left.dimension),
|
||||
)),
|
||||
(Some(difference), None)
|
||||
if difference == &right.key && left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(left.clone()))
|
||||
}
|
||||
(None, Some(difference))
|
||||
if !subtract
|
||||
&& difference == &left.key
|
||||
&& left.dimension == right.dimension =>
|
||||
{
|
||||
Ok(Named(right.clone()))
|
||||
}
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
},
|
||||
_ => Err(NumericError::IncompatibleTypes),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, false)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.multiply(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
let output = self.multiplicative_type(rhs, true)?;
|
||||
Ok(Self::new(
|
||||
output,
|
||||
self.coordinate.divide(&rhs.coordinate, meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
fn multiplicative_type(
|
||||
&self,
|
||||
rhs: &Self,
|
||||
divide: bool,
|
||||
) -> Result<NumericType<K>, NumericError> {
|
||||
use NumericType::Number;
|
||||
if self.value_type.is_affine() || rhs.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
match (&self.value_type, &rhs.value_type) {
|
||||
(Number, Number) => Ok(Number),
|
||||
(_, Number) => Ok(self.value_type.clone()),
|
||||
(Number, _) if !divide => Ok(rhs.value_type.clone()),
|
||||
_ => {
|
||||
let left = self.value_type.dimension();
|
||||
let right = rhs.value_type.dimension();
|
||||
let dimension = if divide {
|
||||
left.checked_quotient(right)?
|
||||
} else {
|
||||
left.checked_product(right)?
|
||||
};
|
||||
Ok(NumericType::anonymous(dimension))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.negated(meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
Ok(Self::new(
|
||||
self.value_type.clone(),
|
||||
self.coordinate.absolute(meter)?,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
|
||||
return Err(NumericError::IntegerPower);
|
||||
}
|
||||
if self.value_type.is_affine() {
|
||||
return Err(NumericError::AffineOperation);
|
||||
}
|
||||
let output = match exponent {
|
||||
0 => NumericType::Number,
|
||||
1 => self.value_type.clone(),
|
||||
_ => NumericType::anonymous(self.value_type.dimension().checked_power(exponent)?),
|
||||
};
|
||||
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
|
||||
}
|
||||
|
||||
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if self.value_type != rhs.value_type {
|
||||
return Err(NumericError::IncompatibleTypes);
|
||||
}
|
||||
self.coordinate.compare(&rhs.coordinate, meter)
|
||||
}
|
||||
|
||||
pub fn as_kind(
|
||||
&self,
|
||||
target: &ResolvedKind<K>,
|
||||
meter: &mut WorkMeter,
|
||||
) -> Result<Self, NumericError> {
|
||||
meter.ensure_active()?;
|
||||
if target.is_affine()
|
||||
|| matches!(self.value_type, NumericType::Named(_))
|
||||
|| self.value_type.dimension() != target.dimension
|
||||
{
|
||||
return Err(NumericError::KindAssertion);
|
||||
}
|
||||
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
|
||||
Ok(Self::new(
|
||||
NumericType::Named(target.clone()),
|
||||
self.coordinate.clone(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,804 @@
|
||||
use super::*;
|
||||
use num_bigint::BigInt;
|
||||
use num_traits::One;
|
||||
use std::cmp::Ordering;
|
||||
|
||||
fn meter() -> WorkMeter {
|
||||
WorkMeter::new(WorkLimits::REFERENCE)
|
||||
}
|
||||
|
||||
fn exact(n: impl ToString, d: impl ToString) -> Exact {
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn source(text: &str) -> Exact {
|
||||
Exact::parse_literal(text, &mut meter()).unwrap()
|
||||
}
|
||||
|
||||
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
|
||||
assert_eq!(
|
||||
value.to_wire(),
|
||||
ExactWire {
|
||||
numerator: n.to_string(),
|
||||
denominator: d.to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Independent bounded i128 oracle. It does not call the production Exact/GCD
|
||||
// implementation when deriving expected arithmetic or ordering.
|
||||
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
|
||||
assert_ne!(d, 0);
|
||||
if d < 0 {
|
||||
n = -n;
|
||||
d = -d;
|
||||
}
|
||||
let (mut a, mut b) = (n.abs(), d);
|
||||
while b != 0 {
|
||||
(a, b) = (b, a % b);
|
||||
}
|
||||
(n / a, d / a)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arithmetic_matches_independent_small_integer_oracle() {
|
||||
for a in [-5_i128, -2, 0, 1, 4] {
|
||||
for b in [1_i128, 2, 3, 7] {
|
||||
for c in [-5_i128, -2, 0, 1, 4] {
|
||||
for d in [1_i128, 2, 3, 7] {
|
||||
let (an, ad) = small_fraction(a, b);
|
||||
let (bn, bd) = small_fraction(c, d);
|
||||
let left = exact(an, ad);
|
||||
let right = exact(bn, bd);
|
||||
let mut work = meter();
|
||||
let (n, den) = small_fraction(a * d + c * b, b * d);
|
||||
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * d - c * b, b * d);
|
||||
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
|
||||
let (n, den) = small_fraction(a * c, b * d);
|
||||
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
|
||||
if c != 0 {
|
||||
let (n, den) = small_fraction(a * d, b * c);
|
||||
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
|
||||
} else {
|
||||
assert_eq!(
|
||||
left.divide(&right, &mut work),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
left.compare(&right, &mut work).unwrap(),
|
||||
(a * d).cmp(&(c * b))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decimals_are_exact_and_source_ratios_reduce() {
|
||||
assert_fraction(
|
||||
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
|
||||
3,
|
||||
10,
|
||||
);
|
||||
assert_fraction(&source("0.50"), 1, 2);
|
||||
assert_fraction(&source("12.50e-2"), 1, 8);
|
||||
assert_fraction(&source("1e+3"), 1000, 1);
|
||||
assert_fraction(&source("0001.2500"), 5, 4);
|
||||
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
|
||||
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
|
||||
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
|
||||
assert_eq!(source("42").canonical_literal(), "42");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn powers_and_signs_have_explicit_domains() {
|
||||
let x = exact(-2, 3);
|
||||
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
|
||||
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
|
||||
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
|
||||
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
|
||||
let zero = Exact::from_integer(0);
|
||||
assert_eq!(
|
||||
zero.power(0, &mut meter()),
|
||||
Err(NumericError::ZeroPowerZero)
|
||||
);
|
||||
assert_eq!(
|
||||
zero.power(-1, &mut meter()),
|
||||
Err(NumericError::DivisionByZero)
|
||||
);
|
||||
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
|
||||
assert_eq!(
|
||||
x.power(i8::MIN, &mut meter()),
|
||||
Err(NumericError::IntegerPower)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_wire_rejects_both_component_noncanonical_forms() {
|
||||
for (n, d) in [
|
||||
("+1", "1"),
|
||||
("01", "1"),
|
||||
("-0", "1"),
|
||||
("1", "+1"),
|
||||
("1", "-1"),
|
||||
("1", "01"),
|
||||
("2", "4"),
|
||||
("0", "3"),
|
||||
("", "1"),
|
||||
("1", ""),
|
||||
("1.0", "1"),
|
||||
("1", "1.0"),
|
||||
("1e1", "1"),
|
||||
("1", "1e1"),
|
||||
(" 1", "1"),
|
||||
("1", "1 "),
|
||||
] {
|
||||
assert!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: n.into(),
|
||||
denominator: d.into()
|
||||
},
|
||||
&mut meter(),
|
||||
)
|
||||
.is_err(),
|
||||
"accepted {n}/{d}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
Exact::from_wire(
|
||||
&ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: "0".into()
|
||||
},
|
||||
&mut meter()
|
||||
),
|
||||
Err(NumericError::ZeroDenominator)
|
||||
);
|
||||
assert_fraction(&exact(0, 1), 0, 1);
|
||||
assert_fraction(&exact(-1, 2), -1, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_uses_closed_canonical_string_wire() {
|
||||
let value = exact(-3, 7);
|
||||
let encoded = serde_json::to_value(&value).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"numerator":"-3","denominator":"7"})
|
||||
);
|
||||
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
|
||||
for invalid in [
|
||||
r#"{"numerator":1,"denominator":"2"}"#,
|
||||
r#"{"numerator":"1","denominator":2}"#,
|
||||
r#"{"numerator":"1","denominator":"2","other":0}"#,
|
||||
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
|
||||
r#"{"numerator":"1"}"#,
|
||||
r#"{"numerator":"2","denominator":"4"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_reader_does_not_fold_expression_unary_minus() {
|
||||
assert_eq!(
|
||||
Exact::parse_literal("-2", &mut meter()),
|
||||
Err(NumericError::InvalidLiteral)
|
||||
);
|
||||
let negative_literal = source("ratio(-2, 1)");
|
||||
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
|
||||
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
|
||||
// Equality of coordinates does not claim AST equality. The expression
|
||||
// parser must preserve UnaryNegate independently of its operand literal.
|
||||
assert_eq!(source("2").canonical_literal(), "2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_syntax_and_decimal_exponent_are_bounded() {
|
||||
for invalid in [
|
||||
"",
|
||||
"-",
|
||||
"+1",
|
||||
".5",
|
||||
"1.",
|
||||
"1.2.3",
|
||||
"1e",
|
||||
"1ee2",
|
||||
"1e+-2",
|
||||
"1_000",
|
||||
"NaN",
|
||||
"Infinity",
|
||||
"1/2",
|
||||
"ratio(1)",
|
||||
"ratio(1,2,3)",
|
||||
"ratio(1,-2)",
|
||||
"ratio(+1,2)",
|
||||
"ratio(1,2) + 1",
|
||||
"12",
|
||||
] {
|
||||
assert!(
|
||||
Exact::parse_literal(invalid, &mut meter()).is_err(),
|
||||
"{invalid}"
|
||||
);
|
||||
}
|
||||
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
|
||||
assert_eq!(
|
||||
Exact::parse_literal(invalid, &mut meter()),
|
||||
Err(NumericError::DecimalExponent)
|
||||
);
|
||||
}
|
||||
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
|
||||
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
|
||||
Err(NumericError::LiteralTooLong)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
|
||||
let packets: serde_json::Value =
|
||||
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
|
||||
for packet in packets.as_array().unwrap() {
|
||||
let wire: ExactWire =
|
||||
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
|
||||
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
|
||||
let spelling = packet["canonical_source"].as_str().unwrap();
|
||||
assert_eq!(expected.canonical_literal(), spelling);
|
||||
assert_eq!(
|
||||
Exact::parse_literal(spelling, &mut meter()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let serialized = serde_json::to_string(&expected).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_str::<Exact>(&serialized).unwrap(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(expected.to_wire(), wire);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
|
||||
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
|
||||
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
|
||||
let n = (BigInt::one() << 4096_usize) - 1_u8;
|
||||
let d = &n - 1_u8;
|
||||
let value = exact(&n, &d);
|
||||
let result = value.add(&value, &mut meter()).unwrap();
|
||||
assert_fraction(&result, &n, &d / 2_u8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_products_cancel_before_reduced_component_limit() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let d = &n - 1_u8;
|
||||
let left = exact(&n, &d);
|
||||
let right = exact(&d, &n);
|
||||
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
|
||||
assert_fraction(
|
||||
&left
|
||||
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
|
||||
.unwrap(),
|
||||
0,
|
||||
1,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_reduced_component_overflows_are_reported() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let mut work = meter();
|
||||
let failure = exact(&n, 1)
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(work.exhaustion().is_none());
|
||||
let failure = exact(1, &n)
|
||||
.divide(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
failure,
|
||||
NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Denominator,
|
||||
bits: 4097,
|
||||
..
|
||||
})
|
||||
));
|
||||
// Value overflow is a nonterminal supported-operation failure; fuel
|
||||
// exhaustion, in contrast, terminates the entire request.
|
||||
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
|
||||
let oversized = (BigInt::one() << 4096_usize).to_string();
|
||||
for wire in [
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: "1".into(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: "1".into(),
|
||||
denominator: oversized.clone(),
|
||||
},
|
||||
ExactWire {
|
||||
numerator: oversized.clone(),
|
||||
denominator: oversized,
|
||||
},
|
||||
] {
|
||||
assert!(matches!(
|
||||
Exact::from_wire(&wire, &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_precharge_precedes_big_integer_result_allocation() {
|
||||
let n = BigInt::one() << 4095_usize;
|
||||
let left = exact(&n, 1);
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
|
||||
let failure = left
|
||||
.multiply(&Exact::from_integer(2), &mut work)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().numeric_units, 0);
|
||||
assert!(matches!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Numeric(NumericOperation::Multiply)
|
||||
));
|
||||
// With available fuel the same operation reaches the 4097-bit result check.
|
||||
assert!(matches!(
|
||||
left.multiply(&Exact::from_integer(2), &mut meter()),
|
||||
Err(NumericError::Limit(NumericLimit::Bits { .. }))
|
||||
));
|
||||
let too_wide_wire = ExactWire {
|
||||
numerator: (BigInt::one() << 4096_usize).to_string(),
|
||||
denominator: "1".into(),
|
||||
};
|
||||
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
assert!(
|
||||
Exact::from_wire(&too_wide_wire, &mut parse_work)
|
||||
.unwrap_err()
|
||||
.is_terminal_work_exhaustion()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisional_limits_and_effective_limits_round_trip() {
|
||||
let limits = WorkLimits::new(500, 2000).unwrap();
|
||||
let encoded = serde_json::to_value(limits).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
|
||||
limits
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(1_000_001, 0),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert_eq!(
|
||||
WorkLimits::new(0, 50_000_001),
|
||||
Err(NumericError::InvalidWorkLimits)
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
|
||||
// One-word addition: three products at 2, one sum at 2,
|
||||
// normalization at 67 => 75 logical numeric units.
|
||||
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
|
||||
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
|
||||
assert_eq!(work.usage().numeric_units, 75);
|
||||
assert!(work.exhaustion().is_none());
|
||||
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
|
||||
let at_failure = work.usage();
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap(),
|
||||
&WorkExhaustion {
|
||||
resource: WorkResource::NumericUnits,
|
||||
used: 75,
|
||||
requested: 3,
|
||||
limit: 75,
|
||||
site: WorkSite::Numeric(NumericOperation::Negate),
|
||||
}
|
||||
);
|
||||
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
|
||||
assert_eq!(
|
||||
Exact::from_integer(1)
|
||||
.divide(&Exact::from_integer(0), &mut work)
|
||||
.unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
first
|
||||
);
|
||||
assert_eq!(work.usage(), at_failure);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap();
|
||||
let failure = work
|
||||
.numeric(NumericOperation::Multiply, u64::MAX)
|
||||
.unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage(), WorkUsage::default());
|
||||
assert_eq!(
|
||||
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
|
||||
failure
|
||||
);
|
||||
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
|
||||
assert!(dynamic.visit(EvaluationStep::Target).is_err());
|
||||
assert_eq!(dynamic.usage(), WorkUsage::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_targets_share_one_aggregate_meter() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
work.visit(EvaluationStep::Expression).unwrap();
|
||||
work.visit(EvaluationStep::Target).unwrap();
|
||||
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(work.usage().dynamic_events, 3);
|
||||
}
|
||||
|
||||
// A direct bounded call-tree workload, not an expression interpreter/dialect.
|
||||
fn expanded_calls(
|
||||
depth: u8,
|
||||
x: u64,
|
||||
work: &mut WorkMeter,
|
||||
entered: &mut u64,
|
||||
) -> Result<u64, NumericError> {
|
||||
work.visit(EvaluationStep::CalculationEntry)?;
|
||||
*entered += 1;
|
||||
if depth == 0 {
|
||||
return Ok(x);
|
||||
}
|
||||
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
|
||||
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
|
||||
Ok(left + right)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
|
||||
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
|
||||
assert!(failure.is_terminal_work_exhaustion());
|
||||
assert_eq!(entered, 10_000);
|
||||
assert_eq!(work.usage().dynamic_events, 10_000);
|
||||
assert_eq!(
|
||||
work.exhaustion().unwrap().site,
|
||||
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
|
||||
);
|
||||
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
|
||||
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
|
||||
let mut entered = 0;
|
||||
assert_eq!(
|
||||
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
|
||||
496
|
||||
);
|
||||
assert_eq!(entered, 63);
|
||||
assert!(bounded.exhaustion().is_none());
|
||||
}
|
||||
|
||||
fn dim(value: [i16; 7]) -> Dimension {
|
||||
Dimension::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
|
||||
ResolvedKind::linear(key.to_owned(), dimension)
|
||||
}
|
||||
|
||||
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
|
||||
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_use_checked_seven_axis_integer_algebra() {
|
||||
let length = dim([1, 0, 0, 0, 0, 0, 0]);
|
||||
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
|
||||
let time = dim([0, 0, 1, 0, 0, 0, 0]);
|
||||
assert_eq!(
|
||||
mass.checked_product(length)
|
||||
.unwrap()
|
||||
.checked_quotient(time.checked_power(2).unwrap())
|
||||
.unwrap(),
|
||||
dim([1, 1, -2, 0, 0, 0, 0])
|
||||
);
|
||||
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
|
||||
assert!(matches!(
|
||||
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
|
||||
Err(NumericError::DimensionExponent {
|
||||
axis: DimensionAxis::Length,
|
||||
value: 33
|
||||
})
|
||||
));
|
||||
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
|
||||
assert!(length.checked_power(i8::MIN).is_err());
|
||||
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
|
||||
assert_eq!(
|
||||
serde_json::to_value(time).unwrap(),
|
||||
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
|
||||
);
|
||||
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
|
||||
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_unit_coordinates_are_exact_and_nominal() {
|
||||
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
|
||||
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
|
||||
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
|
||||
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
|
||||
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
|
||||
let total = receive.add(&dispatch, &mut meter()).unwrap();
|
||||
assert_fraction(total.coordinate(), 7, 4);
|
||||
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
|
||||
let different = Unit::new(
|
||||
linear("different-kind", duration.dimension()),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ms.convert(&source("750"), &different, &mut meter()),
|
||||
Err(NumericError::IncompatibleTypes)
|
||||
);
|
||||
let inconsistent = Unit::new(
|
||||
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
|
||||
source("1"),
|
||||
source("0"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
ms.convert(&source("1"), &inconsistent, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
|
||||
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
|
||||
assert_eq!(
|
||||
Unit::new(duration, source("1"), source("1")).unwrap_err(),
|
||||
NumericError::LinearOffset
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
|
||||
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
|
||||
let fahrenheit = Unit::new(
|
||||
point.clone(),
|
||||
source("ratio(5,9)"),
|
||||
source("ratio(45967,180)"),
|
||||
)
|
||||
.unwrap();
|
||||
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
|
||||
assert_fraction(
|
||||
&celsius
|
||||
.convert(&source("0"), &fahrenheit, &mut meter())
|
||||
.unwrap(),
|
||||
32,
|
||||
1,
|
||||
);
|
||||
assert_fraction(
|
||||
&fahrenheit
|
||||
.convert(&source("70"), &celsius, &mut meter())
|
||||
.unwrap(),
|
||||
190,
|
||||
9,
|
||||
);
|
||||
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
|
||||
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
|
||||
let difference = p20.subtract(&p10, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
difference.value_type(),
|
||||
&NumericType::Named(interval.clone())
|
||||
);
|
||||
assert_fraction(
|
||||
&difference
|
||||
.display_in(&kelvin_interval, &mut meter())
|
||||
.unwrap(),
|
||||
10,
|
||||
1,
|
||||
);
|
||||
let delta5 = named(5, &interval);
|
||||
let p25 = p20.add(&delta5, &mut meter()).unwrap();
|
||||
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
|
||||
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
|
||||
assert!(p20.add(&p10, &mut meter()).is_err());
|
||||
assert!(delta5.subtract(&p20, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
p20.power(1, &mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(
|
||||
p20.negated(&mut meter()),
|
||||
Err(NumericError::AffineOperation)
|
||||
);
|
||||
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
|
||||
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
|
||||
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
|
||||
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
|
||||
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
|
||||
// The fixed multiply stage produces 2^4096, so it must fail before offset
|
||||
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
|
||||
let large = BigInt::one() << 4095_usize;
|
||||
let magnitude = exact(&large, 1);
|
||||
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
|
||||
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
|
||||
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
|
||||
let mut work = meter();
|
||||
for outcome in [
|
||||
unit.to_canonical(&magnitude, &mut work),
|
||||
unit.from_canonical(&magnitude, &mut work),
|
||||
] {
|
||||
assert!(matches!(
|
||||
outcome,
|
||||
Err(NumericError::Limit(NumericLimit::Bits {
|
||||
component: NumberComponent::Numerator,
|
||||
bits: 4097,
|
||||
limit: MAX_COMPONENT_BITS,
|
||||
..
|
||||
}))
|
||||
));
|
||||
}
|
||||
assert!(work.exhaustion().is_none());
|
||||
let half = exact(&large >> 1_usize, 1);
|
||||
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
|
||||
assert_eq!(
|
||||
unit.from_canonical(&Exact::from_integer(0), &mut work)
|
||||
.unwrap(),
|
||||
half
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
|
||||
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let a = named(3, &force)
|
||||
.multiply(&named(4, &length), &mut meter())
|
||||
.unwrap();
|
||||
let b = named(2, &force)
|
||||
.multiply(&named(7, &length), &mut meter())
|
||||
.unwrap();
|
||||
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
|
||||
let sum = a.add(&b, &mut meter()).unwrap();
|
||||
let result = sum.as_kind(&energy, &mut meter()).unwrap();
|
||||
assert_fraction(result.coordinate(), 26, 1);
|
||||
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
|
||||
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
|
||||
assert!(result.add(&sum, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
result.as_kind(&energy, &mut meter()),
|
||||
Err(NumericError::KindAssertion)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
|
||||
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
|
||||
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
|
||||
let result = named(2, &mass)
|
||||
.multiply(&named(3, &length), &mut meter())
|
||||
.unwrap()
|
||||
.divide(&named(2, &time), &mut meter())
|
||||
.unwrap()
|
||||
.as_kind(&momentum, &mut meter())
|
||||
.unwrap();
|
||||
assert_fraction(result.coordinate(), 3, 1);
|
||||
let scalar = NumericValue::number(source("2"));
|
||||
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
|
||||
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
|
||||
assert_fraction(scaled.coordinate(), 6, 1);
|
||||
assert_eq!(
|
||||
named(3, &time)
|
||||
.divide(&scalar, &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Named(time.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
scalar
|
||||
.divide(&named(3, &time), &mut meter())
|
||||
.unwrap()
|
||||
.value_type(),
|
||||
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
|
||||
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
|
||||
let torque = linear("torque", energy.dimension());
|
||||
let left = named(7, &energy);
|
||||
let right = named(7, &torque);
|
||||
assert!(left.add(&right, &mut meter()).is_err());
|
||||
assert!(left.compare(&right, &mut meter()).is_err());
|
||||
let ratio = left.divide(&right, &mut meter()).unwrap();
|
||||
assert_eq!(ratio.value_type(), &NumericType::Number);
|
||||
assert_fraction(ratio.coordinate(), 1, 1);
|
||||
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
|
||||
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
|
||||
assert_eq!(
|
||||
asserted.value_type(),
|
||||
&NumericType::Named(named_ratio.clone())
|
||||
);
|
||||
assert!(asserted.add(&ratio, &mut meter()).is_err());
|
||||
assert_eq!(
|
||||
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
|
||||
NumericValue::number(source("1"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numerical_power_types_and_dimension_overflow_are_checked() {
|
||||
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
|
||||
let value = named(2, &length);
|
||||
assert_eq!(
|
||||
value.power(0, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Number
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(1, &mut meter()).unwrap().value_type(),
|
||||
value.value_type()
|
||||
);
|
||||
assert_eq!(
|
||||
value.power(-2, &mut meter()).unwrap().value_type(),
|
||||
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
|
||||
);
|
||||
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
|
||||
assert!(
|
||||
named(1, &large_dimension)
|
||||
.multiply(&value, &mut meter())
|
||||
.is_err()
|
||||
);
|
||||
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
|
||||
}
|
||||
@@ -81,7 +81,6 @@ fn diagram(
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
mod semantic_source;
|
||||
#[path = "migration_v2/source.rs"]
|
||||
mod v2_source;
|
||||
pub(crate) use crate::source_defaults::Mode as DeclarationFormat;
|
||||
use crate::source_defaults::{self, Context as DefaultContext};
|
||||
use crate::*;
|
||||
use serde_json::{Map, Value};
|
||||
use std::{collections::BTreeMap, fmt};
|
||||
@@ -645,25 +647,34 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
source.len()
|
||||
}
|
||||
|
||||
/// Canonical output uses persistent aliases and explicit identities. Typed
|
||||
/// nested records are ordinary language fields; JSON documents are not embedded.
|
||||
/// Canonical output uses persistent aliases and explicit identities, omitting
|
||||
/// only values supplied by source defaults. Typed nested records are ordinary
|
||||
/// language fields; JSON documents are not embedded.
|
||||
pub fn format(project: &Project) -> String {
|
||||
let mut out = std::format!(
|
||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n",
|
||||
project.alias,
|
||||
quote(&project.name),
|
||||
project.id,
|
||||
quote(&project.description)
|
||||
);
|
||||
for (_, declaration) in format_declarations(project) {
|
||||
if !project.description.is_empty() {
|
||||
out.push_str(&std::format!(
|
||||
" description {};\n",
|
||||
quote(&project.description)
|
||||
));
|
||||
}
|
||||
out.push('\n');
|
||||
for (_, declaration) in format_declarations(project, DeclarationFormat::Compact) {
|
||||
out.push_str(&declaration);
|
||||
}
|
||||
out.push_str("}\n");
|
||||
out
|
||||
}
|
||||
|
||||
/// The same typed declaration writer is used by flat and modular source output.
|
||||
pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
/// Shared typed writer for canonical output and lossless editing. Complete edit
|
||||
/// comparisons retain defaults; emitted declarations and value fragments apply
|
||||
/// only the omission rules valid in their selected source context.
|
||||
pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) -> Vec<(Id, String)> {
|
||||
let aliases = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
@@ -675,6 +686,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut fields = payload(&serde_json::to_value(&e.body).unwrap());
|
||||
fields.insert("description".into(), Value::String(e.description.clone()));
|
||||
fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap());
|
||||
let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode);
|
||||
write_declaration(
|
||||
&mut out,
|
||||
&element_word(e.kind(), e.layer()),
|
||||
@@ -690,6 +702,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut out = String::new();
|
||||
let mut fields = payload(&serde_json::to_value(&r.body).unwrap());
|
||||
fields.insert("description".into(), Value::String(r.description.clone()));
|
||||
let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode);
|
||||
write_declaration(
|
||||
&mut out,
|
||||
&relation_word(r.kind()),
|
||||
@@ -705,6 +718,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut out = String::new();
|
||||
let mut fields = record_fields(d);
|
||||
fields.remove("nodes");
|
||||
let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode);
|
||||
write_declaration(
|
||||
&mut out,
|
||||
"diagram",
|
||||
@@ -719,6 +733,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
let mut out = String::new();
|
||||
let mut fields = record_fields(n);
|
||||
fields.insert("diagram".into(), Value::String(d.id.to_string()));
|
||||
let fields = source_defaults::fields(DefaultContext::Node, &fields, mode);
|
||||
write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases);
|
||||
declarations.push((n.id, out));
|
||||
}
|
||||
@@ -731,7 +746,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
&a.alias,
|
||||
Some(&a.name),
|
||||
a.id,
|
||||
&record_fields(a),
|
||||
&source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode),
|
||||
&aliases,
|
||||
);
|
||||
declarations.push((a.id, out));
|
||||
@@ -744,7 +759,7 @@ pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
|
||||
&t.alias,
|
||||
Some(&t.name),
|
||||
t.id,
|
||||
&record_fields(t),
|
||||
&source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode),
|
||||
&aliases,
|
||||
);
|
||||
declarations.push((t.id, out));
|
||||
@@ -1742,7 +1757,9 @@ pub(crate) fn resolve_declarations(
|
||||
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
||||
}
|
||||
let mut body = serde_json::to_value(initial).unwrap();
|
||||
let defaults = if legacy {
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Element(kind))
|
||||
} else if legacy {
|
||||
crate::persistence::v3::legacy_defaults(kind)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_else(|| payload(&body))
|
||||
@@ -1799,7 +1816,12 @@ pub(crate) fn resolve_declarations(
|
||||
let mut body =
|
||||
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
|
||||
.unwrap();
|
||||
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Relation(kind))
|
||||
} else {
|
||||
payload(&body)
|
||||
};
|
||||
let merged = merge_fields(defaults, fields).map_err(&conversion_error)?;
|
||||
replace_payload(&mut body, merged);
|
||||
let body = serde_json::from_value(body)
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
@@ -1823,8 +1845,11 @@ pub(crate) fn resolve_declarations(
|
||||
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
|
||||
}
|
||||
let defaults =
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default(),"layout":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Diagram)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
record.insert("nodes".into(), serde_json::json!({}));
|
||||
@@ -1839,7 +1864,10 @@ pub(crate) fn resolve_declarations(
|
||||
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Node)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, false);
|
||||
let node: NodePlacement = serde_json::from_value(Value::Object(record))
|
||||
@@ -1851,7 +1879,10 @@ pub(crate) fn resolve_declarations(
|
||||
return Err(conversion_error("Missing required field 'owner'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Artifact)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let artifact = serde_json::from_value(Value::Object(record))
|
||||
@@ -1867,7 +1898,10 @@ pub(crate) fn resolve_declarations(
|
||||
}
|
||||
}
|
||||
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
let defaults = if project.schema_version >= 7 {
|
||||
source_defaults::parser_fields(DefaultContext::Trace)
|
||||
} else { defaults.as_object().unwrap().clone() };
|
||||
let mut record = merge_fields(defaults, fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
let trace = if project.schema_version < 5 {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod analysis_numeric;
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
@@ -8,9 +9,9 @@ pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod routing;
|
||||
pub mod scenario_layout;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
mod source_defaults;
|
||||
pub mod source_files;
|
||||
pub mod source_project;
|
||||
pub mod topology_context;
|
||||
@@ -19,9 +20,6 @@ pub mod validation;
|
||||
pub mod view_options;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use scenario_layout::{
|
||||
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
|
||||
};
|
||||
pub use semantic::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
|
||||
+34
-27
@@ -1436,7 +1436,6 @@ pub enum ProjectionKind {
|
||||
FunctionalDataflow,
|
||||
Capabilities,
|
||||
StateMachine { machine: Id },
|
||||
Scenario { scenario: Id },
|
||||
}
|
||||
impl ProjectionKind {
|
||||
pub fn state_machine(self) -> Option<Id> {
|
||||
@@ -1446,30 +1445,8 @@ impl ProjectionKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The semantic owner of a saved Scenario representation.
|
||||
pub fn scenario(self) -> Option<Id> {
|
||||
match self {
|
||||
Self::Scenario { scenario } => Some(scenario),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly classify architecture views; absence of a state machine is insufficient.
|
||||
pub fn is_architecture(self) -> bool {
|
||||
match self {
|
||||
Self::Overview
|
||||
| Self::Decomposition
|
||||
| Self::Allocation
|
||||
| Self::Traceability
|
||||
| Self::FunctionalDataflow
|
||||
| Self::Capabilities => true,
|
||||
Self::StateMachine { .. } | Self::Scenario { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn includes(self, kind: Kind) -> bool {
|
||||
match self {
|
||||
Self::Scenario { .. } => false,
|
||||
Self::StateMachine { .. } => {
|
||||
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
|
||||
}
|
||||
@@ -1494,7 +1471,6 @@ impl std::fmt::Display for ProjectionKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::StateMachine { .. } => "State machine",
|
||||
Self::Scenario { .. } => "Scenario",
|
||||
Self::Allocation => "Architecture · allocated work",
|
||||
Self::FunctionalDataflow => "Functional dataflow",
|
||||
Self::Capabilities => "Missions & capabilities",
|
||||
@@ -1519,8 +1495,6 @@ pub struct Diagram {
|
||||
pub nodes: BTreeMap<Id, NodePlacement>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<EdgeRoute>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layout: Option<crate::scenario_layout::DiagramLayout>,
|
||||
}
|
||||
/// Authored presentation for one existing relation or trace in one diagram.
|
||||
/// Computed endpoints and automatic routing elbows are never persisted here.
|
||||
@@ -1528,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 {
|
||||
@@ -1947,7 +1955,6 @@ impl Project {
|
||||
view_options: crate::view_options::ViewOptions::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
|
||||
@@ -278,11 +278,6 @@ fn project_diagram_impl(
|
||||
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
|
||||
index: Option<&crate::view_options::ViewIndex>,
|
||||
) -> ProjectedView {
|
||||
// Saved Scenario layout is not an architecture/state projection. Its dedicated
|
||||
// renderer is not wired yet; never interpret its payload through this fallback.
|
||||
if d.projection.scenario().is_some() {
|
||||
return ProjectedView::default();
|
||||
}
|
||||
let parents = parents(p, d);
|
||||
let mut nodes = Vec::new();
|
||||
for n in d.nodes.values() {
|
||||
@@ -399,7 +394,6 @@ fn project_diagram_impl(
|
||||
continue;
|
||||
}
|
||||
let included = match d.projection {
|
||||
ProjectionKind::Scenario { .. } => false,
|
||||
ProjectionKind::StateMachine { machine } => matches!(&r.body,
|
||||
RelationBody::StateTransition { transition_kind, .. }
|
||||
if *transition_kind != TransitionKind::Internal
|
||||
|
||||
@@ -203,81 +203,7 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Every semantic/presentation reference in a diagram, excluding its own identities.
|
||||
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
|
||||
let mut copy = d.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_diagram_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
|
||||
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
|
||||
d.view_options.visit_references(&mut visit);
|
||||
match &mut d.projection {
|
||||
ProjectionKind::StateMachine { machine } => visit(machine),
|
||||
ProjectionKind::Scenario { scenario } => visit(scenario),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(layout) = &mut d.layout {
|
||||
layout.visit_references(&mut visit);
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
visit(&mut route.edge);
|
||||
}
|
||||
for node in d.nodes.values_mut() {
|
||||
for anchor in &mut node.port_anchors {
|
||||
visit(&mut anchor.port);
|
||||
}
|
||||
visit(&mut node.element);
|
||||
}
|
||||
}
|
||||
|
||||
/// Preflight reference-key collisions before the caller changes object identities.
|
||||
/// Fresh targets are allowed. This is not whole-project admission: unrelated
|
||||
/// invalid records remain repairable within a transaction. Future typed reference
|
||||
/// domains must compose their own preflight here before any remap mutation.
|
||||
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
if old == new {
|
||||
return Ok(());
|
||||
}
|
||||
let remap = |id: Id| if id == old { new } else { id };
|
||||
for diagram in p.diagrams.values() {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
continue;
|
||||
};
|
||||
let mut lanes = std::collections::BTreeMap::new();
|
||||
for lane in &layout.lanes {
|
||||
let affected = lane.lifeline == old;
|
||||
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario lane position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut messages = std::collections::BTreeMap::new();
|
||||
for message in &layout.messages {
|
||||
let mut address = message.address.clone();
|
||||
let affected = address.occurrence == old || address.references.contains(&old);
|
||||
address.occurrence = remap(address.occurrence);
|
||||
for reference in &mut address.references {
|
||||
*reference = remap(*reference);
|
||||
}
|
||||
if let Some(previous_affected) = messages.insert(address, affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario message position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
check_remap(p, old, new)?;
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
@@ -293,11 +219,31 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelEr
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
visit_diagram_refs(d, |id| {
|
||||
d.view_options.visit_references(|id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
if let ProjectionKind::StateMachine { machine } = &mut d.projection
|
||||
&& *machine == old
|
||||
{
|
||||
*machine = new;
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
if route.edge == old {
|
||||
route.edge = new;
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
for anchor in &mut n.port_anchors {
|
||||
if anchor.port == old {
|
||||
anchor.port = new;
|
||||
}
|
||||
}
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in p.artifact_links.values_mut() {
|
||||
if a.owner == old {
|
||||
@@ -319,7 +265,6 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelEr
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
@@ -394,8 +339,7 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
|
||||
for d in p.diagrams.values() {
|
||||
if d.projection
|
||||
.state_machine()
|
||||
.or_else(|| d.projection.scenario())
|
||||
.is_some_and(|owner| removed.contains(&owner))
|
||||
.is_some_and(|machine| removed.contains(&machine))
|
||||
{
|
||||
removed.insert(d.id);
|
||||
}
|
||||
@@ -468,7 +412,6 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.retain_references(|id| !removed.contains(id));
|
||||
crate::scenario_layout::prune_deleted(d, &removed);
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
@@ -646,7 +589,3 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "references/scenario_layout_tests.rs"]
|
||||
mod scenario_layout_tests;
|
||||
|
||||
@@ -1,388 +0,0 @@
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
lanes: [Id; 2],
|
||||
messages: [Id; 2],
|
||||
views: [Id; 2],
|
||||
}
|
||||
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) =
|
||||
&mut project.diagrams.get_mut(&view).unwrap().layout
|
||||
else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn admitted(project: &Project) {
|
||||
let errors: Vec<_> = crate::validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == crate::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
|
||||
.unwrap();
|
||||
let lanes = ["Sender", "Receiver"].map(|name| {
|
||||
project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
|
||||
.unwrap()
|
||||
});
|
||||
let messages = ["First message", "Second message"].map(|name| {
|
||||
let id = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(lanes[0]),
|
||||
receiver: Some(lanes[1]),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
id
|
||||
});
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = messages.to_vec();
|
||||
let views = ["delivery_view", "review_view"].map(|alias| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[0],
|
||||
x: 120.,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[1],
|
||||
x: 420.,
|
||||
},
|
||||
],
|
||||
messages: vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[0]),
|
||||
send_y: 220.,
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[1]),
|
||||
send_y: 360.,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
lanes,
|
||||
messages,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
|
||||
let mut f = fixture();
|
||||
let before = f.project.clone();
|
||||
let temporary = Id::new_v4();
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
admitted(&f.project);
|
||||
assert_eq!(
|
||||
f.project.elements[&f.owner].steps().unwrap(),
|
||||
&vec![f.messages[1], f.messages[0]]
|
||||
);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(
|
||||
rows.lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[1],
|
||||
x: 120.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[0],
|
||||
x: 420.
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 220.
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[0]),
|
||||
send_y: 360.
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
// Reverse the swaps: no values or identities other than those requested drift.
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
|
||||
for message in [false, true] {
|
||||
let mut f = fixture();
|
||||
let [old, new] = if message { f.messages } else { f.lanes };
|
||||
let before = f.project.clone();
|
||||
assert!(check_remap(&f.project, old, new).is_err());
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
|
||||
let mut f = fixture();
|
||||
let fresh = Id::new_v4();
|
||||
// A repair candidate can already have a dangling coordinate key. It must not
|
||||
// silently merge with the live lane, even though the destination ID is vacant.
|
||||
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
|
||||
let before = f.project.clone();
|
||||
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
|
||||
let mut f = fixture();
|
||||
let [old, new] = [Id::new_v4(), Id::new_v4()];
|
||||
let rows = layout_mut(&mut f.project, f.views[0]);
|
||||
// Future/invalid expanded candidates still participate in reference safety.
|
||||
rows.messages[0].address.references = vec![old];
|
||||
rows.messages[1].address = OccurrenceEndpoint {
|
||||
references: vec![new],
|
||||
occurrence: f.messages[0],
|
||||
};
|
||||
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
|
||||
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
|
||||
let before = f.project.clone();
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
let fresh = Id::new_v4();
|
||||
remap_references(&mut f.project, old, fresh).unwrap();
|
||||
let rows = layout(&f.project, f.views[0]);
|
||||
assert_eq!(rows.messages[0].address.references, vec![fresh]);
|
||||
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
|
||||
assert_eq!(rows.messages[1].address.references, vec![new]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
|
||||
let mut f = fixture();
|
||||
let other_owner = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
|
||||
.unwrap();
|
||||
let mut other_view = f.project.diagrams[&f.views[0]].clone();
|
||||
other_view.id = Id::new_v4();
|
||||
other_view.alias = "independent_view".into();
|
||||
other_view.projection = ProjectionKind::Scenario {
|
||||
scenario: other_owner,
|
||||
};
|
||||
other_view.layout = None;
|
||||
f.project.diagrams.insert(other_view.id, other_view.clone());
|
||||
admitted(&f.project);
|
||||
let fresh_owner = Id::new_v4();
|
||||
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
|
||||
.unwrap()
|
||||
.project;
|
||||
for view in f.views {
|
||||
assert_eq!(
|
||||
f.project.diagrams[&view].projection.scenario(),
|
||||
Some(fresh_owner)
|
||||
);
|
||||
}
|
||||
let impact = deletion_impact(&f.project, fresh_owner);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.chain(&f.lanes)
|
||||
.chain(&f.messages)
|
||||
.all(|id| impact.contains(id))
|
||||
);
|
||||
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
|
||||
delete_cascade(&mut f.project, fresh_owner);
|
||||
assert_eq!(f.project.diagrams[&other_view.id], other_view);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.all(|id| !f.project.diagrams.contains_key(id))
|
||||
);
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
|
||||
let mut f = fixture();
|
||||
let lane = f.project.elements[&f.lanes[0]].clone();
|
||||
let message = f.project.elements[&f.messages[0]].clone();
|
||||
delete_cascade(&mut f.project, lane.id);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
|
||||
// Missing endpoint assignment does not erase a still-live message's spacing.
|
||||
assert_eq!(rows.messages.len(), 2);
|
||||
}
|
||||
let mut pending = BTreeSet::new();
|
||||
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
|
||||
// Pruning happens during deletion, before later batch commands can recreate IDs.
|
||||
assert!(
|
||||
layout(&f.project, f.views[0])
|
||||
.messages
|
||||
.iter()
|
||||
.all(|row| row.address.occurrence != message.id)
|
||||
);
|
||||
f.project.elements.insert(lane.id, lane);
|
||||
f.project.elements.insert(message.id, message);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.insert(0, f.messages[0]);
|
||||
finish_deletions(&mut f.project, &pending);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
}
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
|
||||
let mut f = fixture();
|
||||
let unrelated = Id::new_v4();
|
||||
let rows = layout_mut(&mut f.project, f.views[1]);
|
||||
let invalid = ScenarioLanePosition {
|
||||
lifeline: unrelated,
|
||||
x: MAX_COORDINATE * 2.,
|
||||
};
|
||||
rows.lanes.extend([invalid.clone(), invalid]);
|
||||
let fresh = Id::new_v4();
|
||||
let untouched = f.project.diagrams[&f.views[1]].clone();
|
||||
let before = f.project.clone();
|
||||
remap_references(&mut f.project, f.owner, f.owner).unwrap();
|
||||
assert_eq!(f.project, before);
|
||||
// The duplicate lane records do not involve the remapped message identity.
|
||||
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).lanes,
|
||||
layout_from(&untouched).lanes
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).messages[0]
|
||||
.address
|
||||
.occurrence,
|
||||
fresh
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
|
||||
}
|
||||
|
||||
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("missing layout")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
|
||||
let mut f = fixture();
|
||||
// Expanded addresses are not admitted in this slice, but deleting their
|
||||
// explicit references must still make an intermediate repair candidate safe.
|
||||
layout_mut(&mut f.project, f.views[0]).messages[0]
|
||||
.address
|
||||
.references = vec![f.lanes[0]];
|
||||
delete_cascade(&mut f.project, f.lanes[0]);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[0]).messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
|
||||
assert!(f.project.elements.contains_key(&f.messages[0]));
|
||||
admitted(&f.project);
|
||||
}
|
||||
+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 }
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
//! Authored Scenario presentation. Structural admission never changes event order.
|
||||
use crate::{
|
||||
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
|
||||
Project, SharedBody, validation,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DiagramLayout {
|
||||
Scenario(ScenarioLayout),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLayout {
|
||||
#[serde(default)]
|
||||
pub lanes: Vec<ScenarioLanePosition>,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<ScenarioMessagePosition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLanePosition {
|
||||
pub lifeline: Id,
|
||||
pub x: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioMessagePosition {
|
||||
pub address: OccurrenceEndpoint,
|
||||
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
|
||||
pub send_y: f32,
|
||||
}
|
||||
|
||||
impl DiagramLayout {
|
||||
/// Visit every explicit reference, including unsupported paths in a candidate.
|
||||
/// The caller owns remap preflight and final atomic validation.
|
||||
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
|
||||
match self {
|
||||
Self::Scenario(layout) => {
|
||||
for lane in &mut layout.lanes {
|
||||
visit(&mut lane.lifeline);
|
||||
}
|
||||
for message in &mut layout.messages {
|
||||
for reference in &mut message.address.references {
|
||||
visit(reference);
|
||||
}
|
||||
visit(&mut message.address.occurrence);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
|
||||
/// One payload plus one record per lane/message. No graph objects are introduced.
|
||||
pub fn record_count(diagram: &Diagram) -> usize {
|
||||
match &diagram.layout {
|
||||
None => 0,
|
||||
Some(DiagramLayout::Scenario(layout)) => 1usize
|
||||
.saturating_add(layout.lanes.len())
|
||||
.saturating_add(layout.messages.len()),
|
||||
}
|
||||
}
|
||||
|
||||
fn coordinate(value: f32) -> bool {
|
||||
value.is_finite() && value.abs() <= MAX_COORDINATE
|
||||
}
|
||||
|
||||
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
|
||||
let scenario = diagram
|
||||
.projection
|
||||
.scenario()
|
||||
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
|
||||
if !p
|
||||
.elements
|
||||
.get(&scenario)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
|
||||
));
|
||||
}
|
||||
Ok(scenario)
|
||||
}
|
||||
|
||||
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
|
||||
p.elements.get(&lifeline).is_some_and(|element| {
|
||||
element.kind() == Kind::Lifeline
|
||||
&& element.parent == Some(owner)
|
||||
&& element.layer() == layer
|
||||
})
|
||||
}
|
||||
|
||||
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
|
||||
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
|
||||
/// cycles terminate, and malformed parent/step links do not manufacture membership.
|
||||
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
|
||||
let mut messages = BTreeSet::new();
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut pending = vec![owner];
|
||||
while let Some(scope) = pending.pop() {
|
||||
if !visited.insert(scope) {
|
||||
continue;
|
||||
}
|
||||
let Some(element) = p.elements.get(&scope) else {
|
||||
continue;
|
||||
};
|
||||
for id in element.steps().into_iter().flatten() {
|
||||
let Some(item) = p.elements.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if item.parent != Some(scope) || item.layer() != layer {
|
||||
continue;
|
||||
}
|
||||
match item.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
}) => {
|
||||
messages.insert(*id);
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => {
|
||||
for branch in branches {
|
||||
if p.elements.get(branch).is_some_and(|operand| {
|
||||
operand.parent == Some(*id)
|
||||
&& operand.layer() == layer
|
||||
&& matches!(
|
||||
operand.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
}) {
|
||||
pending.push(*branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
|
||||
if diagram.projection.scenario().is_none() {
|
||||
if diagram.layout.is_some() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_projection",
|
||||
"layout",
|
||||
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let scenario = match owner(p, diagram) {
|
||||
Ok(owner) => owner,
|
||||
Err(error) => {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_context",
|
||||
"projection",
|
||||
error.0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_content",
|
||||
"layout",
|
||||
"Scenario views do not accept architecture node placements or edge routes.",
|
||||
);
|
||||
}
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
return;
|
||||
};
|
||||
let mut lanes = BTreeSet::new();
|
||||
for lane in &layout.lanes {
|
||||
if !lanes.insert(lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_lane",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per lifeline.",
|
||||
);
|
||||
}
|
||||
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_lifeline",
|
||||
"layout",
|
||||
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
|
||||
);
|
||||
}
|
||||
if !coordinate(lane.x) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
let members = direct_messages(p, scenario, diagram.layer);
|
||||
let mut addresses = BTreeSet::new();
|
||||
for message in &layout.messages {
|
||||
// Reject expanded paths without cloning or indexing their potentially large contents.
|
||||
if !message.address.references.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_address",
|
||||
"layout",
|
||||
"Expanded-reference message positions are not supported; references must be empty.",
|
||||
);
|
||||
} else if !addresses.insert(message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_message",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per message address.",
|
||||
);
|
||||
}
|
||||
if !members.contains(&message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_message",
|
||||
"layout",
|
||||
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
|
||||
);
|
||||
}
|
||||
if !coordinate(message.send_y) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
|
||||
let scenario = owner(p, diagram)?;
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Scenario views do not accept architecture node placements or edge routes.".into(),
|
||||
));
|
||||
}
|
||||
Ok((diagram, scenario))
|
||||
}
|
||||
|
||||
fn check_message(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
owner: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<(), ModelError> {
|
||||
if !address.references.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Expanded-reference message positions are not supported; references must be empty."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
|
||||
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
|
||||
let DiagramLayout::Scenario(layout) = diagram
|
||||
.layout
|
||||
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
layout
|
||||
}
|
||||
|
||||
/// Target-scoped preparation, not whole-project or whole-layout validation.
|
||||
/// Unrelated intermediate errors remain repairable by later commands in a batch.
|
||||
pub fn set_lane(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
|
||||
return Err(ModelError(
|
||||
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.lanes
|
||||
.iter()
|
||||
.filter(|row| row.lifeline == position.lifeline)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate lane position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.lanes
|
||||
.iter_mut()
|
||||
.find(|row| row.lifeline == position.lifeline)
|
||||
{
|
||||
if existing.x != position.x {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.lanes.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, lifeline) {
|
||||
return Err(ModelError(
|
||||
"Removing a lane position requires an owned Lifeline.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.lanes.retain(|row| row.lifeline != lifeline);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn set_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, &position.address)?;
|
||||
if !coordinate(position.send_y) {
|
||||
return Err(ModelError(
|
||||
"Message position requires a finite bounded send_y coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|row| row.address == position.address)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate message position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.messages
|
||||
.iter_mut()
|
||||
.find(|row| row.address == position.address)
|
||||
{
|
||||
if existing.send_y != position.send_y {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.messages.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, address)?;
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.messages.retain(|row| &row.address != address);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Prune only identities in the caller's actual deletion closure, never visibility
|
||||
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
|
||||
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
|
||||
layout
|
||||
.lanes
|
||||
.retain(|lane| !deleted.contains(&lane.lifeline));
|
||||
layout.messages.retain(|message| {
|
||||
!deleted.contains(&message.address.occurrence)
|
||||
&& !message
|
||||
.address
|
||||
.references
|
||||
.iter()
|
||||
.any(|id| deleted.contains(id))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,800 +0,0 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
|
||||
ScenarioViewpoint,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
performer: Id,
|
||||
lane: Id,
|
||||
repeated_lane: Id,
|
||||
first: Id,
|
||||
second: Id,
|
||||
nested: Id,
|
||||
fragment: Id,
|
||||
branch: Id,
|
||||
view: Id,
|
||||
other_view: Id,
|
||||
exchange: Id,
|
||||
}
|
||||
|
||||
fn set_body(element: &mut Element, body: OccurrenceBody) {
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
fn diagram(owner: Id, alias: &str) -> Diagram {
|
||||
Diagram {
|
||||
id: Id::new_v4(),
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
fn assert_admitted(project: &Project) {
|
||||
let errors: Vec<_> = validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn has_code(project: &Project, code: &str) -> bool {
|
||||
validation::validate(project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == code)
|
||||
}
|
||||
fn layout(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("expected saved payload")
|
||||
};
|
||||
layout
|
||||
}
|
||||
fn save(project: &mut Project, replacement: Diagram) {
|
||||
project.diagrams.insert(replacement.id, replacement);
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
project.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let performer = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
|
||||
.unwrap();
|
||||
let receiver = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
|
||||
.unwrap();
|
||||
let lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
|
||||
.unwrap();
|
||||
let repeated_lane = project
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Controller review lane",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let receiver_lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
|
||||
.unwrap();
|
||||
for (id, represented) in [
|
||||
(lane, performer),
|
||||
(repeated_lane, performer),
|
||||
(receiver_lane, receiver),
|
||||
] {
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let exchange = Id::new_v4();
|
||||
project.relations.insert(
|
||||
exchange,
|
||||
Relation {
|
||||
id: exchange,
|
||||
alias: "delivery_channel".into(),
|
||||
name: "Delivery".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
|
||||
},
|
||||
);
|
||||
let first = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
|
||||
.unwrap();
|
||||
let second = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
|
||||
.unwrap();
|
||||
for (id, sender) in [(first, lane), (second, repeated_lane)] {
|
||||
set_body(
|
||||
project.elements.get_mut(&id).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver_lane),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let fragment = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Optional delivery",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
|
||||
.unwrap();
|
||||
let nested = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Unfinished nested delivery",
|
||||
Some(branch),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&nested).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&branch).unwrap(),
|
||||
OccurrenceBody::Branch {
|
||||
guard: "requested".into(),
|
||||
occurrences: vec![nested],
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![branch],
|
||||
},
|
||||
);
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![first, second, fragment];
|
||||
let view_record = diagram(owner, "nominal_view");
|
||||
let other_record = diagram(owner, "review_view");
|
||||
let view = view_record.id;
|
||||
let other_view = other_record.id;
|
||||
save(&mut project, view_record);
|
||||
save(&mut project, other_record);
|
||||
assert_admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
performer,
|
||||
lane,
|
||||
repeated_lane,
|
||||
first,
|
||||
second,
|
||||
nested,
|
||||
fragment,
|
||||
branch,
|
||||
view,
|
||||
other_view,
|
||||
exchange,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
|
||||
let mut f = fixture();
|
||||
let semantics = (f.project.elements.clone(), f.project.relations.clone());
|
||||
for (view, line, x) in [
|
||||
(f.view, f.lane, 420.),
|
||||
(f.view, f.repeated_lane, 680.),
|
||||
(f.other_view, f.lane, 900.),
|
||||
] {
|
||||
let result =
|
||||
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
for (view, id, y) in [
|
||||
(f.view, f.first, 200.),
|
||||
(f.view, f.second, 350.),
|
||||
(f.other_view, f.first, 260.),
|
||||
] {
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: y,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view]).lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.repeated_lane,
|
||||
x: 680.
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view])
|
||||
.messages
|
||||
.iter()
|
||||
.map(|r| (r.address.occurrence, r.send_y))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(f.first, 200.), (f.second, 350.)]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
|
||||
260.
|
||||
);
|
||||
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
|
||||
assert_eq!(
|
||||
(f.project.elements.clone(), f.project.relations.clone()),
|
||||
semantics
|
||||
);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.nested),
|
||||
send_y: -80.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
let before = f.project.clone();
|
||||
let expanded = OccurrenceEndpoint {
|
||||
references: vec![f.fragment],
|
||||
occurrence: f.nested,
|
||||
};
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: expanded.clone(),
|
||||
send_y: 400.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(remove_message(&f.project, f.view, &expanded).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
|
||||
assert!(has_code(&f.project, "scenario_layout_address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
|
||||
let mut f = fixture();
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.performer,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let foreign = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
|
||||
.unwrap();
|
||||
let foreign_lane = f
|
||||
.project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
|
||||
.unwrap();
|
||||
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.first);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positions_reject_even_when_values_match() {
|
||||
let mut f = fixture();
|
||||
let lane = ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.,
|
||||
};
|
||||
let message = ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
};
|
||||
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
entries.lanes = vec![lane.clone(), lane.clone()];
|
||||
entries.messages = vec![message.clone(), message.clone()];
|
||||
assert!(has_code(&f.project, "duplicate_scenario_lane"));
|
||||
assert!(has_code(&f.project, "duplicate_scenario_message"));
|
||||
assert!(set_lane(&f.project, f.view, lane).is_err());
|
||||
assert!(set_message(&f.project, f.view, message).is_err());
|
||||
// Explicit removal can repair all duplicates of the targeted identity.
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
|
||||
let mut f = fixture();
|
||||
let absent = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
|
||||
assert_eq!(
|
||||
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
|
||||
absent
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_value(&absent)
|
||||
.unwrap()
|
||||
.get("layout")
|
||||
.is_none()
|
||||
);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
layout(&result).messages[0].send_y.to_bits(),
|
||||
(-0.0_f32).to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&result).unwrap(),
|
||||
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
|
||||
);
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
assert_eq!(
|
||||
result.layout,
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
|
||||
);
|
||||
assert_ne!(result, absent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
|
||||
let mut f = fixture();
|
||||
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
let before = serde_json::to_string(&f.project).unwrap();
|
||||
for value in [
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
MAX_COORDINATE + 1.,
|
||||
] {
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
|
||||
let mut f = fixture();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.push(ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: f32::INFINITY,
|
||||
});
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 300.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
|
||||
let mut f = fixture();
|
||||
let scenario = f.project.diagrams[&f.view].projection;
|
||||
assert_eq!(scenario.scenario(), Some(f.owner));
|
||||
assert!(!scenario.is_architecture());
|
||||
assert!(!scenario.includes(Kind::Lifeline));
|
||||
assert!(ProjectionKind::Overview.is_architecture());
|
||||
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
|
||||
assert!(has_code(&f.project, "scenario_diagram_context"));
|
||||
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.view)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(crate::EdgeRoute {
|
||||
edge: f.exchange,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
});
|
||||
assert!(has_code(&f.project, "scenario_diagram_content"));
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
|
||||
assert!(has_code(&f.project, "scenario_layout_projection"));
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
|
||||
f.project.diagrams[&f.view]
|
||||
);
|
||||
assert_eq!(
|
||||
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
|
||||
serde_json::json!(f.first)
|
||||
);
|
||||
let mut unknown = encoded.clone();
|
||||
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
|
||||
let mut missing = encoded;
|
||||
missing["layout"]["Scenario"]["messages"][0]["address"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("references");
|
||||
assert!(serde_json::from_value::<Diagram>(missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
|
||||
let mut f = fixture();
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let mut copied = f.project.diagrams[&f.view].clone();
|
||||
copied.id = f.other_view;
|
||||
copied.alias = "review_view".into();
|
||||
copied.name = "Review view".into();
|
||||
save(&mut f.project, copied.clone());
|
||||
assert_admitted(&f.project);
|
||||
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
|
||||
let mut candidate = f.project.diagrams[&f.view].clone();
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
|
||||
assert!(layout(&candidate).lanes.is_empty());
|
||||
assert_eq!(layout(&candidate).messages.len(), 1);
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
|
||||
assert!(layout(&candidate).messages.is_empty());
|
||||
assert!(candidate.layout.is_some());
|
||||
assert_eq!(f.project.diagrams[&f.other_view], copied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_visitor_and_record_count_cover_explicit_rows() {
|
||||
let f = fixture();
|
||||
let mut d = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(record_count(&d), 0);
|
||||
let rows = payload(&mut d);
|
||||
rows.lanes.push(ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 50.,
|
||||
});
|
||||
rows.messages.push(ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
});
|
||||
assert_eq!(record_count(&d), 3);
|
||||
let replacement = Id::new_v4();
|
||||
d.layout.as_mut().unwrap().visit_references(|id| {
|
||||
if *id == f.first {
|
||||
*id = replacement;
|
||||
}
|
||||
});
|
||||
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
|
||||
assert_eq!(layout(&d).messages[0].send_y, 100.);
|
||||
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
|
||||
let mut f = fixture();
|
||||
let called = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
f.project.elements.get_mut(&called).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Called Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let called_message = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&called_message).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&called)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![called_message];
|
||||
let usage = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&usage).unwrap(),
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(called),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(usage);
|
||||
assert_admitted(&f.project);
|
||||
for target in [
|
||||
address(called_message),
|
||||
address(usage),
|
||||
OccurrenceEndpoint {
|
||||
references: vec![usage],
|
||||
occurrence: called_message,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: target,
|
||||
send_y: 200.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod source_lifecycle;
|
||||
@@ -1,245 +0,0 @@
|
||||
//! Source/command lifecycle checks for the candidate payload. Public version
|
||||
//! assignment remains the coordinated codec integration gate.
|
||||
use super::*;
|
||||
use crate::{Command, Session, language, prepare_command, source_project::*};
|
||||
|
||||
fn authored(f: &Fixture) -> CompiledSourceProject {
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &initial.index.declarations[&f.view];
|
||||
let source = bundle.files.get_mut(&location.path).unwrap();
|
||||
let text = format!(
|
||||
r#"diagram nominal_view "Nominal review" id "{}" {{
|
||||
projection {{ Scenario {{ scenario @{}; }}; }};
|
||||
layout {{ Scenario {{
|
||||
lanes [
|
||||
// Controller spacing rationale.
|
||||
{{ lifeline @ // first lane identity
|
||||
{}; x 4.20e2; }},
|
||||
// Repeated performer has its own lane.
|
||||
{{ lifeline @{}; x 680; }}
|
||||
];
|
||||
messages [
|
||||
// First delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
|
||||
// Second delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
|
||||
];
|
||||
}}; }};
|
||||
}}
|
||||
"#,
|
||||
f.view,
|
||||
f.project.elements[&f.owner].alias,
|
||||
f.project.elements[&f.lane].alias,
|
||||
f.project.elements[&f.repeated_lane].alias,
|
||||
f.project.elements[&f.first].alias,
|
||||
f.project.elements[&f.second].alias
|
||||
);
|
||||
source.replace_range(location.span.start..location.span.end, &text);
|
||||
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut changed = original.decoded.project.clone();
|
||||
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
|
||||
rows.lanes.swap(0, 1);
|
||||
rows.messages.swap(0, 1);
|
||||
rows.messages[0].send_y = 390.;
|
||||
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
assert_eq!(&updated.index.declarations[&f.view].path, path);
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(
|
||||
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("send_y 2.00e2"), "{text}");
|
||||
assert!(text.contains("send_y 390"), "{text}");
|
||||
for (other, bytes) in &original.bundle.files {
|
||||
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
|
||||
assert_eq!(&updated.bundle.files[other], bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
compile_bundle(&updated.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
changed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let temporary = Id::new_v4();
|
||||
let command = Command::Batch(vec![
|
||||
Command::Reidentify(f.first, temporary),
|
||||
Command::Reidentify(f.second, f.first),
|
||||
Command::Reidentify(temporary, f.second),
|
||||
]);
|
||||
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
|
||||
let mut changed = prepared.project;
|
||||
payload(changed.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.swap(0, 1);
|
||||
let updated = update_bundle_with_identity_map(
|
||||
&original,
|
||||
&changed,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
let first_comment = text.find("// First delivery").unwrap();
|
||||
assert!(text[first_comment..].contains(&format!(
|
||||
"occurrence @{}",
|
||||
changed.elements[&f.second].alias
|
||||
)));
|
||||
assert!(text[first_comment..].contains("send_y 2.00e2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut session = Session::new(original.decoded.project.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::SetScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 510.,
|
||||
},
|
||||
},
|
||||
Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
},
|
||||
]))
|
||||
.unwrap();
|
||||
let changed = session.project().clone();
|
||||
assert_eq!(session.revision(), 1);
|
||||
assert_eq!(changed.elements, original.decoded.project.elements);
|
||||
assert_eq!(changed.relations, original.decoded.project.relations);
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &original.decoded.project);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &changed);
|
||||
session
|
||||
.execute(Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), 3);
|
||||
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(noop.bundle, updated.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
|
||||
let f = fixture();
|
||||
let before = language::format(&f.project);
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session
|
||||
.execute(Command::RemoveScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
lifeline: f.lane,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
assert_eq!(language::format(session.project()), before);
|
||||
let mut present = f.project.clone();
|
||||
present.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let text = language::format(&present);
|
||||
assert_ne!(text, before);
|
||||
assert_eq!(language::parse(&text).unwrap(), present);
|
||||
assert_eq!(language::parse(&before).unwrap(), f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let before = original.bundle.clone();
|
||||
let path = original.index.declarations[&f.view].path.clone();
|
||||
for replacement in ["x 1e30", "x not_a_number"] {
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace("x 4.20e2", replacement);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace(
|
||||
&format!("occurrence @{}", f.project.elements[&f.first].alias),
|
||||
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
|
||||
);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
|
||||
let f = fixture();
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &compiled.index.declarations[&f.view];
|
||||
let path = location.path.clone();
|
||||
bundle.files.get_mut(&path).unwrap().insert_str(
|
||||
location.span.end - 1,
|
||||
"\n layout // explicitly automatic\n none;\n",
|
||||
);
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let mut described = original.decoded.project.clone();
|
||||
described.diagrams.get_mut(&f.view).unwrap().description =
|
||||
"Review spacing independently".into();
|
||||
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
|
||||
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
|
||||
assert_eq!(updated.decoded.project, described);
|
||||
let mut empty = described.clone();
|
||||
empty.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(present.decoded.project, empty);
|
||||
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
|
||||
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(cleared.decoded.project, described);
|
||||
assert!(!cleared.bundle.files[&path].contains("layout"));
|
||||
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
|
||||
}
|
||||
@@ -23,22 +23,6 @@ pub enum Command {
|
||||
EditRelation(Id, Relation),
|
||||
CreateDiagram(Diagram),
|
||||
EditDiagram(Id, Diagram),
|
||||
SetScenarioLanePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
},
|
||||
RemoveScenarioLanePosition {
|
||||
diagram: Id,
|
||||
lifeline: Id,
|
||||
},
|
||||
SetScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
},
|
||||
RemoveScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
address: OccurrenceEndpoint,
|
||||
},
|
||||
CreateArtifact(ArtifactLink),
|
||||
EditArtifact(Id, ArtifactLink),
|
||||
CreateTrace(Trace),
|
||||
@@ -504,26 +488,6 @@ fn apply_command(
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::SetScenarioLanePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
|
||||
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::SetScenarioMessagePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioMessagePosition { diagram, address } => {
|
||||
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::CreateArtifact(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.artifact_links.insert(value.id, value.clone());
|
||||
@@ -914,7 +878,6 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
return Ok(());
|
||||
}
|
||||
vacant(p, new)?;
|
||||
crate::references::check_remap(p, old, new)?;
|
||||
if p.id == old {
|
||||
p.id = new;
|
||||
} else if let Some(mut v) = p.elements.remove(&old) {
|
||||
@@ -941,17 +904,12 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::references::remap_references(p, old, new)?;
|
||||
crate::references::remap_references(p, old, new);
|
||||
Ok(())
|
||||
}
|
||||
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
|
||||
if d.projection.scenario().is_some() {
|
||||
return Err(ModelError(
|
||||
"Scenario layout requires the Scenario geometry operation.".into(),
|
||||
));
|
||||
}
|
||||
let parents = crate::projection::parents(p, d);
|
||||
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||
for (id, parent) in &parents {
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
//! Ordinary source defaults shared by current parsing and declaration rendering.
|
||||
//!
|
||||
//! These are source omission defaults, not graphical creation defaults. The
|
||||
//! policy operates on complete typed declaration maps. Required named fields
|
||||
//! and selected constructor payloads remain distinct from omission defaults.
|
||||
//! Historical versions use their existing frozen parser paths. Semantic baseline
|
||||
//! history is never traversed or normalized here.
|
||||
use crate::{Kind, RelationKind};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Context {
|
||||
Element(Kind),
|
||||
Relation(RelationKind),
|
||||
Diagram,
|
||||
Node,
|
||||
Artifact,
|
||||
Trace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum Mode {
|
||||
CompleteForEdit,
|
||||
Compact,
|
||||
/// Internal lossless-edit fragments: retain every top-level value while
|
||||
/// compacting its selected constructor's optional nested fields.
|
||||
CompactValues,
|
||||
}
|
||||
|
||||
/// Top-level field inventory for the current ordinary reader. Callers must
|
||||
/// check required fields before merging; null entries only admit their names.
|
||||
/// This never supplies defaults inside a selected constructor.
|
||||
pub(crate) fn parser_fields(context: Context) -> Map<String, Value> {
|
||||
let mut fields = declaration_defaults(context);
|
||||
let required: &[&str] = match context {
|
||||
Context::Element(kind) => {
|
||||
fields.remove("description");
|
||||
fields.remove("parent");
|
||||
if kind == Kind::Port {
|
||||
&["contract"]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
Context::Relation(kind) => {
|
||||
fields.remove("description");
|
||||
match kind {
|
||||
RelationKind::ContextualLink => &["owner", "source", "target", "purpose"],
|
||||
RelationKind::FunctionalExchange => {
|
||||
&["source", "target", "source_port", "target_port"]
|
||||
}
|
||||
_ => &["source", "target"],
|
||||
}
|
||||
}
|
||||
Context::Diagram => &[],
|
||||
Context::Node => &["element"],
|
||||
Context::Artifact => &["owner"],
|
||||
Context::Trace => &["oa", "sa"],
|
||||
};
|
||||
for name in required {
|
||||
fields.insert((*name).into(), Value::Null);
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
pub(crate) fn fields(
|
||||
context: Context,
|
||||
input: &Map<String, Value>,
|
||||
mode: Mode,
|
||||
) -> Map<String, Value> {
|
||||
let mut result = input.clone();
|
||||
restore_serialization_skips(context, &mut result);
|
||||
if mode != Mode::CompleteForEdit {
|
||||
compact_nested(context, &mut result);
|
||||
}
|
||||
if mode == Mode::Compact {
|
||||
// Compact a composite and its declared default by the same nested rules
|
||||
// before whole-field comparison. This also makes compact input stable:
|
||||
// e.g. String{minimum_length:0} still denotes the default definition.
|
||||
let mut defaults = declaration_defaults(context);
|
||||
compact_nested(context, &mut defaults);
|
||||
omit_defaults(&mut result, &defaults);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>) {
|
||||
match context {
|
||||
Context::Element(Kind::Occurrence) => {
|
||||
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
|
||||
exchange
|
||||
.entry("operation".to_owned())
|
||||
.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!([]));
|
||||
}
|
||||
Context::Trace => {
|
||||
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
|
||||
baseline.entry("semantic".to_owned()).or_insert(Value::Null);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn declaration_defaults(context: Context) -> Map<String, Value> {
|
||||
let mut defaults = match context {
|
||||
Context::Element(kind) => element_defaults(kind),
|
||||
Context::Relation(kind) => relation_defaults(kind),
|
||||
Context::Diagram => object(json!({
|
||||
"description": "", "layer": "Oa", "projection": "Overview",
|
||||
"routes": [], "view_options": {
|
||||
"hidden_edges": [], "relation_kinds": "All",
|
||||
"work_categories": "All", "participant_categories": "All",
|
||||
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
|
||||
}
|
||||
})),
|
||||
Context::Node => object(json!({
|
||||
"position": {"x": 0.0, "y": 0.0},
|
||||
"size": {"width": 224.0, "height": 94.0},
|
||||
"collapsed": false, "port_anchors": []
|
||||
})),
|
||||
Context::Artifact => object(json!({
|
||||
"description": "", "relation": "References", "uri": "",
|
||||
"revision": null, "fragment": null
|
||||
})),
|
||||
Context::Trace => object(json!({
|
||||
"description": "", "kind": "Refinement", "baseline": null
|
||||
})),
|
||||
};
|
||||
match context {
|
||||
Context::Element(_) => {
|
||||
defaults.insert("description".into(), json!(""));
|
||||
defaults.insert("parent".into(), Value::Null);
|
||||
}
|
||||
Context::Relation(_) => {
|
||||
defaults.insert("description".into(), json!(""));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
defaults
|
||||
}
|
||||
|
||||
fn element_defaults(kind: Kind) -> Map<String, Value> {
|
||||
if let Some(state_kind) = kind.state_kind() {
|
||||
// The declaration selects this discriminator; no GUI factory is used.
|
||||
return object(json!({
|
||||
"kind": state_kind, "entry": "", "exit": "", "do_activity": ""
|
||||
}));
|
||||
}
|
||||
match kind {
|
||||
Kind::Process | Kind::FunctionalChain => object(json!({"steps": []})),
|
||||
Kind::ExchangeItem => object(json!({
|
||||
"kind": "Information", "mechanism": "Unspecified", "supertype": null
|
||||
})),
|
||||
Kind::ExchangeCategory => object(json!({"family": "Work", "exchanges": []})),
|
||||
Kind::DataType => object(json!({
|
||||
"definition": {"String": {"minimum_length": 0, "maximum_length": null}},
|
||||
"default": null, "unit": ""
|
||||
})),
|
||||
Kind::DataField => object(json!({
|
||||
"type_ref": "String", "minimum": 1, "maximum": 1, "unit": "",
|
||||
"direction": "Unspecified", "storage": "Value", "default": null
|
||||
})),
|
||||
Kind::Interface => object(json!({"supertype": null})),
|
||||
// Port.contract is required, even when its explicit value is Legacy.
|
||||
Kind::Port => object(json!({"legacy": null})),
|
||||
Kind::Scenario => object(json!({
|
||||
"viewpoint": "MixedLegacy", "context": null,
|
||||
"precondition": "", "postcondition": "", "steps": []
|
||||
})),
|
||||
Kind::Lifeline => object(json!({"represented": null})),
|
||||
Kind::Occurrence => object(json!({
|
||||
"body": {"Exchange": {
|
||||
"exchange": null, "sender": null, "receiver": null,
|
||||
"message_kind": "Asynchronous", "reply_to": null,
|
||||
"values": [], "operation": null
|
||||
}},
|
||||
"precondition": "", "postcondition": ""
|
||||
})),
|
||||
Kind::Event => object(json!({"body": {"Signal": {"items": []}}})),
|
||||
Kind::Constraint => object(json!({"expression": "", "constrained": []})),
|
||||
// Unit-body declarations have no additional omission defaults. A future
|
||||
// Kind reaches this branch without gaining speculative payload rules.
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn relation_defaults(kind: RelationKind) -> Map<String, Value> {
|
||||
match kind {
|
||||
RelationKind::InterfaceItemAllocation => object(json!({
|
||||
"order": 0, "transmission": "Unspecified", "acquisition": "Unspecified"
|
||||
})),
|
||||
RelationKind::DataAssociation => object(json!({
|
||||
"source_role": "", "target_role": "",
|
||||
"source_minimum": 0, "target_minimum": 0,
|
||||
"source_maximum": null, "target_maximum": null,
|
||||
"source_navigable": false, "target_navigable": false
|
||||
})),
|
||||
RelationKind::Interaction | RelationKind::FunctionalExchange => {
|
||||
object(json!({"items": []}))
|
||||
}
|
||||
RelationKind::BoundaryExchange => object(json!({"items": [], "oriented": true})),
|
||||
RelationKind::CommunicationMean => object(json!({"items": [], "oriented": false})),
|
||||
RelationKind::CapabilityExtend => object(json!({"condition": ""})),
|
||||
RelationKind::SequenceLink => object(json!({"guard": ""})),
|
||||
RelationKind::StateTransition => object(json!({
|
||||
"transition_kind": "External", "trigger": "", "guard": "", "effect": "",
|
||||
"triggers": [], "effects": []
|
||||
})),
|
||||
RelationKind::StateAction => object(json!({"phase": "Do"})),
|
||||
// source/target, FunctionalExchange ports and ContextualLink's owner,
|
||||
// endpoints and purpose are required, despite factory placeholder values.
|
||||
_ => Map::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
|
||||
match context {
|
||||
Context::Element(Kind::DataType) => compact_data_definition(fields),
|
||||
Context::Element(Kind::Port) => {
|
||||
if let Some(legacy) = fields.get_mut("legacy").and_then(Value::as_object_mut) {
|
||||
omit_nulls(legacy, &["interface"]);
|
||||
}
|
||||
// Every selected PortContract payload field remains required.
|
||||
}
|
||||
Context::Element(Kind::Occurrence) => compact_occurrence(fields),
|
||||
Context::Diagram => {
|
||||
if let Some(options) = fields
|
||||
.get_mut("view_options")
|
||||
.and_then(Value::as_object_mut)
|
||||
{
|
||||
omit_defaults(
|
||||
options,
|
||||
&object(json!({
|
||||
"hidden_edges": [], "relation_kinds": "All",
|
||||
"work_categories": "All", "participant_categories": "All",
|
||||
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
|
||||
})),
|
||||
);
|
||||
}
|
||||
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, "source_attachment": null, "target_attachment": null}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Route entries, EdgeLabel coordinates, projection machine IDs, and
|
||||
// Only([])/Any([]) selectors remain even when their values are empty.
|
||||
}
|
||||
Context::Trace => {
|
||||
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
|
||||
omit_nulls(baseline, &["semantic"]);
|
||||
}
|
||||
// No recursion through snapshots or semantic historical facets.
|
||||
}
|
||||
// DataValue/TypeRef payloads, EventBody fields, Node geometry/anchors,
|
||||
// contextual endpoints/purposes, and other required records stay intact.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_data_definition(fields: &mut Map<String, Value>) {
|
||||
for tag in ["Integer", "Real"] {
|
||||
if let Some(payload) = constructor_payload(fields, "definition", tag) {
|
||||
omit_nulls(payload, &["minimum", "maximum"]);
|
||||
}
|
||||
}
|
||||
if let Some(payload) = constructor_payload(fields, "definition", "String") {
|
||||
omit_nulls(payload, &["maximum_length"]);
|
||||
// minimum_length is required inside a selected String constructor.
|
||||
}
|
||||
if let Some(payload) = constructor_payload(fields, "definition", "Record") {
|
||||
omit_nulls(payload, &["supertype"]);
|
||||
}
|
||||
// Boolean has no payload; Enumeration.literals remains required even [].
|
||||
}
|
||||
|
||||
fn compact_occurrence(fields: &mut Map<String, Value>) {
|
||||
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
|
||||
omit_defaults(
|
||||
exchange,
|
||||
&object(json!({
|
||||
"exchange": null, "sender": null, "receiver": null,
|
||||
"message_kind": "Asynchronous", "reply_to": null,
|
||||
"values": [], "operation": null
|
||||
})),
|
||||
);
|
||||
if let Some(operation) = exchange.get_mut("operation").and_then(Value::as_object_mut) {
|
||||
omit_nulls(operation, &["allocation"]);
|
||||
// Keep the containing operation field even if its record becomes {}:
|
||||
// explicit unresolved operation intent is not an absent operation.
|
||||
}
|
||||
if let Some(values) = exchange.get_mut("values").and_then(Value::as_array_mut) {
|
||||
for value in values {
|
||||
if let Some(message_value) = value.as_object_mut() {
|
||||
omit_defaults(message_value, &object(json!({"path": []})));
|
||||
}
|
||||
}
|
||||
}
|
||||
// MessageKind::Timer.expression, MessageValue.field/expression remain.
|
||||
}
|
||||
for (tag, optional) in [
|
||||
("Action", &["action", "lifeline"][..]),
|
||||
("State", &["state", "lifeline"][..]),
|
||||
("SequenceReference", &["sequence"][..]),
|
||||
("ScenarioUse", &["scenario"][..]),
|
||||
] {
|
||||
if let Some(payload) = constructor_payload(fields, "body", tag) {
|
||||
omit_nulls(payload, optional);
|
||||
}
|
||||
}
|
||||
// ScenarioUse.bindings, Control.kind, Fragment.operator/branches and
|
||||
// Branch.guard/occurrences are required, including empty strings/lists.
|
||||
}
|
||||
|
||||
fn constructor_payload<'a>(
|
||||
fields: &'a mut Map<String, Value>,
|
||||
key: &str,
|
||||
tag: &str,
|
||||
) -> Option<&'a mut Map<String, Value>> {
|
||||
let wrapper = fields.get_mut(key)?.as_object_mut()?;
|
||||
if wrapper.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
wrapper.get_mut(tag)?.as_object_mut()
|
||||
}
|
||||
|
||||
fn omit_nulls(fields: &mut Map<String, Value>, names: &[&str]) {
|
||||
for name in names {
|
||||
if fields.get(*name).is_some_and(Value::is_null) {
|
||||
fields.remove(*name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn omit_defaults(fields: &mut Map<String, Value>, defaults: &Map<String, Value>) {
|
||||
for (name, default) in defaults {
|
||||
if fields.get(name).is_some_and(|value| same(value, default)) {
|
||||
fields.remove(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Comparison recurses only to compare a known field with its exact declared
|
||||
// default. It never traverses arbitrary data looking for values to remove.
|
||||
fn same(value: &Value, default: &Value) -> bool {
|
||||
match (value, default) {
|
||||
(Value::Object(a), Value::Object(b)) => {
|
||||
a.len() == b.len()
|
||||
&& a.iter()
|
||||
.all(|(key, value)| b.get(key).is_some_and(|d| same(value, d)))
|
||||
}
|
||||
(Value::Array(a), Value::Array(b)) => {
|
||||
a.len() == b.len() && a.iter().zip(b).all(|(value, d)| same(value, d))
|
||||
}
|
||||
(Value::Number(a), Value::Number(b)) => {
|
||||
// serde_json numeric equality alone can equate -0.0 with +0.0.
|
||||
// Extracted f32 coordinates are represented exactly as f64 here.
|
||||
a == b
|
||||
&& match (a.as_f64(), b.as_f64()) {
|
||||
(Some(x), Some(y)) if x == 0.0 && y == 0.0 => x.to_bits() == y.to_bits(),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
_ => value == default,
|
||||
}
|
||||
}
|
||||
|
||||
fn object(value: Value) -> Map<String, Value> {
|
||||
match value {
|
||||
Value::Object(fields) => fields,
|
||||
_ => unreachable!("literal source default is an object"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "source_defaults_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,718 @@
|
||||
//! Independent literal oracles for the production source-default policy.
|
||||
use super::{self as policy, Context, Mode};
|
||||
use crate::{Kind, RelationKind};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
#[test]
|
||||
fn complete_comparison_keeps_populated_serializer_skip_values() {
|
||||
for (context, input) in [
|
||||
(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"operation":{"allocation":"allocation-id"},"values":[]}}}),
|
||||
),
|
||||
(
|
||||
Context::Node,
|
||||
json!({"port_anchors":[{"port":"port-id","side":"Left","offset":0.0}]}),
|
||||
),
|
||||
(
|
||||
Context::Trace,
|
||||
json!({"baseline":{"semantic":{"historical":"payload"}}}),
|
||||
),
|
||||
] {
|
||||
assert_eq!(rendered(context, &input, Mode::CompleteForEdit), input);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_value_fragments_keep_selected_constructor_requirements() {
|
||||
for (context, input, expected) in [
|
||||
(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
|
||||
json!({"definition":{"String":{"minimum_length":0}},"default":null,"unit":""}),
|
||||
),
|
||||
(
|
||||
Context::Element(Kind::Event),
|
||||
json!({"body":{"Signal":{"items":[]}}}),
|
||||
json!({"body":{"Signal":{"items":[]}}}),
|
||||
),
|
||||
(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"operation":{"allocation":null},"values":[]}},"precondition":""}),
|
||||
json!({"body":{"Exchange":{"operation":{}}},"precondition":""}),
|
||||
),
|
||||
(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"ScenarioUse":{"scenario":null,"bindings":[]}}}),
|
||||
json!({"body":{"ScenarioUse":{"bindings":[]}}}),
|
||||
),
|
||||
] {
|
||||
assert_eq!(rendered(context, &input, Mode::CompactValues), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_scenario_default_is_independent_of_graphical_creation() {
|
||||
use crate::{ElementBody, Layer, ScenarioViewpoint, SharedBody, language};
|
||||
let project = language::parse(
|
||||
"arcadia 7; project p \"P\" { oa_scenario o \"O\" {} sa_scenario s \"S\" {} }",
|
||||
)
|
||||
.unwrap();
|
||||
for element in project.elements.values() {
|
||||
assert!(matches!(
|
||||
element.body.shared(),
|
||||
Some(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(!matches!(
|
||||
ElementBody::new(Kind::Scenario, element.layer())
|
||||
.unwrap()
|
||||
.shared(),
|
||||
Some(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
let source = language::format(&project);
|
||||
assert!(!source.contains("description"));
|
||||
assert!(!source.contains("parent"));
|
||||
assert!(!source.contains("viewpoint"));
|
||||
assert_eq!(language::parse(&source).unwrap(), project);
|
||||
|
||||
let mut typed = project;
|
||||
for element in typed.elements.values_mut() {
|
||||
let viewpoint = if element.layer() == Layer::Oa {
|
||||
ScenarioViewpoint::OperationalActivity
|
||||
} else {
|
||||
ScenarioViewpoint::Functional
|
||||
};
|
||||
*element.body.shared_mut().unwrap() = SharedBody::Scenario {
|
||||
viewpoint,
|
||||
context: None,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: vec![],
|
||||
};
|
||||
}
|
||||
let source = language::format(&typed);
|
||||
assert!(source.contains("viewpoint OperationalActivity;"));
|
||||
assert!(source.contains("viewpoint Functional;"));
|
||||
assert_eq!(language::parse(&source).unwrap(), typed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_parser_defaults_preserve_required_fields_and_kind_checks() {
|
||||
for (declaration, message) in [
|
||||
(
|
||||
"oa_port p0 \"Port\" {}",
|
||||
"Missing required field 'contract'",
|
||||
),
|
||||
(
|
||||
"allocation r \"R\" { source @owner; }",
|
||||
"Missing required field 'target'",
|
||||
),
|
||||
(
|
||||
"functional_exchange r \"R\" { source @owner; target @work; }",
|
||||
"Missing required field 'source_port'",
|
||||
),
|
||||
(
|
||||
"contextual_link r \"R\" {}",
|
||||
"Missing required field 'owner'",
|
||||
),
|
||||
("node n { diagram @d; }", "Missing required field 'element'"),
|
||||
("artifact a \"A\" {}", "Missing required field 'owner'"),
|
||||
("trace t \"T\" {}", "Missing required field 'oa'"),
|
||||
(
|
||||
"oa_state s \"S\" { kind Initial; }",
|
||||
"Declaration kind disagrees",
|
||||
),
|
||||
(
|
||||
"oa_activity a \"A\" { unknown true; }",
|
||||
"Unknown field 'unknown'",
|
||||
),
|
||||
(
|
||||
"oa_data_type t \"T\" { definition { String {}; }; }",
|
||||
"minimum_length",
|
||||
),
|
||||
(
|
||||
"oa_occurrence o \"O\" { body { ScenarioUse {}; }; }",
|
||||
"bindings",
|
||||
),
|
||||
] {
|
||||
let source = format!(
|
||||
"arcadia 7; project p \"P\" {{ oa_entity owner \"Owner\" {{}} oa_activity work \"Work\" {{}} diagram d \"D\" {{}} {declaration} }}"
|
||||
);
|
||||
let errors = crate::language::parse(&source).unwrap_err();
|
||||
assert!(
|
||||
errors.iter().any(|error| error.message.contains(message)),
|
||||
"{declaration}: {errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered(context: Context, input: &Value, mode: Mode) -> Value {
|
||||
Value::Object(policy::fields(context, input.as_object().unwrap(), mode))
|
||||
}
|
||||
|
||||
fn compact(context: Context, input: Value, expected: Value) {
|
||||
let original = input.clone();
|
||||
let result = rendered(context, &input, Mode::Compact);
|
||||
assert_eq!(result, expected, "context {context:?}");
|
||||
assert_eq!(input, original, "the input remains immutable");
|
||||
assert_eq!(
|
||||
rendered(context, &result, Mode::Compact),
|
||||
result,
|
||||
"compact policy is idempotent"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_comparison_restores_only_the_three_known_skips() {
|
||||
let exchange = json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}});
|
||||
let mut complete = exchange.clone();
|
||||
complete["body"]["Exchange"]["operation"] = Value::Null;
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Element(Kind::Occurrence),
|
||||
&exchange,
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
complete
|
||||
);
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Node,
|
||||
&json!({"element":"e","diagram":"d"}),
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
json!({"element":"e","diagram":"d","port_anchors":[]})
|
||||
);
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Trace,
|
||||
&json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""}}}),
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""},"semantic":null}})
|
||||
);
|
||||
for input in [
|
||||
json!({"body":{"Action":{"action":null,"lifeline":null}}}),
|
||||
json!({"body":{"State":{"state":null,"lifeline":null}}}),
|
||||
] {
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Element(Kind::Occurrence),
|
||||
&input,
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
input
|
||||
);
|
||||
}
|
||||
for input in [
|
||||
json!({"baseline":null}),
|
||||
json!({"baseline":{"semantic":{"historical":[null,[],false,0,""]},"oa":{"name":"n","description":""},"sa":{"name":"s","description":""}}}),
|
||||
] {
|
||||
assert_eq!(
|
||||
rendered(Context::Trace, &input, Mode::CompleteForEdit),
|
||||
input
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_element_omits_only_empty_common_defaults() {
|
||||
for kind in Kind::ALL {
|
||||
// Future/unknown payloads are preservation sentinels, not admitted source.
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"description":"","parent":null,"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
|
||||
json!({"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"description":" \n","parent":"owner"}),
|
||||
json!({"description":" \n","parent":"owner"}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_element_defaults_are_context_specific() {
|
||||
for kind in [Kind::Process, Kind::FunctionalChain] {
|
||||
compact(Context::Element(kind), json!({"steps":[]}), json!({}));
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"steps":["second","first"]}),
|
||||
json!({"steps":["second","first"]}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeItem),
|
||||
json!({"kind":"Information","mechanism":"Unspecified","supertype":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeItem),
|
||||
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
|
||||
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeCategory),
|
||||
json!({"family":"Work","exchanges":[]}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::ExchangeCategory),
|
||||
json!({"family":"Participant","exchanges":["x"]}),
|
||||
json!({"family":"Participant","exchanges":["x"]}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Interface),
|
||||
json!({"supertype":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Lifeline),
|
||||
json!({"represented":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Constraint),
|
||||
json!({"expression":"","constrained":[]}),
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scenario_source_default_never_uses_the_graphical_viewpoint_default() {
|
||||
compact(
|
||||
Context::Element(Kind::Scenario),
|
||||
json!({"viewpoint":"MixedLegacy","context":null,"precondition":"","postcondition":"","steps":[]}),
|
||||
json!({}),
|
||||
);
|
||||
for viewpoint in [
|
||||
"OperationalActivity",
|
||||
"Functional",
|
||||
"ParticipantInteraction",
|
||||
"BoundaryInterface",
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Scenario),
|
||||
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","postcondition":"","steps":["two","one"]}),
|
||||
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","steps":["two","one"]}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_state_header_supplies_its_own_kind() {
|
||||
for (kind, name) in [
|
||||
(Kind::State, "State"),
|
||||
(Kind::Mode, "Mode"),
|
||||
(Kind::Initial, "Initial"),
|
||||
(Kind::Final, "Final"),
|
||||
(Kind::Choice, "Choice"),
|
||||
(Kind::Junction, "Junction"),
|
||||
(Kind::Fork, "Fork"),
|
||||
(Kind::Join, "Join"),
|
||||
(Kind::ShallowHistory, "ShallowHistory"),
|
||||
(Kind::DeepHistory, "DeepHistory"),
|
||||
(Kind::EntryPoint, "EntryPoint"),
|
||||
(Kind::ExitPoint, "ExitPoint"),
|
||||
(Kind::Terminate, "Terminate"),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(kind),
|
||||
json!({"kind":name,"entry":"","exit":"","do_activity":""}),
|
||||
json!({}),
|
||||
);
|
||||
}
|
||||
// Invalid mismatches must not be silently normalized by the writer policy.
|
||||
compact(
|
||||
Context::Element(Kind::Mode),
|
||||
json!({"kind":"State","entry":"work"}),
|
||||
json!({"kind":"State","entry":"work"}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_field_unbounded_and_present_empty_values_are_not_defaults() {
|
||||
compact(
|
||||
Context::Element(Kind::DataField),
|
||||
json!({"type_ref":"String","minimum":1,"maximum":1,"unit":"","direction":"Unspecified","storage":"Value","default":null}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::DataField),
|
||||
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
|
||||
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
|
||||
);
|
||||
for value in [
|
||||
json!({"Boolean":false}),
|
||||
json!({"Integer":0}),
|
||||
json!({"Real":0.0}),
|
||||
json!({"String":""}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::DataField),
|
||||
json!({"default":[value.clone()]}),
|
||||
json!({"default":[value]}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn data_definition_whole_default_and_selected_required_fields_differ() {
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
|
||||
json!({}),
|
||||
);
|
||||
for (input, expected) in [
|
||||
(
|
||||
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
json!({"Integer":{}}),
|
||||
),
|
||||
(
|
||||
json!({"Real":{"minimum":null,"maximum":2.5}}),
|
||||
json!({"Real":{"maximum":2.5}}),
|
||||
),
|
||||
(
|
||||
json!({"String":{"minimum_length":4,"maximum_length":null}}),
|
||||
json!({"String":{"minimum_length":4}}),
|
||||
),
|
||||
(json!({"Record":{"supertype":null}}), json!({"Record":{}})),
|
||||
(
|
||||
json!({"Enumeration":{"literals":[]}}),
|
||||
json!({"Enumeration":{"literals":[]}}),
|
||||
),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":input}),
|
||||
json!({"definition":expected}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":"Boolean","default":{"Boolean":false}}),
|
||||
json!({"definition":"Boolean","default":{"Boolean":false}}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_contract_and_its_required_payload_stay_explicit() {
|
||||
compact(
|
||||
Context::Element(Kind::Port),
|
||||
json!({"contract":"Legacy","legacy":{"direction":"Out","interface":null}}),
|
||||
json!({"contract":"Legacy","legacy":{"direction":"Out"}}),
|
||||
);
|
||||
for contract in [
|
||||
json!({"FunctionInput":{"incoming_items":[]}}),
|
||||
json!({"FunctionOutput":{"outgoing_items":[]}}),
|
||||
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Port),
|
||||
json!({"contract":contract.clone(),"legacy":null}),
|
||||
json!({"contract":contract}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_whole_default_is_distinct_from_selected_constructor_payload() {
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}),
|
||||
json!({}),
|
||||
);
|
||||
for (input, expected) in [
|
||||
(
|
||||
json!({"Action":{"action":null,"lifeline":null}}),
|
||||
json!({"Action":{}}),
|
||||
),
|
||||
(
|
||||
json!({"State":{"state":null,"lifeline":null}}),
|
||||
json!({"State":{}}),
|
||||
),
|
||||
(
|
||||
json!({"SequenceReference":{"sequence":null}}),
|
||||
json!({"SequenceReference":{}}),
|
||||
),
|
||||
(
|
||||
json!({"ScenarioUse":{"scenario":null,"bindings":[]}}),
|
||||
json!({"ScenarioUse":{"bindings":[]}}),
|
||||
),
|
||||
(
|
||||
json!({"Branch":{"guard":"","occurrences":[]}}),
|
||||
json!({"Branch":{"guard":"","occurrences":[]}}),
|
||||
),
|
||||
(
|
||||
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
|
||||
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
|
||||
),
|
||||
(
|
||||
json!({"Control":{"kind":"Choice"}}),
|
||||
json!({"Control":{"kind":"Choice"}}),
|
||||
),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":input}),
|
||||
json!({"body":expected}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_operation_and_required_message_value_are_preserved() {
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[{"field":"f","path":[],"expression":""}],"operation":{"allocation":null}}}}),
|
||||
json!({"body":{"Exchange":{"values":[{"field":"f","expression":""}],"operation":{}}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request","values":[],"operation":null}}}),
|
||||
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request"}}}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_body_whole_default_does_not_prune_selected_required_fields() {
|
||||
compact(
|
||||
Context::Element(Kind::Event),
|
||||
json!({"body":{"Signal":{"items":[]}}}),
|
||||
json!({}),
|
||||
);
|
||||
for body in [
|
||||
json!({"Signal":{"items":["i"]}}),
|
||||
json!({"Time":{"kind":"After","expression":""}}),
|
||||
json!({"Change":{"expression":""}}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::Event),
|
||||
json!({"body":body.clone()}),
|
||||
json!({"body":body}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_required_endpoints_and_contextual_payloads_remain() {
|
||||
for kind in RelationKind::ALL {
|
||||
compact(
|
||||
Context::Relation(kind),
|
||||
json!({"description":"","source":"s","target":"t","future":[]}),
|
||||
json!({"source":"s","target":"t","future":[]}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Relation(RelationKind::FunctionalExchange),
|
||||
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp","items":[]}),
|
||||
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp"}),
|
||||
);
|
||||
let contextual = json!({"owner":"o","source":{"references":[],"occurrence":"s"},"target":{"references":[],"occurrence":"t"},"purpose":{"Precedence":{"guard":""}}});
|
||||
compact(
|
||||
Context::Relation(RelationKind::ContextualLink),
|
||||
contextual.clone(),
|
||||
contextual,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_defaults_preserve_contextual_orientation_and_multiplicity() {
|
||||
for (kind, orientation) in [
|
||||
(RelationKind::BoundaryExchange, true),
|
||||
(RelationKind::CommunicationMean, false),
|
||||
] {
|
||||
compact(
|
||||
Context::Relation(kind),
|
||||
json!({"oriented":orientation,"items":[]}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(kind),
|
||||
json!({"oriented":!orientation}),
|
||||
json!({"oriented":!orientation}),
|
||||
);
|
||||
}
|
||||
compact(
|
||||
Context::Relation(RelationKind::DataAssociation),
|
||||
json!({"source_role":"","target_role":"","source_minimum":0,"target_minimum":0,"source_maximum":null,"target_maximum":null,"source_navigable":false,"target_navigable":false}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::DataAssociation),
|
||||
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
|
||||
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::InterfaceItemAllocation),
|
||||
json!({"order":0,"transmission":"Unspecified","acquisition":"Unspecified"}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::CapabilityExtend),
|
||||
json!({"condition":""}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::SequenceLink),
|
||||
json!({"guard":""}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::StateAction),
|
||||
json!({"phase":"Do"}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::StateTransition),
|
||||
json!({"transition_kind":"External","trigger":"","guard":"","effect":"","triggers":[],"effects":[]}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Relation(RelationKind::StateTransition),
|
||||
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
|
||||
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagram_options_remove_only_unrestricted_defaults() {
|
||||
let defaults = json!({"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"});
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":defaults}),
|
||||
json!({}),
|
||||
);
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"hidden_edges":[],"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"participant_categories":"All","coverage":"All","work_contexts":{"Any":[]},"exchange_labels":"Name"}}),
|
||||
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"work_contexts":{"Any":[]}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"routes":[{"edge":"e","waypoints":[],"label":null}]}),
|
||||
json!({"routes":[{"edge":"e"}]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_whole_defaults_preserve_negative_zero_and_required_coordinates() {
|
||||
compact(
|
||||
Context::Node,
|
||||
json!({"diagram":"d","element":"e","position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false}),
|
||||
json!({"diagram":"d","element":"e"}),
|
||||
);
|
||||
for position in [
|
||||
json!({"x":-0.0,"y":0.0}),
|
||||
json!({"x":0.0,"y":-0.0}),
|
||||
json!({"x":1.0,"y":0.0}),
|
||||
] {
|
||||
let result = rendered(
|
||||
Context::Node,
|
||||
&json!({"position":position.clone()}),
|
||||
Mode::Compact,
|
||||
);
|
||||
assert_eq!(result, json!({"position":position.clone()}));
|
||||
for coordinate in ["x", "y"] {
|
||||
assert_eq!(
|
||||
result["position"][coordinate].as_f64().unwrap().to_bits(),
|
||||
position[coordinate].as_f64().unwrap().to_bits(),
|
||||
"coordinate sign is part of fidelity"
|
||||
);
|
||||
}
|
||||
}
|
||||
compact(
|
||||
Context::Node,
|
||||
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
|
||||
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_present_empty_strings_and_trace_history_stay_distinct() {
|
||||
compact(
|
||||
Context::Artifact,
|
||||
json!({"owner":"o","description":"","relation":"References","uri":"","revision":null,"fragment":null}),
|
||||
json!({"owner":"o"}),
|
||||
);
|
||||
compact(
|
||||
Context::Artifact,
|
||||
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
|
||||
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
|
||||
);
|
||||
compact(
|
||||
Context::Trace,
|
||||
json!({"oa":"o","sa":"s","description":"","kind":"Refinement","baseline":null}),
|
||||
json!({"oa":"o","sa":"s"}),
|
||||
);
|
||||
compact(
|
||||
Context::Trace,
|
||||
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
|
||||
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
|
||||
);
|
||||
let baseline = json!({"oa":{"name":"o","description":""},"sa":{"name":"s","description":""},"semantic":{"facets":[{"Value":null},{"Value":[]},{"Value":false},{"Value":0},{"Value":""}]}});
|
||||
compact(
|
||||
Context::Trace,
|
||||
json!({"kind":"Manual","baseline":baseline.clone()}),
|
||||
json!({"kind":"Manual","baseline":baseline}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_future_fields_prevent_whole_default_removal() {
|
||||
// These sentinels exercise preservation, not current parser admission of
|
||||
// unknown fields. Only known slots may lose their redundant defaults.
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null,"future":[]}}}),
|
||||
json!({"definition":{"String":{"minimum_length":0,"future":[]}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Element(Kind::Occurrence),
|
||||
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[],"operation":null,"future":false}}}),
|
||||
json!({"body":{"Exchange":{"future":false}}}),
|
||||
);
|
||||
compact(
|
||||
Context::Diagram,
|
||||
json!({"view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name","future":null}}),
|
||||
json!({"view_options":{"future":null}}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_multikey_constructor_wrappers_are_not_reinterpreted() {
|
||||
for definition in [
|
||||
json!({"Future":{"minimum":null,"maximum":null}}),
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null},"Future":{}}),
|
||||
] {
|
||||
compact(
|
||||
Context::Element(Kind::DataType),
|
||||
json!({"definition":definition.clone()}),
|
||||
json!({"definition":definition}),
|
||||
);
|
||||
}
|
||||
for body in [
|
||||
json!({"Future":{"operation":null,"values":[]}}),
|
||||
json!({"Exchange":{"exchange":null,"values":[]},"Future":{}}),
|
||||
] {
|
||||
let fields = json!({"body":body});
|
||||
assert_eq!(
|
||||
rendered(
|
||||
Context::Element(Kind::Occurrence),
|
||||
&fields,
|
||||
Mode::CompleteForEdit
|
||||
),
|
||||
fields,
|
||||
"do not invent a selected Exchange constructor in an unknown wrapper",
|
||||
);
|
||||
compact(Context::Element(Kind::Occurrence), fields.clone(), fields);
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ pub enum DiskExpectation {
|
||||
|
||||
/// `bundle` retains detached and invalid buffers. Compile `reachable_bundle()`;
|
||||
/// use `diagnostics` to explain files that discovery could not read or parse.
|
||||
/// Diagnostic spans describe `bundle` bytes, including before UUID stabilization.
|
||||
/// Expectations always describe disk bytes before UUID stabilization.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiskProject {
|
||||
@@ -526,10 +527,11 @@ pub fn discover_missing(
|
||||
result.expectations.keys().chain(result.bundle.files.keys()),
|
||||
)?;
|
||||
if result.diagnostics.is_empty() {
|
||||
match compile_bundle(&result.reachable_bundle(), limits) {
|
||||
Ok(compiled) => result.diagnostics = compiled.diagnostics,
|
||||
Err(diagnostics) => result.diagnostics = diagnostics,
|
||||
}
|
||||
// Discovery retains raw editor bytes. Compilation may insert UUIDs or
|
||||
// rewrite older syntax, so its returned locations belong to a different
|
||||
// bundle. Lint still performs semantic migration and model validation;
|
||||
// Open/Apply separately compile and install the rewritten source.
|
||||
result.diagnostics = lint_bundle(&result.reachable_bundle(), limits);
|
||||
}
|
||||
let pending = journal_path(&result.root, &result.bundle.entry);
|
||||
if fs::symlink_metadata(&pending).is_ok() {
|
||||
|
||||
@@ -726,7 +726,8 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
|
||||
pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
let ownership = default_ownership(project);
|
||||
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
|
||||
let mut declarations = language::format_declarations(project);
|
||||
let mut declarations =
|
||||
language::format_declarations(project, language::DeclarationFormat::Compact);
|
||||
declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases));
|
||||
let mut files: BTreeMap<LogicalPath, String> = BTreeMap::new();
|
||||
for (id, declaration) in declarations {
|
||||
@@ -735,11 +736,13 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
.or_default()
|
||||
.push_str(&unindent(&declaration));
|
||||
}
|
||||
let mut entry = format!(
|
||||
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
|
||||
project_header(project),
|
||||
quote(&project.description)
|
||||
);
|
||||
let mut entry = format!("arcadia {SCHEMA_VERSION};\n{}{{\n", project_header(project));
|
||||
if !project.description.is_empty() {
|
||||
entry.push_str(&format!(
|
||||
" description {};\n",
|
||||
quote(&project.description)
|
||||
));
|
||||
}
|
||||
for path in files.keys() {
|
||||
entry.push_str(&format!(" include {};\n", quote(path)));
|
||||
}
|
||||
@@ -850,8 +853,25 @@ pub fn update_bundle_with_identity_map(
|
||||
)]);
|
||||
}
|
||||
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
|
||||
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
|
||||
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
// Comparison must include defaults even when persistence serialization or
|
||||
// compact output omits them. Otherwise an unrelated payload edit erases
|
||||
// unchanged explicit fields from the authored document.
|
||||
let before: BTreeMap<_, _> =
|
||||
language::format_declarations(old, language::DeclarationFormat::CompleteForEdit)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let after: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let compact: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::Compact)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let compact_values: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::CompactValues)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let old_aliases: BTreeMap<_, _> = old
|
||||
.identities()
|
||||
.into_iter()
|
||||
@@ -907,6 +927,27 @@ pub fn update_bundle_with_identity_map(
|
||||
};
|
||||
let mut previous = parse(old_text)?;
|
||||
let mut next = parse(new_text)?;
|
||||
let next_id = identities[&id];
|
||||
let value_text = &compact_values[&next_id];
|
||||
let insertion = InsertionSyntax::new(
|
||||
&next.fields,
|
||||
&parse(value_text)?.fields,
|
||||
value_text,
|
||||
&parse(&compact[&next_id])?.fields,
|
||||
);
|
||||
let old_kind = previous.header["kind"];
|
||||
let new_kind = next.header["kind"];
|
||||
if old_text[old_kind.start..old_kind.end] != new_text[new_kind.start..new_kind.end]
|
||||
&& let language::EditKind::Record(actual_fields) = &actual.fields.kind
|
||||
&& let language::EditKind::Record(previous_fields) = &mut previous.fields.kind
|
||||
{
|
||||
// An implicit value is supplied by its declaration context.
|
||||
// After a kind change, an unchanged value may need explicit
|
||||
// syntax (e.g. BoundaryExchange true -> CommunicationMean true).
|
||||
// Reconsider only absent fields; authored explicit values still
|
||||
// compare against their complete old values and keep trivia.
|
||||
previous_fields.retain(|key, _| actual_fields.contains_key(key));
|
||||
}
|
||||
if actual.lexical_owner.is_some()
|
||||
&& let language::EditKind::Record(fields) = &actual.fields.kind
|
||||
&& !fields.contains_key("parent")
|
||||
@@ -923,6 +964,7 @@ pub fn update_bundle_with_identity_map(
|
||||
source,
|
||||
before: old_text,
|
||||
after: new_text,
|
||||
insertion: Some(&insertion),
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
@@ -930,18 +972,7 @@ pub fn update_bundle_with_identity_map(
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
let context = if old.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
Some(&previous.fields),
|
||||
&next.fields,
|
||||
context,
|
||||
local,
|
||||
);
|
||||
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
|
||||
}
|
||||
}
|
||||
if old.id != project.id || old.alias != project.alias || old.name != project.name {
|
||||
@@ -964,6 +995,7 @@ pub fn update_bundle_with_identity_map(
|
||||
source,
|
||||
before: &before,
|
||||
after: &after,
|
||||
insertion: None,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
@@ -1022,7 +1054,7 @@ pub fn update_bundle_with_identity_map(
|
||||
project,
|
||||
identities,
|
||||
&original_order,
|
||||
&after,
|
||||
&compact,
|
||||
limits,
|
||||
)?;
|
||||
let result = compile_bundle(&bundle, limits)?;
|
||||
@@ -1130,25 +1162,64 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
// Schema context is explicit: similarly named fields in arbitrary records must
|
||||
// never acquire Scenario row identity or move their authored trivia.
|
||||
#[derive(Clone, Copy)]
|
||||
enum EditContext {
|
||||
Other,
|
||||
Diagram,
|
||||
Layout,
|
||||
Scenario,
|
||||
Lanes,
|
||||
Messages,
|
||||
// Value fragments retain required payload fields even when the enclosing
|
||||
// declaration field is itself omittable. For example, a selected String type
|
||||
// needs minimum_length; an Event Signal needs items. The complete tree remains
|
||||
// the comparison authority; this parallel tree is only an insertion template.
|
||||
struct InsertionSyntax<'a> {
|
||||
source: &'a str,
|
||||
values: BTreeMap<usize, Span>,
|
||||
omitted_fields: BTreeSet<usize>,
|
||||
}
|
||||
impl EditContext {
|
||||
fn field(self, name: &str) -> Self {
|
||||
match (self, name) {
|
||||
(Self::Diagram, "layout") => Self::Layout,
|
||||
(Self::Layout, "Scenario") => Self::Scenario,
|
||||
(Self::Scenario, "lanes") => Self::Lanes,
|
||||
(Self::Scenario, "messages") => Self::Messages,
|
||||
_ => Self::Other,
|
||||
impl<'a> InsertionSyntax<'a> {
|
||||
fn new(
|
||||
complete: &language::EditValue,
|
||||
compact_values: &language::EditValue,
|
||||
source: &'a str,
|
||||
compact: &language::EditValue,
|
||||
) -> Self {
|
||||
let mut result = Self {
|
||||
source,
|
||||
values: BTreeMap::new(),
|
||||
omitted_fields: BTreeSet::new(),
|
||||
};
|
||||
result.collect(complete, compact_values);
|
||||
if let (language::EditKind::Record(all), language::EditKind::Record(kept)) =
|
||||
(&complete.kind, &compact.kind)
|
||||
{
|
||||
for (key, field) in all {
|
||||
if !kept.contains_key(key) {
|
||||
result.omitted_fields.insert(field.value.span.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
fn collect(&mut self, complete: &language::EditValue, compact: &language::EditValue) {
|
||||
// Borrow the existing compact text once. Copying each containing record
|
||||
// here would duplicate a large nested payload at every depth.
|
||||
self.values.insert(complete.span.start, compact.span);
|
||||
match (&complete.kind, &compact.kind) {
|
||||
(language::EditKind::Record(all), language::EditKind::Record(kept)) => {
|
||||
for (key, field) in all {
|
||||
if let Some(next) = kept.get(key) {
|
||||
self.collect(&field.value, &next.value);
|
||||
} else {
|
||||
self.omitted_fields.insert(field.value.span.start);
|
||||
}
|
||||
}
|
||||
}
|
||||
(language::EditKind::List(all), language::EditKind::List(kept)) => {
|
||||
assert_eq!(
|
||||
all.len(),
|
||||
kept.len(),
|
||||
"compaction preserves array membership"
|
||||
);
|
||||
for (field, next) in all.iter().zip(kept) {
|
||||
self.collect(field, next);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1157,6 +1228,7 @@ struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
after: &'a str,
|
||||
insertion: Option<&'a InsertionSyntax<'a>>,
|
||||
old_aliases: &'a BTreeMap<String, String>,
|
||||
new_aliases: &'a BTreeMap<String, String>,
|
||||
// Source3 migration only adds path qualification; the existing field identity
|
||||
@@ -1165,6 +1237,16 @@ struct SyntaxUpdate<'a> {
|
||||
identity_origins: Option<&'a BTreeMap<Id, Id>>,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn inserted(&self, value: &language::EditValue) -> &str {
|
||||
self.insertion
|
||||
.and_then(|syntax| {
|
||||
syntax
|
||||
.values
|
||||
.get(&value.span.start)
|
||||
.map(|span| &syntax.source[span.start..span.end])
|
||||
})
|
||||
.unwrap_or(&self.after[value.span.start..value.span.end])
|
||||
}
|
||||
fn header(
|
||||
&self,
|
||||
actual: &BTreeMap<&str, Span>,
|
||||
@@ -1219,7 +1301,6 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &language::EditValue,
|
||||
before: Option<&language::EditValue>,
|
||||
after: &language::EditValue,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
use language::EditKind;
|
||||
@@ -1235,7 +1316,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.record(actual.span, fields, previous, next, context, edits);
|
||||
self.record(actual.span, fields, previous, next, edits);
|
||||
}
|
||||
(EditKind::List(items), EditKind::List(next)) => {
|
||||
let previous = before.and_then(|old| {
|
||||
@@ -1245,7 +1326,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.list(actual.span, items, previous, next, context, edits);
|
||||
self.list(actual.span, items, previous, next, edits);
|
||||
}
|
||||
(EditKind::Reference(_), EditKind::Reference(alias)) => {
|
||||
let token = significant(&self.source[actual.span.start..actual.span.end])
|
||||
@@ -1278,7 +1359,7 @@ impl SyntaxUpdate<'_> {
|
||||
_ => {
|
||||
erase_syntax(self.source, actual.span, edits);
|
||||
let text = inserted_value(
|
||||
&self.after[after.span.start..after.span.end],
|
||||
self.inserted(after),
|
||||
indent_at(self.source, actual.span.start),
|
||||
self.source,
|
||||
);
|
||||
@@ -1298,7 +1379,6 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &BTreeMap<String, language::EditField>,
|
||||
before: Option<&BTreeMap<String, language::EditField>>,
|
||||
after: &BTreeMap<String, language::EditField>,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
// A typed constructor tag change retains shared fields and their trivia.
|
||||
@@ -1322,13 +1402,7 @@ impl SyntaxUpdate<'_> {
|
||||
},
|
||||
new_key.clone(),
|
||||
));
|
||||
self.value(
|
||||
&field.value,
|
||||
Some(&old.value),
|
||||
&new.value,
|
||||
context.field(new_key),
|
||||
edits,
|
||||
);
|
||||
self.value(&field.value, Some(&old.value), &new.value, edits);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1340,16 +1414,9 @@ impl SyntaxUpdate<'_> {
|
||||
.and_then(|fields| fields.get(key))
|
||||
.map(|field| &field.value),
|
||||
&next.value,
|
||||
context.field(key),
|
||||
edits,
|
||||
);
|
||||
} else if !(matches!(context, EditContext::Diagram)
|
||||
&& key == "layout"
|
||||
&& before.is_some_and(|fields| !fields.contains_key(key)))
|
||||
{
|
||||
// Canonical None omits layout. Preserve an authored explicit
|
||||
// none while another field changes; a real Some->None clear
|
||||
// still removes the former payload and retains its trivia.
|
||||
} else {
|
||||
erase_syntax(self.source, field.span, edits);
|
||||
}
|
||||
}
|
||||
@@ -1357,6 +1424,9 @@ impl SyntaxUpdate<'_> {
|
||||
.iter()
|
||||
.filter(|(key, field)| {
|
||||
!actual.contains_key(*key)
|
||||
&& self.insertion.is_none_or(|syntax| {
|
||||
!syntax.omitted_fields.contains(&field.value.span.start)
|
||||
})
|
||||
&& before
|
||||
.and_then(|fields| fields.get(*key))
|
||||
.is_none_or(|old| !self.same(&old.value, &field.value))
|
||||
@@ -1375,11 +1445,7 @@ impl SyntaxUpdate<'_> {
|
||||
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
|
||||
let mut text = String::new();
|
||||
for (key, field) in added {
|
||||
let value = inserted_value(
|
||||
&self.after[field.value.span.start..field.value.span.end],
|
||||
&indent,
|
||||
self.source,
|
||||
);
|
||||
let value = inserted_value(self.inserted(&field.value), &indent, self.source);
|
||||
if multiline {
|
||||
text.push_str(&indent);
|
||||
text.push_str(&format!("{key} {value};{}", newline(self.source)));
|
||||
@@ -1404,7 +1470,6 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &[language::EditValue],
|
||||
before: Option<&[language::EditValue]>,
|
||||
after: &[language::EditValue],
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
@@ -1417,7 +1482,6 @@ impl SyntaxUpdate<'_> {
|
||||
self.old_aliases,
|
||||
self.legacy_value_paths,
|
||||
None,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1430,7 +1494,6 @@ impl SyntaxUpdate<'_> {
|
||||
self.new_aliases,
|
||||
self.legacy_value_paths,
|
||||
self.identity_origins,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1459,7 +1522,7 @@ impl SyntaxUpdate<'_> {
|
||||
.all(|(i, source)| *source == Some(i))
|
||||
{
|
||||
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
|
||||
self.value(current, previous.get(i), next, EditContext::Other, edits);
|
||||
self.value(current, previous.get(i), next, edits);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1492,13 +1555,7 @@ impl SyntaxUpdate<'_> {
|
||||
let chunk = chunks[index];
|
||||
text.push_str(&self.source[chunk.start..current.span.start]);
|
||||
let mut local = Vec::new();
|
||||
self.value(
|
||||
current,
|
||||
previous.get(index),
|
||||
next,
|
||||
EditContext::Other,
|
||||
&mut local,
|
||||
);
|
||||
self.value(current, previous.get(index), next, &mut local);
|
||||
text.push_str(&changed_text(self.source, current.span, local));
|
||||
suffix = &self.source[current.span.end..chunk.end];
|
||||
} else {
|
||||
@@ -1508,11 +1565,7 @@ impl SyntaxUpdate<'_> {
|
||||
} else {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&inserted_value(
|
||||
&self.after[next.span.start..next.span.end],
|
||||
&indent,
|
||||
self.source,
|
||||
));
|
||||
text.push_str(&inserted_value(self.inserted(next), &indent, self.source));
|
||||
suffix = "";
|
||||
}
|
||||
if i + 1 < after.len() {
|
||||
@@ -1541,58 +1594,14 @@ impl SyntaxUpdate<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn scenario_item_key(
|
||||
value: &language::EditValue,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
let EditKind::Record(fields) = &value.kind else {
|
||||
return None;
|
||||
};
|
||||
let reference = |value: &language::EditValue| {
|
||||
if let EditKind::Reference(alias) = &value.kind {
|
||||
aliases.get(alias).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
match context {
|
||||
EditContext::Lanes => {
|
||||
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
|
||||
}
|
||||
EditContext::Messages => {
|
||||
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
|
||||
return None;
|
||||
};
|
||||
let occurrence = reference(&address.get("occurrence")?.value)?;
|
||||
let path: Vec<_> = match address.get("references") {
|
||||
None => Vec::new(),
|
||||
Some(field) => {
|
||||
let EditKind::List(path) = &field.value.kind else {
|
||||
return None;
|
||||
};
|
||||
path.iter().map(reference).collect::<Option<Vec<_>>>()?
|
||||
}
|
||||
};
|
||||
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
origins: Option<&BTreeMap<Id, Id>>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
if matches!(context, EditContext::Lanes | EditContext::Messages) {
|
||||
return scenario_item_key(value, aliases, context);
|
||||
}
|
||||
if matches!(value.kind, EditKind::Record(_))
|
||||
&& let Some(key) = language::semantic_row_key(
|
||||
&source[value.span.start..value.span.end],
|
||||
|
||||
@@ -11,7 +11,10 @@ pub(super) fn upgrade(
|
||||
.as_ref()
|
||||
.expect("an explicit source migration report");
|
||||
let project = ¤t.decoded.project;
|
||||
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
let canonical: BTreeMap<_, _> =
|
||||
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
|
||||
.into_iter()
|
||||
.collect();
|
||||
let changed: BTreeSet<_> = report
|
||||
.semantic_defaults
|
||||
.iter()
|
||||
@@ -59,6 +62,7 @@ pub(super) fn upgrade(
|
||||
source,
|
||||
before: source,
|
||||
after,
|
||||
insertion: None,
|
||||
old_aliases: &aliases,
|
||||
new_aliases: &aliases,
|
||||
legacy_value_paths: true,
|
||||
@@ -68,11 +72,6 @@ pub(super) fn upgrade(
|
||||
&actual.fields,
|
||||
Some(&actual.fields),
|
||||
&next.fields,
|
||||
if project.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
},
|
||||
edits.entry(path.clone()).or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -619,7 +619,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, d.id, "name", &d.name);
|
||||
text(&mut out, d.id, "description", &d.description);
|
||||
crate::scenario_layout::validate_diagram(p, d, &mut out);
|
||||
let option_issues = if d.projection == ProjectionKind::Traceability {
|
||||
crate::view_options::validate_options_cross_layer(p, &d.view_options)
|
||||
} else {
|
||||
@@ -644,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,344 @@
|
||||
//! Existing source-7 nested omission compatibility, independently of formatting.
|
||||
//!
|
||||
//! Selected constructors use their own field contracts. These handwritten
|
||||
//! sources and literal models distinguish nullable optional slots from required
|
||||
//! fields; they do not prescribe compact canonical output or broaden admission.
|
||||
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn bundle(declarations: &str) -> SourceBundle {
|
||||
let source = format!(
|
||||
"arcadia 7;\nproject p \"Admission\" id \"00000000-0000-4000-8000-000000000001\" {{\n{declarations}\n}}\n"
|
||||
);
|
||||
SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
}
|
||||
}
|
||||
|
||||
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
|
||||
Element {
|
||||
id: id(number),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_admitted(input: &SourceBundle, elements: Vec<Element>) -> Project {
|
||||
let expected = Project {
|
||||
schema_version: 7,
|
||||
id: id(1),
|
||||
alias: "p".into(),
|
||||
name: "Admission".into(),
|
||||
description: String::new(),
|
||||
elements: elements.into_iter().map(|e| (e.id, e)).collect(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
artifact_links: BTreeMap::new(),
|
||||
traces: BTreeMap::new(),
|
||||
};
|
||||
let compiled = compile_bundle(input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, expected, "complete literal model");
|
||||
assert_eq!(
|
||||
compiled.bundle, *input,
|
||||
"fixed IDs require no source stabilization"
|
||||
);
|
||||
compiled.decoded.project
|
||||
}
|
||||
|
||||
fn prefix(layer: Layer) -> &'static str {
|
||||
match layer {
|
||||
Layer::Oa => "oa",
|
||||
Layer::Sa => "sa",
|
||||
}
|
||||
}
|
||||
|
||||
fn data_bundle(layer: Layer, definition: &str) -> SourceBundle {
|
||||
bundle(&format!(
|
||||
" {}_data_type subject \"Subject\" id \"00000000-0000-4000-8000-000000000002\" {{ definition {{ {definition}; }}; }}",
|
||||
prefix(layer)
|
||||
))
|
||||
}
|
||||
|
||||
fn admit_data(layer: Layer, source: &str, definition: DataTypeDefinition) -> Project {
|
||||
assert_admitted(
|
||||
&data_bundle(layer, source),
|
||||
vec![element(
|
||||
2,
|
||||
"subject",
|
||||
"Subject",
|
||||
None,
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::DataType {
|
||||
definition,
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
},
|
||||
),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
fn assert_missing(input: &SourceBundle, field: &str) {
|
||||
let errors = compile_bundle(input, ProjectLimits::default()).unwrap_err();
|
||||
assert_eq!(
|
||||
errors.len(),
|
||||
1,
|
||||
"expect the targeted admission error: {errors:?}"
|
||||
);
|
||||
let diagnostic = &errors[0].diagnostic;
|
||||
assert_eq!(diagnostic.code, "invalid_source", "{errors:?}");
|
||||
assert!(
|
||||
diagnostic.message.to_lowercase().contains("missing") && diagnostic.message.contains(field),
|
||||
"must reject the missing constructor field {field}, not an unrelated model rule: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn integer_optional_bounds_can_be_omitted_individually_and_together() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (omitted, explicit, minimum, maximum) in [
|
||||
(
|
||||
"Integer {}",
|
||||
"Integer { minimum none; maximum none; }",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Integer { maximum 9; }",
|
||||
"Integer { minimum none; maximum 9; }",
|
||||
None,
|
||||
Some(9),
|
||||
),
|
||||
(
|
||||
"Integer { minimum -3; }",
|
||||
"Integer { minimum -3; maximum none; }",
|
||||
Some(-3),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let expected = DataTypeDefinition::Integer { minimum, maximum };
|
||||
assert_eq!(
|
||||
admit_data(layer, omitted, expected.clone()),
|
||||
admit_data(layer, explicit, expected),
|
||||
"omitted and explicit optional Integer bounds agree"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_optional_bounds_can_be_omitted_individually_and_together() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (omitted, explicit, minimum, maximum) in [
|
||||
(
|
||||
"Real {}",
|
||||
"Real { minimum none; maximum none; }",
|
||||
None,
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Real { maximum 2.5; }",
|
||||
"Real { minimum none; maximum 2.5; }",
|
||||
None,
|
||||
Some(2.5),
|
||||
),
|
||||
(
|
||||
"Real { minimum -1.25; }",
|
||||
"Real { minimum -1.25; maximum none; }",
|
||||
Some(-1.25),
|
||||
None,
|
||||
),
|
||||
] {
|
||||
let expected = DataTypeDefinition::Real { minimum, maximum };
|
||||
assert_eq!(
|
||||
admit_data(layer, omitted, expected.clone()),
|
||||
admit_data(layer, explicit, expected),
|
||||
"omitted and explicit optional Real bounds agree"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn string_optional_maximum_can_be_omitted_without_resetting_required_minimum() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let expected = DataTypeDefinition::String {
|
||||
minimum_length: 4,
|
||||
maximum_length: None,
|
||||
};
|
||||
assert_eq!(
|
||||
admit_data(layer, "String { minimum_length 4; }", expected.clone()),
|
||||
admit_data(
|
||||
layer,
|
||||
"String { minimum_length 4; maximum_length none; }",
|
||||
expected,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicitly_selected_string_requires_minimum_even_when_whole_definition_has_a_default() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(&data_bundle(layer, "String {}"), "minimum_length");
|
||||
assert_missing(
|
||||
&data_bundle(layer, "String { maximum_length none; }"),
|
||||
"minimum_length",
|
||||
);
|
||||
admit_data(
|
||||
layer,
|
||||
"String { minimum_length 0; }",
|
||||
DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_numeric_maximum_is_distinct_from_an_explicit_zero_bound() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let unbounded = admit_data(
|
||||
layer,
|
||||
"Integer { minimum -3; }",
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: Some(-3),
|
||||
maximum: None,
|
||||
},
|
||||
);
|
||||
let bounded = admit_data(
|
||||
layer,
|
||||
"Integer { minimum -3; maximum 0; }",
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: Some(-3),
|
||||
maximum: Some(0),
|
||||
},
|
||||
);
|
||||
assert_ne!(unbounded, bounded, "zero is a bound, not omitted syntax");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_has_no_required_payload_field_and_omitted_supertype_means_none() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let expected = DataTypeDefinition::Record { supertype: None };
|
||||
assert_eq!(
|
||||
admit_data(layer, "Record {}", expected.clone()),
|
||||
admit_data(layer, "Record { supertype none; }", expected),
|
||||
);
|
||||
let errors = compile_bundle(
|
||||
&data_bundle(layer, "Record { fields []; }"),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(errors.len(), 1, "{errors:?}");
|
||||
assert_eq!(errors[0].diagnostic.code, "invalid_source", "{errors:?}");
|
||||
assert!(
|
||||
errors[0]
|
||||
.diagnostic
|
||||
.message
|
||||
.contains("unknown field `fields`"),
|
||||
"Record remains a closed constructor: {errors:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enumeration_still_requires_its_list_even_when_empty_list_is_admitted() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(&data_bundle(layer, "Enumeration {}"), "literals");
|
||||
admit_data(
|
||||
layer,
|
||||
"Enumeration { literals []; }",
|
||||
DataTypeDefinition::Enumeration { literals: vec![] },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn port_bundle(layer: Layer, fields: &str) -> SourceBundle {
|
||||
let owner_kind = match layer {
|
||||
Layer::Oa => "oa_entity",
|
||||
Layer::Sa => "sa_system",
|
||||
};
|
||||
bundle(&format!(
|
||||
" {owner_kind} owner \"Owner\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n {}_port subject \"Subject\" id \"00000000-0000-4000-8000-000000000003\" {{ parent @owner; {fields} }}",
|
||||
prefix(layer)
|
||||
))
|
||||
}
|
||||
|
||||
fn admit_port(layer: Layer, fields: &str) -> Project {
|
||||
let owner_body = match layer {
|
||||
Layer::Oa => ElementBody::Oa(OaBody::Entity),
|
||||
Layer::Sa => ElementBody::Sa(SaBody::System),
|
||||
};
|
||||
assert_admitted(
|
||||
&port_bundle(layer, fields),
|
||||
vec![
|
||||
element(2, "owner", "Owner", None, owner_body),
|
||||
element(
|
||||
3,
|
||||
"subject",
|
||||
"Subject",
|
||||
Some(id(2)),
|
||||
ElementBody::shared_in(
|
||||
layer,
|
||||
SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::Out,
|
||||
interface: None,
|
||||
}),
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_port_interface_can_be_omitted_but_preserves_direction() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_eq!(
|
||||
admit_port(layer, "contract Legacy; legacy { direction Out; };"),
|
||||
admit_port(
|
||||
layer,
|
||||
"contract Legacy; legacy { direction Out; interface none; };"
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_port_direction_remains_required_inside_selected_record() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(
|
||||
&port_bundle(layer, "contract Legacy; legacy {};"),
|
||||
"direction",
|
||||
);
|
||||
assert_missing(
|
||||
&port_bundle(layer, "contract Legacy; legacy { interface none; };"),
|
||||
"direction",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_declaration_still_requires_explicit_contract() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert_missing(
|
||||
&port_bundle(layer, "legacy { direction Out; };"),
|
||||
"contract",
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! Public flat/modular formatter fidelity over frozen input and bounded mutations.
|
||||
//! Legacy fixture migration uses the public parser. The original adapter tests
|
||||
//! and exact inputs remain archived at source commit 2a64344; this suite exercises
|
||||
//! production formatters, not that frozen experiment or lossless model edits.
|
||||
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const COASTAL: &str = include_str!("../../../examples/coastal-rescue.arcadia");
|
||||
const KEYED: &str =
|
||||
include_str!("../../../harness/fixtures/release1-native/semantic-keyed.arcadia");
|
||||
const STATES: &str =
|
||||
include_str!("../../../harness/fixtures/release1-native/state-actions.arcadia");
|
||||
const DENSE: &str =
|
||||
include_str!("../../../harness/fixtures/release1-native/dense-40/dense-40.arcadia");
|
||||
const OPERATIONS: &str = include_str!(
|
||||
"../../../harness/fixtures/interface-operation-probe/output/boundary-only.arcadia"
|
||||
);
|
||||
|
||||
fn read(source: &str) -> Project {
|
||||
language::parse(source).unwrap()
|
||||
}
|
||||
|
||||
fn identity(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn assert_common_omissions(project: &Project, compiled: &CompiledSourceProject) {
|
||||
if project.description.is_empty() {
|
||||
assert!(compiled.index.project_description.is_none());
|
||||
}
|
||||
let descriptions = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| (e.id, &e.description))
|
||||
.chain(project.relations.values().map(|r| (r.id, &r.description)))
|
||||
.chain(project.diagrams.values().map(|d| (d.id, &d.description)))
|
||||
.chain(
|
||||
project
|
||||
.artifact_links
|
||||
.values()
|
||||
.map(|a| (a.id, &a.description)),
|
||||
)
|
||||
.chain(project.traces.values().map(|t| (t.id, &t.description)));
|
||||
for (id, description) in descriptions {
|
||||
if description.is_empty() {
|
||||
assert!(
|
||||
!compiled
|
||||
.index
|
||||
.fields
|
||||
.contains_key(&(id, "description".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
for element in project.elements.values().filter(|e| e.parent.is_none()) {
|
||||
assert!(
|
||||
!compiled
|
||||
.index
|
||||
.fields
|
||||
.contains_key(&(element.id, "parent".into()))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_fidelity(label: &str, project: &Project) -> (Project, String) {
|
||||
let flat = language::format(project);
|
||||
let compact_model = read(&flat);
|
||||
assert_eq!(
|
||||
compact_model, *project,
|
||||
"{label}: every persistent field survives"
|
||||
);
|
||||
|
||||
let input = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), flat.clone())]),
|
||||
};
|
||||
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.decoded.project, *project,
|
||||
"{label}: source-project compile"
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.bundle, input,
|
||||
"{label}: compact identities/source are stable"
|
||||
);
|
||||
|
||||
assert_common_omissions(project, &compiled);
|
||||
assert_eq!(
|
||||
language::format(&compact_model),
|
||||
flat,
|
||||
"{label}: flat idempotence"
|
||||
);
|
||||
|
||||
let modular = format_bundle(project);
|
||||
assert!(
|
||||
modular.files.len() > 1,
|
||||
"{label}: exercise generated includes"
|
||||
);
|
||||
let modular_compiled = compile_bundle(&modular, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
modular_compiled.decoded.project, *project,
|
||||
"{label}: complete modular model"
|
||||
);
|
||||
assert_eq!(
|
||||
modular_compiled.bundle, modular,
|
||||
"{label}: modular source/IDs are stable"
|
||||
);
|
||||
assert_common_omissions(project, &modular_compiled);
|
||||
assert_eq!(
|
||||
format_bundle(&compact_model),
|
||||
modular,
|
||||
"{label}: flat-to-modular determinism"
|
||||
);
|
||||
assert_eq!(
|
||||
format_bundle(&modular_compiled.decoded.project),
|
||||
modular,
|
||||
"{label}: modular idempotence"
|
||||
);
|
||||
|
||||
// Pinned measured compact bytes from the published fidelity-1 stderr.log at
|
||||
// 2a64344, not a comparison against today's (now compact) canonical writer.
|
||||
// A small header-layout allowance avoids enforcing incidental blank lines.
|
||||
let measured_compact_bytes = match label {
|
||||
"coastal-source3" => 28_465,
|
||||
"keyed-source5-history" => 12_066,
|
||||
"state-actions-source5" => 6_673,
|
||||
"dense40-source6" => 19_342,
|
||||
"boundary-operations-source6" => 3_660,
|
||||
"state-negative-zero" => 6_672,
|
||||
"keyed-present-empty-default" => 12_108,
|
||||
"coastal-present-empty-artifact-strings" => 28_483,
|
||||
"boundary-selected-and-unresolved-operations" => 3_806,
|
||||
_ => panic!("missing independent compact measurement for {label}"),
|
||||
};
|
||||
assert!(
|
||||
flat.len() <= measured_compact_bytes + 16,
|
||||
"{label}: {} bytes exceeds compact evidence budget {}",
|
||||
flat.len(),
|
||||
measured_compact_bytes + 16
|
||||
);
|
||||
(compact_model, flat)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrated_coastal_oa_sa_views_ports_artifacts_and_traces_roundtrip() {
|
||||
assert!(COASTAL.starts_with("arcadia 3;"));
|
||||
let project = read(COASTAL);
|
||||
assert!(project.elements.values().any(|e| e.layer() == Layer::Oa));
|
||||
assert!(project.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||
assert!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.any(|e| matches!(e.shared(), Some(SharedBody::Port { .. })))
|
||||
);
|
||||
assert!(project.elements.values().any(
|
||||
|e| matches!(e.shared(), Some(SharedBody::Scenario { steps, .. }) if !steps.is_empty())
|
||||
));
|
||||
assert!(project.diagrams.values().any(|d| d.layer == Layer::Oa
|
||||
&& d.projection == ProjectionKind::FunctionalDataflow
|
||||
&& !d.nodes.is_empty()));
|
||||
assert!(project.diagrams.values().any(|d| d.layer == Layer::Sa
|
||||
&& d.projection == ProjectionKind::Allocation
|
||||
&& !d.nodes.is_empty()));
|
||||
assert!(!project.artifact_links.is_empty());
|
||||
assert!(project.traces.values().any(|t| t.baseline.is_some()));
|
||||
assert_fidelity("coastal-source3", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyed_scenarios_records_and_historical_binding_annotation_facets_roundtrip() {
|
||||
let project = read(KEYED);
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
assert!(project.elements.values().any(|e| e.layer() == layer
|
||||
&& matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { .. },
|
||||
..
|
||||
})
|
||||
)));
|
||||
assert!(project.elements.values().any(|e| e.layer() == layer && matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { bindings, .. }, .. }) if !bindings.is_empty())));
|
||||
}
|
||||
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.iter().any(|v| !v.path.is_empty() && !v.expression.is_empty()))));
|
||||
let facets: Vec<_> = project
|
||||
.traces
|
||||
.values()
|
||||
.filter_map(|t| t.baseline.as_ref()?.semantic.as_ref())
|
||||
.flat_map(|baseline| &baseline.facets)
|
||||
.collect();
|
||||
assert!(
|
||||
facets
|
||||
.iter()
|
||||
.any(|facet| matches!(facet, semantic::FacetReview::Binding { .. }))
|
||||
);
|
||||
assert!(
|
||||
facets
|
||||
.iter()
|
||||
.any(|facet| matches!(facet, semantic::FacetReview::Annotation { .. }))
|
||||
);
|
||||
assert!(
|
||||
KEYED.contains("uuid \""),
|
||||
"fixture contains historical identity atoms"
|
||||
);
|
||||
let (_, source) = assert_fidelity("keyed-source5-history", &project);
|
||||
assert!(
|
||||
source.contains("uuid \""),
|
||||
"historical atoms remain historical"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_machine_projection_typed_actions_and_bounded_integer_roundtrip() {
|
||||
let project = read(STATES);
|
||||
let machine = identity(&project, "operating");
|
||||
let view = &project.diagrams[&identity(&project, "state_view")];
|
||||
assert_eq!(view.projection, ProjectionKind::StateMachine { machine });
|
||||
assert!(!view.nodes.is_empty());
|
||||
for phase in [
|
||||
StateActionKind::Entry,
|
||||
StateActionKind::Do,
|
||||
StateActionKind::Exit,
|
||||
] {
|
||||
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateAction { phase: actual, .. } if *actual == phase)));
|
||||
}
|
||||
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateTransition { transition_kind: TransitionKind::Internal, triggers, effects, .. } if !triggers.is_empty() && !effects.is_empty())));
|
||||
assert!(
|
||||
matches!(project.elements[&identity(&project, "retry_budget")].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(4) }, unit, .. }) if unit == "attempts")
|
||||
);
|
||||
assert_fidelity("state-actions-source5", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forty_exchange_diagram_with_categories_and_processes_roundtrip() {
|
||||
let project = read(DENSE);
|
||||
assert_eq!(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Interaction)
|
||||
.count(),
|
||||
40
|
||||
);
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::ExchangeCategory)
|
||||
.count(),
|
||||
5
|
||||
);
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Process)
|
||||
.count(),
|
||||
3
|
||||
);
|
||||
let view = &project.diagrams[&identity(&project, "forty_exchange_view")];
|
||||
assert_eq!(view.projection, ProjectionKind::FunctionalDataflow);
|
||||
assert_eq!(view.nodes.len(), 2);
|
||||
assert!(view.routes.is_empty());
|
||||
assert_eq!(
|
||||
view.view_options.relation_kinds,
|
||||
view_options::RelationKinds::All
|
||||
);
|
||||
assert_fidelity("dense40-source6", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn component_ports_interface_operations_and_boundary_calls_roundtrip() {
|
||||
let project = read(OPERATIONS);
|
||||
assert_eq!(
|
||||
project
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(project.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
..
|
||||
})
|
||||
)));
|
||||
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Scenario { viewpoint: ScenarioViewpoint::BoundaryInterface, steps, .. }) if steps.len() == 2)));
|
||||
assert_eq!(
|
||||
project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
operation: None,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
))
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_fidelity("boundary-operations-source6", &project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_zero_node_coordinate_survives_canonical_compact_and_bundle_compile() {
|
||||
let mut project = read(STATES);
|
||||
let diagram = identity(&project, "state_view");
|
||||
let node = identity(&project, "view_machine");
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position = Position { x: -0.0, y: 0.0 };
|
||||
let (decoded, flat) = assert_fidelity("state-negative-zero", &project);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), flat)]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let modular = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap();
|
||||
// Project/Position PartialEq alone equates the signs of zero. Check bits in
|
||||
// each independently decoded representation, including source-project compile.
|
||||
for actual in [
|
||||
&decoded,
|
||||
&compiled.decoded.project,
|
||||
&modular.decoded.project,
|
||||
] {
|
||||
let position = &actual.diagrams[&diagram].nodes[&node].position;
|
||||
assert_eq!(position.x.to_bits(), (-0.0_f32).to_bits());
|
||||
assert_eq!(position.y.to_bits(), 0.0_f32.to_bits());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_empty_field_default_and_unbounded_maximum_remain_distinct_from_omission() {
|
||||
let mut project = read(KEYED);
|
||||
let field_id = identity(&project, "oa_navigation_latitude");
|
||||
let Some(SharedBody::DataField {
|
||||
minimum,
|
||||
maximum,
|
||||
default,
|
||||
storage,
|
||||
..
|
||||
}) = project.elements.get_mut(&field_id).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("fixture must contain the claimed DataField");
|
||||
};
|
||||
assert_eq!(*minimum, 0);
|
||||
assert_eq!(*maximum, Some(1));
|
||||
assert_eq!(*default, None);
|
||||
assert_eq!(*storage, FieldStorage::Value);
|
||||
*maximum = None;
|
||||
*default = Some(vec![]);
|
||||
let (decoded, _) = assert_fidelity("keyed-present-empty-default", &project);
|
||||
assert!(
|
||||
matches!(decoded.elements[&field_id].shared(), Some(SharedBody::DataField { maximum: None, default: Some(values), .. }) if values.is_empty())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn present_empty_artifact_revision_and_fragment_survive() {
|
||||
let mut project = read(COASTAL);
|
||||
let artifact_id = identity(&project, "console_enclosure_cad");
|
||||
let artifact = project.artifact_links.get_mut(&artifact_id).unwrap();
|
||||
artifact.revision = Some(String::new());
|
||||
artifact.fragment = Some(String::new());
|
||||
let (decoded, _) = assert_fidelity("coastal-present-empty-artifact-strings", &project);
|
||||
assert_eq!(
|
||||
decoded.artifact_links[&artifact_id].revision.as_deref(),
|
||||
Some("")
|
||||
);
|
||||
assert_eq!(
|
||||
decoded.artifact_links[&artifact_id].fragment.as_deref(),
|
||||
Some("")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_and_unresolved_operation_intent_survive_as_distinct_present_records() {
|
||||
let mut project = read(OPERATIONS);
|
||||
let allocation = identity(&project, "operation_probe_30");
|
||||
assert_eq!(
|
||||
project.relations[&allocation].kind(),
|
||||
RelationKind::InterfaceItemAllocation
|
||||
);
|
||||
let selected = identity(&project, "operation_probe_43");
|
||||
let unresolved = identity(&project, "operation_probe_44");
|
||||
for (subject, value) in [(selected, Some(allocation)), (unresolved, None)] {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { operation, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&subject).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("fixture must contain a Call Exchange");
|
||||
};
|
||||
assert_eq!(*operation, None);
|
||||
*operation = Some(OperationUse { allocation: value });
|
||||
}
|
||||
let (decoded, _) = assert_fidelity("boundary-selected-and-unresolved-operations", &project);
|
||||
assert!(
|
||||
matches!(decoded.elements[&selected].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(OperationUse { allocation: Some(actual) }), .. }, .. }) if *actual == allocation)
|
||||
);
|
||||
assert!(matches!(
|
||||
decoded.elements[&unresolved].shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
operation: Some(OperationUse { allocation: None }),
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
//! Focused public-output contracts. Field locations come from the real compiler;
|
||||
//! token comparisons ignore layout while retaining typed tags and required syntax.
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn project(declarations: &str) -> Project {
|
||||
language::parse(&format!(
|
||||
"arcadia 7; project p \"P\" id \"00000000-0000-4000-8000-000000000001\" {{ {declarations} }}"
|
||||
)).unwrap()
|
||||
}
|
||||
|
||||
fn exports(project: &Project) -> Vec<CompiledSourceProject> {
|
||||
let flat = language::format(project);
|
||||
assert_eq!(language::parse(&flat).unwrap(), *project);
|
||||
let flat_bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), flat)]),
|
||||
};
|
||||
[flat_bundle, format_bundle(project)]
|
||||
.into_iter()
|
||||
.map(|bundle| {
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, *project);
|
||||
assert_eq!(
|
||||
compiled.bundle, bundle,
|
||||
"all identities are explicit and stable"
|
||||
);
|
||||
compiled
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn identity(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn field(compiled: &CompiledSourceProject, alias: &str, name: &str) -> Option<String> {
|
||||
let location = compiled
|
||||
.index
|
||||
.fields
|
||||
.get(&(identity(&compiled.decoded.project, alias), name.into()))?;
|
||||
let text = &compiled.bundle.files[&location.path][location.span.start..location.span.end];
|
||||
Some(
|
||||
language::tokenize(text)
|
||||
.into_iter()
|
||||
.filter(|token| {
|
||||
!matches!(
|
||||
token.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.map(|token| &text[token.span.start..token.span.end])
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_project_has_no_synthetic_description_or_module() {
|
||||
let empty = project("");
|
||||
for compiled in exports(&empty) {
|
||||
assert!(compiled.index.project_description.is_none());
|
||||
assert!(compiled.index.fields.is_empty());
|
||||
assert!(compiled.index.includes.is_empty());
|
||||
assert_eq!(compiled.bundle.files.len(), 1);
|
||||
assert_eq!(compiled.index.declarations.len(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_empty_descriptions_disappear_whitespace_and_text_survive_exactly() {
|
||||
let mut model =
|
||||
project("oa_activity work \"Work\" id \"00000000-0000-4000-8000-000000000002\" {}");
|
||||
let work = identity(&model, "work");
|
||||
for description in ["", " \t\n", "A quoted \"requirement\" — 雨"] {
|
||||
model.description = description.into();
|
||||
model.elements.get_mut(&work).unwrap().description = description.into();
|
||||
for compiled in exports(&model) {
|
||||
assert_eq!(
|
||||
compiled.index.project_description.is_some(),
|
||||
!description.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "work", "description").is_some(),
|
||||
!description.is_empty()
|
||||
);
|
||||
assert!(field(&compiled, "work", "parent").is_none());
|
||||
// exports compares the complete model, including exact whitespace
|
||||
// and escapes in both project and element descriptions.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sa_view_keeps_its_layer_and_node_references_but_omits_presentation_defaults() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
diagram view "View" id "00000000-0000-4000-8000-000000000003" { layer Sa; }
|
||||
node placement id "00000000-0000-4000-8000-000000000004" {
|
||||
diagram @view; element @work;
|
||||
position { x 0.0; y 0.0; }; size { width 224.0; height 94.0; };
|
||||
collapsed false; port_anchors [];
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert_eq!(field(&compiled, "view", "layer").as_deref(), Some("Sa"));
|
||||
for name in ["description", "projection", "routes", "view_options"] {
|
||||
assert!(field(&compiled, "view", name).is_none(), "view.{name}");
|
||||
}
|
||||
assert_eq!(
|
||||
field(&compiled, "placement", "diagram").as_deref(),
|
||||
Some("@ view")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "placement", "element").as_deref(),
|
||||
Some("@ work")
|
||||
);
|
||||
for name in ["position", "size", "collapsed", "port_anchors"] {
|
||||
assert!(
|
||||
field(&compiled, "placement", name).is_none(),
|
||||
"placement.{name}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_type_defaults_disappear_without_losing_required_or_present_empty_values() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_data_type implicit "Implicit" id "00000000-0000-4000-8000-000000000002" {}
|
||||
sa_data_type integer "Integer" id "00000000-0000-4000-8000-000000000003" {
|
||||
definition { Integer { minimum none; maximum none; }; };
|
||||
}
|
||||
sa_data_type text "Text" id "00000000-0000-4000-8000-000000000004" {
|
||||
definition { String { minimum_length 4; maximum_length none; }; };
|
||||
}
|
||||
sa_data_type record "Record" id "00000000-0000-4000-8000-000000000005" {
|
||||
definition { Record { supertype none; }; };
|
||||
}
|
||||
sa_data_type flag "Flag" id "00000000-0000-4000-8000-000000000006" {
|
||||
definition Boolean; default { Boolean false; };
|
||||
}
|
||||
sa_data_field optional "Optional" id "00000000-0000-4000-8000-000000000007" {
|
||||
parent @record; minimum 0; maximum none; default [];
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert!(field(&compiled, "implicit", "definition").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "integer", "definition").as_deref(),
|
||||
Some("{ Integer { } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "text", "definition").as_deref(),
|
||||
Some("{ String { minimum_length 4 ; } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "record", "definition").as_deref(),
|
||||
Some("{ Record { } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "flag", "definition").as_deref(),
|
||||
Some("Boolean")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "flag", "default").as_deref(),
|
||||
Some("{ Boolean false ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "optional", "minimum").as_deref(),
|
||||
Some("0")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "optional", "maximum").as_deref(),
|
||||
Some("none")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "optional", "default").as_deref(),
|
||||
Some("[ ]")
|
||||
);
|
||||
assert!(field(&compiled, "optional", "type_ref").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_port_contract_and_empty_item_list_survive_while_optional_interface_disappears() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
sa_port sink "Sink" id "00000000-0000-4000-8000-000000000003" {
|
||||
parent @work; contract { FunctionInput { incoming_items []; }; };
|
||||
}
|
||||
sa_system host "Host" id "00000000-0000-4000-8000-000000000004" {}
|
||||
sa_port preserved "Preserved" id "00000000-0000-4000-8000-000000000005" {
|
||||
parent @host; contract Legacy; legacy { direction Out; interface none; };
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert_eq!(
|
||||
field(&compiled, "sink", "contract").as_deref(),
|
||||
Some("{ FunctionInput { incoming_items [ ] ; } ; }")
|
||||
);
|
||||
assert!(field(&compiled, "sink", "legacy").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "preserved", "contract").as_deref(),
|
||||
Some("Legacy")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "preserved", "legacy").as_deref(),
|
||||
Some("{ direction Out ; }")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_scenario_default_and_selected_occurrence_constructor_remain_distinct() {
|
||||
let model = project(
|
||||
r#"
|
||||
sa_scenario mixed "Mixed" id "00000000-0000-4000-8000-000000000002" { viewpoint MixedLegacy; }
|
||||
sa_scenario strict "Strict" id "00000000-0000-4000-8000-000000000003" { viewpoint Functional; }
|
||||
oa_process flow "Flow" id "00000000-0000-4000-8000-000000000004" { steps [@exchange, @action]; }
|
||||
oa_occurrence exchange "Exchange" id "00000000-0000-4000-8000-000000000005" {
|
||||
parent @flow; body { Exchange {}; };
|
||||
}
|
||||
oa_occurrence action "Action" id "00000000-0000-4000-8000-000000000006" {
|
||||
parent @flow; body { Action { action none; lifeline none; }; };
|
||||
}
|
||||
"#,
|
||||
);
|
||||
for compiled in exports(&model) {
|
||||
assert!(field(&compiled, "mixed", "viewpoint").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "strict", "viewpoint").as_deref(),
|
||||
Some("Functional")
|
||||
);
|
||||
assert!(field(&compiled, "exchange", "body").is_none());
|
||||
assert_eq!(
|
||||
field(&compiled, "action", "body").as_deref(),
|
||||
Some("{ Action { } ; }")
|
||||
);
|
||||
assert_eq!(
|
||||
field(&compiled, "action", "parent").as_deref(),
|
||||
Some("@ flow")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
//! Exact-source regressions for fields skipped by persistence serialization.
|
||||
//!
|
||||
//! The archived Windows baseline confirmed three exact-source failures after
|
||||
//! full model equality passed. Complete comparison now preserves these fields;
|
||||
//! the former ignored witnesses are normal regression tests.
|
||||
//! No-op and same-record header controls remain normal tests: they must preserve
|
||||
//! the same fixtures without entering the changed-payload record-erasure branch.
|
||||
|
||||
use oa_core::{source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
const EXCHANGE_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_activity first "First" id "00000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity second "Second" id "00000000-0000-4000-8000-000000000003" {}
|
||||
interaction flow "Flow" id "00000000-0000-4000-8000-000000000004" {
|
||||
source @first; target @second;
|
||||
}
|
||||
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000005" {
|
||||
steps [@subject];
|
||||
}
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000006" {
|
||||
parent @owner;
|
||||
body { Exchange {
|
||||
exchange none;
|
||||
sender none; receiver none;
|
||||
message_kind Asynchronous; reply_to none; values [];
|
||||
operation none ; // Keep this authored default and its spelling.
|
||||
}; };
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const NODE_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
diagram view "View" id "00000000-0000-4000-8000-000000000003" {}
|
||||
node placement id "00000000-0000-4000-8000-000000000004" {
|
||||
diagram @view; element @work;
|
||||
position { x 12.5; y -4.0; };
|
||||
port_anchors [] ; // Keep explicit automatic placement.
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const TRACE_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
|
||||
sa_function function "Function" id "00000000-0000-4000-8000-000000000003" {}
|
||||
trace mapping "Mapping" id "00000000-0000-4000-8000-000000000004" {
|
||||
oa @work; sa @function; kind Manual;
|
||||
baseline {
|
||||
oa { name "Work"; description "Old OA snapshot"; };
|
||||
sa { name "Function"; description "Old SA snapshot"; };
|
||||
semantic none ; // Keep the explicit prose-only baseline.
|
||||
};
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const CONSTRUCTOR_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" {
|
||||
steps [@subject];
|
||||
}
|
||||
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
|
||||
parent @owner;
|
||||
body { Action {
|
||||
action none;
|
||||
lifeline none ; // Keep this shared field's distinctive spelling.
|
||||
}; };
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
const RELATION_CONTEXT_SOURCE: &str = r#"arcadia 7;
|
||||
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
|
||||
oa_entity first "First" id "00000000-0000-4000-8000-000000000002" {}
|
||||
oa_entity second "Second" id "00000000-0000-4000-8000-000000000003" {}
|
||||
// These port declarations are untouched by the relation context change.
|
||||
oa_port output "Output" id "00000000-0000-4000-8000-000000000005" {
|
||||
parent @first; contract Legacy;
|
||||
legacy { direction Out; interface none; };
|
||||
}
|
||||
oa_port input "Input" id "00000000-0000-4000-8000-000000000006" {
|
||||
parent @second; contract Legacy;
|
||||
legacy { direction In; interface none; };
|
||||
}
|
||||
boundary_exchange link "Link" id "00000000-0000-4000-8000-000000000004" {
|
||||
source @output; target @input;
|
||||
oriented true ; // Retain this rationale when changing the default value.
|
||||
items [];
|
||||
}
|
||||
}
|
||||
"#;
|
||||
|
||||
fn id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
|
||||
}
|
||||
|
||||
fn source_bundle(source: &str) -> SourceBundle {
|
||||
SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source.into())]),
|
||||
}
|
||||
}
|
||||
|
||||
fn project() -> Project {
|
||||
Project {
|
||||
schema_version: 7,
|
||||
id: id(1),
|
||||
alias: "p".into(),
|
||||
name: "Preservation".into(),
|
||||
description: String::new(),
|
||||
elements: BTreeMap::new(),
|
||||
relations: BTreeMap::new(),
|
||||
diagrams: BTreeMap::new(),
|
||||
artifact_links: BTreeMap::new(),
|
||||
traces: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
|
||||
Element {
|
||||
id: id(number),
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
parent,
|
||||
body,
|
||||
}
|
||||
}
|
||||
|
||||
// Handwritten typed expectations are independent of canonical serialization and
|
||||
// source hydration. No model factories or generated/random identities are used.
|
||||
fn exchange_project() -> Project {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(2, "first", "First", None, ElementBody::Oa(OaBody::Activity)),
|
||||
element(
|
||||
3,
|
||||
"second",
|
||||
"Second",
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Activity),
|
||||
),
|
||||
element(
|
||||
5,
|
||||
"owner",
|
||||
"Owner",
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Process { steps: vec![id(6)] }),
|
||||
),
|
||||
element(
|
||||
6,
|
||||
"subject",
|
||||
"Subject",
|
||||
Some(id(5)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
})),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
expected.relations.insert(
|
||||
id(4),
|
||||
Relation {
|
||||
id: id(4),
|
||||
alias: "flow".into(),
|
||||
name: "Flow".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::Interaction {
|
||||
source: id(2),
|
||||
target: id(3),
|
||||
items: vec![],
|
||||
},
|
||||
},
|
||||
);
|
||||
expected
|
||||
}
|
||||
|
||||
fn node_project() -> Project {
|
||||
let mut expected = project();
|
||||
expected.elements.insert(
|
||||
id(2),
|
||||
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
|
||||
);
|
||||
expected.diagrams.insert(
|
||||
id(3),
|
||||
Diagram {
|
||||
id: id(3),
|
||||
alias: "view".into(),
|
||||
name: "View".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: view_options::ViewOptions {
|
||||
hidden_edges: vec![],
|
||||
relation_kinds: view_options::RelationKinds::All,
|
||||
work_categories: view_options::CategorySelection::All,
|
||||
participant_categories: view_options::CategorySelection::All,
|
||||
coverage: view_options::Coverage::All,
|
||||
work_contexts: view_options::ContextSelection::All,
|
||||
exchange_labels: view_options::ExchangeLabels::Name,
|
||||
},
|
||||
nodes: BTreeMap::from([(
|
||||
id(4),
|
||||
NodePlacement {
|
||||
id: id(4),
|
||||
alias: "placement".into(),
|
||||
element: id(2),
|
||||
position: Position { x: 12.5, y: -4.0 },
|
||||
size: Size {
|
||||
width: 224.0,
|
||||
height: 94.0,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: vec![],
|
||||
},
|
||||
)]),
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
expected
|
||||
}
|
||||
|
||||
fn trace_project() -> Project {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
|
||||
element(
|
||||
3,
|
||||
"function",
|
||||
"Function",
|
||||
None,
|
||||
ElementBody::Sa(SaBody::Function),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
expected.traces.insert(
|
||||
id(4),
|
||||
Trace {
|
||||
id: id(4),
|
||||
alias: "mapping".into(),
|
||||
name: "Mapping".into(),
|
||||
description: String::new(),
|
||||
oa: id(2),
|
||||
sa: id(3),
|
||||
kind: TraceKind::Manual,
|
||||
baseline: Some(TraceBaseline {
|
||||
oa: TracedProperties {
|
||||
name: "Work".into(),
|
||||
description: "Old OA snapshot".into(),
|
||||
},
|
||||
sa: TracedProperties {
|
||||
name: "Function".into(),
|
||||
description: "Old SA snapshot".into(),
|
||||
},
|
||||
semantic: None,
|
||||
}),
|
||||
},
|
||||
);
|
||||
expected
|
||||
}
|
||||
|
||||
fn checked_fixture(source: &str, expected: &Project) -> CompiledSourceProject {
|
||||
let input = source_bundle(source);
|
||||
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
compiled.decoded.project, *expected,
|
||||
"fixture model admission"
|
||||
);
|
||||
assert_eq!(
|
||||
compiled.bundle, input,
|
||||
"fixed IDs must avoid stabilization edits"
|
||||
);
|
||||
compiled
|
||||
}
|
||||
|
||||
fn replacement(source: &str, before: &str, after: &str) -> String {
|
||||
assert_eq!(
|
||||
source.matches(before).count(),
|
||||
1,
|
||||
"unique authored edit token"
|
||||
);
|
||||
source.replacen(before, after, 1)
|
||||
}
|
||||
|
||||
fn assert_preserved_update(
|
||||
original: &CompiledSourceProject,
|
||||
expected: &Project,
|
||||
expected_source: &str,
|
||||
) {
|
||||
// Independently admit the exact expected spelling before testing the updater.
|
||||
let expected_bundle = source_bundle(expected_source);
|
||||
let authored = compile_bundle(&expected_bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
authored.decoded.project, *expected,
|
||||
"expected source/model agree"
|
||||
);
|
||||
assert_eq!(
|
||||
authored.bundle, expected_bundle,
|
||||
"expected source is already stable"
|
||||
);
|
||||
|
||||
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
updated.decoded.project, *expected,
|
||||
"entire intended model survives"
|
||||
);
|
||||
assert_eq!(
|
||||
updated.bundle, expected_bundle,
|
||||
"only the requested token may change; preserve explicit defaults and all trivia"
|
||||
);
|
||||
}
|
||||
|
||||
// Context changes may introduce/remove redundant default syntax. Admit each
|
||||
// narrowly enumerated spelling against the same literal target model, and then
|
||||
// compare complete source bundles. No normalization may mask edits to unrelated
|
||||
// fields, declarations, whitespace, or comments.
|
||||
fn assert_context_update(
|
||||
original: &CompiledSourceProject,
|
||||
expected: &Project,
|
||||
allowed_sources: &[String],
|
||||
) {
|
||||
let allowed: Vec<_> = allowed_sources
|
||||
.iter()
|
||||
.map(|source| {
|
||||
let bundle = source_bundle(source);
|
||||
let authored = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
authored.decoded.project, *expected,
|
||||
"allowed source/model agree"
|
||||
);
|
||||
assert_eq!(authored.bundle, bundle, "allowed source is already stable");
|
||||
bundle
|
||||
})
|
||||
.collect();
|
||||
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(
|
||||
updated.decoded.project, *expected,
|
||||
"entire context-change model survives"
|
||||
);
|
||||
assert!(
|
||||
allowed.contains(&updated.bundle),
|
||||
"preserve unchanged source exactly; only listed changed/default syntax alternatives are allowed\nactual: {:?}\nallowed: {:?}",
|
||||
updated.bundle,
|
||||
allowed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_exchange_operation_none_survives_noop() {
|
||||
let expected = exchange_project();
|
||||
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||
assert_preserved_update(&original, &expected, EXCHANGE_SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_exchange_operation_none_survives_header_rename() {
|
||||
let mut expected = exchange_project();
|
||||
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||
expected.elements.get_mut(&id(6)).unwrap().name = "Renamed subject".into();
|
||||
let source = replacement(EXCHANGE_SOURCE, "\"Subject\"", "\"Renamed subject\"");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_exchange_operation_none_survives_exchange_binding_edit() {
|
||||
let mut expected = exchange_project();
|
||||
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { exchange, .. },
|
||||
..
|
||||
}) = expected.elements.get_mut(&id(6)).unwrap().body.shared_mut()
|
||||
else {
|
||||
panic!("literal fixture must be an Exchange occurrence");
|
||||
};
|
||||
*exchange = Some(id(4));
|
||||
let source = replacement(EXCHANGE_SOURCE, "exchange none;", "exchange @flow;");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_node_empty_port_anchors_survive_noop() {
|
||||
let expected = node_project();
|
||||
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||
assert_preserved_update(&original, &expected, NODE_SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_node_empty_port_anchors_survive_header_alias_rename() {
|
||||
let mut expected = node_project();
|
||||
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||
// Nodes have no display-name header. Rename the same node's header alias.
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&id(3))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.alias = "renamed_placement".into();
|
||||
let source = replacement(
|
||||
NODE_SOURCE,
|
||||
"node placement id",
|
||||
"node renamed_placement id",
|
||||
);
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_node_empty_port_anchors_survive_position_edit() {
|
||||
let mut expected = node_project();
|
||||
let original = checked_fixture(NODE_SOURCE, &expected);
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&id(3))
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.position
|
||||
.x = 25.0;
|
||||
let source = replacement(NODE_SOURCE, "x 12.5;", "x 25.0;");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_semantic_none_survives_noop() {
|
||||
let expected = trace_project();
|
||||
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||
assert_preserved_update(&original, &expected, TRACE_SOURCE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_semantic_none_survives_header_rename() {
|
||||
let mut expected = trace_project();
|
||||
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||
expected.traces.get_mut(&id(4)).unwrap().name = "Renamed mapping".into();
|
||||
let source = replacement(TRACE_SOURCE, "\"Mapping\"", "\"Renamed mapping\"");
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_trace_semantic_none_survives_snapshot_prose_edit() {
|
||||
let mut expected = trace_project();
|
||||
let original = checked_fixture(TRACE_SOURCE, &expected);
|
||||
expected
|
||||
.traces
|
||||
.get_mut(&id(4))
|
||||
.unwrap()
|
||||
.baseline
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.oa
|
||||
.description = "Revised OA snapshot".into();
|
||||
let source = replacement(
|
||||
TRACE_SOURCE,
|
||||
"\"Old OA snapshot\"",
|
||||
"\"Revised OA snapshot\"",
|
||||
);
|
||||
assert_preserved_update(&original, &expected, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_change_removes_inapplicable_default_and_keeps_shared_default() {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(
|
||||
2,
|
||||
"owner",
|
||||
"Owner",
|
||||
None,
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Scenario {
|
||||
viewpoint: ScenarioViewpoint::MixedLegacy,
|
||||
context: None,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
steps: vec![id(3)],
|
||||
})),
|
||||
),
|
||||
element(
|
||||
3,
|
||||
"subject",
|
||||
"Subject",
|
||||
Some(id(2)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
})),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
let original = checked_fixture(CONSTRUCTOR_SOURCE, &expected);
|
||||
expected.elements.get_mut(&id(3)).unwrap().body =
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::State {
|
||||
state: None,
|
||||
lifeline: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
}));
|
||||
// Remove only the inapplicable field's syntax: its separating space and all
|
||||
// surrounding trivia stay. The shared lifeline spelling/comment is exact.
|
||||
let source = replacement(CONSTRUCTOR_SOURCE, "body { Action {", "body { State {");
|
||||
let compact = replacement(&source, "action none;", " ");
|
||||
let expanded = replacement(
|
||||
&compact,
|
||||
" }; };",
|
||||
" state none;\n }; };",
|
||||
);
|
||||
// Omission of newly applicable state and explicit state none both mean the
|
||||
// literal State target; neither may retain the old action field.
|
||||
assert_context_update(&original, &expected, &[compact, expanded]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relation_kind_change_updates_explicit_value_when_context_default_changes() {
|
||||
let mut expected = project();
|
||||
expected.elements = [
|
||||
element(2, "first", "First", None, ElementBody::Oa(OaBody::Entity)),
|
||||
element(3, "second", "Second", None, ElementBody::Oa(OaBody::Entity)),
|
||||
element(
|
||||
5,
|
||||
"output",
|
||||
"Output",
|
||||
Some(id(2)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::Out,
|
||||
interface: None,
|
||||
}),
|
||||
})),
|
||||
),
|
||||
element(
|
||||
6,
|
||||
"input",
|
||||
"Input",
|
||||
Some(id(3)),
|
||||
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
direction: PortDirection::In,
|
||||
interface: None,
|
||||
}),
|
||||
})),
|
||||
),
|
||||
]
|
||||
.into_iter()
|
||||
.map(|element| (element.id, element))
|
||||
.collect();
|
||||
expected.relations.insert(
|
||||
id(4),
|
||||
Relation {
|
||||
id: id(4),
|
||||
alias: "link".into(),
|
||||
name: "Link".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::BoundaryExchange {
|
||||
source: id(5),
|
||||
target: id(6),
|
||||
oriented: true,
|
||||
items: vec![],
|
||||
},
|
||||
},
|
||||
);
|
||||
let original = checked_fixture(RELATION_CONTEXT_SOURCE, &expected);
|
||||
// BoundaryExchange connects participant ports; CommunicationMean connects
|
||||
// their OA entity owners. Endpoint changes are explicit parts of this target,
|
||||
// not inferred or normalized away. Port records remain exactly unchanged.
|
||||
expected.relations.get_mut(&id(4)).unwrap().body = RelationBody::CommunicationMean {
|
||||
source: id(2),
|
||||
target: id(3),
|
||||
oriented: false,
|
||||
items: vec![],
|
||||
};
|
||||
let source = replacement(
|
||||
RELATION_CONTEXT_SOURCE,
|
||||
"boundary_exchange link",
|
||||
"communication_mean link",
|
||||
);
|
||||
let source = replacement(&source, "source @output;", "source @first;");
|
||||
let source = replacement(&source, "target @input;", "target @second;");
|
||||
let explicit = replacement(&source, "oriented true ;", "oriented false ;");
|
||||
// Three spaces between name/value plus one before the semicolon survive
|
||||
// syntax removal. The following rationale comment and all other bytes stay.
|
||||
let omitted = replacement(&source, "oriented true ;", " ");
|
||||
assert_context_update(&original, &expected, &[explicit, omitted]);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! Public update witnesses for compact insertion and complete comparison.
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn compile(body: &str) -> CompiledSourceProject {
|
||||
compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 7; project p \"P\" { include \"model.arcadia\"; }".into(),
|
||||
),
|
||||
("model.arcadia".into(), body.into()),
|
||||
]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn update(before: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
|
||||
let result = update_bundle(before, project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(result.decoded.project, *project);
|
||||
assert_eq!(
|
||||
result.bundle.files["project.arcadia"],
|
||||
before.bundle.files["project.arcadia"]
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&result.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
assert_eq!(
|
||||
update_bundle(&result, project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
result
|
||||
}
|
||||
fn words(text: &str) -> Vec<&str> {
|
||||
language::tokenize(text)
|
||||
.into_iter()
|
||||
.filter(|t| {
|
||||
!matches!(
|
||||
t.class,
|
||||
language::TokenClass::Whitespace | language::TokenClass::Comment
|
||||
)
|
||||
})
|
||||
.map(|t| &text[t.span.start..t.span.end])
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_exchange_body_changes_to_one_compact_action_constructor() {
|
||||
let before = compile(
|
||||
"oa_scenario s \"S\" { steps [@o]; }\noa_occurrence o \"O\" { parent @s; // keep this\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let occurrence = id(&project, "o");
|
||||
let Some(SharedBody::Occurrence { body, .. }) =
|
||||
project.elements.get_mut(&occurrence).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("occurrence")
|
||||
};
|
||||
assert!(matches!(body, OccurrenceBody::Exchange { .. }));
|
||||
*body = OccurrenceBody::Action {
|
||||
action: None,
|
||||
lifeline: None,
|
||||
};
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert_eq!(tokens.iter().filter(|&&t| t == "body").count(), 1);
|
||||
assert!(tokens.contains(&"Action"));
|
||||
for absent in ["Exchange", "action", "lifeline", "operation", "description"] {
|
||||
assert!(!tokens.contains(&absent), "{absent}: {text}");
|
||||
}
|
||||
assert!(text.contains("parent @s; // keep this"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_signal_constructor_still_inserts_its_required_empty_items() {
|
||||
let before =
|
||||
compile("oa_event e \"E\" { body { Time { expression \"1 s\"; kind After; }; }; }\n");
|
||||
let mut project = before.decoded.project.clone();
|
||||
let event = id(&project, "e");
|
||||
let Some(SharedBody::Event { body }) = project.elements.get_mut(&event).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("event")
|
||||
};
|
||||
*body = EventBody::Signal { items: vec![] };
|
||||
let result = update(&before, &project);
|
||||
let tokens = words(&result.bundle.files["model.arcadia"]);
|
||||
assert!(tokens.contains(&"Signal"));
|
||||
assert!(tokens.windows(4).any(|w| w == ["items", "[", "]", ";"]));
|
||||
assert!(!tokens.contains(&"expression"));
|
||||
assert!(!tokens.contains(&"kind"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_to_default_string_record_retains_required_minimum_only() {
|
||||
let before = compile("oa_data_type t \"T\" { definition Boolean; // typed rationale\n}\n");
|
||||
let mut project = before.decoded.project.clone();
|
||||
let datatype = id(&project, "t");
|
||||
let Some(SharedBody::DataType { definition, .. }) =
|
||||
project.elements.get_mut(&datatype).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("data type")
|
||||
};
|
||||
*definition = DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
};
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert!(tokens.contains(&"String"));
|
||||
assert!(tokens.windows(3).any(|w| w == ["minimum_length", "0", ";"]));
|
||||
assert!(!tokens.contains(&"maximum_length"));
|
||||
assert!(text.contains("// typed rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inserted_route_list_item_omits_only_optional_fields() {
|
||||
let before = compile(
|
||||
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction flow \"Flow\" { source @a; target @b; }\ndiagram d \"D\" { routes []; // routing rationale\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let diagram = id(&project, "d");
|
||||
let edge = id(&project, "flow");
|
||||
// Deserialize only the explicit witness value, independently of omission policy.
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes =
|
||||
serde_json::from_value(serde_json::json!([{"edge":edge,"waypoints":[],"label":null}]))
|
||||
.unwrap();
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert!(tokens.windows(4).any(|w| w == ["edge", "@", "flow", ";"]));
|
||||
assert!(!tokens.contains(&"waypoints"));
|
||||
assert!(!tokens.contains(&"label"));
|
||||
assert!(text.contains("// routing rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_sign_change_inserts_position_without_default_siblings() {
|
||||
let before = compile(
|
||||
"oa_activity a \"A\" {}\ndiagram d \"D\" {}\nnode n { diagram @d; element @a; // placement rationale\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let diagram = id(&project, "d");
|
||||
let node = id(&project, "n");
|
||||
assert_eq!(
|
||||
project.diagrams[&diagram].nodes[&node].position.x.to_bits(),
|
||||
0.0_f32.to_bits()
|
||||
);
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.position
|
||||
.x = -0.0;
|
||||
let result = update(&before, &project);
|
||||
assert_eq!(
|
||||
result.decoded.project.diagrams[&diagram].nodes[&node]
|
||||
.position
|
||||
.x
|
||||
.to_bits(),
|
||||
(-0.0_f32).to_bits()
|
||||
);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
let tokens = words(text);
|
||||
assert!(tokens.windows(3).any(|w| w == ["x", "-0.0", ";"]));
|
||||
for absent in ["size", "collapsed", "port_anchors"] {
|
||||
assert!(!tokens.contains(&absent));
|
||||
}
|
||||
assert!(text.contains("// placement rationale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_change_materializes_unchanged_value_when_its_old_omission_default_changes() {
|
||||
let before = compile(
|
||||
"oa_entity a \"A\" {}\noa_entity b \"B\" {}\noa_port out \"Out\" { parent @a; contract Legacy; legacy { direction Out; }; }\noa_port input \"In\" { parent @b; contract Legacy; legacy { direction In; }; }\nboundary_exchange flow \"Flow\" { source @out; target @input; // orientation inherited\n}\n",
|
||||
);
|
||||
let mut project = before.decoded.project.clone();
|
||||
let flow = id(&project, "flow");
|
||||
assert!(matches!(
|
||||
project.relations[&flow].body,
|
||||
RelationBody::BoundaryExchange { oriented: true, .. }
|
||||
));
|
||||
project.relations.get_mut(&flow).unwrap().body = RelationBody::CommunicationMean {
|
||||
source: id(&before.decoded.project, "a"),
|
||||
target: id(&before.decoded.project, "b"),
|
||||
oriented: true,
|
||||
items: vec![],
|
||||
};
|
||||
let result = update(&before, &project);
|
||||
let text = &result.bundle.files["model.arcadia"];
|
||||
assert!(
|
||||
words(text)
|
||||
.windows(3)
|
||||
.any(|w| w == ["oriented", "true", ";"])
|
||||
);
|
||||
assert!(text.contains("// orientation inherited"));
|
||||
let before_ports = &before.bundle.files["model.arcadia"];
|
||||
assert_eq!(
|
||||
text.split("communication_mean").next().unwrap(),
|
||||
before_ports.split("boundary_exchange").next().unwrap()
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,16 @@ use oa_core::{language, persistence, validation, *};
|
||||
fn read(source: &str) -> Project {
|
||||
language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}"))
|
||||
}
|
||||
fn replace_once(source: &str, before: &str, after: &str) -> String {
|
||||
assert_eq!(
|
||||
source.matches(before).count(),
|
||||
1,
|
||||
"unique mutation anchor {before:?}"
|
||||
);
|
||||
let changed = source.replacen(before, after, 1);
|
||||
assert_ne!(changed, source, "test mutation must change the source");
|
||||
changed
|
||||
}
|
||||
fn rejected(source: &str, fragment: &str) {
|
||||
let errors = language::parse(source).expect_err(source);
|
||||
assert!(
|
||||
@@ -753,8 +763,8 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
||||
source.push('}');
|
||||
let model = read(&source);
|
||||
let canonical = language::format(&model);
|
||||
assert!(!canonical.contains("transition_kind External;"));
|
||||
for fragment in [
|
||||
"transition_kind External;",
|
||||
"transition_kind Local;",
|
||||
"transition_kind Internal;",
|
||||
"triggers [@deadline, @assess];",
|
||||
@@ -776,6 +786,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
||||
.find(|r| r.alias == "recover")
|
||||
.unwrap();
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
trigger,
|
||||
guard,
|
||||
effect,
|
||||
@@ -784,32 +795,42 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
||||
else {
|
||||
panic!("expected transition")
|
||||
};
|
||||
assert_eq!(*transition_kind, TransitionKind::External);
|
||||
assert_eq!(trigger, "Operator confirms\nНе запускать код");
|
||||
assert_eq!(guard, "available && status == \"Ready\"");
|
||||
assert_eq!(effect, "Record the assessment");
|
||||
// External is omitted in compact output. Insert an explicit invalid
|
||||
// discriminator into Recover using its unique, nondefault trigger list.
|
||||
let invalid_kind = replace_once(
|
||||
&canonical,
|
||||
"triggers [@deadline, @assess];",
|
||||
"transition_kind Unsupported;\n triggers [@deadline, @assess];",
|
||||
);
|
||||
rejected(&invalid_kind, "Unsupported");
|
||||
for invalid in [
|
||||
canonical.replace("transition_kind External;", "transition_kind Unsupported;"),
|
||||
canonical.replace(
|
||||
replace_once(
|
||||
&canonical,
|
||||
"triggers [@deadline, @assess];",
|
||||
"triggers [\"deadline\", @assess];",
|
||||
),
|
||||
canonical.replace(
|
||||
replace_once(
|
||||
&canonical,
|
||||
"triggers [@deadline, @assess];",
|
||||
"triggers [@deadline, @deadline];",
|
||||
),
|
||||
canonical.replace("effects [@assess];", "effects [@payload];"),
|
||||
canonical.replace("kind After;", "kind Relative;"),
|
||||
canonical.replace("items [@payload];", "items [@assess];"),
|
||||
replace_once(&canonical, "effects [@assess];", "effects [@payload];"),
|
||||
replace_once(&canonical, "kind After;", "kind Relative;"),
|
||||
replace_once(&canonical, "items [@payload];", "items [@assess];"),
|
||||
] {
|
||||
assert!(
|
||||
language::parse(&invalid).is_err(),
|
||||
"invalid typed state/event source accepted"
|
||||
);
|
||||
}
|
||||
let legacy = canonical
|
||||
.replace("transition_kind External;", "")
|
||||
.replace("triggers [@deadline, @assess];", "")
|
||||
.replace("effects [@assess];", "");
|
||||
// Canonical source already omits External. Removing the supplied lists
|
||||
// independently exercises their empty omission defaults and retained prose.
|
||||
let legacy = replace_once(&canonical, "triggers [@deadline, @assess];", "");
|
||||
let legacy = replace_once(&legacy, "effects [@assess];", "");
|
||||
let legacy = read(&legacy);
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
@@ -829,7 +850,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
|
||||
(retained_trigger, retained_guard, retained_effect),
|
||||
(trigger, guard, effect)
|
||||
);
|
||||
let draft = canonical.replace("expression \"30 s\";", "expression \"\";");
|
||||
let draft = replace_once(&canonical, "expression \"30 s\";", "expression \"\";");
|
||||
assert!(language::parse(&draft).is_ok());
|
||||
let deadline = model
|
||||
.elements
|
||||
@@ -1038,7 +1059,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
"fixture must cover actual old scenario content"
|
||||
);
|
||||
let canonical = language::format(&from_source);
|
||||
assert!(canonical.contains("viewpoint MixedLegacy;"));
|
||||
assert!(!canonical.contains("viewpoint MixedLegacy;"));
|
||||
for relation in from_source.relations.values() {
|
||||
if let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
@@ -767,7 +769,15 @@ fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_rec
|
||||
assert_eq!(cleared.decoded.project, prepared.project);
|
||||
let text = &cleared.bundle.files["project.arcadia"];
|
||||
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
|
||||
assert!(!text.contains("port_anchors"));
|
||||
// Lossless editing may retain the authored empty field and its annotations.
|
||||
// Canonical export, rather than a model update, normalizes default syntax.
|
||||
assert!(!language::format(&cleared.decoded.project).contains("port_anchors"));
|
||||
assert_eq!(
|
||||
update_bundle(&cleared, &cleared.decoded.project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
cleared.bundle
|
||||
);
|
||||
let prepared = prepare_command(
|
||||
&cleared.decoded.project,
|
||||
&Command::SetPortAnchor {
|
||||
|
||||
@@ -41,7 +41,6 @@ fn fixture() -> Fixture {
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
@@ -71,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,
|
||||
@@ -690,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()),
|
||||
});
|
||||
|
||||
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
|
||||
assert_eq!(session.project(), &after);
|
||||
let fresh = Id::new_v4();
|
||||
let mut reidentified = after.clone();
|
||||
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
|
||||
references::remap_references(&mut reidentified, f.r1, fresh);
|
||||
let mut e = reidentified.elements.remove(&f.r1).unwrap();
|
||||
e.id = fresh;
|
||||
reidentified.elements.insert(fresh, e);
|
||||
|
||||
@@ -383,3 +383,87 @@ fn atomic_save_preserves_existing_unix_permissions() {
|
||||
0o640
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_diagnostics_keep_raw_no_uuid_spans() {
|
||||
let directory = setup();
|
||||
let path = directory.path().join("parts/a.arcadia");
|
||||
let raw =
|
||||
"// Unicode: 航海\nsa_function first \"First\" {}\nsa_function second \"Second\" {}\n";
|
||||
fs::write(&path, raw).unwrap();
|
||||
let disk = load_project(
|
||||
&directory.path().join("project.arcadia"),
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(disk.bundle.files["parts/a.arcadia"], raw);
|
||||
let issue = disk
|
||||
.diagnostics
|
||||
.iter()
|
||||
.find(|issue| {
|
||||
issue.diagnostic.code == "unallocated_leaf"
|
||||
&& issue.diagnostic.message.contains("Second")
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(issue.path, "parts/a.arcadia");
|
||||
assert_eq!(issue.diagnostic.line, 3);
|
||||
assert_eq!(issue.diagnostic.column, 1);
|
||||
let span = issue.diagnostic.span;
|
||||
assert_eq!(span.start, raw.find("sa_function second").unwrap());
|
||||
assert_eq!(
|
||||
&raw[span.start..span.end],
|
||||
"sa_function second \"Second\" {}"
|
||||
);
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_ne!(compiled.bundle, disk.bundle);
|
||||
assert!(compiled.diagnostics.iter().any(|later| {
|
||||
later.diagnostic.code == issue.diagnostic.code
|
||||
&& later.diagnostic.message == issue.diagnostic.message
|
||||
&& later.diagnostic.span.start > span.start
|
||||
}));
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), raw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_diagnoses_legacy_semantics_before_source_rewrite() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory.path().join("project.arcadia");
|
||||
let raw = include_str!("fixtures/v3-data-contract.arcadia");
|
||||
fs::write(&path, raw).unwrap();
|
||||
let disk = load_project(&path, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(disk.bundle.files["project.arcadia"], raw);
|
||||
assert!(!disk.diagnostics.is_empty());
|
||||
assert!(
|
||||
disk.diagnostics
|
||||
.iter()
|
||||
.all(|issue| { issue.diagnostic.severity == oa_core::validation::Severity::Warning })
|
||||
);
|
||||
assert_eq!(
|
||||
disk.diagnostics,
|
||||
lint_bundle(&disk.reachable_bundle(), ProjectLimits::default())
|
||||
);
|
||||
let compiled = compile_bundle(&disk.reachable_bundle(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
|
||||
assert_ne!(compiled.bundle, disk.bundle);
|
||||
for issue in &disk.diagnostics {
|
||||
assert!(compiled.diagnostics.iter().any(|later| {
|
||||
later.diagnostic.code == issue.diagnostic.code
|
||||
&& later.diagnostic.object_id == issue.diagnostic.object_id
|
||||
&& later.diagnostic.message == issue.diagnostic.message
|
||||
}));
|
||||
let span = issue.diagnostic.span;
|
||||
assert!(raw.get(span.start..span.end).is_some());
|
||||
}
|
||||
// Invalid legacy references still fail during semantic resolution on the
|
||||
// authored bytes; deferring the source rewrite does not hide those errors.
|
||||
let invalid = raw.replace("@coordinate", "@missing");
|
||||
fs::write(&path, &invalid).unwrap();
|
||||
let broken = load_project(&path, ProjectLimits::default()).unwrap();
|
||||
assert!(broken.diagnostics.iter().any(|issue| {
|
||||
issue.diagnostic.severity == oa_core::validation::Severity::Error
|
||||
&& issue.diagnostic.message.contains("@missing")
|
||||
}));
|
||||
assert_eq!(broken.bundle.files["project.arcadia"], invalid);
|
||||
assert!(compile_bundle(&broken.reachable_bundle(), ProjectLimits::default()).is_err());
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), invalid);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
@@ -302,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,
|
||||
});
|
||||
@@ -363,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,
|
||||
});
|
||||
@@ -423,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,
|
||||
});
|
||||
@@ -457,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,
|
||||
},
|
||||
@@ -584,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,
|
||||
@@ -653,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,
|
||||
});
|
||||
@@ -731,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,
|
||||
|
||||
@@ -0,0 +1,836 @@
|
||||
//! Pure, bounded allocation-drag composition. See the v2 interaction contract.
|
||||
//!
|
||||
//! These values are not input capabilities. The App must independently admit a
|
||||
//! clean press and retire its token on every document/input/camera lifetime
|
||||
//! change. Cache a SemanticPlan per token and destination; only `place` belongs
|
||||
//! on pointer motion. `finish` rechecks the live accepted Project and commands,
|
||||
//! but Document::execute still owns source admission and atomic history.
|
||||
use oa_core::{
|
||||
projection::{self, ProjectedNode, ProjectedView},
|
||||
validation::{self, Issue, Severity},
|
||||
view_options::ViewIndex,
|
||||
*,
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Destination {
|
||||
LayoutOnly,
|
||||
Performer(Id),
|
||||
OutsidePerformers,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Uniform {
|
||||
Assigned(Id),
|
||||
Unassigned,
|
||||
MixedOrUnresolved,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Leaf {
|
||||
pub direct: Option<Relation>,
|
||||
pub role_assignments: Vec<Relation>,
|
||||
pub role_realizations: Vec<Relation>,
|
||||
pub effective_performer: Option<Id>,
|
||||
}
|
||||
|
||||
/// Exact semantic scope S, leaf scope L and active placement scope Q, including
|
||||
/// folded/unplaced leaves. Retaining records detects first-child responsibility
|
||||
/// transfer. Revision and input-epoch guards remain the caller's responsibility.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Scope {
|
||||
pub project: Id,
|
||||
pub work: Id,
|
||||
pub placement: Id,
|
||||
pub work_records: BTreeMap<Id, Element>,
|
||||
pub leaves: BTreeMap<Id, Leaf>,
|
||||
pub placements: BTreeMap<Id, NodePlacement>,
|
||||
pub diagram: Diagram,
|
||||
pub uniform: Uniform,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ChangedRecord {
|
||||
pub id: Id,
|
||||
pub name: String,
|
||||
pub category: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Refusal {
|
||||
Ineligible(String),
|
||||
StaleScope,
|
||||
MixedLayoutRequired,
|
||||
RoleExitRequired {
|
||||
work: Id,
|
||||
roles: Vec<Id>,
|
||||
performers: Vec<Id>,
|
||||
message: String,
|
||||
},
|
||||
MissingAllocationId(Id),
|
||||
InvalidCandidate(String),
|
||||
CollateralChangeRequired(Vec<ChangedRecord>),
|
||||
LayoutRequired(String),
|
||||
}
|
||||
|
||||
pub fn capture(p: &Project, diagram: Id, work: Id, placement: Id) -> Result<Scope, Refusal> {
|
||||
validate_candidate(p).map_err(|e| Refusal::InvalidCandidate(e.to_string()))?;
|
||||
let d = p.diagrams.get(&diagram).ok_or(Refusal::StaleScope)?;
|
||||
let root = p.elements.get(&work).ok_or(Refusal::StaleScope)?;
|
||||
if !root.kind().is_work()
|
||||
|| d.projection != ProjectionKind::Allocation
|
||||
|| d.layer != root.layer()
|
||||
{
|
||||
return Err(Refusal::Ineligible(
|
||||
"Select an activity or function in an Allocation view.".into(),
|
||||
));
|
||||
}
|
||||
if d.nodes.get(&placement).is_none_or(|n| n.element != work) {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let mut work_records = BTreeMap::new();
|
||||
let mut leaves = BTreeMap::new();
|
||||
let mut pending = vec![work];
|
||||
while let Some(id) = pending.pop() {
|
||||
let element = p.elements.get(&id).ok_or(Refusal::StaleScope)?;
|
||||
if element.kind() != root.kind() || work_records.insert(id, element.clone()).is_some() {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let children: Vec<_> = p
|
||||
.children(id)
|
||||
.filter(|e| e.kind() == root.kind())
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
if !children.is_empty() {
|
||||
pending.extend(children);
|
||||
continue;
|
||||
}
|
||||
let direct: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::Allocation && r.target() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
if direct.len() > 1 {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let role_assignments: Vec<_> = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == id)
|
||||
.cloned()
|
||||
.collect();
|
||||
let roles: BTreeSet<_> = role_assignments.iter().map(Relation::source).collect();
|
||||
let role_realizations = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::RoleRealization && roles.contains(&r.target()))
|
||||
.cloned()
|
||||
.collect();
|
||||
leaves.insert(
|
||||
id,
|
||||
Leaf {
|
||||
direct: direct.into_iter().next(),
|
||||
role_assignments,
|
||||
role_realizations,
|
||||
effective_performer: validation::effective_performer(p, id),
|
||||
},
|
||||
);
|
||||
}
|
||||
if leaves.is_empty() {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let uniform = if leaves
|
||||
.values()
|
||||
.all(|l| l.direct.is_none() && l.role_assignments.is_empty())
|
||||
{
|
||||
Uniform::Unassigned
|
||||
} else if let Some(performer) = leaves.values().next().and_then(|l| l.effective_performer)
|
||||
&& leaves
|
||||
.values()
|
||||
.all(|l| l.effective_performer == Some(performer))
|
||||
{
|
||||
Uniform::Assigned(performer)
|
||||
} else {
|
||||
Uniform::MixedOrUnresolved
|
||||
};
|
||||
let placements = d
|
||||
.nodes
|
||||
.iter()
|
||||
.filter(|(_, n)| work_records.contains_key(&n.element))
|
||||
.map(|(id, n)| (*id, n.clone()))
|
||||
.collect();
|
||||
Ok(Scope {
|
||||
project: p.id,
|
||||
work,
|
||||
placement,
|
||||
work_records,
|
||||
leaves,
|
||||
placements,
|
||||
diagram: d.clone(),
|
||||
uniform,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SemanticPlan {
|
||||
pub scope: Scope,
|
||||
pub destination: Destination,
|
||||
pub label: String,
|
||||
pub warnings: Vec<Issue>,
|
||||
accepted: Project,
|
||||
candidate: Project,
|
||||
index: ViewIndex,
|
||||
commands: Vec<Command>,
|
||||
before_visible: ProjectedView,
|
||||
before_expanded: ProjectedView,
|
||||
}
|
||||
|
||||
impl SemanticPlan {
|
||||
/// All preview drawing, ports and routing must use this candidate and index.
|
||||
pub fn project(&self) -> &Project {
|
||||
&self.candidate
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub fn index(&self) -> &ViewIndex {
|
||||
&self.index
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocation identities are allocated once by the gesture owner, keyed by leaf,
|
||||
/// and reused on target revisits and release. No random identity generation here.
|
||||
pub fn plan_semantics(
|
||||
p: &Project,
|
||||
scope: &Scope,
|
||||
destination: Destination,
|
||||
allocation_ids: &BTreeMap<Id, Id>,
|
||||
) -> Result<SemanticPlan, Refusal> {
|
||||
if &capture(p, scope.diagram.id, scope.work, scope.placement)? != scope {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
if scope.work_records.len() > 1 && scope.uniform == Uniform::MixedOrUnresolved {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
if let Destination::Performer(id) = destination {
|
||||
let valid = p
|
||||
.elements
|
||||
.get(&id)
|
||||
.is_some_and(|e| e.kind().is_performer() && e.layer() == scope.diagram.layer);
|
||||
if !valid {
|
||||
return Err(Refusal::Ineligible(
|
||||
"Choose a performer in this layer.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let before_index = ViewIndex::new(p);
|
||||
let before_visible = projection::project_diagram_with_index(p, &scope.diagram, &before_index);
|
||||
let before_expanded = expanded_view(p, &scope.diagram, scope, &before_index);
|
||||
if scope.work_records.len() > 1 && !coherent(scope, &before_expanded) {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
let same_performer =
|
||||
matches!(destination, Destination::Performer(id) if scope.uniform == Uniform::Assigned(id));
|
||||
let mut expected = p.clone();
|
||||
let mut commands = Vec::new();
|
||||
let mut deleted = BTreeSet::new();
|
||||
if destination != Destination::LayoutOnly && !same_performer {
|
||||
for (work, leaf) in &scope.leaves {
|
||||
match destination {
|
||||
Destination::Performer(performer) => {
|
||||
if let Some(existing) = &leaf.direct {
|
||||
if existing.source() != performer {
|
||||
let mut edited = existing.clone();
|
||||
edited.body = RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: *work,
|
||||
};
|
||||
expected.relations.insert(edited.id, edited.clone());
|
||||
commands.push(Command::EditRelation(edited.id, edited));
|
||||
}
|
||||
} else {
|
||||
let id = *allocation_ids
|
||||
.get(work)
|
||||
.ok_or(Refusal::MissingAllocationId(*work))?;
|
||||
if id.is_nil() || expected.contains(id) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"The gesture's allocation identity is already in use.".into(),
|
||||
));
|
||||
}
|
||||
let name = format!("{} performs {}", p.named(performer), p.named(*work));
|
||||
let relation = Relation {
|
||||
id,
|
||||
alias: expected.fresh_alias(&name, "allocation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body: RelationBody::Allocation {
|
||||
source: performer,
|
||||
target: *work,
|
||||
},
|
||||
};
|
||||
expected.relations.insert(id, relation.clone());
|
||||
commands.push(Command::CreateRelation(relation));
|
||||
}
|
||||
}
|
||||
Destination::OutsidePerformers => {
|
||||
if !leaf.role_assignments.is_empty() {
|
||||
let roles: Vec<_> = leaf
|
||||
.role_assignments
|
||||
.iter()
|
||||
.map(Relation::source)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let performers: Vec<_> = leaf
|
||||
.role_realizations
|
||||
.iter()
|
||||
.map(Relation::source)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
let message = if leaf.direct.is_some() && performers.len() == 1 {
|
||||
format!(
|
||||
"Removing direct assignment would place {} in {} through its role.",
|
||||
p.named(*work),
|
||||
p.named(performers[0])
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{} retains role responsibility through {}{}; edit that responsibility before moving outside performers.",
|
||||
p.named(*work),
|
||||
roles
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", "),
|
||||
if performers.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
" ({})",
|
||||
performers
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
}
|
||||
)
|
||||
};
|
||||
return Err(Refusal::RoleExitRequired {
|
||||
work: *work,
|
||||
roles,
|
||||
performers,
|
||||
message,
|
||||
});
|
||||
}
|
||||
if let Some(existing) = &leaf.direct {
|
||||
expected.relations.remove(&existing.id);
|
||||
deleted.insert(existing.id);
|
||||
commands.push(Command::Delete(existing.id));
|
||||
}
|
||||
}
|
||||
Destination::LayoutOnly => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
// A route cannot survive the intentional deletion of its own edge, even in
|
||||
// another diagram. No other saved-view cleanup is authorized by this drag.
|
||||
for diagram in expected.diagrams.values_mut() {
|
||||
diagram.routes.retain(|r| !deleted.contains(&r.edge));
|
||||
}
|
||||
let actual = prepare_command(p, &Command::Batch(commands.clone()))
|
||||
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
||||
.project;
|
||||
exact_project(&expected, &actual)?;
|
||||
let retained_roles = scope
|
||||
.leaves
|
||||
.values()
|
||||
.any(|l| !l.role_assignments.is_empty());
|
||||
let label = match destination {
|
||||
Destination::LayoutOnly => "Arrange only".into(),
|
||||
Destination::Performer(id) if same_performer => format!("Arrange within {}", p.named(id)),
|
||||
Destination::Performer(id) => format!(
|
||||
"Assign {} leaf {} to {}{}",
|
||||
scope.leaves.len(),
|
||||
match (scope.diagram.layer, scope.leaves.len()) {
|
||||
(Layer::Oa, 1) => "activity",
|
||||
(Layer::Oa, _) => "activities",
|
||||
(Layer::Sa, 1) => "function",
|
||||
(Layer::Sa, _) => "functions",
|
||||
},
|
||||
p.named(id),
|
||||
if retained_roles {
|
||||
" — explicit assignment; role responsibility retained"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
),
|
||||
Destination::OutsidePerformers if commands.is_empty() => {
|
||||
"Arrange unassigned work — decomposition retained".into()
|
||||
}
|
||||
Destination::OutsidePerformers => "Remove assignment — decomposition retained".into(),
|
||||
};
|
||||
let warnings = validation::validate(&actual)
|
||||
.into_iter()
|
||||
.filter(|i| i.severity == Severity::Warning)
|
||||
.collect();
|
||||
let index = ViewIndex::new(&actual);
|
||||
Ok(SemanticPlan {
|
||||
scope: scope.clone(),
|
||||
destination,
|
||||
label,
|
||||
warnings,
|
||||
accepted: p.clone(),
|
||||
candidate: actual,
|
||||
index,
|
||||
commands,
|
||||
before_visible,
|
||||
before_expanded,
|
||||
})
|
||||
}
|
||||
|
||||
/// World-space rectangle in the gesture's frozen transform. The UI resolver
|
||||
/// owns half-open pointer hits, semantic target precedence and occlusion.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Rect {
|
||||
pub position: Position,
|
||||
pub size: Size,
|
||||
}
|
||||
|
||||
impl Rect {
|
||||
fn valid(self) -> bool {
|
||||
[
|
||||
self.position.x,
|
||||
self.position.y,
|
||||
self.size.width,
|
||||
self.size.height,
|
||||
]
|
||||
.iter()
|
||||
.all(|n| n.is_finite() && n.abs() <= MAX_COORDINATE)
|
||||
&& self.size.width > 0.
|
||||
&& self.size.height > 0.
|
||||
}
|
||||
fn contains(self, other: Self) -> bool {
|
||||
other.position.x >= self.position.x
|
||||
&& other.position.y >= self.position.y
|
||||
&& other.position.x + other.size.width <= self.position.x + self.size.width
|
||||
&& other.position.y + other.size.height <= self.position.y + self.size.height
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct TargetPresentation {
|
||||
pub placement: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PresentationContext {
|
||||
/// Visible world rectangle derived from the actual captured pan/zoom/bounds.
|
||||
pub viewport: Rect,
|
||||
pub zoom: f32,
|
||||
pub target: Option<TargetPresentation>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PlacementPlan {
|
||||
pub diagram: Diagram,
|
||||
pub view: ProjectedView,
|
||||
pub delta: Position,
|
||||
pub unfolded: Option<Id>,
|
||||
requested: Position,
|
||||
presentation: PresentationContext,
|
||||
commands: Vec<Command>,
|
||||
}
|
||||
|
||||
/// `requested` is the original selected placement position plus pointer motion
|
||||
/// in the frozen transform (therefore preserving the press offset). Snap only
|
||||
/// this root; apply the resulting identical delta to captured Q and its routes.
|
||||
/// No whole Project clone, validator or semantic ViewIndex rebuild occurs here.
|
||||
pub fn place(
|
||||
plan: &SemanticPlan,
|
||||
requested: Position,
|
||||
presentation: PresentationContext,
|
||||
) -> Result<PlacementPlan, Refusal> {
|
||||
if !requested.x.is_finite()
|
||||
|| !requested.y.is_finite()
|
||||
|| !presentation.viewport.valid()
|
||||
|| !presentation.zoom.is_finite()
|
||||
|| presentation.zoom <= 0.
|
||||
{
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
let root = &plan.scope.placements[&plan.scope.placement];
|
||||
let delta = Position {
|
||||
x: (requested.x / 10.).round() * 10. - root.position.x,
|
||||
y: (requested.y / 10.).round() * 10. - root.position.y,
|
||||
};
|
||||
let mut diagram = plan.candidate.diagrams[&plan.scope.diagram.id].clone();
|
||||
let mut commands = Vec::new();
|
||||
let mut deltas = BTreeMap::new();
|
||||
for (id, original) in &plan.scope.placements {
|
||||
let node = diagram.nodes.get_mut(id).ok_or(Refusal::StaleScope)?;
|
||||
node.position = Position {
|
||||
x: original.position.x + delta.x,
|
||||
y: original.position.y + delta.y,
|
||||
};
|
||||
if !(Rect {
|
||||
position: node.position,
|
||||
size: node.size,
|
||||
})
|
||||
.valid()
|
||||
{
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"Moved coordinates exceed the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
deltas.insert(*id, delta);
|
||||
if node != original {
|
||||
commands.push(Command::EditNode {
|
||||
diagram: diagram.id,
|
||||
original: *id,
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Resolve endpoints using the captured accepted semantics, not a newly
|
||||
// regrouped descendant walk. Own-edge deletions were removed above already.
|
||||
for route in projection::translated_routes(&plan.accepted, &plan.scope.diagram, &deltas) {
|
||||
if let Some(existing) = diagram.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
if route.waypoints.iter().any(|p| {
|
||||
!p.x.is_finite()
|
||||
|| !p.y.is_finite()
|
||||
|| p.x.abs() > MAX_COORDINATE
|
||||
|| p.y.abs() > MAX_COORDINATE
|
||||
}) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"Moved route exceeds the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
*existing = route.clone();
|
||||
commands.push(Command::SetEdgeRoute {
|
||||
diagram: diagram.id,
|
||||
route,
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut unfolded = None;
|
||||
if let Destination::Performer(performer) = plan.destination {
|
||||
let target = presentation.target.ok_or_else(|| {
|
||||
Refusal::LayoutRequired("A visible accepted performer target is required.".into())
|
||||
})?;
|
||||
let node = diagram
|
||||
.nodes
|
||||
.get_mut(&target.placement)
|
||||
.ok_or(Refusal::StaleScope)?;
|
||||
let visible_target = plan
|
||||
.before_visible
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.id == target.placement && n.element == performer);
|
||||
if node.element != performer || !visible_target {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
if node.collapsed && !plan.commands.is_empty() {
|
||||
node.collapsed = false;
|
||||
unfolded = Some(node.id);
|
||||
commands.push(Command::EditNode {
|
||||
diagram: diagram.id,
|
||||
original: node.id,
|
||||
node: node.clone(),
|
||||
});
|
||||
}
|
||||
} else if presentation.target.is_some() {
|
||||
return Err(Refusal::Ineligible(
|
||||
"This layout/outside destination has no performer target.".into(),
|
||||
));
|
||||
}
|
||||
let view = projection::project_diagram_with_index(&plan.candidate, &diagram, &plan.index);
|
||||
let expanded = expanded_view(&plan.candidate, &diagram, &plan.scope, &plan.index);
|
||||
check_geometry(plan, &view, &expanded, delta)?;
|
||||
let selected = view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == plan.scope.placement)
|
||||
.ok_or_else(|| {
|
||||
Refusal::LayoutRequired("The selected work header would be hidden.".into())
|
||||
})?;
|
||||
let header = Rect {
|
||||
position: selected.position,
|
||||
size: Size {
|
||||
width: selected.size.width,
|
||||
height: selected.size.height.min(62.),
|
||||
},
|
||||
};
|
||||
if !presentation.viewport.contains(header) {
|
||||
return Err(Refusal::LayoutRequired(
|
||||
"Make room or adjust the view, then drag again.".into(),
|
||||
));
|
||||
}
|
||||
// Destination identity lives in a fixed toolbar slot. Frozen hit geometry
|
||||
// and its outline never introduce an opaque label over this work header.
|
||||
Ok(PlacementPlan {
|
||||
diagram,
|
||||
view,
|
||||
delta,
|
||||
unfolded,
|
||||
requested,
|
||||
presentation,
|
||||
commands,
|
||||
})
|
||||
}
|
||||
|
||||
/// Final semantic/presentation equality gate, after the App has checked its
|
||||
/// current token, owner, revision, clean drafts, frozen target and transform.
|
||||
/// Recompute geometry to avoid accepting a modified/stale placement result.
|
||||
/// None means a snapped no-op: the caller must not execute or add history.
|
||||
pub fn finish(
|
||||
plan: &SemanticPlan,
|
||||
live: &Project,
|
||||
placement: &PlacementPlan,
|
||||
) -> Result<Option<Command>, Refusal> {
|
||||
if live != &plan.accepted {
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let checked = place(plan, placement.requested, placement.presentation)?;
|
||||
if checked.diagram != placement.diagram
|
||||
|| checked.view != placement.view
|
||||
|| checked.delta != placement.delta
|
||||
|| checked.unfolded != placement.unfolded
|
||||
|| checked.commands != placement.commands
|
||||
{
|
||||
return Err(Refusal::StaleScope);
|
||||
}
|
||||
let mut expected = plan.candidate.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(checked.diagram.id, checked.diagram);
|
||||
let mut commands = plan.commands.clone();
|
||||
commands.extend(checked.commands);
|
||||
let command = Command::Batch(commands);
|
||||
let actual = prepare_command(live, &command)
|
||||
.map_err(|e| Refusal::InvalidCandidate(e.to_string()))?
|
||||
.project;
|
||||
exact_project(&expected, &actual)?;
|
||||
Ok((actual != *live).then_some(command))
|
||||
}
|
||||
|
||||
fn expanded_view(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
scope: &Scope,
|
||||
index: &ViewIndex,
|
||||
) -> ProjectedView {
|
||||
let mut expanded = diagram.clone();
|
||||
for id in scope.placements.keys() {
|
||||
if let Some(node) = expanded.nodes.get_mut(id) {
|
||||
node.collapsed = false;
|
||||
}
|
||||
}
|
||||
projection::project_diagram_with_index(p, &expanded, index)
|
||||
}
|
||||
|
||||
fn ancestors(view: &ProjectedView) -> BTreeMap<Id, BTreeSet<Id>> {
|
||||
let parents: BTreeMap<_, _> = view.nodes.iter().map(|n| (n.id, n.visual_parent)).collect();
|
||||
parents
|
||||
.keys()
|
||||
.map(|node| {
|
||||
let mut current = parents[node];
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = current {
|
||||
if !seen.insert(id) {
|
||||
break;
|
||||
}
|
||||
current = parents.get(&id).copied().flatten();
|
||||
}
|
||||
(*node, seen)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn coherent(scope: &Scope, view: &ProjectedView) -> bool {
|
||||
let ancestors = ancestors(view);
|
||||
scope.placements.keys().all(|id| {
|
||||
ancestors
|
||||
.get(id)
|
||||
.is_some_and(|parents| *id == scope.placement || parents.contains(&scope.placement))
|
||||
})
|
||||
}
|
||||
|
||||
fn overlap(a: &ProjectedNode, b: &ProjectedNode) -> f32 {
|
||||
let width = (a.position.x + a.size.width).min(b.position.x + b.size.width)
|
||||
- a.position.x.max(b.position.x);
|
||||
let height = (a.position.y + a.size.height).min(b.position.y + b.size.height)
|
||||
- a.position.y.max(b.position.y);
|
||||
width.max(0.) * height.max(0.)
|
||||
}
|
||||
|
||||
fn overlaps(before: &ProjectedView, after: &ProjectedView) -> bool {
|
||||
// Build geometry ancestry once, avoiding a repeated descendant walk for
|
||||
// every pair during pointer motion. Pairwise collision checking is bounded
|
||||
// to these two projected scenes and does not traverse/clone model semantics.
|
||||
let before_ancestors = ancestors(before);
|
||||
let after_ancestors = ancestors(after);
|
||||
let prior_nodes: BTreeMap<_, _> = before.nodes.iter().map(|n| (n.id, n)).collect();
|
||||
for (i, a) in after.nodes.iter().enumerate() {
|
||||
for b in &after.nodes[i + 1..] {
|
||||
if after_ancestors[&a.id].contains(&b.id) || after_ancestors[&b.id].contains(&a.id) {
|
||||
continue;
|
||||
}
|
||||
let prior = match (prior_nodes.get(&a.id), prior_nodes.get(&b.id)) {
|
||||
(Some(x), Some(y))
|
||||
if !before_ancestors[&x.id].contains(&y.id)
|
||||
&& !before_ancestors[&y.id].contains(&x.id) =>
|
||||
{
|
||||
overlap(x, y)
|
||||
}
|
||||
_ => 0.,
|
||||
};
|
||||
if overlap(a, b) > prior + 0.01 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn check_geometry(
|
||||
plan: &SemanticPlan,
|
||||
visible: &ProjectedView,
|
||||
expanded: &ProjectedView,
|
||||
delta: Position,
|
||||
) -> Result<(), Refusal> {
|
||||
if visible.nodes.iter().chain(&expanded.nodes).any(|n| {
|
||||
!(Rect {
|
||||
position: n.position,
|
||||
size: n.size,
|
||||
})
|
||||
.valid()
|
||||
}) {
|
||||
return Err(Refusal::InvalidCandidate(
|
||||
"The projected enclosure exceeds the model bounds.".into(),
|
||||
));
|
||||
}
|
||||
if plan.scope.work_records.len() > 1 {
|
||||
if !coherent(&plan.scope, expanded) {
|
||||
return Err(Refusal::MixedLayoutRequired);
|
||||
}
|
||||
for id in plan.scope.placements.keys() {
|
||||
let old = plan
|
||||
.before_expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *id)
|
||||
.ok_or(Refusal::MixedLayoutRequired)?;
|
||||
let new = expanded
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.id == *id)
|
||||
.ok_or(Refusal::MixedLayoutRequired)?;
|
||||
if (new.position.x - old.position.x - delta.x).abs() > 0.01
|
||||
|| (new.position.y - old.position.y - delta.y).abs() > 0.01
|
||||
|| (new.size.width - old.size.width).abs() > 0.01
|
||||
|| (new.size.height - old.size.height).abs() > 0.01
|
||||
{
|
||||
return Err(Refusal::LayoutRequired("This work subtree requires an explicit layout; its footprint cannot be translated unchanged.".into()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if overlaps(&plan.before_visible, visible) || overlaps(&plan.before_expanded, expanded) {
|
||||
return Err(Refusal::LayoutRequired("Make room before moving work: the candidate would introduce or enlarge overlapping cards.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compare complete records, not just removed IDs. Deleting an Allocation can
|
||||
/// rewrite a surviving Constraint, remove artifacts/traces or prune view options.
|
||||
fn exact_project(expected: &Project, actual: &Project) -> Result<(), Refusal> {
|
||||
if expected == actual {
|
||||
return Ok(());
|
||||
}
|
||||
let mut records = Vec::new();
|
||||
fn changes<T: PartialEq>(
|
||||
expected: &BTreeMap<Id, T>,
|
||||
actual: &BTreeMap<Id, T>,
|
||||
category: &'static str,
|
||||
project: &Project,
|
||||
other: &Project,
|
||||
records: &mut Vec<ChangedRecord>,
|
||||
) {
|
||||
let ids: BTreeSet<_> = expected.keys().chain(actual.keys()).copied().collect();
|
||||
for id in ids {
|
||||
if expected.get(&id) != actual.get(&id) {
|
||||
records.push(ChangedRecord {
|
||||
id,
|
||||
name: if project.contains(id) {
|
||||
project.named(id)
|
||||
} else {
|
||||
other.named(id)
|
||||
}
|
||||
.into(),
|
||||
category,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
changes(
|
||||
&expected.elements,
|
||||
&actual.elements,
|
||||
"element",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.relations,
|
||||
&actual.relations,
|
||||
"relation",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.diagrams,
|
||||
&actual.diagrams,
|
||||
"diagram",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.artifact_links,
|
||||
&actual.artifact_links,
|
||||
"artifact",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
changes(
|
||||
&expected.traces,
|
||||
&actual.traces,
|
||||
"trace",
|
||||
expected,
|
||||
actual,
|
||||
&mut records,
|
||||
);
|
||||
if expected.schema_version != actual.schema_version
|
||||
|| expected.id != actual.id
|
||||
|| expected.alias != actual.alias
|
||||
|| expected.name != actual.name
|
||||
|| expected.description != actual.description
|
||||
{
|
||||
records.push(ChangedRecord {
|
||||
id: expected.id,
|
||||
name: expected.name.clone(),
|
||||
category: "project",
|
||||
});
|
||||
}
|
||||
Err(Refusal::CollateralChangeRequired(records))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,268 @@
|
||||
//! A narrow dispatch boundary for Allocation work gestures. Iced rows visit
|
||||
//! siblings after capture; refusing inside Canvas alone still blurs an inspector
|
||||
//! field. Refuse before dispatching that press to any ordinary child instead.
|
||||
use crate::{
|
||||
app::{
|
||||
App, Message,
|
||||
allocation_drag::{self, Event as WorkEvent},
|
||||
},
|
||||
diagram::DiagramCanvas,
|
||||
};
|
||||
use iced::advanced::{
|
||||
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
|
||||
widget::{Id, Operation, Tree, tree},
|
||||
};
|
||||
use iced::{
|
||||
Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse,
|
||||
};
|
||||
|
||||
pub const CANVAS_ID: &str = "allocation-work-canvas";
|
||||
pub const ARRANGE_SHORTCUT: &str = "Alt+A";
|
||||
|
||||
struct Root<'a> {
|
||||
app: &'a App,
|
||||
content: Element<'a, Message>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
pointer: Option<Point>,
|
||||
refused_left: bool,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct CanvasBounds(Option<Rectangle>);
|
||||
impl Operation for CanvasBounds {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
|
||||
if id == Some(&Id::new(CANVAS_ID)) {
|
||||
self.0 = Some(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn root<'a>(app: &'a App, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
|
||||
Element::new(Root {
|
||||
app,
|
||||
content: content.into(),
|
||||
})
|
||||
}
|
||||
fn arrange_key(event: &Event) -> bool {
|
||||
matches!(event, Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(key), modifiers, repeat: false, ..
|
||||
}) if key.eq_ignore_ascii_case("a") && *modifiers == keyboard::Modifiers::ALT)
|
||||
}
|
||||
impl Root<'_> {
|
||||
fn dirty_object(&self, bounds: Rectangle, screen: Point) -> bool {
|
||||
let Some(diagram) = self.app.active_diagram else {
|
||||
return false;
|
||||
};
|
||||
if !allocation_drag::contains(bounds, screen) {
|
||||
return false;
|
||||
}
|
||||
let canvas = DiagramCanvas {
|
||||
allocation: Some(self.app.allocation_capability()),
|
||||
project: self.app.project(),
|
||||
view_index: Some(self.app.document.view_index()),
|
||||
diagram,
|
||||
selected: self.app.selected,
|
||||
navigation_focus: None,
|
||||
zoom: self.app.zoom,
|
||||
pan: self.app.pan,
|
||||
context: self.app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
selected_attachment: None,
|
||||
editable: false,
|
||||
placing: false,
|
||||
connection: None,
|
||||
};
|
||||
canvas.work_dirty_point(bounds, screen)
|
||||
}
|
||||
}
|
||||
impl Widget<Message, Theme, Renderer> for Root<'_> {
|
||||
fn tag(&self) -> tree::Tag {
|
||||
tree::Tag::of::<State>()
|
||||
}
|
||||
fn state(&self) -> tree::State {
|
||||
tree::State::new(State::default())
|
||||
}
|
||||
fn children(&self) -> Vec<Tree> {
|
||||
vec![Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut Tree) {
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
}
|
||||
fn size(&self) -> Size<Length> {
|
||||
self.content.as_widget().size()
|
||||
}
|
||||
fn layout(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
renderer: &Renderer,
|
||||
limits: &layout::Limits,
|
||||
) -> layout::Node {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree.children[0], renderer, limits)
|
||||
}
|
||||
fn operate(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
layout: Layout<'_>,
|
||||
renderer: &Renderer,
|
||||
operation: &mut dyn Operation,
|
||||
) {
|
||||
self.content
|
||||
.as_widget_mut()
|
||||
.operate(&mut tree.children[0], layout, renderer, operation);
|
||||
}
|
||||
fn update(
|
||||
&mut self,
|
||||
tree: &mut Tree,
|
||||
event: &Event,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
renderer: &Renderer,
|
||||
clipboard: &mut dyn Clipboard,
|
||||
shell: &mut Shell<'_, Message>,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
let state = tree.state.downcast_mut::<State>();
|
||||
match event {
|
||||
Event::Mouse(mouse::Event::CursorMoved { position }) => state.pointer = Some(*position),
|
||||
Event::Mouse(mouse::Event::CursorLeft) => state.pointer = None,
|
||||
Event::Window(iced::window::Event::RedrawRequested(_)) => {
|
||||
state.pointer = cursor.position()
|
||||
}
|
||||
Event::Window(iced::window::Event::Unfocused) => {
|
||||
state.pointer = None;
|
||||
state.refused_left = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if state.refused_left {
|
||||
if matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
|
||||
) {
|
||||
state.refused_left = false;
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Mouse(mouse::Event::CursorMoved { .. })) {
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if self.app.allocation_input_owner() && !shell.is_event_captured() {
|
||||
if arrange_key(event) {
|
||||
// App rechecks clean ownership before arming. A dirty field
|
||||
// retains its actual widget focus and selection on refusal.
|
||||
shell.publish(Message::WorkDrag(WorkEvent::ArrangeNext));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if self.app.allocation_drag.arrange_next
|
||||
&& matches!(
|
||||
event,
|
||||
Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::Escape),
|
||||
..
|
||||
})
|
||||
)
|
||||
{
|
||||
shell.publish(Message::Escape);
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
if !self.app.allocation_drag_clean()
|
||||
&& matches!(
|
||||
event,
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
|
||||
)
|
||||
&& let Some(screen) = state.pointer.or_else(|| cursor.position())
|
||||
{
|
||||
let mut canvas = CanvasBounds::default();
|
||||
self.content.as_widget_mut().operate(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
&mut canvas,
|
||||
);
|
||||
if canvas
|
||||
.0
|
||||
.is_some_and(|bounds| self.dirty_object(bounds, screen))
|
||||
{
|
||||
state.refused_left = true;
|
||||
shell.publish(Message::WorkDrag(WorkEvent::Refused(
|
||||
self.app.allocation_drag.epoch,
|
||||
allocation_drag::FINISH_EDIT.into(),
|
||||
)));
|
||||
shell.capture_event();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.content.as_widget_mut().update(
|
||||
&mut tree.children[0],
|
||||
event,
|
||||
layout,
|
||||
cursor,
|
||||
renderer,
|
||||
clipboard,
|
||||
shell,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn draw(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
renderer: &mut Renderer,
|
||||
theme: &Theme,
|
||||
style: &renderer::Style,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
) {
|
||||
self.content.as_widget().draw(
|
||||
&tree.children[0],
|
||||
renderer,
|
||||
theme,
|
||||
style,
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
);
|
||||
}
|
||||
fn mouse_interaction(
|
||||
&self,
|
||||
tree: &Tree,
|
||||
layout: Layout<'_>,
|
||||
cursor: mouse::Cursor,
|
||||
viewport: &Rectangle,
|
||||
renderer: &Renderer,
|
||||
) -> mouse::Interaction {
|
||||
self.content.as_widget().mouse_interaction(
|
||||
&tree.children[0],
|
||||
layout,
|
||||
cursor,
|
||||
viewport,
|
||||
renderer,
|
||||
)
|
||||
}
|
||||
fn overlay<'b>(
|
||||
&'b mut self,
|
||||
tree: &'b mut Tree,
|
||||
layout: Layout<'b>,
|
||||
renderer: &Renderer,
|
||||
viewport: &Rectangle,
|
||||
translation: Vector,
|
||||
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
|
||||
self.content.as_widget_mut().overlay(
|
||||
&mut tree.children[0],
|
||||
layout,
|
||||
renderer,
|
||||
viewport,
|
||||
translation,
|
||||
)
|
||||
}
|
||||
}
|
||||
+649
-708
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,672 @@
|
||||
//! Allocation work-body input admission. Gesture authority lives here, while
|
||||
//! the canvas retains ordered native pointer positions and frozen hit geometry.
|
||||
use super::{App, Message, Page};
|
||||
use crate::{allocation_drag as plan, route_edit::Context};
|
||||
use iced::{Point, Rectangle, Task, Vector};
|
||||
use oa_core::{projection::ProjectedView, routing::RoutedView, *};
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
pub const FINISH_EDIT: &str = "Finish or cancel the current edit before selecting or moving work.";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Stamp {
|
||||
pub context: Context,
|
||||
pub epoch: u64,
|
||||
pub snapshot: usize,
|
||||
pub bounds: Rectangle,
|
||||
pub pan: Vector,
|
||||
pub zoom: f32,
|
||||
}
|
||||
impl Stamp {
|
||||
pub fn world(self, screen: Point) -> Point {
|
||||
Point::new(
|
||||
(screen.x - self.bounds.x - self.pan.x) / self.zoom,
|
||||
(screen.y - self.bounds.y - self.pan.y) / self.zoom,
|
||||
)
|
||||
}
|
||||
pub fn viewport(self) -> plan::Rect {
|
||||
let p = self.world(Point::new(self.bounds.x, self.bounds.y));
|
||||
plan::Rect {
|
||||
position: Position { x: p.x, y: p.y },
|
||||
size: Size {
|
||||
width: self.bounds.width / self.zoom,
|
||||
height: self.bounds.height / self.zoom,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Target {
|
||||
pub element: Id,
|
||||
pub placement: Id,
|
||||
pub region: Rectangle,
|
||||
pub name: String,
|
||||
pub ancestors: Vec<Id>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Frozen {
|
||||
pub targets: Vec<Target>,
|
||||
cards: Vec<Rectangle>,
|
||||
controls: Vec<Rectangle>,
|
||||
edges: Vec<(Point, Point)>,
|
||||
radius: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Hit {
|
||||
Destination(plan::Destination, Option<usize>),
|
||||
OutsideViewport,
|
||||
Occluded,
|
||||
Ambiguous(Vec<usize>),
|
||||
}
|
||||
pub fn contains(r: Rectangle, p: Point) -> bool {
|
||||
p.x >= r.x && p.y >= r.y && p.x < r.x + r.width && p.y < r.y + r.height
|
||||
}
|
||||
impl Frozen {
|
||||
pub fn capture(
|
||||
project: &Project,
|
||||
scope: &plan::Scope,
|
||||
view: &ProjectedView,
|
||||
routed: &RoutedView,
|
||||
zoom: f32,
|
||||
) -> Self {
|
||||
let mut targets = Vec::new();
|
||||
let mut cards = Vec::new();
|
||||
for n in &view.nodes {
|
||||
if scope.placements.contains_key(&n.id) {
|
||||
continue;
|
||||
}
|
||||
let region = Rectangle::new(
|
||||
Point::new(n.position.x, n.position.y),
|
||||
iced::Size::new(n.size.width, n.size.height),
|
||||
);
|
||||
let Some(e) = project.elements.get(&n.element) else {
|
||||
cards.push(region);
|
||||
continue;
|
||||
};
|
||||
if e.kind().is_performer() && e.layer() == scope.diagram.layer {
|
||||
let mut ancestors = Vec::new();
|
||||
let mut parent = e.parent;
|
||||
while let Some(id) = parent {
|
||||
if ancestors.contains(&id) {
|
||||
break;
|
||||
}
|
||||
ancestors.push(id);
|
||||
parent = project.elements.get(&id).and_then(|e| e.parent);
|
||||
}
|
||||
let name = project.named(e.id).to_owned();
|
||||
let collapsed = scope.diagram.nodes[&n.id].collapsed;
|
||||
targets.push(Target {
|
||||
element: e.id,
|
||||
placement: n.id,
|
||||
region: if collapsed {
|
||||
Rectangle {
|
||||
height: region.height.min(62.),
|
||||
..region
|
||||
}
|
||||
} else {
|
||||
region
|
||||
},
|
||||
name,
|
||||
ancestors,
|
||||
});
|
||||
} else {
|
||||
cards.push(region);
|
||||
}
|
||||
}
|
||||
let radius = (10. / zoom).max(10.);
|
||||
let mut controls: Vec<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|port| !scope.placements.contains_key(&port.owner))
|
||||
.map(|port| {
|
||||
Rectangle::new(
|
||||
Point::new(port.position.x - radius, port.position.y - radius),
|
||||
iced::Size::new(radius * 2., radius * 2.),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut edges = Vec::new();
|
||||
for edge in routed.edges.values() {
|
||||
if let Some(relation) = project.relations.get(&edge.id) {
|
||||
for end in routing::association_end_geometry(relation, edge) {
|
||||
if !end.label.text.is_empty() {
|
||||
controls.push(Rectangle::new(
|
||||
Point::new(end.label.bounds.position.x, end.label.bounds.position.y),
|
||||
iced::Size::new(
|
||||
end.label.bounds.size.width,
|
||||
end.label.bounds.size.height,
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !edge.label.text.is_empty() {
|
||||
controls.push(Rectangle::new(
|
||||
Point::new(edge.label.bounds.position.x, edge.label.bounds.position.y),
|
||||
iced::Size::new(edge.label.bounds.size.width, edge.label.bounds.size.height),
|
||||
));
|
||||
}
|
||||
for pin in &edge.pins {
|
||||
controls.push(Rectangle::new(
|
||||
Point::new(pin.x - 8. / zoom, pin.y - 8. / zoom),
|
||||
iced::Size::new(16. / zoom, 16. / zoom),
|
||||
));
|
||||
}
|
||||
for points in edge.points.windows(2) {
|
||||
edges.push((
|
||||
Point::new(points[0].x, points[0].y),
|
||||
Point::new(points[1].x, points[1].y),
|
||||
));
|
||||
}
|
||||
}
|
||||
Self {
|
||||
targets,
|
||||
cards,
|
||||
controls,
|
||||
edges,
|
||||
radius: 6. / zoom,
|
||||
}
|
||||
}
|
||||
pub fn resolve(&self, stamp: Stamp, screen: Option<Point>, arrange: bool) -> Hit {
|
||||
let Some(screen) = screen.filter(|p| contains(stamp.bounds, *p)) else {
|
||||
return Hit::OutsideViewport;
|
||||
};
|
||||
let p = stamp.world(screen);
|
||||
// Drag status lives in the toolbar, outside this actual canvas viewport.
|
||||
if self
|
||||
.controls
|
||||
.iter()
|
||||
.chain(&self.cards)
|
||||
.any(|r| contains(*r, p))
|
||||
|| self.edges.iter().any(|(a, b)| {
|
||||
let d = *b - *a;
|
||||
let length = d.x * d.x + d.y * d.y;
|
||||
let t = if length == 0. {
|
||||
0.
|
||||
} else {
|
||||
(((p.x - a.x) * d.x + (p.y - a.y) * d.y) / length).clamp(0., 1.)
|
||||
};
|
||||
p.distance(*a + d * t) <= self.radius
|
||||
})
|
||||
{
|
||||
return Hit::Occluded;
|
||||
}
|
||||
if arrange {
|
||||
return Hit::Destination(plan::Destination::LayoutOnly, None);
|
||||
}
|
||||
let hits: Vec<_> = self
|
||||
.targets
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, t)| contains(t.region, p))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
for (i, a) in hits.iter().enumerate() {
|
||||
for b in &hits[i + 1..] {
|
||||
let a = &self.targets[*a];
|
||||
let b = &self.targets[*b];
|
||||
if !a.ancestors.contains(&b.element) && !b.ancestors.contains(&a.element) {
|
||||
return Hit::Ambiguous(hits);
|
||||
}
|
||||
}
|
||||
}
|
||||
match hits
|
||||
.into_iter()
|
||||
.max_by_key(|i| self.targets[*i].ancestors.len())
|
||||
{
|
||||
Some(i) => Hit::Destination(
|
||||
plan::Destination::Performer(self.targets[i].element),
|
||||
Some(i),
|
||||
),
|
||||
None => Hit::Destination(plan::Destination::OutsidePerformers, None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Press {
|
||||
pub stamp: Stamp,
|
||||
pub token: Id,
|
||||
pub start: Point,
|
||||
pub scope: plan::Scope,
|
||||
pub frozen: Frozen,
|
||||
pub arrange: bool,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Press(Arc<Press>),
|
||||
Preview(Stamp, Id, Option<Point>),
|
||||
Release(Stamp, Id, Option<Point>),
|
||||
Refused(u64, String),
|
||||
Cancel(Id),
|
||||
ArrangeNext,
|
||||
Unfocused,
|
||||
KeyboardBoundary,
|
||||
}
|
||||
pub struct Preview {
|
||||
pub token: Id,
|
||||
pub semantic: Arc<plan::SemanticPlan>,
|
||||
pub placement: plan::PlacementPlan,
|
||||
pub routed: Arc<RoutedView>,
|
||||
}
|
||||
struct Active {
|
||||
press: Arc<Press>,
|
||||
inspection_targets: Vec<usize>,
|
||||
ids: BTreeMap<Id, Id>,
|
||||
cache: Vec<(
|
||||
plan::Destination,
|
||||
Result<Arc<plan::SemanticPlan>, plan::Refusal>,
|
||||
)>,
|
||||
}
|
||||
pub struct Feedback {
|
||||
pub press: Arc<Press>,
|
||||
pub targets: Vec<usize>,
|
||||
/// Last captured target names for inspection only; never resolver input.
|
||||
pub inspection_targets: Vec<usize>,
|
||||
pub text: String,
|
||||
pub refused: bool,
|
||||
}
|
||||
impl Feedback {
|
||||
pub fn inspection_identity(&self) -> String {
|
||||
if self.refused && self.targets.is_empty() && !self.inspection_targets.is_empty() {
|
||||
let names = self
|
||||
.inspection_targets
|
||||
.iter()
|
||||
.map(|index| self.press.frozen.targets[*index].name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ");
|
||||
format!(
|
||||
"{}\nLast target{}: {}\nReturn to the canvas to choose a destination; releasing here cancels the drag.",
|
||||
self.destination_identity(),
|
||||
if self.inspection_targets.len() > 1 {
|
||||
"s (ambiguous)"
|
||||
} else {
|
||||
""
|
||||
},
|
||||
names
|
||||
)
|
||||
} else {
|
||||
self.destination_identity()
|
||||
}
|
||||
}
|
||||
/// Captured accepted names, independent of clipped status/refusal prose.
|
||||
pub fn destination_identity(&self) -> String {
|
||||
match self.targets.as_slice() {
|
||||
[] => if self.press.arrange {
|
||||
"Arrange only"
|
||||
} else if self.refused {
|
||||
"No eligible destination"
|
||||
} else {
|
||||
"Outside performers"
|
||||
}
|
||||
.into(),
|
||||
[index] => self.press.frozen.targets[*index].name.clone(),
|
||||
indices => format!(
|
||||
"Ambiguous: {}",
|
||||
indices
|
||||
.iter()
|
||||
.map(|index| self.press.frozen.targets[*index].name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" / ")
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
pub epoch: u64,
|
||||
pub arrange_next: bool,
|
||||
active: Option<Active>,
|
||||
pub preview: Option<Arc<Preview>>,
|
||||
pub feedback: Option<Feedback>,
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Capability<'a> {
|
||||
pub state: &'a State,
|
||||
pub clean: bool,
|
||||
pub snapshot: usize,
|
||||
}
|
||||
impl State {
|
||||
fn retire(&mut self) {
|
||||
self.epoch = self.epoch.wrapping_add(1);
|
||||
self.active = None;
|
||||
self.preview = None;
|
||||
self.feedback = None;
|
||||
self.arrange_next = false;
|
||||
}
|
||||
}
|
||||
pub fn refusal(r: &plan::Refusal) -> String {
|
||||
match r {
|
||||
plan::Refusal::Ineligible(s) | plan::Refusal::InvalidCandidate(s) | plan::Refusal::LayoutRequired(s) => s.clone(),
|
||||
plan::Refusal::StaleScope => "The view changed. Start this drag again.".into(),
|
||||
plan::Refusal::MixedLayoutRequired => "This work has a distributed or mixed assignment. Drag a specific leaf; arranging this group needs a layout step.".into(),
|
||||
plan::Refusal::RoleExitRequired { message, .. } => message.clone(),
|
||||
plan::Refusal::MissingAllocationId(_) => "Assignment identity expired. Start this drag again.".into(),
|
||||
plan::Refusal::CollateralChangeRequired(records) => format!(
|
||||
"Assignment needs dependent edits to {}. Resolve these before dragging again.",
|
||||
records.iter().map(|r| r.name.as_str()).collect::<Vec<_>>().join(", ")
|
||||
),
|
||||
}
|
||||
}
|
||||
impl App {
|
||||
pub fn allocation_drag_clean(&self) -> bool {
|
||||
self.allocation_input_owner()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& !self.behavior.has_pending()
|
||||
}
|
||||
pub(crate) fn allocation_input_owner(&self) -> bool {
|
||||
self.page == Page::Diagram
|
||||
&& self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.is_some_and(|d| d.projection == ProjectionKind::Allocation)
|
||||
&& self.architecture.is_none()
|
||||
&& self.connection.is_none()
|
||||
&& self.model_menu.is_none()
|
||||
&& !self.reference_input_active()
|
||||
&& !self.busy
|
||||
&& !self.hosted.visible
|
||||
&& self.workflow.is_none()
|
||||
&& self.pending.is_none()
|
||||
&& self.file_prompt.is_none()
|
||||
&& !self.delete_pending
|
||||
&& self.diagram_usages.is_none()
|
||||
}
|
||||
pub fn allocation_capability(&self) -> Capability<'_> {
|
||||
Capability {
|
||||
state: &self.allocation_drag,
|
||||
clean: self.allocation_drag_clean(),
|
||||
snapshot: Arc::as_ptr(&self.document.snapshot()) as usize,
|
||||
}
|
||||
}
|
||||
fn allocation_stamp_valid(&self, stamp: Stamp) -> bool {
|
||||
self.allocation_drag_clean()
|
||||
&& self.route_context() == Some(stamp.context)
|
||||
&& self.allocation_drag.epoch == stamp.epoch
|
||||
&& Arc::as_ptr(&self.document.snapshot()) as usize == stamp.snapshot
|
||||
&& self.pan == stamp.pan
|
||||
&& self.zoom == stamp.zoom
|
||||
&& self.canvas_size.is_none_or(|s| s == stamp.bounds.size())
|
||||
&& stamp.bounds.width > 0.
|
||||
&& stamp.bounds.height > 0.
|
||||
}
|
||||
/// Called before general input dispatch. Even equal-valued actions (Fit,
|
||||
/// Undo/Redo, tool/modal round trips) retire old render-time capabilities.
|
||||
pub(super) fn allocation_before(&mut self, message: &Message) {
|
||||
if !matches!(
|
||||
message,
|
||||
Message::WorkDrag(_)
|
||||
| Message::CheckpointTick
|
||||
| Message::CheckpointWritten(..)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(..)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::SourceViewport(_)
|
||||
| Message::SourceSymbolsReady(_)
|
||||
| Message::SourceRenameReady(_)
|
||||
| Message::DiagramUsagesReady(_)
|
||||
| Message::DiagramRevealViewport(..)
|
||||
) {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
}
|
||||
pub(super) fn allocation_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.route_context(),
|
||||
self.pan,
|
||||
self.zoom,
|
||||
self.canvas_size,
|
||||
self.window_size,
|
||||
self.allocation_drag_clean(),
|
||||
Arc::as_ptr(&self.document.snapshot()) as usize,
|
||||
)
|
||||
}
|
||||
pub(super) fn allocation_retire(&mut self) {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
fn select_work_clean(&mut self, press: &Press) -> bool {
|
||||
if !self.allocation_stamp_valid(press.stamp) {
|
||||
return false;
|
||||
}
|
||||
self.selected = press.scope.work;
|
||||
self.selected_node = Some(press.scope.placement);
|
||||
self.selected_bend = None;
|
||||
self.refresh_draft();
|
||||
true
|
||||
}
|
||||
pub(super) fn allocation_press_owns_current_view(&self, press: &Press) -> bool {
|
||||
self.allocation_drag.active.is_none()
|
||||
&& self.allocation_stamp_valid(press.stamp)
|
||||
&& press.arrange == self.allocation_drag.arrange_next
|
||||
}
|
||||
pub(super) fn allocation_event_envelope(&self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Cancel(token) | Event::Preview(_, token, _) | Event::Release(_, token, _) => {
|
||||
self.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.press.token == *token)
|
||||
}
|
||||
Event::Refused(epoch, _) => *epoch == self.allocation_drag.epoch,
|
||||
// Invalid Press packets deliberately report why the view or draft
|
||||
// changed. Leave their existing validation and feedback intact.
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
pub(super) fn allocation_event(&mut self, event: Event) -> Task<Message> {
|
||||
if !self.allocation_event_envelope(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::ArrangeNext => {
|
||||
self.allocation_drag.retire();
|
||||
if self.allocation_drag_clean() {
|
||||
self.allocation_drag.arrange_next = true;
|
||||
self.status = "Arrange next drag armed · moves work only · Esc cancels".into();
|
||||
} else {
|
||||
self.status = FINISH_EDIT.into();
|
||||
}
|
||||
}
|
||||
Event::Unfocused => self.allocation_drag.retire(),
|
||||
Event::KeyboardBoundary => {
|
||||
if self.allocation_drag.active.is_some() {
|
||||
self.allocation_drag.retire();
|
||||
}
|
||||
}
|
||||
Event::Cancel(token) => {
|
||||
if self
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.press.token == token)
|
||||
{
|
||||
self.allocation_drag.retire();
|
||||
self.status = "Work drag canceled".into();
|
||||
}
|
||||
}
|
||||
Event::Refused(epoch, text) => {
|
||||
if epoch == self.allocation_drag.epoch {
|
||||
self.allocation_drag.retire();
|
||||
self.status = text;
|
||||
}
|
||||
}
|
||||
Event::Press(press) => {
|
||||
if self.allocation_drag.active.is_some() {
|
||||
return Task::none();
|
||||
}
|
||||
if !self.allocation_press_owns_current_view(&press) {
|
||||
self.status = if self.allocation_drag_clean() {
|
||||
"The view changed. Start this drag again.".into()
|
||||
} else {
|
||||
FINISH_EDIT.into()
|
||||
};
|
||||
return Task::none();
|
||||
}
|
||||
let checked = plan::capture(
|
||||
self.project(),
|
||||
press.scope.diagram.id,
|
||||
press.scope.work,
|
||||
press.scope.placement,
|
||||
);
|
||||
if checked.as_ref() != Ok(&press.scope) || !self.select_work_clean(&press) {
|
||||
self.status = checked
|
||||
.err()
|
||||
.as_ref()
|
||||
.map(refusal)
|
||||
.unwrap_or_else(|| "The view changed. Start this drag again.".into());
|
||||
self.allocation_drag.retire();
|
||||
return Task::none();
|
||||
}
|
||||
let ids = press
|
||||
.scope
|
||||
.leaves
|
||||
.keys()
|
||||
.map(|id| (*id, Id::new_v4()))
|
||||
.collect();
|
||||
self.allocation_drag.arrange_next = false;
|
||||
self.allocation_drag.preview = None;
|
||||
self.allocation_drag.feedback = None;
|
||||
self.allocation_drag.active = Some(Active {
|
||||
press,
|
||||
inspection_targets: Vec::new(),
|
||||
ids,
|
||||
cache: Vec::new(),
|
||||
});
|
||||
}
|
||||
Event::Preview(stamp, token, pointer) => {
|
||||
self.allocation_motion(stamp, token, pointer, false)
|
||||
}
|
||||
Event::Release(stamp, token, pointer) => {
|
||||
self.allocation_motion(stamp, token, pointer, true)
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn allocation_motion(
|
||||
&mut self,
|
||||
stamp: Stamp,
|
||||
token: Id,
|
||||
pointer: Option<Point>,
|
||||
release: bool,
|
||||
) {
|
||||
if self
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.is_none_or(|a| a.press.token != token)
|
||||
{
|
||||
return; // An old packet cannot cancel a newer gesture.
|
||||
}
|
||||
if !self.allocation_stamp_valid(stamp) {
|
||||
self.allocation_drag.retire();
|
||||
self.status = if self.allocation_drag_clean() {
|
||||
"The view changed. Start this drag again.".into()
|
||||
} else {
|
||||
FINISH_EDIT.into()
|
||||
};
|
||||
return;
|
||||
}
|
||||
let Some(mut active) = self.allocation_drag.active.take() else {
|
||||
return;
|
||||
};
|
||||
if active.press.token != token || active.press.stamp != stamp {
|
||||
self.allocation_drag.active = Some(active);
|
||||
return;
|
||||
}
|
||||
let press = active.press.clone();
|
||||
let moved = pointer.is_some_and(|p| p.distance(press.start) > 3.);
|
||||
let hit = press.frozen.resolve(stamp, pointer, press.arrange);
|
||||
if moved {
|
||||
match &hit {
|
||||
Hit::Destination(_, Some(target)) => active.inspection_targets = vec![*target],
|
||||
Hit::Ambiguous(targets) => active.inspection_targets = targets.clone(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut targets = Vec::new();
|
||||
let mut preview = None;
|
||||
let result: Result<String, String> = match hit {
|
||||
Hit::OutsideViewport => Err(if release {
|
||||
"Work drag canceled outside the canvas."
|
||||
} else {
|
||||
"Pointer outside canvas · return to choose a destination; releasing here cancels the drag."
|
||||
}.into()),
|
||||
_ if !moved => Ok("Work selected".into()),
|
||||
Hit::Occluded => Err("Aim at exposed performer space or empty canvas; this card, port, connection or control blocks the drop.".into()),
|
||||
Hit::Ambiguous(indices) => {
|
||||
targets = indices;
|
||||
Err("Two unrelated performers overlap here. Use an exposed region.".into())
|
||||
}
|
||||
Hit::Destination(destination, target) => {
|
||||
targets.extend(target);
|
||||
let semantic = if let Some((_, result)) = active.cache.iter().find(|(d, _)| *d == destination) {
|
||||
result.clone()
|
||||
} else {
|
||||
let result = plan::plan_semantics(self.project(), &press.scope, destination, &active.ids).map(Arc::new);
|
||||
active.cache.push((destination, result.clone()));
|
||||
result
|
||||
};
|
||||
semantic.map_err(|r| refusal(&r)).and_then(|semantic| {
|
||||
if stamp.zoom * 16. < 9. {
|
||||
return Err("Make room or adjust the view, then drag again. Zoom in to read the work and destination.".into());
|
||||
}
|
||||
let pointer = pointer.unwrap();
|
||||
let delta = pointer - press.start;
|
||||
let original = press.scope.placements[&press.scope.placement].position;
|
||||
let requested = Position { x: original.x + delta.x / stamp.zoom, y: original.y + delta.y / stamp.zoom };
|
||||
let presentation = plan::PresentationContext {
|
||||
viewport: stamp.viewport(), zoom: stamp.zoom,
|
||||
target: target.map(|i| {
|
||||
let t = &press.frozen.targets[i];
|
||||
plan::TargetPresentation { placement: t.placement }
|
||||
}),
|
||||
};
|
||||
let placement = plan::place(&semantic, requested, presentation).map_err(|r| refusal(&r))?;
|
||||
let label = if semantic.warnings.is_empty() { semantic.label.clone() } else {
|
||||
format!("{} · {} model warning(s) retained", semantic.label, semantic.warnings.len())
|
||||
};
|
||||
if release {
|
||||
let command = plan::finish(&semantic, self.project(), &placement).map_err(|r| refusal(&r))?;
|
||||
if let Some(command) = command {
|
||||
// Bypass applying selectors and unrelated repair helpers. The
|
||||
// planner's exact Batch enters the authoritative source pipeline.
|
||||
self.document.execute(command).map_err(|e| format!("{} · retry this drag after correcting the source issue", e))?;
|
||||
self.sync();
|
||||
self.selected = press.scope.work;
|
||||
self.selected_node = Some(press.scope.placement);
|
||||
self.refresh_draft();
|
||||
}
|
||||
} else {
|
||||
let routed = Arc::new(routing::route_diagram(semantic.project(), &placement.diagram, &placement.view));
|
||||
preview = Some(Arc::new(Preview { token, semantic, placement, routed }));
|
||||
}
|
||||
Ok(label)
|
||||
})
|
||||
}
|
||||
};
|
||||
let refused = result.is_err();
|
||||
let text = result.unwrap_or_else(|text| text);
|
||||
self.status = text.clone();
|
||||
if release {
|
||||
self.allocation_drag.retire();
|
||||
} else {
|
||||
let inspection_targets = active.inspection_targets.clone();
|
||||
self.allocation_drag.active = Some(active);
|
||||
self.allocation_drag.preview = preview;
|
||||
self.allocation_drag.feedback = Some(Feedback {
|
||||
press,
|
||||
targets,
|
||||
inspection_targets,
|
||||
text,
|
||||
refused,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "allocation_drag_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,728 @@
|
||||
//! Actual App root, retained Iced tree, property widgets and production renderer.
|
||||
//! Unlike packet-only tests, these prove the next key still reaches the field.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::{Event as Input, keyboard};
|
||||
|
||||
fn character(text: &str, modifiers: keyboard::Modifiers) -> Input {
|
||||
Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Character(text.into()),
|
||||
modified_key: keyboard::Key::Character(text.into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers,
|
||||
text: Some(text.into()),
|
||||
repeat: false,
|
||||
})
|
||||
}
|
||||
async fn mount() -> (Fixture, Mounted) {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
f.app.window_size = iced::Size::new(2000., 1400.);
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
// First sensor measurement intentionally Fits; now use a fixed known camera.
|
||||
f.app.zoom = 1.;
|
||||
f.app.pan = Vector::ZERO;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
(f, mounted)
|
||||
}
|
||||
fn canvas_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID)
|
||||
}
|
||||
fn at(mounted: &mut Mounted, f: &Fixture, world: Position) -> Point {
|
||||
let bounds = canvas_bounds(mounted, &f.app);
|
||||
Point::new(
|
||||
bounds.x + world.x * f.app.zoom + f.app.pan.x,
|
||||
bounds.y + world.y * f.app.zoom + f.app.pan.y,
|
||||
)
|
||||
}
|
||||
fn name_field(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.filter(|b| b.x > app.window_size.width - 320.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("actual inspector Name TextInput")
|
||||
}
|
||||
fn description_field(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.into_iter()
|
||||
.map(|(_, bounds, _)| bounds)
|
||||
.filter(|b| b.x > app.window_size.width - 320. && b.height >= 90.)
|
||||
.min_by(|a, b| a.y.total_cmp(&b.y))
|
||||
.expect("actual inspector Description TextEditor")
|
||||
}
|
||||
fn is_focused(mounted: &mut Mounted, app: &App, field: Rectangle) -> bool {
|
||||
mounted
|
||||
.geometry(app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, focused)| *bounds == field && *focused)
|
||||
}
|
||||
async fn type_key(mounted: &mut Mounted, app: &mut App, text: &str) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(
|
||||
app,
|
||||
&[character(text, keyboard::Modifiers::empty())],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await
|
||||
}
|
||||
async fn refused_click_keeps_field(mounted: &mut Mounted, f: &mut Fixture, field: Rectangle) {
|
||||
let before = f.app.document.bundle().clone();
|
||||
let selected = (f.app.selected, f.app.selected_node);
|
||||
let point = at(mounted, f, xy(140., 535.));
|
||||
let messages = mounted.click(&mut f.app, point).await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::Refused(..))))
|
||||
);
|
||||
assert!(!messages.iter().any(|m| matches!(
|
||||
m,
|
||||
Message::Select(_) | Message::SelectNode(_) | Message::MoveNode(..)
|
||||
)));
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert_eq!((f.app.selected, f.app.selected_node), selected);
|
||||
assert!(
|
||||
is_focused(mounted, &f.app, field),
|
||||
"refused press must not reach the outside-press unfocus handler"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_app_tree_dirty_name_retains_focus_and_middle_caret_for_next_key() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let field = name_field(&mut mounted, &f.app);
|
||||
mounted.click(&mut f.app, field.center()).await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Home)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
type_key(&mut mounted, &mut f.app, "X").await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::ArrowLeft)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "XWork");
|
||||
refused_click_keeps_field(&mut mounted, &mut f, field).await;
|
||||
let messages = type_key(&mut mounted, &mut f.app, "Y").await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::Field(path, _) if path == "/name"))
|
||||
);
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, "YXWork");
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_app_tree_dirty_description_retains_focus_and_middle_caret_for_next_key() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let field = description_field(&mut mounted, &f.app);
|
||||
mounted
|
||||
.click(&mut f.app, Point::new(field.x + 16., field.y + 16.))
|
||||
.await;
|
||||
type_key(&mut mounted, &mut f.app, "A").await;
|
||||
type_key(&mut mounted, &mut f.app, "B").await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::ArrowLeft)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
|
||||
"AB"
|
||||
);
|
||||
refused_click_keeps_field(&mut mounted, &mut f, field).await;
|
||||
let messages = type_key(&mut mounted, &mut f.app, "X").await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::FieldText(path, _) if path == "/description"))
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.draft.as_ref().unwrap().inputs["/description"].raw,
|
||||
"AXB"
|
||||
);
|
||||
assert!(!f.app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_keyboard_arrange_shortcut_consumes_once_escape_cancels_and_dirty_owner_refuses() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let shortcut = character("a", keyboard::Modifiers::ALT);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
std::slice::from_ref(&shortcut),
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::ArrangeNext)))
|
||||
);
|
||||
assert!(f.app.allocation_drag.arrange_next);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[key(keyboard::key::Named::Escape)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
std::slice::from_ref(&shortcut),
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
let from = at(&mut mounted, &f, xy(140., 535.));
|
||||
let to = from + Vector::new(30., 0.);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
messages
|
||||
.iter()
|
||||
.any(|m| matches!(m, Message::WorkDrag(Event::Press(p)) if p.arrange))
|
||||
);
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(f.app.document.revision(), 1, "{}", f.app.status);
|
||||
assert!(f.app.project().relations.is_empty());
|
||||
let field = name_field(&mut mounted, &f.app);
|
||||
mounted.click(&mut f.app, field.center()).await;
|
||||
type_key(&mut mounted, &mut f.app, "X").await;
|
||||
let raw = f.app.draft.as_ref().unwrap().inputs["/name"].raw.clone();
|
||||
let before = f.app.document.bundle().clone();
|
||||
mounted
|
||||
.batch(&mut f.app, &[shortcut], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!f.app.allocation_drag.arrange_next);
|
||||
assert_eq!(f.app.status, FINISH_EDIT);
|
||||
assert!(is_focused(&mut mounted, &f.app, field));
|
||||
assert_eq!(f.app.draft.as_ref().unwrap().inputs["/name"].raw, raw);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
fn canvas_pixels(pixels: &[u8], width: usize, canvas: Rectangle) -> Vec<u8> {
|
||||
let x = canvas.x.ceil() as usize;
|
||||
let y = canvas.y.ceil() as usize;
|
||||
let right = (canvas.x + canvas.width).floor() as usize;
|
||||
let bottom = (canvas.y + canvas.height).floor() as usize;
|
||||
(y..bottom)
|
||||
.flat_map(|row| {
|
||||
pixels[(row * width + x) * 4..(row * width + right) * 4]
|
||||
.iter()
|
||||
.copied()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_rendered_canvas_pixels_do_not_change_when_toolbar_feedback_text_changes() {
|
||||
let (mut f, mut mounted) = mount().await;
|
||||
let mut project = f.app.project().clone();
|
||||
let target = project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.a)
|
||||
.unwrap();
|
||||
target.position.y = -41.;
|
||||
target.size.height = 300.;
|
||||
replace(&mut f, project);
|
||||
f.app.zoom = 0.75;
|
||||
f.app.pan = Vector::new(15., 15.);
|
||||
let from = at(&mut mounted, &f, xy(140., 535.));
|
||||
let to = at(&mut mounted, &f, xy(200., 80.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
let feedback = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.expect("active frozen destination");
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
let target = &feedback.press.frozen.targets[feedback.targets[0]];
|
||||
assert!(target.region.y * f.app.zoom + f.app.pan.y < 0.);
|
||||
assert_eq!(feedback.destination_identity(), "A");
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
let first = mounted.pixels(&f.app);
|
||||
f.app.allocation_drag.feedback.as_mut().unwrap().text =
|
||||
"Very different drag status, retained outside the readable canvas. ".repeat(10);
|
||||
let second = mounted.pixels(&f.app);
|
||||
assert_ne!(first, second, "the actual toolbar status changes");
|
||||
assert_eq!(
|
||||
canvas_pixels(&first, f.app.window_size.width as usize, canvas),
|
||||
canvas_pixels(&second, f.app.window_size.width as usize, canvas),
|
||||
"status must not obscure any candidate header or destination identity"
|
||||
);
|
||||
}
|
||||
|
||||
fn destination_bounds(mounted: &mut Mounted, app: &App) -> Rectangle {
|
||||
mounted.geometry(app).bounds("allocation-destination")
|
||||
}
|
||||
fn assert_on_screen(bounds: Rectangle, app: &App) {
|
||||
assert!(
|
||||
bounds.x >= 0. && bounds.y >= 0. && bounds.width >= 400. && bounds.height == 26.,
|
||||
"{bounds:?}"
|
||||
);
|
||||
assert!(
|
||||
bounds.x + bounds.width <= app.window_size.width
|
||||
&& bounds.y + bounds.height <= app.window_size.height,
|
||||
"{bounds:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_app_tree_exact_incoming_drag_keeps_destination_visible_and_canvas_stable() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for (minimum, name, collapsed, zoom, pan) in [
|
||||
(false, "Receiver", false, 1., Vector::ZERO),
|
||||
(false, "Receiver", false, 0.75, Vector::new(37., -15.)),
|
||||
(
|
||||
false,
|
||||
"Receiving subsystem",
|
||||
true,
|
||||
1.25,
|
||||
Vector::new(-30., 23.),
|
||||
),
|
||||
(
|
||||
false,
|
||||
"Receiving subsystem",
|
||||
true,
|
||||
1.,
|
||||
Vector::new(0., -100.),
|
||||
),
|
||||
(true, "Receiving subsystem", true, 1., Vector::ZERO),
|
||||
(
|
||||
true,
|
||||
"Receiving subsystem WWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWWW",
|
||||
true,
|
||||
1.,
|
||||
Vector::new(0., -100.),
|
||||
),
|
||||
] {
|
||||
let mut f = fixture(layer);
|
||||
let mut project = f.app.project().clone();
|
||||
project.elements.get_mut(&f.a).unwrap().name = name.into();
|
||||
let diagram = project.diagrams.get_mut(&f.diagram).unwrap();
|
||||
let target = diagram
|
||||
.nodes
|
||||
.values_mut()
|
||||
.find(|n| n.element == f.a)
|
||||
.unwrap();
|
||||
target.collapsed = collapsed;
|
||||
let target_id = target.id;
|
||||
// At minimum size, keep the source in the visible canvas; preserve
|
||||
// the same standard (40,35) name grab and original target geometry.
|
||||
let source_y = if minimum { 210. } else { 500. };
|
||||
diagram.nodes.get_mut(&f.node).unwrap().position.y = source_y;
|
||||
replace(&mut f, project);
|
||||
f.app.layer = layer;
|
||||
f.app.window_size = if minimum {
|
||||
iced::Size::new(1100., 700.)
|
||||
} else {
|
||||
iced::Size::new(2000., 1400.)
|
||||
};
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
f.app.zoom = zoom;
|
||||
f.app.pan = pan;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
let slot = destination_bounds(&mut mounted, &f.app);
|
||||
assert_on_screen(slot, &f.app);
|
||||
assert!(slot.y + slot.height <= canvas.y);
|
||||
let idle = mounted.pixels(&f.app);
|
||||
let before = f.app.project().clone();
|
||||
let source = f.app.document.bundle().clone();
|
||||
let from = at(&mut mounted, &f, xy(140., source_y + 35.));
|
||||
// Use the middle of the snap cell. Fractional App-layout origins
|
||||
// can perturb an exact half-grid tie by one floating-point ULP.
|
||||
// The packet/planner cases retain the original y140 counterexample.
|
||||
let to = at(&mut mounted, &f, xy(160., 145.));
|
||||
assert!(
|
||||
canvas.contains(from) && canvas.contains(to),
|
||||
"{canvas:?} {from:?} {to:?}"
|
||||
);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
let feedback = f
|
||||
.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.expect(&f.app.status);
|
||||
assert!(!feedback.refused, "{}", feedback.text);
|
||||
assert_eq!(feedback.destination_identity(), name);
|
||||
let preview = f.app.allocation_drag.preview.as_ref().expect(&f.app.status);
|
||||
assert_eq!(
|
||||
preview.placement.diagram.nodes[&f.node].position,
|
||||
xy(120., 110.),
|
||||
"{layer:?}, minimum={minimum}, zoom={zoom}, pan={pan:?}, canvas={canvas:?}"
|
||||
);
|
||||
assert!(!preview.placement.diagram.nodes[&target_id].collapsed);
|
||||
let selected = preview
|
||||
.placement
|
||||
.view
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|node| node.id == f.node)
|
||||
.unwrap();
|
||||
let selected_header = Rectangle::new(
|
||||
Point::new(
|
||||
canvas.x + selected.position.x * zoom + pan.x,
|
||||
canvas.y + selected.position.y * zoom + pan.y,
|
||||
),
|
||||
iced::Size::new(
|
||||
selected.size.width * zoom,
|
||||
selected.size.height.min(62.) * zoom,
|
||||
),
|
||||
);
|
||||
assert!(canvas.contains(Point::new(selected_header.x, selected_header.y)));
|
||||
assert!(
|
||||
selected_header.x + selected_header.width <= canvas.x + canvas.width
|
||||
&& selected_header.y + selected_header.height <= canvas.y + canvas.height
|
||||
);
|
||||
let mut expected = preview.semantic.project().clone();
|
||||
expected
|
||||
.diagrams
|
||||
.insert(f.diagram, preview.placement.diagram.clone());
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
assert!(!f.app.document.can_undo());
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
let active = mounted.pixels(&f.app);
|
||||
if minimum {
|
||||
record_render_evidence(
|
||||
&format!(
|
||||
"{layer:?}-minimum-{}",
|
||||
if pan.y < 0. { "top" } else { "normal" }
|
||||
),
|
||||
&active,
|
||||
&f.app,
|
||||
slot,
|
||||
canvas,
|
||||
);
|
||||
}
|
||||
let width = f.app.window_size.width as usize;
|
||||
assert_ne!(
|
||||
canvas_pixels(&idle, width, slot),
|
||||
canvas_pixels(&active, width, slot),
|
||||
"actual destination text is painted in its dedicated visible slot"
|
||||
);
|
||||
f.app.allocation_drag.feedback.as_mut().unwrap().text =
|
||||
"Long refusal/status prose must not obscure work or destination. ".repeat(30);
|
||||
let verbose = mounted.pixels(&f.app);
|
||||
assert_ne!(active, verbose);
|
||||
assert_eq!(
|
||||
canvas_pixels(&active, width, canvas),
|
||||
canvas_pixels(&verbose, width, canvas)
|
||||
);
|
||||
assert_eq!(
|
||||
canvas_pixels(&active, width, slot),
|
||||
canvas_pixels(&verbose, width, slot)
|
||||
);
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
if pan.y == -100. {
|
||||
// A genuine clipping refusal in the same held gesture, then
|
||||
// recovery at the original exact point: no camera rebasing.
|
||||
let clipped = at(&mut mounted, &f, xy(160., 110.));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved {
|
||||
position: clipped,
|
||||
})],
|
||||
mouse::Cursor::Available(clipped),
|
||||
)
|
||||
.await;
|
||||
assert!(f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved { position: to })],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert!(!f.app.allocation_drag.feedback.as_ref().unwrap().refused);
|
||||
}
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(f.app.project(), &expected, "{}", f.app.status);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
assert_eq!(destination_bounds(&mut mounted, &f.app), slot);
|
||||
let after = f.app.document.bundle().clone();
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before);
|
||||
assert_eq!(f.app.document.bundle(), &source);
|
||||
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);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Optional reproducible visual evidence; normal test runs create no artifacts.
|
||||
fn record_render_evidence(
|
||||
name: &str,
|
||||
pixels: &[u8],
|
||||
app: &App,
|
||||
slot: Rectangle,
|
||||
canvas: Rectangle,
|
||||
) {
|
||||
let Some(path) = std::env::var_os("ARCADIA_RENDER_EVIDENCE_DIR") else {
|
||||
return;
|
||||
};
|
||||
let path = std::path::PathBuf::from(path);
|
||||
std::fs::create_dir_all(&path).unwrap();
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path.join(format!("{name}.rgba")))
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut file, pixels).unwrap();
|
||||
let metadata = serde_json::json!({
|
||||
"width": app.window_size.width as u32, "height": app.window_size.height as u32,
|
||||
"destination": [slot.x, slot.y, slot.width, slot.height],
|
||||
"canvas": [canvas.x, canvas.y, canvas.width, canvas.height],
|
||||
"identity": app.allocation_drag.feedback.as_ref().map(|f| f.destination_identity()),
|
||||
});
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(path.join(format!("{name}.json")))
|
||||
.unwrap();
|
||||
std::io::Write::write_all(&mut file, &serde_json::to_vec_pretty(&metadata).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn actual_toolbar_hover_inspects_last_full_name_without_authorizing_an_outside_drop() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
let mut f = fixture(layer);
|
||||
let name = "Receiving subsystem with a long exact identity beyond the compact label";
|
||||
let mut project = f.app.project().clone();
|
||||
project.elements.get_mut(&f.a).unwrap().name = name.into();
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&f.diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&f.node)
|
||||
.unwrap()
|
||||
.position
|
||||
.y = 210.;
|
||||
replace(&mut f, project);
|
||||
f.app.layer = layer;
|
||||
f.app.window_size = iced::Size::new(1100., 700.);
|
||||
let _ = f.app.update(Message::Select(f.work));
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
f.app.zoom = 1.;
|
||||
f.app.pan = Vector::ZERO;
|
||||
f.app.fit_to_view = false;
|
||||
mounted.settle(&mut f.app).await;
|
||||
let canvas = canvas_bounds(&mut mounted, &f.app);
|
||||
let slot = destination_bounds(&mut mounted, &f.app);
|
||||
let from = at(&mut mounted, &f, xy(140., 245.));
|
||||
let to = at(&mut mounted, &f, xy(160., 145.));
|
||||
let before = f.app.document.bundle().clone();
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.destination_identity(),
|
||||
name
|
||||
);
|
||||
assert!(f.app.allocation_drag.preview.is_some());
|
||||
let hover = Point::new(slot.x + 45., slot.y + 8.);
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::CursorMoved { position: hover })],
|
||||
mouse::Cursor::Available(hover),
|
||||
)
|
||||
.await;
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(feedback.refused);
|
||||
assert!(feedback.targets.is_empty());
|
||||
assert_eq!(feedback.destination_identity(), "No eligible destination");
|
||||
assert!(
|
||||
feedback
|
||||
.inspection_identity()
|
||||
.contains(&format!("Last target: {name}"))
|
||||
);
|
||||
assert!(f.app.allocation_drag.preview.is_none());
|
||||
assert!(f.app.allocation_drag.active.is_some());
|
||||
assert!(f.app.status.starts_with("Pointer outside canvas"));
|
||||
let inspected = mounted.pixels_at(&f.app, mouse::Cursor::Available(hover));
|
||||
// Iced's Tooltip overlay draws its child but exposes no Operation
|
||||
// traversal. Compare the real rendered overlay instead of inventing
|
||||
// container geometry that the installed widget cannot report.
|
||||
let inspection = std::mem::take(
|
||||
&mut f
|
||||
.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.inspection_targets,
|
||||
);
|
||||
let without_name = mounted.pixels_at(&f.app, mouse::Cursor::Available(hover));
|
||||
let width = f.app.window_size.width as usize;
|
||||
assert_ne!(
|
||||
inspected, without_name,
|
||||
"full captured name changes the actual hovered tooltip"
|
||||
);
|
||||
assert_eq!(
|
||||
canvas_pixels(&inspected, width, canvas),
|
||||
canvas_pixels(&without_name, width, canvas)
|
||||
);
|
||||
assert_eq!(
|
||||
canvas_pixels(&inspected, width, slot),
|
||||
canvas_pixels(&without_name, width, slot)
|
||||
);
|
||||
f.app
|
||||
.allocation_drag
|
||||
.feedback
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.inspection_targets = inspection;
|
||||
assert_eq!(canvas_bounds(&mut mounted, &f.app), canvas);
|
||||
record_render_evidence(
|
||||
&format!("{layer:?}-name-inspection"),
|
||||
&inspected,
|
||||
&f.app,
|
||||
slot,
|
||||
canvas,
|
||||
);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
|
||||
// Inspection grants no mutation authority. Releasing over it cancels.
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[Input::Mouse(mouse::Event::ButtonReleased(
|
||||
mouse::Button::Left,
|
||||
))],
|
||||
mouse::Cursor::Available(hover),
|
||||
)
|
||||
.await;
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.feedback.is_none());
|
||||
assert_eq!(f.app.status, "Work drag canceled outside the canvas.");
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
assert!(!f.app.document.can_undo());
|
||||
// A fresh gesture cannot inherit the previous inspection name.
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: from }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: hover }),
|
||||
],
|
||||
mouse::Cursor::Available(hover),
|
||||
)
|
||||
.await;
|
||||
let feedback = f.app.allocation_drag.feedback.as_ref().unwrap();
|
||||
assert!(feedback.inspection_targets.is_empty());
|
||||
assert!(!feedback.inspection_identity().contains(name));
|
||||
mounted
|
||||
.batch(
|
||||
&mut f.app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: to }),
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
],
|
||||
mouse::Cursor::Available(to),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
validation::effective_performer(f.app.project(), f.work),
|
||||
Some(f.a)
|
||||
);
|
||||
assert_eq!(f.app.document.revision(), 1);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,203 @@
|
||||
//! Captured real canvas packets against a later navigation or live gesture.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Target};
|
||||
|
||||
fn navigate_work(f: &mut Fixture) -> usage::Usage {
|
||||
let message = f.app.diagram_usage_message(f.work);
|
||||
let _ = f.app.update(message);
|
||||
let panel = f.app.diagram_usages.as_ref().unwrap();
|
||||
let token = panel.token;
|
||||
let context = panel.context;
|
||||
let process = f.app.behavior.topology.usage_snapshot().into_query_state();
|
||||
let completion = usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result: Ok(usage::collect(
|
||||
f.app.project(),
|
||||
f.work,
|
||||
&process,
|
||||
&crate::behavior::scenario::State::default(),
|
||||
)),
|
||||
};
|
||||
let _ = f.app.update(Message::DiagramUsagesReady(completion));
|
||||
let found = f
|
||||
.app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.usages
|
||||
.iter()
|
||||
.find(|usage| usage.target == Target::Placement(f.node))
|
||||
.unwrap()
|
||||
.clone();
|
||||
let _ = f.app.update(Message::DiagramUsage(usage::Event::Choose(
|
||||
token,
|
||||
found.clone(),
|
||||
)));
|
||||
assert!(f.app.pending_diagram_reveal.is_some());
|
||||
assert!(f.app.diagram_focus(found.surface).is_some());
|
||||
found
|
||||
}
|
||||
|
||||
fn assert_stale_noop(app: &mut App, message: Message) {
|
||||
let before = app.document.snapshot();
|
||||
let selection = (app.selected, app.selected_node, app.active_diagram);
|
||||
let camera = (app.zoom, app.pan, app.canvas_epoch, app.camera_epoch);
|
||||
let pending = app.pending_diagram_reveal.clone();
|
||||
let focus = app
|
||||
.diagram_navigation_focus
|
||||
.as_ref()
|
||||
.map(|focus| (focus.context, focus.usage.clone(), focus.selected));
|
||||
let allocation = (app.allocation_drag.epoch, app.allocation_drag.arrange_next);
|
||||
let active = app
|
||||
.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|active| (active.press.token, active.cache.len()));
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let status = app.status.clone();
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
(app.selected, app.selected_node, app.active_diagram),
|
||||
selection
|
||||
);
|
||||
assert_eq!(
|
||||
(app.zoom, app.pan, app.canvas_epoch, app.camera_epoch),
|
||||
camera
|
||||
);
|
||||
assert_eq!(app.pending_diagram_reveal, pending);
|
||||
assert_eq!(
|
||||
app.diagram_navigation_focus.as_ref().map(|focus| (
|
||||
focus.context,
|
||||
focus.usage.clone(),
|
||||
focus.selected
|
||||
)),
|
||||
focus
|
||||
);
|
||||
assert_eq!(
|
||||
(app.allocation_drag.epoch, app.allocation_drag.arrange_next),
|
||||
allocation
|
||||
);
|
||||
assert_eq!(
|
||||
app.allocation_drag
|
||||
.active
|
||||
.as_ref()
|
||||
.map(|active| (active.press.token, active.cache.len())),
|
||||
active
|
||||
);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
fn stale_packets(press: &Press) -> Vec<Message> {
|
||||
vec![
|
||||
Message::WorkDrag(Event::Cancel(press.token)),
|
||||
Message::WorkDrag(Event::Preview(press.stamp, press.token, Some(press.start))),
|
||||
Message::WorkDrag(Event::Release(press.stamp, press.token, Some(press.start))),
|
||||
Message::WorkDrag(Event::Refused(press.stamp.epoch, "old refusal".into())),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_gesture_packets_preserve_navigation_and_a_new_live_cancel_still_works() {
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let before = f.app.document.snapshot();
|
||||
let old = press(&f);
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
old.token
|
||||
);
|
||||
let found = navigate_work(&mut f);
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert_ne!(f.app.allocation_drag.epoch, old.stamp.epoch);
|
||||
for message in stale_packets(&old) {
|
||||
assert_stale_noop(&mut f.app, message);
|
||||
}
|
||||
// A rejected press still explains the invalid view, but cannot consume the
|
||||
// new destination's pending first measurement.
|
||||
let pending = f.app.pending_diagram_reveal.clone();
|
||||
let epoch = f.app.allocation_drag.epoch;
|
||||
let _ = f.app.update(Message::WorkDrag(Event::Press(old.clone())));
|
||||
assert_eq!(f.app.pending_diagram_reveal, pending);
|
||||
assert_eq!(f.app.allocation_drag.epoch, epoch);
|
||||
assert!(f.app.diagram_focus(found.surface).is_some());
|
||||
assert_eq!(f.app.status, "The view changed. Start this drag again.");
|
||||
let (context, _, epoch) = f.app.pending_diagram_reveal.clone().unwrap();
|
||||
let _ = f.app.update(Message::DiagramRevealViewport(
|
||||
context,
|
||||
epoch,
|
||||
bounds().size(),
|
||||
));
|
||||
assert!(f.app.pending_diagram_reveal.is_none());
|
||||
assert!(f.app.diagram_focus(found.surface).is_some());
|
||||
|
||||
// Restore the fixture's camera through current App actions, then capture a
|
||||
// fresh packet through the actual DiagramCanvas rather than seeding active.
|
||||
let factor = 1. / f.app.zoom;
|
||||
let _ = f.app.update(Message::Zoom(factor));
|
||||
let delta = -f.app.pan;
|
||||
let _ = f.app.update(Message::Pan(delta));
|
||||
let current = press(&f);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Press(current.clone())));
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
current.token
|
||||
);
|
||||
assert!(
|
||||
f.app.diagram_navigation_focus.is_none(),
|
||||
"live press retires navigation"
|
||||
);
|
||||
for message in stale_packets(&old) {
|
||||
assert_stale_noop(&mut f.app, message);
|
||||
}
|
||||
let epoch = f.app.allocation_drag.epoch;
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Cancel(current.token)));
|
||||
assert!(f.app.allocation_drag.active.is_none());
|
||||
assert!(f.app.allocation_drag.epoch > epoch);
|
||||
assert_eq!(f.app.status, "Work drag canceled");
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_eq!(f.app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_chooser_envelopes_preserve_a_current_canvas_gesture() {
|
||||
let mut previous = fixture(Layer::Oa);
|
||||
let old_open = previous.app.diagram_usage_message(previous.work);
|
||||
let _ = previous.app.update(old_open.clone());
|
||||
let old_token = previous.app.diagram_usages.as_ref().unwrap().token;
|
||||
let mut f = fixture(Layer::Oa);
|
||||
let current = press(&f);
|
||||
let _ = f
|
||||
.app
|
||||
.update(Message::WorkDrag(Event::Press(current.clone())));
|
||||
assert_stale_noop(&mut f.app, old_open);
|
||||
assert_stale_noop(
|
||||
&mut f.app,
|
||||
Message::DiagramUsage(usage::Event::Close(old_token)),
|
||||
);
|
||||
assert_stale_noop(
|
||||
&mut f.app,
|
||||
Message::DiagramUsage(usage::Event::Search(old_token, "old".into())),
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.allocation_drag.active.as_ref().unwrap().press.token,
|
||||
current.token
|
||||
);
|
||||
let open = f.app.diagram_usage_message(f.work);
|
||||
let _ = f.app.update(open);
|
||||
assert!(
|
||||
f.app.diagram_usages.is_some(),
|
||||
"current chooser remains live"
|
||||
);
|
||||
assert!(
|
||||
f.app.allocation_drag.active.is_none(),
|
||||
"current chooser retires gesture"
|
||||
);
|
||||
}
|
||||
@@ -138,7 +138,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
.diagrams
|
||||
.get(&s.diagram)
|
||||
.ok_or("The creation draft's diagram is missing.")?;
|
||||
if !d.projection.is_architecture()
|
||||
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|
||||
|| s.query.len() > 16384
|
||||
|| s.page > 1_000_000
|
||||
|| s.point
|
||||
@@ -180,7 +180,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
}
|
||||
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
|
||||
let mut values = match (d.layer, d.projection) {
|
||||
(_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![],
|
||||
(_, ProjectionKind::StateMachine { .. }) => vec![],
|
||||
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
|
||||
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
|
||||
(Layer::Oa, ProjectionKind::Capabilities) => vec![
|
||||
@@ -208,12 +208,11 @@ pub fn kinds(app: &App) -> Vec<Kind> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
|
||||
d.projection.is_architecture()
|
||||
&& p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
}
|
||||
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
|
||||
// Existing System remains a useful choice even when the singleton exists.
|
||||
@@ -258,17 +257,6 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
|
||||
}
|
||||
impl App {
|
||||
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
|
||||
if matches!(
|
||||
&event,
|
||||
Event::Start(..) | Event::Allocate(..) | Event::Existing(..)
|
||||
) && !self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.is_some_and(|d| d.projection.is_architecture())
|
||||
{
|
||||
self.status = "Choose an architecture view before adding architecture content.".into();
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
|
||||
if self.architecture.is_some() {
|
||||
|
||||
@@ -75,9 +75,11 @@ 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,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
|
||||
@@ -89,17 +91,8 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(
|
||||
canvas
|
||||
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
|
||||
.is_none()
|
||||
);
|
||||
let _ = canvas.update(
|
||||
&mut state,
|
||||
&CanvasEvent::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(iced::Point::new(150., 120.)),
|
||||
);
|
||||
// Program receives keys already admitted by diagram::focus::Owner. Real
|
||||
// unfocused/Inspector refusal is covered by app::diagram_focus_tests.
|
||||
let action = canvas
|
||||
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
|
||||
.unwrap();
|
||||
|
||||
@@ -230,9 +230,11 @@ 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,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let action = canvas
|
||||
@@ -305,9 +307,11 @@ 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,
|
||||
allocation: None,
|
||||
};
|
||||
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
|
||||
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
|
||||
@@ -321,16 +325,11 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(canvas.update(&mut state, &key, bounds, cursor).is_none());
|
||||
let _ = canvas.update(
|
||||
&mut state,
|
||||
&iced::widget::canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
cursor,
|
||||
);
|
||||
// Program receives keys already admitted by diagram::focus::Owner. Real
|
||||
// unfocused/Inspector refusal is covered by app::diagram_focus_tests.
|
||||
let message = canvas
|
||||
.update(&mut state, &key, bounds, cursor)
|
||||
.expect("focused F2 opens the selected semantic name")
|
||||
.expect("admitted F2 opens the selected semantic name")
|
||||
.into_inner()
|
||||
.0
|
||||
.unwrap();
|
||||
|
||||
@@ -291,7 +291,7 @@ fn pending_behavior_blocks_navigation_property_edits_and_model_history() {
|
||||
Message::BrowseGroup(crate::explorer::Group::Work),
|
||||
Message::Field("/name".into(), "Overwrite properties".into()),
|
||||
Message::Command(Command::Rename(fixture.source, "Overwrite model".into())),
|
||||
Message::ConfirmDelete,
|
||||
Message::ConfirmDelete(0),
|
||||
Message::Undo,
|
||||
Message::Redo,
|
||||
Message::ShortcutUndo(false),
|
||||
|
||||
@@ -0,0 +1,594 @@
|
||||
//! Delete through the actual App widget tree, ordinary focus, and source history.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
canvas_workspace::tests::{Mounted, focused, key},
|
||||
scenario, topology,
|
||||
};
|
||||
use iced::{Event as Input, Point, Size, Vector, keyboard, mouse};
|
||||
|
||||
fn app(p: Project, owner: Id) -> App {
|
||||
let mut app = App::with_project(p);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
app.window_size = Size::new(1900., 1200.);
|
||||
app
|
||||
}
|
||||
fn point(mounted: &mut Mounted, app: &App, id: Id) -> Point {
|
||||
if let Some(context) = app.topology_context() {
|
||||
let bounds = mounted.geometry(app).bounds("process-viewport");
|
||||
let local = app
|
||||
.behavior
|
||||
.topology
|
||||
.removal_test_point(app, context.owner, id, None);
|
||||
bounds.position() + Vector::new(local.x, local.y)
|
||||
} else {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let bounds = mounted.geometry(app).bounds("scenario-viewport");
|
||||
let local = scenario::removal_test_point(app, context.scenario, id);
|
||||
bounds.position() + Vector::new(local.x, local.y)
|
||||
}
|
||||
}
|
||||
async fn select(mounted: &mut Mounted, app: &mut App, id: Id) {
|
||||
let target = point(mounted, app, id);
|
||||
mounted.click(app, target).await;
|
||||
assert_eq!(app.selected, id, "real pointer selects intended occurrence");
|
||||
}
|
||||
fn remove_key(shift: bool) -> Input {
|
||||
let mut event = key(keyboard::key::Named::Delete);
|
||||
if shift && let Input::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) = &mut event {
|
||||
*modifiers = keyboard::Modifiers::SHIFT;
|
||||
}
|
||||
event
|
||||
}
|
||||
fn removal(message: &Message) -> bool {
|
||||
matches!(
|
||||
message,
|
||||
Message::TopologyCanvas(topology::CanvasEvent::Remove(..))
|
||||
| Message::ScenarioCanvas(scenario::CanvasEvent::Remove(..))
|
||||
)
|
||||
}
|
||||
async fn delete(mounted: &mut Mounted, app: &mut App, shift: bool) -> u64 {
|
||||
let messages = mounted
|
||||
.batch(app, &[remove_key(shift)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(
|
||||
messages.iter().any(removal),
|
||||
"focused viewport emits removal: {messages:?}"
|
||||
);
|
||||
let target = app.delete_target.as_ref().expect("captured confirmation");
|
||||
assert_eq!(target.snapshot.request.object, app.selected);
|
||||
assert_eq!(target.placement, None);
|
||||
assert_eq!(target.snapshot.request.placement, None);
|
||||
target.snapshot.token
|
||||
}
|
||||
async fn send(mounted: &mut Mounted, app: &mut App, message: Message) {
|
||||
let task = app.update(message);
|
||||
mounted.perform(app, task).await;
|
||||
}
|
||||
fn consistent(app: &App) {
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_process_delete_occurrence_or_link_preserves_reusable_work_and_source_history() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for link in [false, true] {
|
||||
let f = topology::direct_tests::fixture(layer);
|
||||
let target = if link { f.link } else { f.nodes[0] };
|
||||
let mut app = app(f.p.clone(), f.owner);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
// No extra redraw between release and first Delete.
|
||||
let token = delete(&mut mounted, &mut app, link).await;
|
||||
assert_eq!(
|
||||
app.delete_target.as_ref().unwrap().behavior_owner,
|
||||
Some(f.owner)
|
||||
);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(target));
|
||||
assert!(f.work.iter().all(|id| app.project().contains(*id)));
|
||||
assert!(app.project().contains(f.exchange));
|
||||
assert!(app.project().contains(f.nodes[1]));
|
||||
assert_eq!(app.project().contains(f.nodes[0]), link);
|
||||
assert!(!app.project().contains(f.link));
|
||||
consistent(&app);
|
||||
let removed = app.document.bundle().clone();
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Redo).await;
|
||||
assert_eq!(app.document.bundle(), &removed);
|
||||
consistent(&app);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_delete_message_or_participant_preserves_reusable_targets() {
|
||||
for participant in [false, true] {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
let target = if participant { f.sender } else { f.call };
|
||||
let mut app = app(f.project.clone(), f.owner);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let token = delete(&mut mounted, &mut app, participant).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(target));
|
||||
assert!(app.project().contains(f.exchange));
|
||||
assert!(app.project().contains(f.receiver));
|
||||
assert!(app.project().contains(f.reply));
|
||||
for element in f
|
||||
.project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|element| element.parent != Some(f.owner))
|
||||
{
|
||||
assert!(
|
||||
app.project().contains(element.id),
|
||||
"referenced definitions survive"
|
||||
);
|
||||
}
|
||||
if participant {
|
||||
assert!(app.project().contains(f.call));
|
||||
for element in app.project().elements.values() {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
sender, receiver, ..
|
||||
},
|
||||
..
|
||||
}) = element.shared()
|
||||
{
|
||||
assert_ne!(*sender, Some(target));
|
||||
assert_ne!(*receiver, Some(target));
|
||||
}
|
||||
}
|
||||
}
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
consistent(&app);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_use_delete_keeps_definition_and_second_use_and_rejects_owner_replay() {
|
||||
let (p, owners, uses) = scenario::direct_tests::navigation_fixture();
|
||||
let mut app = app(p, owners[0]);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let stale = delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let token = delete(&mut mounted, &mut app, true).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(stale)).await;
|
||||
assert!(app.delete_pending, "old token does not cancel new dialog");
|
||||
// Keep selection and epoch deliberately unchanged: owner is an independent witness.
|
||||
app.behavior.active_owner = Some(owners[1]);
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
app.behavior.active_owner = Some(owners[0]);
|
||||
mounted.settle(&mut app).await;
|
||||
select(&mut mounted, &mut app, uses[0]).await;
|
||||
let token = delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(uses[0]));
|
||||
assert!(app.project().contains(uses[1]));
|
||||
assert!(owners.iter().all(|id| app.project().contains(*id)));
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_tab_and_f6_retire_same_batch_delete_and_window_focus() {
|
||||
for process in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
for boundary in [
|
||||
keyboard::key::Named::Tab,
|
||||
keyboard::key::Named::F6,
|
||||
keyboard::key::Named::Escape,
|
||||
] {
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(boundary), remove_key(false)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages.iter().any(removal),
|
||||
"{boundary:?} retires keyboard admission within the batch"
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
if boundary == keyboard::key::Named::Tab {
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.count(),
|
||||
1,
|
||||
"Tab completes traversal to one real control"
|
||||
);
|
||||
let mut backwards = key(keyboard::key::Named::Tab);
|
||||
if let Input::Keyboard(keyboard::Event::KeyPressed { modifiers, .. }) =
|
||||
&mut backwards
|
||||
{
|
||||
*modifiers = keyboard::Modifiers::SHIFT;
|
||||
}
|
||||
mounted
|
||||
.batch(&mut app, &[backwards], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
} else {
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::F6), remove_key(false)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages.iter().any(removal),
|
||||
"F6 entry completes after the batch, before a later Delete"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
focused(&mut mounted, &app, "viewport"),
|
||||
"{boundary:?} supports keyboard-only return to the viewport"
|
||||
);
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
Input::Window(iced::window::Event::Unfocused),
|
||||
remove_key(false),
|
||||
],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(!messages.iter().any(removal));
|
||||
assert!(!app.delete_pending);
|
||||
// A new actual click reacquires ownership after each boundary.
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_clean_inspector_click_and_tab_own_delete_then_f6_returns_to_canvas() {
|
||||
for process in [false, true] {
|
||||
for tab_entry in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let original = app.document.bundle().clone();
|
||||
let original_name = app.draft.as_ref().unwrap().inputs["/name"].raw.clone();
|
||||
// The first text input in the right-hand Inspector is the name.
|
||||
// Verify its actual binding below, rather than seeding focus state.
|
||||
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("visible Inspector name input");
|
||||
if tab_entry {
|
||||
// Canvas Tab stays within canvas controls. Escape then ordinary
|
||||
// Tab follows the existing Inspector entry policy.
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::Escape)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::Tab)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
mounted.click(&mut app, field.center()).await;
|
||||
}
|
||||
assert!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, value)| *bounds == field && *value),
|
||||
"actual {} focuses the Inspector name",
|
||||
if tab_entry { "Tab" } else { "click" }
|
||||
);
|
||||
assert!(!focused(&mut mounted, &app, "viewport"));
|
||||
assert!(!app.draft_dirty());
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::End), remove_key(true)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::Field(path, value) if path == "/name" && *value == original_name
|
||||
)));
|
||||
assert!(!messages.iter().any(removal));
|
||||
assert!(!app.delete_pending);
|
||||
assert!(!app.draft_dirty(), "Delete at end keeps the field clean");
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(keyboard::key::Named::F6)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(focused(&mut mounted, &app, "viewport"));
|
||||
assert_eq!(
|
||||
mounted
|
||||
.geometry(&app)
|
||||
.focused
|
||||
.iter()
|
||||
.filter(|(_, _, value)| *value)
|
||||
.count(),
|
||||
1,
|
||||
"F6 releases actual Inspector focus"
|
||||
);
|
||||
delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::CancelDelete).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_clean_text_focus_and_modifiers_cannot_delete_selected_occurrence() {
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
struct FocusText(iced::Rectangle);
|
||||
impl Operation for FocusText {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
|
||||
visit(self);
|
||||
}
|
||||
fn focusable(
|
||||
&mut self,
|
||||
_: Option<&iced::advanced::widget::Id>,
|
||||
bounds: iced::Rectangle,
|
||||
state: &mut dyn operation::Focusable,
|
||||
) {
|
||||
if bounds == self.0 {
|
||||
state.focus();
|
||||
} else {
|
||||
state.unfocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
for process in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let original = app.document.bundle().clone();
|
||||
for modifiers in [
|
||||
keyboard::Modifiers::CTRL,
|
||||
keyboard::Modifiers::ALT,
|
||||
keyboard::Modifiers::LOGO,
|
||||
] {
|
||||
let mut event = remove_key(false);
|
||||
if let Input::Keyboard(keyboard::Event::KeyPressed {
|
||||
modifiers: value, ..
|
||||
}) = &mut event
|
||||
{
|
||||
*value = modifiers;
|
||||
}
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[event], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!messages.iter().any(removal));
|
||||
}
|
||||
let field = mounted
|
||||
.geometry(&app)
|
||||
.text_inputs
|
||||
.into_iter()
|
||||
.find(|bounds| bounds.width > 40.)
|
||||
.expect("real text field");
|
||||
mounted.operate(&app, &mut FocusText(field));
|
||||
assert!(!app.draft_dirty());
|
||||
let messages = mounted
|
||||
.batch(&mut app, &[remove_key(true)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!messages.iter().any(removal));
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_scenario_fragment_and_branch_delete_only_owned_occurrences() {
|
||||
for whole_fragment in [false, true] {
|
||||
let (f, branch, anchor, state, reused, remote) =
|
||||
scenario::direct_tests::placement_fixture();
|
||||
let fragment = f.project.elements[&branch].parent.unwrap();
|
||||
let target = if whole_fragment { fragment } else { branch };
|
||||
let mut app = app(f.project.clone(), f.owner);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let token = delete(&mut mounted, &mut app, false).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(target));
|
||||
assert!(!app.project().contains(anchor));
|
||||
assert!(!app.project().contains(branch));
|
||||
assert_eq!(app.project().contains(fragment), !whole_fragment);
|
||||
for id in [
|
||||
f.call, f.reply, f.sender, f.receiver, f.exchange, state, reused, remote,
|
||||
] {
|
||||
assert!(app.project().contains(id));
|
||||
}
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_qualified_process_child_delete_refuses_outer_reference_and_shared_definition() {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
let mut p = f.p;
|
||||
let caller = p
|
||||
.add_element(Kind::Process, Layer::Oa, "Caller", None)
|
||||
.unwrap();
|
||||
let references = ["First use", "Second use"].map(|name| {
|
||||
let mut element = p
|
||||
.make_element(Kind::Occurrence, Layer::Oa, name, Some(caller))
|
||||
.unwrap();
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::SequenceReference {
|
||||
sequence: Some(f.owner),
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
let id = element.id;
|
||||
p = execute_batch(&p, &[Command::CreateElement(element)]).unwrap();
|
||||
id
|
||||
});
|
||||
let mut app = app(p, caller);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let original = app.document.bundle().clone();
|
||||
let endpoint = OccurrenceEndpoint {
|
||||
references: vec![references[0]],
|
||||
occurrence: f.nodes[0],
|
||||
};
|
||||
let bounds = mounted.geometry(&app).bounds("process-viewport");
|
||||
let local =
|
||||
app.behavior
|
||||
.topology
|
||||
.removal_test_point(&app, caller, references[0], Some(&endpoint));
|
||||
let messages = mounted
|
||||
.click(&mut app, bounds.position() + Vector::new(local.x, local.y))
|
||||
.await;
|
||||
assert!(messages.iter().any(|message| matches!(
|
||||
message,
|
||||
Message::TopologyCanvas(topology::CanvasEvent::SelectQualified(..))
|
||||
)));
|
||||
assert_eq!(app.selected, references[0]);
|
||||
assert!(
|
||||
app.behavior
|
||||
.topology
|
||||
.has_qualified_selection(app.project(), caller)
|
||||
);
|
||||
mounted
|
||||
.batch(&mut app, &[remove_key(false)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
assert!(app.status.contains("Open the referenced definition"));
|
||||
select(&mut mounted, &mut app, references[0]).await;
|
||||
assert!(
|
||||
!app.behavior
|
||||
.topology
|
||||
.has_qualified_selection(app.project(), caller)
|
||||
);
|
||||
let token = delete(&mut mounted, &mut app, true).await;
|
||||
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
|
||||
assert!(!app.project().contains(references[0]));
|
||||
assert!(app.project().contains(references[1]));
|
||||
assert!(app.project().contains(f.owner));
|
||||
assert!(f.nodes.iter().all(|id| app.project().contains(*id)));
|
||||
consistent(&app);
|
||||
send(&mut mounted, &mut app, Message::Undo).await;
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_behavior_pan_and_stale_removal_intent_do_not_delete() {
|
||||
for process in [false, true] {
|
||||
let (mut app, target) = if process {
|
||||
let f = topology::direct_tests::fixture(Layer::Oa);
|
||||
(app(f.p, f.owner), f.nodes[0])
|
||||
} else {
|
||||
let f = scenario::direct_tests::fixture();
|
||||
(app(f.project, f.owner), f.call)
|
||||
};
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let original = app.document.bundle().clone();
|
||||
let cursor = point(&mut mounted, &app, target);
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[
|
||||
Input::Mouse(mouse::Event::CursorMoved { position: cursor }),
|
||||
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
|
||||
remove_key(false),
|
||||
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
|
||||
],
|
||||
mouse::Cursor::Available(cursor),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!messages.iter().any(removal),
|
||||
"held pan never becomes an occurrence deletion"
|
||||
);
|
||||
assert!(!app.delete_pending);
|
||||
select(&mut mounted, &mut app, target).await;
|
||||
let messages = mounted.input(&app, &[remove_key(false)], mouse::Cursor::Unavailable);
|
||||
let stale = messages
|
||||
.into_iter()
|
||||
.find(removal)
|
||||
.expect("captured live removal");
|
||||
app.canvas_epoch = app.canvas_epoch.wrapping_add(1);
|
||||
send(&mut mounted, &mut app, stale).await;
|
||||
assert!(!app.delete_pending);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
//! Keyboard ownership is exercised on the mounted App, not raw Canvas Program.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::{Mounted, key};
|
||||
use iced::advanced::widget::{Operation, operation};
|
||||
use iced::{Point, Rectangle, Size, Vector, keyboard::key::Named, mouse};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn f2(mounted: &mut Mounted, app: &mut App) -> Vec<Message> {
|
||||
mounted
|
||||
.batch(app, &[key(Named::F2)], mouse::Cursor::Unavailable)
|
||||
.await
|
||||
}
|
||||
|
||||
fn rename_emitted(messages: &[Message]) -> bool {
|
||||
messages.iter().any(|message| {
|
||||
matches!(
|
||||
message,
|
||||
Message::Architecture(architecture_authoring::Event::Rename(..))
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn mounted_f2_requires_canvas_focus_and_yields_to_clean_inspector() {
|
||||
for layer in [Layer::Oa, Layer::Sa] {
|
||||
for programmatic in [true, false] {
|
||||
let mut project = Project::empty();
|
||||
let object = project
|
||||
.add_element(
|
||||
if layer == Layer::Oa {
|
||||
Kind::Entity
|
||||
} else {
|
||||
Kind::System
|
||||
},
|
||||
layer,
|
||||
"Original team",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let diagram = project.ensure_diagram(layer);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Overview;
|
||||
let node = project.place(diagram, object, Position { x: 80., y: 80. });
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.layer = layer;
|
||||
app.active_diagram = Some(diagram);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let _ = app.update(Message::Select(object));
|
||||
let before = app.document.snapshot();
|
||||
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;
|
||||
|
||||
// Selection alone does not grant the canvas keyboard ownership.
|
||||
assert!(!rename_emitted(&f2(&mut mounted, &mut app).await));
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
|
||||
let bounds = mounted
|
||||
.geometry(&app)
|
||||
.bounds(crate::allocation_input::CANVAS_ID);
|
||||
let projected = projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.into_iter()
|
||||
.find(|item| item.id == node)
|
||||
.unwrap();
|
||||
let point = Point::new(
|
||||
bounds.x + projected.position.x + 40.,
|
||||
bounds.y + projected.position.y + 35.,
|
||||
);
|
||||
mounted.click(&mut app, point).await;
|
||||
assert_eq!((app.selected, app.selected_node), (object, Some(node)));
|
||||
assert!(rename_emitted(&f2(&mut mounted, &mut app).await));
|
||||
let rename = app.architecture.as_ref().expect("canvas F2 opens rename");
|
||||
assert!(rename.inline());
|
||||
assert!(matches!(
|
||||
&rename.saved.intent,
|
||||
architecture_authoring::Intent::Rename { element, name }
|
||||
if *element == object && name == "Original team"
|
||||
));
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!app.document.can_undo(),
|
||||
"opening rename is not a model edit"
|
||||
);
|
||||
|
||||
let task = app.update(Message::Escape);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert!(app.architecture.is_none());
|
||||
mounted.click(&mut app, point).await;
|
||||
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("Inspector name field is mounted");
|
||||
if programmatic {
|
||||
mounted.operate(&app, &mut FocusField(field));
|
||||
} else {
|
||||
let messages = mounted
|
||||
.batch(
|
||||
&mut app,
|
||||
&[key(Named::Tab), key(Named::F2)],
|
||||
mouse::Cursor::Unavailable,
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
!rename_emitted(&messages),
|
||||
"Tab must retire F2 admission before its queued focus task"
|
||||
);
|
||||
}
|
||||
let geometry = mounted.geometry(&app);
|
||||
assert!(
|
||||
geometry
|
||||
.focused
|
||||
.iter()
|
||||
.any(|(_, bounds, focused)| { *bounds == field && *focused })
|
||||
);
|
||||
assert!(geometry.focused.iter().any(|(id, _, focused)| {
|
||||
id.as_ref() == Some(&iced::advanced::widget::Id::from("diagram-keyboard-owner"))
|
||||
&& !*focused
|
||||
}));
|
||||
assert!(!app.draft_dirty(), "the Inspector is still clean");
|
||||
assert!(!rename_emitted(&f2(&mut mounted, &mut app).await));
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().inputs["/name"].raw,
|
||||
"Original team"
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! Captured diagram removal and model-confirmation lifetimes.
|
||||
use super::*;
|
||||
use crate::{model_menu, route_edit::Context};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Intent {
|
||||
pub context: Context,
|
||||
pub object: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub model: bool,
|
||||
}
|
||||
|
||||
pub struct Confirmation {
|
||||
pub snapshot: model_menu::Opened,
|
||||
pub selection: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub behavior_owner: Option<Id>,
|
||||
}
|
||||
|
||||
pub fn enabled(app: &App) -> bool {
|
||||
!app.busy && focus_enabled(app)
|
||||
}
|
||||
|
||||
pub(crate) fn focus_enabled(app: &App) -> bool {
|
||||
crate::behavior::canvas_workspace::keyboard_owner_enabled(app)
|
||||
&& !app.draft_dirty()
|
||||
&& !app.source_dirty()
|
||||
&& !app.behavior.has_pending()
|
||||
&& app
|
||||
.architecture
|
||||
.as_ref()
|
||||
.is_none_or(|state| !state.inline())
|
||||
}
|
||||
|
||||
pub(crate) fn behavior_owner(app: &App) -> Option<Id> {
|
||||
app.topology_context()
|
||||
.map(|context| context.owner)
|
||||
.or_else(|| app.scenario_context().map(|context| context.scenario))
|
||||
}
|
||||
|
||||
fn placement(app: &App) -> Option<Id> {
|
||||
if behavior_owner(app).is_some() {
|
||||
None
|
||||
} else if app.page == Page::Behavior {
|
||||
app.behavior.diagram.placement
|
||||
} else {
|
||||
app.selected_node
|
||||
}
|
||||
}
|
||||
|
||||
/// The derived canvases remove semantic occurrences, not reusable definitions.
|
||||
/// Kept beside target capture so confirmation rendering uses the same meaning.
|
||||
pub(crate) fn explanation(app: &App, object: Id) -> &'static str {
|
||||
if app
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.any(|diagram| diagram.nodes.contains_key(&object))
|
||||
{
|
||||
return "Remove only this diagram placement. The model element and its placements in other diagrams remain. Undo restores this placement.";
|
||||
}
|
||||
if app
|
||||
.project()
|
||||
.relations
|
||||
.get(&object)
|
||||
.is_some_and(|relation| {
|
||||
matches!(
|
||||
relation.kind(),
|
||||
RelationKind::StateAction | RelationKind::StateTransition
|
||||
)
|
||||
})
|
||||
{
|
||||
return "Remove this state relation. Its states and referenced activities or functions remain. Undo restores the relation.";
|
||||
}
|
||||
if app
|
||||
.project()
|
||||
.relations
|
||||
.get(&object)
|
||||
.is_some_and(|relation| {
|
||||
matches!(
|
||||
relation.kind(),
|
||||
RelationKind::SequenceLink | RelationKind::ContextualLink
|
||||
)
|
||||
})
|
||||
{
|
||||
return "Remove this process link. Its endpoint occurrences and referenced reusable model elements are preserved. Undo restores the deletion.";
|
||||
}
|
||||
match app
|
||||
.project()
|
||||
.elements
|
||||
.get(&object)
|
||||
.and_then(|element| element.shared())
|
||||
{
|
||||
Some(SharedBody::Lifeline { .. }) => {
|
||||
"Remove this participant from the scenario. Its represented model element is preserved. Messages and other occurrences remain with affected participant references cleared. Undo restores the deletion."
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { .. } | OccurrenceBody::Branch { .. },
|
||||
..
|
||||
}) => {
|
||||
"Remove this occurrence and its owned branches and steps. Referenced reusable model elements are preserved. Undo restores the deletion."
|
||||
}
|
||||
Some(SharedBody::Occurrence { .. }) => {
|
||||
"Remove this occurrence and its dependent links. Referenced reusable model elements are preserved. Undo restores the deletion."
|
||||
}
|
||||
_ => {
|
||||
"Owned children, incident relations, occurrences, placements and trace links will also be removed. The traced counterpart itself is preserved. Undo restores the deletion."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn remove_from_diagram(&mut self, intent: Intent) {
|
||||
if !enabled(self) || self.connection.is_some() || self.selected_bend.is_some() {
|
||||
return;
|
||||
}
|
||||
let current = match self.page {
|
||||
Page::Diagram if self.architecture.as_ref().is_none_or(|s| !s.saved.placing) => {
|
||||
self.route_context()
|
||||
}
|
||||
Page::Behavior
|
||||
if self.behavior.diagram.tool == crate::behavior::state_edit::Tool::Select =>
|
||||
{
|
||||
self.state_context()
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let selected_object = self
|
||||
.project()
|
||||
.diagrams
|
||||
.get(&intent.context.diagram)
|
||||
.and_then(|diagram| diagram.nodes.get(&self.selected))
|
||||
.map_or(self.selected, |node| node.element);
|
||||
if current != Some(intent.context) || selected_object != intent.object {
|
||||
return;
|
||||
}
|
||||
let view = projection::project_view(self.project(), intent.context.diagram);
|
||||
let placement = if self.page == Page::Behavior {
|
||||
self.behavior.diagram.placement
|
||||
} else {
|
||||
self.selected_node
|
||||
};
|
||||
if intent.placement != placement || intent.object == intent.context.diagram {
|
||||
return;
|
||||
}
|
||||
let visible = if let Some(id) = intent.placement {
|
||||
self.project().diagrams[&intent.context.diagram]
|
||||
.nodes
|
||||
.get(&id)
|
||||
.is_some_and(|node| node.element == intent.object)
|
||||
&& view
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|node| node.id == id && node.element == intent.object)
|
||||
} else {
|
||||
view.edges.iter().any(|edge| edge.id == intent.object)
|
||||
|| view.ports.iter().any(|port| port.element == intent.object)
|
||||
|| (self.page == Page::Behavior
|
||||
&& crate::behavior::state_canvas::visible_row_relation(
|
||||
self.project(),
|
||||
intent.context.diagram,
|
||||
&view,
|
||||
intent.object,
|
||||
))
|
||||
};
|
||||
if !visible {
|
||||
return;
|
||||
}
|
||||
if !intent.model
|
||||
&& let Some(placement) = intent.placement
|
||||
{
|
||||
if self.execute(Command::Delete(placement)) {
|
||||
self.selected_node = None;
|
||||
self.behavior.diagram.placement = None;
|
||||
self.selected_bend = None;
|
||||
self.status = "Hidden from this diagram · Model element preserved · Undo restores the placement".into();
|
||||
}
|
||||
} else {
|
||||
self.begin_model_delete_target(intent.object, Some(intent.context));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn begin_model_delete(&mut self, canvas: Option<Context>) {
|
||||
self.begin_model_delete_target(self.selected, canvas);
|
||||
}
|
||||
|
||||
fn begin_model_delete_target(&mut self, object: Id, canvas: Option<Context>) {
|
||||
if object == self.project().id || self.behavior.has_pending() {
|
||||
return;
|
||||
}
|
||||
let mut request = model_menu::Request::object(object, iced::Point::ORIGIN);
|
||||
request.canvas = canvas;
|
||||
request.placement = placement(self);
|
||||
// Opening a modal retires any pending canvas intent before capturing
|
||||
// the confirmation; every new dialog receives its own replay token.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if canvas.is_some() {
|
||||
request.canvas = if self.page == Page::Behavior {
|
||||
self.state_context()
|
||||
} else {
|
||||
self.route_context()
|
||||
};
|
||||
}
|
||||
self.menu_sequence = self.menu_sequence.wrapping_add(1);
|
||||
self.delete_target =
|
||||
model_menu::Opened::new(self, request, self.menu_sequence).map(|snapshot| {
|
||||
Confirmation {
|
||||
snapshot,
|
||||
selection: self.selected,
|
||||
placement: placement(self),
|
||||
behavior_owner: behavior_owner(self),
|
||||
}
|
||||
});
|
||||
self.delete_pending = self.delete_target.is_some();
|
||||
}
|
||||
|
||||
pub(super) fn confirm_model_delete(&mut self, token: u64) {
|
||||
let Some(target) = self.delete_target.as_ref() else {
|
||||
return;
|
||||
};
|
||||
if target.snapshot.token != token {
|
||||
return;
|
||||
}
|
||||
let valid = self.delete_pending
|
||||
&& target.snapshot.valid(self)
|
||||
&& target.selection == self.selected
|
||||
&& target.placement == placement(self)
|
||||
&& target.behavior_owner == behavior_owner(self)
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.source_dirty()
|
||||
&& !self.draft_dirty();
|
||||
let id = target.snapshot.request.object;
|
||||
self.delete_pending = false;
|
||||
self.delete_target = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
if valid {
|
||||
self.execute(Command::Delete(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
//! Navigate first, then remove through the mounted root canvas and history.
|
||||
use super::*;
|
||||
use crate::diagram_usage::{self as usage, Target};
|
||||
|
||||
async fn navigate_then_remove(project: Project, subject: Id, owner: Id, row: bool) {
|
||||
let mut app = App::with_project(project);
|
||||
app.window_size = Size::new(2000., 1400.);
|
||||
let before = app.document.snapshot();
|
||||
let message = app.diagram_usage_message(subject);
|
||||
let _ = app.update(message);
|
||||
let panel = app.diagram_usages.as_ref().unwrap();
|
||||
let token = panel.token;
|
||||
let context = panel.context;
|
||||
// Complete the same query independently without polling its worker Task.
|
||||
let process = app.behavior.topology.usage_snapshot().into_query_state();
|
||||
let completion = usage::Completion {
|
||||
token,
|
||||
context,
|
||||
result: Ok(usage::collect(
|
||||
app.project(),
|
||||
subject,
|
||||
&process,
|
||||
&behavior::scenario::State::default(),
|
||||
)),
|
||||
};
|
||||
let _ = app.update(Message::DiagramUsagesReady(completion));
|
||||
let found = app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.usages
|
||||
.iter()
|
||||
.find(|usage| {
|
||||
if row {
|
||||
matches!(usage.target, Target::StateRow { relation, .. } if relation == subject)
|
||||
} else {
|
||||
matches!(usage.target, Target::Port { port, .. } if port == subject)
|
||||
}
|
||||
})
|
||||
.unwrap()
|
||||
.clone();
|
||||
let _ = app.update(Message::DiagramUsage(usage::Event::Choose(
|
||||
token,
|
||||
found.clone(),
|
||||
)));
|
||||
assert_eq!(app.selected, subject);
|
||||
// refresh_draft normalizes the owner's presentation placement; navigation
|
||||
// must not leave it armed as a removal target.
|
||||
assert!(app.selected_node.is_none());
|
||||
assert!(app.behavior.diagram.placement.is_none());
|
||||
assert_eq!(
|
||||
app.diagram_focus(found.surface).unwrap().target,
|
||||
found.target
|
||||
);
|
||||
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
for model in [false, true] {
|
||||
mounted
|
||||
.perform(
|
||||
&mut app,
|
||||
iced::widget::operation::focus(iced::advanced::widget::Id::from(
|
||||
"diagram-keyboard-owner",
|
||||
)),
|
||||
)
|
||||
.await;
|
||||
let messages = delete(&mut mounted, &mut app, model).await;
|
||||
let request = messages
|
||||
.iter()
|
||||
.find_map(|message| match message {
|
||||
Message::DiagramRemove(intent)
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Remove(intent)) => {
|
||||
Some(intent)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.expect("actual focused canvas emitted a removal intent");
|
||||
assert_eq!(request.object, subject);
|
||||
assert_eq!(request.placement, None);
|
||||
assert_eq!(request.model, model);
|
||||
assert_eq!(
|
||||
app.delete_target.as_ref().unwrap().snapshot.request.object,
|
||||
subject
|
||||
);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
if !model {
|
||||
let task = app.update(Message::CancelDelete);
|
||||
mounted.perform(&mut app, task).await;
|
||||
}
|
||||
}
|
||||
let token = app.delete_target.as_ref().unwrap().snapshot.token;
|
||||
let task = app.update(Message::ConfirmDelete(token));
|
||||
mounted.perform(&mut app, task).await;
|
||||
let expected = execute_batch(&before.decoded.project, &[Command::Delete(subject)]).unwrap();
|
||||
assert_eq!(app.project(), &expected);
|
||||
assert!(app.project().contains(owner));
|
||||
consistent(&app);
|
||||
let task = app.update(Message::Undo);
|
||||
mounted.perform(&mut app, task).await;
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn port_navigation_then_canvas_delete_preserves_owner_and_restores_exact_history() {
|
||||
let mut project = Project::empty();
|
||||
let owner = project.add(Kind::Function, "Compute", 1200., 1500.);
|
||||
let port = Id::new_v4();
|
||||
let element = oa_core::ports::make_port(
|
||||
&project,
|
||||
port,
|
||||
owner,
|
||||
"Input",
|
||||
oa_core::PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project = execute_batch(&project, &[Command::CreateElement(element)]).unwrap();
|
||||
navigate_then_remove(project, port, owner, false).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_row_navigation_then_canvas_delete_preserves_work_and_restores_exact_history() {
|
||||
let mut project = Project::empty();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "Modes", None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Sa, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = project
|
||||
.add_element(Kind::State, Layer::Sa, "Running", Some(region))
|
||||
.unwrap();
|
||||
let work = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Monitor", None)
|
||||
.unwrap();
|
||||
let relation = project.relate(RelationKind::StateAction, "Entry", state, work);
|
||||
let plan = crate::behavior::state_edit::create_view(&project, machine).unwrap();
|
||||
project = execute_batch(&project, &[plan.command]).unwrap();
|
||||
navigate_then_remove(project, relation, work, true).await;
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Complete a navigation camera after the destination reports its real viewport.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::Representation;
|
||||
use crate::diagram_usage::{Context, Surface};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -48,23 +47,11 @@ impl App {
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
|
||||
match c.representation {
|
||||
Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
},
|
||||
Surface::Scenario(c.scenario),
|
||||
*size,
|
||||
c.document == context.document
|
||||
&& c.revision == context.revision
|
||||
&& c.epoch == *epoch
|
||||
&& self.pending_scenario_reveal_visit == Some(c.visit)
|
||||
&& self.page == Page::Behavior
|
||||
&& self.behavior.active_owner == Some(c.scenario)
|
||||
&& self.behavior.scenario.context(
|
||||
self.canvas_document,
|
||||
c.scenario,
|
||||
self.document.revision(),
|
||||
self.canvas_epoch,
|
||||
) == Some(*c),
|
||||
&& c.epoch == *epoch,
|
||||
),
|
||||
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
|
||||
Surface::Saved(c.diagram),
|
||||
@@ -99,12 +86,13 @@ impl App {
|
||||
| Message::DiagramUsagesReady(_)
|
||||
| Message::SourceSymbolsReady(_)
|
||||
| Message::SourceRenameReady(_)
|
||||
| Message::WorkDrag(allocation_drag::Event::KeyboardBoundary)
|
||||
| Message::WorkDrag(allocation_drag::Event::Unfocused)
|
||||
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
|
||||
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
|
||||
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
|
||||
) {
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
if !Self::is_navigation_camera(message) {
|
||||
self.diagram_navigation_focus = None;
|
||||
}
|
||||
@@ -124,13 +112,11 @@ impl App {
|
||||
behavior::topology::CanvasEvent::Camera(..)
|
||||
| behavior::topology::CanvasEvent::Zoom(..)
|
||||
| behavior::topology::CanvasEvent::Scrolled(..)
|
||||
| behavior::topology::CanvasEvent::Focus(..)
|
||||
)
|
||||
| Message::ScenarioCanvas(
|
||||
behavior::scenario::CanvasEvent::Camera(..)
|
||||
| behavior::scenario::CanvasEvent::Zoom(..)
|
||||
| behavior::scenario::CanvasEvent::Scrolled(..)
|
||||
| behavior::scenario::CanvasEvent::Focus(..)
|
||||
)
|
||||
| Message::StateDiagram(
|
||||
behavior::state_edit::Event::Pan(..)
|
||||
@@ -156,6 +142,5 @@ impl App {
|
||||
self.reveal_diagram_usage(&usage);
|
||||
}
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +98,6 @@ fn fixture(canvas: Canvas) -> (App, Usage) {
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
@@ -144,7 +143,6 @@ fn measurement(
|
||||
epoch: u64,
|
||||
surface: Surface,
|
||||
size: Size,
|
||||
visit: Id,
|
||||
) -> Message {
|
||||
match canvas {
|
||||
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
|
||||
@@ -161,10 +159,6 @@ fn measurement(
|
||||
behavior::scenario::Context {
|
||||
document: context.document,
|
||||
scenario: surface.id(),
|
||||
representation: Representation::Derived {
|
||||
owner: surface.id(),
|
||||
},
|
||||
visit,
|
||||
revision: context.revision,
|
||||
epoch,
|
||||
},
|
||||
@@ -182,16 +176,12 @@ fn measurement(
|
||||
}
|
||||
}
|
||||
|
||||
fn stamp(app: &App) -> (Context, u64, Id) {
|
||||
fn stamp(app: &App) -> (Context, u64) {
|
||||
let (context, _, epoch) = app
|
||||
.pending_diagram_reveal
|
||||
.as_ref()
|
||||
.expect("reveal awaits measured destination");
|
||||
(
|
||||
*context,
|
||||
*epoch,
|
||||
app.pending_scenario_reveal_visit.unwrap_or(Id::nil()),
|
||||
)
|
||||
(*context, *epoch)
|
||||
}
|
||||
|
||||
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
@@ -229,11 +219,7 @@ fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
|
||||
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
|
||||
expected.viewport(ACTUAL);
|
||||
expected.zoom(Some(zoom));
|
||||
expected.camera(
|
||||
app.project(),
|
||||
target.surface.id(),
|
||||
behavior::canvas_workspace::Event::Pan(pan),
|
||||
);
|
||||
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
|
||||
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
|
||||
}
|
||||
}
|
||||
@@ -252,10 +238,10 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
|
||||
app.document.dirty(),
|
||||
);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let (context, epoch) = stamp(&app);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -274,7 +260,7 @@ fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_author
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.camera_boundary() == camera);
|
||||
@@ -288,7 +274,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
for invalid in 0..6 {
|
||||
let (mut app, target) = fixture(canvas);
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let (context, epoch) = stamp(&app);
|
||||
let camera = app.camera_boundary();
|
||||
let mut stale = context;
|
||||
let mut wrong_epoch = epoch;
|
||||
@@ -303,13 +289,13 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size, visit),
|
||||
measurement(canvas, stale, wrong_epoch, target.surface, size),
|
||||
);
|
||||
assert!(
|
||||
app.pending_diagram_reveal.is_some(),
|
||||
"{canvas:?} invalid case {invalid}"
|
||||
);
|
||||
assert_eq!(stamp(&app), (context, epoch, visit));
|
||||
assert_eq!(stamp(&app), (context, epoch));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert!(
|
||||
app.camera_boundary() == camera,
|
||||
@@ -317,7 +303,7 @@ fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, context, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -331,10 +317,9 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, old_epoch, visit) = stamp(&app);
|
||||
let (context, old_epoch) = stamp(&app);
|
||||
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
|
||||
let (current, epoch, current_visit) = stamp(&app);
|
||||
assert_eq!(current_visit, visit);
|
||||
let (current, epoch) = stamp(&app);
|
||||
assert_eq!(current, context);
|
||||
assert_eq!(epoch, app.canvas_epoch);
|
||||
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
|
||||
@@ -342,14 +327,14 @@ fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_conte
|
||||
let camera = app.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert_eq!(stamp(&app), (current, epoch, visit));
|
||||
assert_eq!(stamp(&app), (current, epoch));
|
||||
assert!(app.camera_boundary() == camera);
|
||||
}
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL, visit),
|
||||
measurement(canvas, current, epoch, target.surface, ACTUAL),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_centered(&app, canvas, &target);
|
||||
@@ -364,7 +349,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
|
||||
let (mut app, target) = fixture(canvas);
|
||||
let accepted = app.document.snapshot();
|
||||
assert!(app.reveal_diagram_usage(&target));
|
||||
let (context, epoch, visit) = stamp(&app);
|
||||
let (context, epoch) = stamp(&app);
|
||||
let event = match action {
|
||||
0 => match canvas {
|
||||
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
|
||||
@@ -401,7 +386,7 @@ fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping()
|
||||
// restore the old navigation camera after deliberate user input.
|
||||
send(
|
||||
&mut app,
|
||||
measurement(canvas, context, epoch, target.surface, WIDE, visit),
|
||||
measurement(canvas, context, epoch, target.surface, WIDE),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Navigation-only adapter: validates destinations before changing presentation.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation};
|
||||
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -145,14 +144,31 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
|
||||
if let Event::Open(context) = event {
|
||||
if context.document != self.canvas_document
|
||||
|| context.revision != self.document.revision()
|
||||
|| !self.project().contains(context.subject)
|
||||
{
|
||||
return Task::none();
|
||||
pub(super) fn diagram_usage_envelope(&self, event: &Event) -> bool {
|
||||
match event {
|
||||
Event::Open(context) => {
|
||||
context.document == self.canvas_document
|
||||
&& context.revision == self.document.revision()
|
||||
&& self.project().contains(context.subject)
|
||||
}
|
||||
Event::Close(token)
|
||||
| Event::Search(token, _)
|
||||
| Event::Page(token, _)
|
||||
| Event::Move(token, _)
|
||||
| Event::Activate(token)
|
||||
| Event::Destination(token, _)
|
||||
| Event::Choose(token, _) => self
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.is_some_and(|panel| panel.token == *token),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
|
||||
if !self.diagram_usage_envelope(&event) {
|
||||
return Task::none();
|
||||
}
|
||||
if let Event::Open(context) = event {
|
||||
if !self.usage_navigation_available() {
|
||||
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
@@ -165,7 +181,6 @@ impl App {
|
||||
// Only an accepted new chooser supersedes the previous reveal.
|
||||
// Rejected/stale panel envelopes leave its first measurement intact.
|
||||
self.pending_diagram_reveal = None;
|
||||
self.pending_scenario_reveal_visit = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
return Task::batch([
|
||||
@@ -173,22 +188,10 @@ impl App {
|
||||
iced::widget::operation::focus(filter_id),
|
||||
]);
|
||||
}
|
||||
let token = match &event {
|
||||
Event::Open(_) => unreachable!(),
|
||||
Event::Close(t)
|
||||
| Event::Search(t, _)
|
||||
| Event::Page(t, _)
|
||||
| Event::Move(t, _)
|
||||
| Event::Activate(t)
|
||||
| Event::Destination(t, _)
|
||||
| Event::Choose(t, _) => *t,
|
||||
};
|
||||
let Some(panel) = &self.diagram_usages else {
|
||||
return Task::none();
|
||||
};
|
||||
if panel.token != token {
|
||||
return Task::none();
|
||||
}
|
||||
let panel = self
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.expect("admitted chooser token");
|
||||
if matches!(event, Event::Close(_)) {
|
||||
self.diagram_usages = None;
|
||||
if self.page == Page::Text {
|
||||
@@ -288,78 +291,11 @@ impl App {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Explicit saved-Scenario reveal runs before generic Apply. An absent target
|
||||
/// never selects the owner or another saved arrangement as a fallback.
|
||||
pub(super) fn reveal_saved_scenario(&mut self, diagram: Id, selected: Id) -> Task<Message> {
|
||||
if self.diagram_usages.is_some() || !self.usage_navigation_available() {
|
||||
self.status = "Apply or revert pending edits before revealing a saved Scenario. Your inputs are retained.".into();
|
||||
return Task::none();
|
||||
}
|
||||
if presentation::checked_owner(self.project(), Representation::Saved { diagram }).is_none()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(found) = self
|
||||
.current_surface_usages(selected, Surface::Saved(diagram))
|
||||
.into_iter()
|
||||
.find(|row| matches!(row.target, Target::Whole | Target::ScenarioObject(_)))
|
||||
else {
|
||||
self.status = "This object has no visible target in that saved Scenario.".into();
|
||||
return Task::none();
|
||||
};
|
||||
self.reveal_diagram_usage(&found);
|
||||
Task::none()
|
||||
}
|
||||
|
||||
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
|
||||
if !self.usage_navigation_available() {
|
||||
return false;
|
||||
}
|
||||
let previous_layer = self.layer;
|
||||
let scenario_representation = match usage.surface {
|
||||
Surface::Saved(diagram) if self.saved_scenario_diagram(diagram) => {
|
||||
Some(Representation::Saved { diagram })
|
||||
}
|
||||
Surface::Scenario(owner) => Some(Representation::Derived { owner }),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(representation) = scenario_representation {
|
||||
let Some(owner) = presentation::checked_owner(self.project(), representation) else {
|
||||
return false;
|
||||
};
|
||||
if !self
|
||||
.behavior
|
||||
.scenario
|
||||
.representation_usages(self.project(), representation, usage.subject)
|
||||
.iter()
|
||||
.any(|row| row == usage)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// Finish target/camera admission before changing the App route. The
|
||||
// checked immutable owner also makes the following entry infallible.
|
||||
let revealed = match representation {
|
||||
Representation::Derived { .. } => self.behavior.scenario.reveal_usage(
|
||||
self.document.project(),
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
),
|
||||
Representation::Saved { .. } => self.behavior.scenario.reveal_representation_usage(
|
||||
self.document.project(),
|
||||
self.canvas_document,
|
||||
owner,
|
||||
representation,
|
||||
&usage.target,
|
||||
),
|
||||
};
|
||||
if !revealed {
|
||||
return false;
|
||||
}
|
||||
if !self.enter_scenario_representation(representation) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let p = self.document.project();
|
||||
let (selected, placement) = match &usage.target {
|
||||
Target::Whole => (usage.surface.id(), None),
|
||||
@@ -399,10 +335,7 @@ impl App {
|
||||
};
|
||||
self.layer = diagram.layer;
|
||||
self.active_diagram = Some(id);
|
||||
if let Some(owner) = diagram.projection.scenario() {
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
} else if let Some(machine) = diagram.projection.state_machine() {
|
||||
if let Some(machine) = diagram.projection.state_machine() {
|
||||
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 610.).max(280.),
|
||||
(self.window_size.height - 320.).max(240.),
|
||||
@@ -444,14 +377,19 @@ impl App {
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
if !self.behavior.scenario.reveal_usage(
|
||||
p,
|
||||
self.canvas_document,
|
||||
owner,
|
||||
&usage.target,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
self.layer = p.elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.page = Page::Behavior;
|
||||
}
|
||||
}
|
||||
if scenario_representation.is_none() {
|
||||
self.behavior.scenario.forget_view();
|
||||
}
|
||||
self.selected = selected;
|
||||
self.selected_node = placement;
|
||||
self.model_menu = None;
|
||||
@@ -478,18 +416,6 @@ impl App {
|
||||
usage: usage.clone(),
|
||||
selected,
|
||||
});
|
||||
self.pending_scenario_reveal_visit = scenario_representation.and_then(|representation| {
|
||||
let owner = presentation::checked_owner(self.project(), representation)?;
|
||||
self.behavior
|
||||
.scenario
|
||||
.context(
|
||||
self.canvas_document,
|
||||
owner,
|
||||
self.document.revision(),
|
||||
self.canvas_epoch,
|
||||
)
|
||||
.map(|context| context.visit)
|
||||
});
|
||||
self.pending_diagram_reveal = Some((
|
||||
Context {
|
||||
document: self.canvas_document,
|
||||
@@ -510,20 +436,10 @@ impl App {
|
||||
Surface::Saved(id) => {
|
||||
self.active_diagram == Some(id)
|
||||
&& matches!(self.page, Page::Diagram | Page::Behavior)
|
||||
&& (!self.saved_scenario_diagram(id)
|
||||
|| (self.page == Page::Behavior
|
||||
&& self.behavior.scenario.representation()
|
||||
== Some(Representation::Saved { diagram: id })))
|
||||
}
|
||||
Surface::Process(owner) => {
|
||||
Surface::Process(owner) | Surface::Scenario(owner) => {
|
||||
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
|
||||
}
|
||||
Surface::Scenario(owner) => {
|
||||
self.page == Page::Behavior
|
||||
&& self.behavior.active_owner == Some(owner)
|
||||
&& self.behavior.scenario.representation()
|
||||
== Some(Representation::Derived { owner })
|
||||
}
|
||||
};
|
||||
(current_surface
|
||||
&& focus.usage.surface == surface
|
||||
|
||||
@@ -1029,10 +1029,9 @@ fn repeated_preview_arrows_select_the_relation_and_highlight_the_exact_caller_li
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
if call == calls[1]
|
||||
&& let Some((previous_call, previous)) = &previous_camera
|
||||
&& *previous_call != call
|
||||
{
|
||||
if *previous_call != call {
|
||||
assert!(&camera != previous);
|
||||
}
|
||||
assert!(&camera != previous);
|
||||
}
|
||||
if let Some(epoch) = previous_epoch {
|
||||
assert_ne!(app.canvas_epoch, epoch);
|
||||
@@ -1375,7 +1374,7 @@ fn whole_surface_camera_fits_huge_extent_while_target_camera_remains_readable()
|
||||
};
|
||||
let viewport = iced::Size::new(800., 500.);
|
||||
let (zoom, pan) = usage::camera_for(&Target::Whole, bounds, viewport);
|
||||
assert!(zoom < 0.05 && zoom >= 0.0001);
|
||||
assert!((0.0001..0.05).contains(&zoom));
|
||||
assert!(bounds.width * zoom <= viewport.width - 64. + 0.01);
|
||||
assert!(bounds.height * zoom <= viewport.height - 64. + 0.01);
|
||||
assert!((bounds.center_x() * zoom + pan.x - viewport.width / 2.).abs() < 0.01);
|
||||
@@ -1401,5 +1400,3 @@ fn completion_releases_worker_behind_busy_modal_without_navigation() {
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(authored(&app), before);
|
||||
}
|
||||
|
||||
mod saved_scenario;
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
arcadia 7;
|
||||
project incident_workspace "Incident report review" id "70200000-0000-4000-8000-000000000001" {
|
||||
// Two operational activities, with one lifeline appearance per Scenario.
|
||||
oa_activity report_incident "Report incident" id "70200000-0000-4000-8000-000000000002" {}
|
||||
oa_activity assess_incident "Assess incident" id "70200000-0000-4000-8000-000000000003" {}
|
||||
interaction incident_report "Incident report" id "70200000-0000-4000-8000-000000000004" {
|
||||
source @report_incident;
|
||||
target @assess_incident;
|
||||
}
|
||||
|
||||
oa_scenario incident_response "Review a reported incident" id "70200000-0000-4000-8000-000000000010" {
|
||||
viewpoint OperationalActivity;
|
||||
steps [@initial_report, @updated_report, @assess_call, @assess_reply, @unfinished_notice, @use_review];
|
||||
}
|
||||
oa_lifeline reporter "Reporting activity" id "70200000-0000-4000-8000-000000000011" {
|
||||
parent @incident_response; represented @report_incident;
|
||||
}
|
||||
oa_lifeline assessor "Assessment activity" id "70200000-0000-4000-8000-000000000012" {
|
||||
parent @incident_response; represented @assess_incident;
|
||||
}
|
||||
// Both appearances reuse one definition. Navigation must retain the occurrence.
|
||||
oa_occurrence initial_report "Initial incident report" id "70200000-0000-4000-8000-000000000020" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
|
||||
}
|
||||
oa_occurrence updated_report "Updated incident report" id "70200000-0000-4000-8000-000000000021" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange @incident_report; sender @reporter; receiver @assessor; message_kind Asynchronous; }; };
|
||||
}
|
||||
// A correlated self call/reply with deliberately unassigned exchange definitions.
|
||||
oa_occurrence assess_call "Assess report details" id "70200000-0000-4000-8000-000000000022" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Call; }; };
|
||||
}
|
||||
oa_occurrence assess_reply "Assessment ready" id "70200000-0000-4000-8000-000000000023" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange none; sender @assessor; receiver @assessor; message_kind Reply; reply_to @assess_call; }; };
|
||||
}
|
||||
// Intentional unfinished work: no receiver and therefore no fabricated arrow.
|
||||
oa_occurrence unfinished_notice "Follow-up recipient undecided" id "70200000-0000-4000-8000-000000000024" {
|
||||
parent @incident_response;
|
||||
body { Exchange { exchange @incident_report; sender @reporter; receiver none; message_kind Asynchronous; }; };
|
||||
}
|
||||
oa_occurrence use_review "Use the reviewed reporting procedure" id "70200000-0000-4000-8000-000000000025" {
|
||||
parent @incident_response;
|
||||
body { ScenarioUse {
|
||||
bindings [{ local @reporter; referenced @review_reporter; }, { local @assessor; referenced @review_assessor; }];
|
||||
scenario @reporting_review;
|
||||
}; };
|
||||
}
|
||||
|
||||
// This definition stays opaque in the caller. Open it, then Back to the saved view.
|
||||
oa_scenario reporting_review "Reporting procedure detail" id "70200000-0000-4000-8000-000000000030" {
|
||||
viewpoint OperationalActivity;
|
||||
steps [@review_delivery];
|
||||
}
|
||||
oa_lifeline review_reporter "Reporting activity in detail" id "70200000-0000-4000-8000-000000000031" {
|
||||
parent @reporting_review; represented @report_incident;
|
||||
}
|
||||
oa_lifeline review_assessor "Assessment activity in detail" id "70200000-0000-4000-8000-000000000032" {
|
||||
parent @reporting_review; represented @assess_incident;
|
||||
}
|
||||
oa_occurrence review_delivery "Check the incident report" id "70200000-0000-4000-8000-000000000033" {
|
||||
parent @reporting_review;
|
||||
body { Exchange { exchange @incident_report; sender @review_reporter; receiver @review_assessor; message_kind Asynchronous; }; };
|
||||
}
|
||||
|
||||
diagram incident_readable "A - Incident review readable" id "70200000-0000-4000-8000-000000000040" {
|
||||
layer Oa;
|
||||
projection { Scenario { scenario @incident_response; }; };
|
||||
layout { Scenario {
|
||||
lanes [{ lifeline @reporter; x 160; }, { lifeline @assessor; x 640; }];
|
||||
messages [
|
||||
{ address { references []; occurrence @initial_report; }; send_y 160; },
|
||||
{ address { references []; occurrence @updated_report; }; send_y 252; },
|
||||
{ address { references []; occurrence @assess_call; }; send_y 344; },
|
||||
{ address { references []; occurrence @assess_reply; }; send_y 436; }
|
||||
];
|
||||
}; };
|
||||
}
|
||||
diagram incident_wide "B - Incident review negative and far" id "70200000-0000-4000-8000-000000000041" {
|
||||
layer Oa;
|
||||
projection { Scenario { scenario @incident_response; }; };
|
||||
layout { Scenario {
|
||||
lanes [{ lifeline @reporter; x -500; }, { lifeline @assessor; x 50000; }];
|
||||
messages [
|
||||
{ address { references []; occurrence @initial_report; }; send_y 160; },
|
||||
{ address { references []; occurrence @updated_report; }; send_y 252; },
|
||||
{ address { references []; occurrence @assess_call; }; send_y 344; },
|
||||
{ address { references []; occurrence @assess_reply; }; send_y 436; }
|
||||
];
|
||||
}; };
|
||||
}
|
||||
diagram incident_repair "C - Incident review needs layout repair" id "70200000-0000-4000-8000-000000000042" {
|
||||
layer Oa;
|
||||
projection { Scenario { scenario @incident_response; }; };
|
||||
layout { Scenario {
|
||||
// These valid authored lanes must remain saved during complete fallback.
|
||||
lanes [{ lifeline @reporter; x 900; }, { lifeline @assessor; x -500; }];
|
||||
messages [
|
||||
// REPAIR: change only this 300 to 424, then Undo and Redo the edit.
|
||||
{ address { references []; occurrence @assess_reply; }; send_y 300; },
|
||||
// This authored value stays latent while the receiver is missing.
|
||||
{ address { references []; occurrence @unfinished_notice; }; send_y 2000; }
|
||||
];
|
||||
}; };
|
||||
}
|
||||
}
|
||||
@@ -1,356 +0,0 @@
|
||||
//! Actual App chooser/explicit-reveal checks for saved Scenario identity and source history.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, State};
|
||||
use crate::behavior::scenario_layout::{
|
||||
Representation,
|
||||
presentation::{self, PresentationStatus},
|
||||
};
|
||||
use iced::{Size, Vector};
|
||||
|
||||
fn fixture() -> App {
|
||||
let p = language::parse(include_str!("saved_scenario.arcadia")).unwrap();
|
||||
let errors: Vec<_> = oa_core::validation::validate(&p)
|
||||
.into_iter()
|
||||
.filter(|d| d.severity == oa_core::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
App::with_project(p)
|
||||
}
|
||||
fn id(app: &App, alias: &str) -> Id {
|
||||
app.project()
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn representation(view: Id) -> Representation {
|
||||
Representation::Saved { diagram: view }
|
||||
}
|
||||
fn find(app: &App, subject: Id, view: Id) -> Usage {
|
||||
usage::saved_usages_for(app.project(), subject, Some(view))
|
||||
.into_iter()
|
||||
.find(|row| row.target == Target::ScenarioObject(subject))
|
||||
.expect("exact saved occurrence")
|
||||
}
|
||||
fn context(app: &App) -> behavior::scenario::Context {
|
||||
let owner = app.behavior.active_owner.unwrap();
|
||||
app.behavior
|
||||
.scenario
|
||||
.context(
|
||||
app.canvas_document,
|
||||
owner,
|
||||
app.document.revision(),
|
||||
app.canvas_epoch,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn navigate(app: &mut App, subject: Id, view: Id) -> Id {
|
||||
let (token, rows) = open(app, subject);
|
||||
let found = rows
|
||||
.into_iter()
|
||||
.find(|u| u.surface == Surface::Saved(view) && u.target == Target::ScenarioObject(subject))
|
||||
.expect("saved chooser destination");
|
||||
choose(app, token, found);
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.active_diagram, Some(view));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.representation(),
|
||||
Some(representation(view))
|
||||
);
|
||||
assert_eq!(app.selected, subject);
|
||||
token
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_queries_use_each_actual_presentation_and_keep_repeated_appearances_and_references_opaque()
|
||||
{
|
||||
let app = fixture();
|
||||
let owner = id(&app, "incident_response");
|
||||
let first = id(&app, "initial_report");
|
||||
let second = id(&app, "updated_report");
|
||||
let exchange = id(&app, "incident_report");
|
||||
let latent = id(&app, "unfinished_notice");
|
||||
let callee_message = id(&app, "review_delivery");
|
||||
let views = ["incident_readable", "incident_wide", "incident_repair"].map(|a| id(&app, a));
|
||||
let derived = app
|
||||
.behavior
|
||||
.scenario
|
||||
.diagram_usages(app.project(), owner, first)
|
||||
.pop()
|
||||
.unwrap();
|
||||
let rows = views.map(|view| find(&app, first, view));
|
||||
assert_eq!(rows[0].bounds.y - derived.bounds.y, 12.);
|
||||
assert_ne!(rows[0].bounds, rows[1].bounds);
|
||||
assert_eq!(
|
||||
rows[2].bounds, derived.bounds,
|
||||
"complete fallback cannot retain the authored lane override"
|
||||
);
|
||||
for view in views {
|
||||
let represented = usage::saved_usages_for(app.project(), exchange, Some(view));
|
||||
assert_eq!(represented.len(), 2);
|
||||
assert!(
|
||||
represented
|
||||
.iter()
|
||||
.any(|u| u.target == Target::ScenarioObject(first))
|
||||
);
|
||||
assert!(
|
||||
represented
|
||||
.iter()
|
||||
.any(|u| u.target == Target::ScenarioObject(second))
|
||||
);
|
||||
assert!(
|
||||
represented
|
||||
.iter()
|
||||
.all(|u| u.surface == Surface::Saved(view))
|
||||
);
|
||||
assert!(usage::saved_usages_for(app.project(), callee_message, Some(view)).is_empty());
|
||||
assert_eq!(
|
||||
find(&app, latent, view).target,
|
||||
Target::ScenarioObject(latent)
|
||||
);
|
||||
let shown = presentation::present(app.project(), representation(view), owner);
|
||||
let whole = usage::saved_usages_for(app.project(), view, Some(view));
|
||||
assert_eq!(whole.len(), 1);
|
||||
assert_eq!(whole[0].target, Target::Whole);
|
||||
assert_eq!(Some(whole[0].bounds), shown.bounds());
|
||||
}
|
||||
let wide = presentation::present(app.project(), representation(views[1]), owner)
|
||||
.bounds()
|
||||
.unwrap();
|
||||
assert!(wide.x < -500. && wide.x + wide.width > 50000.);
|
||||
let fallback = presentation::present(app.project(), representation(views[2]), owner);
|
||||
assert!(matches!(
|
||||
fallback.status(),
|
||||
PresentationStatus::Fallback { .. }
|
||||
));
|
||||
assert_eq!(fallback.latent_messages(), &[latent]);
|
||||
assert!(!fallback.capabilities().coordinate_input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_chooser_and_explicit_reveal_preserve_complete_source_and_actual_undo_redo() {
|
||||
for alias in ["incident_readable", "incident_wide", "incident_repair"] {
|
||||
let mut app = fixture();
|
||||
let view = id(&app, alias);
|
||||
let first = id(&app, "initial_report");
|
||||
let owner = id(&app, "incident_response");
|
||||
let original = app.document.snapshot();
|
||||
app.document
|
||||
.execute(Command::Rename(first, "Reviewed incident report".into()))
|
||||
.unwrap();
|
||||
let edited = app.document.snapshot();
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
let before = authored(&app);
|
||||
navigate(&mut app, first, view);
|
||||
assert_eq!(app.behavior.active_owner, Some(owner));
|
||||
assert_eq!(authored(&app), before);
|
||||
send(&mut app, Message::RevealInDiagram(view, first));
|
||||
assert_eq!(app.active_diagram, Some(view));
|
||||
assert_eq!(authored(&app), before);
|
||||
assert!(app.document.undo());
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(app.document.redo());
|
||||
assert_eq!(app.project(), &edited.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &edited.bundle);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_reveal_and_chooser_refuse_source_property_and_behavior_drafts() {
|
||||
for chooser in [false, true] {
|
||||
for block in 0..3 {
|
||||
let mut app = fixture();
|
||||
let view = id(&app, "incident_repair");
|
||||
let first = id(&app, "initial_report");
|
||||
let choice = chooser.then(|| {
|
||||
let (token, rows) = open(&mut app, first);
|
||||
(
|
||||
token,
|
||||
rows.into_iter()
|
||||
.find(|u| u.surface == Surface::Saved(view))
|
||||
.unwrap(),
|
||||
)
|
||||
});
|
||||
match block {
|
||||
0 => {
|
||||
let cursor = app.source_editor.cursor();
|
||||
app.source_workspace
|
||||
.edit("? preserve unfinished source".into(), cursor);
|
||||
}
|
||||
1 => {
|
||||
app.draft
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.input("/name", "Unapplied inspector value".into());
|
||||
assert!(app.draft_dirty());
|
||||
}
|
||||
_ => {
|
||||
let p = app.project().clone();
|
||||
app.behavior
|
||||
.creation
|
||||
.update(&p, behavior::creation::Event::Open(Layer::Oa))
|
||||
.unwrap();
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
}
|
||||
let before = authored(&app);
|
||||
let prior = location(&app);
|
||||
let draft = app
|
||||
.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap());
|
||||
if let Some((token, row)) = choice {
|
||||
choose(&mut app, token, row);
|
||||
} else {
|
||||
send(&mut app, Message::RevealInDiagram(view, first));
|
||||
}
|
||||
assert_eq!(authored(&app), before);
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(
|
||||
app.draft
|
||||
.as_ref()
|
||||
.map(|d| serde_json::to_value(d.recovery_snapshot()).unwrap()),
|
||||
draft
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement() {
|
||||
let mut app = fixture();
|
||||
let a = id(&app, "incident_readable");
|
||||
let b = id(&app, "incident_wide");
|
||||
let first = id(&app, "initial_report");
|
||||
let before = authored(&app);
|
||||
let old_token = navigate(&mut app, first, a);
|
||||
let old = context(&app);
|
||||
navigate(&mut app, first, b);
|
||||
navigate(&mut app, first, a);
|
||||
let current = context(&app);
|
||||
assert_ne!(old.visit, current.visit);
|
||||
let held = behavior::scenario::Context {
|
||||
epoch: current.epoch,
|
||||
..old
|
||||
};
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
send(
|
||||
&mut app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))),
|
||||
);
|
||||
send(&mut app, Message::ScenarioCanvas(CanvasEvent::Focus(held)));
|
||||
send(
|
||||
&mut app,
|
||||
Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())),
|
||||
);
|
||||
send(&mut app, Message::DiagramUsage(Event::Close(old_token)));
|
||||
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
let bounds = find(&app, first, a).bounds;
|
||||
send(
|
||||
&mut app,
|
||||
Message::ScenarioCanvas(CanvasEvent::Viewport(current, Size::new(500., 300.))),
|
||||
);
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(context(&app).visit, current.visit);
|
||||
let mut expected = State::default();
|
||||
expected.enter_representation(app.canvas_document, current.scenario, representation(a));
|
||||
expected.viewport(Size::new(500., 300.));
|
||||
expected.zoom(Some(1.25));
|
||||
expected.camera(
|
||||
app.project(),
|
||||
current.scenario,
|
||||
behavior::canvas_workspace::Event::Pan(Vector::new(
|
||||
250. - bounds.center_x() * 1.25,
|
||||
150. - bounds.center_y() * 1.25,
|
||||
)),
|
||||
);
|
||||
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
|
||||
assert_eq!(authored(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retargeted_saved_view_cannot_reveal_a_former_owner_occurrence() {
|
||||
let mut app = fixture();
|
||||
let first = id(&app, "initial_report");
|
||||
let view = id(&app, "incident_readable");
|
||||
let other = id(&app, "reporting_review");
|
||||
let (token, rows) = open(&mut app, first);
|
||||
let old = rows
|
||||
.into_iter()
|
||||
.find(|u| u.surface == Surface::Saved(view))
|
||||
.unwrap();
|
||||
let mut replacement = app.project().diagrams[&view].clone();
|
||||
replacement.projection = ProjectionKind::Scenario { scenario: other };
|
||||
replacement.layout = None;
|
||||
app.document
|
||||
.execute(Command::EditDiagram(view, replacement))
|
||||
.unwrap();
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
let before = authored(&app);
|
||||
let prior = location(&app);
|
||||
choose(&mut app, token, old);
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(authored(&app), before);
|
||||
finish_lookup(&mut app);
|
||||
let token = app.diagram_usages.as_ref().unwrap().token;
|
||||
assert!(
|
||||
app.diagram_usages
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.usages
|
||||
.iter()
|
||||
.all(|u| u.surface != Surface::Saved(view))
|
||||
);
|
||||
send(&mut app, Message::DiagramUsage(Event::Close(token)));
|
||||
send(&mut app, Message::RevealInDiagram(view, first));
|
||||
assert_eq!(location(&app), prior);
|
||||
assert_eq!(authored(&app), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_saved_layout_repair_undo_redo_requeries_authored_and_fallback_geometry_without_extra_history()
|
||||
{
|
||||
let mut app = fixture();
|
||||
let view = id(&app, "incident_repair");
|
||||
let first = id(&app, "initial_report");
|
||||
let reply = id(&app, "assess_reply");
|
||||
let before = app.document.snapshot();
|
||||
let fallback = find(&app, first, view);
|
||||
app.document
|
||||
.execute(Command::SetScenarioMessagePosition {
|
||||
diagram: view,
|
||||
position: oa_core::ScenarioMessagePosition {
|
||||
address: oa_core::OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: reply,
|
||||
},
|
||||
send_y: 424.,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
let repaired = app.document.snapshot();
|
||||
let authored_row = find(&app, first, view);
|
||||
assert_ne!(authored_row.bounds, fallback.bounds);
|
||||
navigate(&mut app, first, view);
|
||||
assert!(app.document.undo());
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(find(&app, first, view).bounds, fallback.bounds);
|
||||
assert!(app.document.redo());
|
||||
app.sync_source();
|
||||
app.refresh_draft();
|
||||
assert_eq!(app.project(), &repaired.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &repaired.bundle);
|
||||
assert_eq!(find(&app, first, view).bounds, authored_row.bounds);
|
||||
}
|
||||
@@ -219,7 +219,7 @@ impl App {
|
||||
|| session.connection.base() != request.connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection: request.connection.clone(),
|
||||
@@ -359,7 +359,7 @@ impl App {
|
||||
|| session.connection.base() != connection.base()
|
||||
})
|
||||
{
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
}
|
||||
self.hosted.session = Some(Session {
|
||||
connection,
|
||||
|
||||
@@ -65,7 +65,6 @@ impl App {
|
||||
owner: context.owner,
|
||||
revision: context.revision,
|
||||
epoch: lifetime,
|
||||
scenario_view: None,
|
||||
};
|
||||
if self.behavior.has_pending()
|
||||
|| self.draft_dirty()
|
||||
@@ -75,11 +74,7 @@ impl App {
|
||||
return Task::none();
|
||||
}
|
||||
match input {
|
||||
Event::Focus
|
||||
| Event::Traverse(_)
|
||||
| Event::ControlFocus(_)
|
||||
| Event::ToggleFocus
|
||||
| Event::Leave => {
|
||||
Event::Traverse(_) | Event::ControlFocus(_) | Event::ToggleFocus | Event::Leave => {
|
||||
return canvas_workspace::focus_task(scope, input);
|
||||
}
|
||||
Event::CancelGesture => self.canvas_epoch = self.canvas_epoch.wrapping_add(1),
|
||||
@@ -98,6 +93,26 @@ impl App {
|
||||
.topology
|
||||
.enter(context.document, context.owner);
|
||||
match event {
|
||||
CanvasEvent::Remove(_, id) => {
|
||||
if diagram_removal::enabled(self)
|
||||
&& self.selected == id
|
||||
&& self.behavior.topology.selecting()
|
||||
{
|
||||
if self
|
||||
.behavior
|
||||
.topology
|
||||
.has_qualified_selection(self.project(), context.owner)
|
||||
{
|
||||
self.status = "Open the referenced definition to delete its contents, or select the outer reference to remove that use.".into();
|
||||
} else if self.behavior.topology.removal_target(
|
||||
self.project(),
|
||||
context.owner,
|
||||
id,
|
||||
) {
|
||||
self.begin_model_delete(None);
|
||||
}
|
||||
}
|
||||
}
|
||||
CanvasEvent::ExpandQualified(_, endpoint) => {
|
||||
let mut state = std::mem::take(&mut self.behavior.topology);
|
||||
let changed = state.expand_context(self.project(), context.owner, endpoint);
|
||||
@@ -144,10 +159,6 @@ impl App {
|
||||
self.behavior.topology.select_context(resolved.address);
|
||||
}
|
||||
}
|
||||
CanvasEvent::Focus(_) => {
|
||||
self.behavior.topology.focused = !self.behavior.topology.focused;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
CanvasEvent::Select(_, id) => {
|
||||
if !self.behavior.has_pending()
|
||||
&& crate::behavior::selected_behavior(self.project(), id) == Some(context.owner)
|
||||
@@ -330,7 +341,7 @@ mod tests {
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() {
|
||||
fn process_zoom_retains_rejected_draft_and_guards_selection() {
|
||||
let (mut app, f) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let initial = app.selected;
|
||||
@@ -340,9 +351,14 @@ mod tests {
|
||||
&mut app,
|
||||
Event::Name(" unfinished contextual work ".into()),
|
||||
);
|
||||
let camera = app.behavior.topology.camera_boundary();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert!(app.behavior.topology.focused);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(
|
||||
context,
|
||||
Some(0.75),
|
||||
)));
|
||||
assert_ne!(app.canvas_epoch, context.epoch);
|
||||
assert!(app.behavior.topology.camera_boundary() != camera);
|
||||
let raw = app.behavior.recovery_snapshot().unwrap();
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
|
||||
@@ -354,8 +370,8 @@ mod tests {
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(Arc::ptr_eq(&original, &app.document.snapshot()));
|
||||
let context = app.topology_context().unwrap();
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
|
||||
assert!(!app.behavior.topology.focused);
|
||||
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Zoom(context, None)));
|
||||
assert_ne!(app.canvas_epoch, context.epoch);
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
}
|
||||
#[test]
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
//! Saved Scenario navigation is presentation state, never a document edit.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation::checked_owner};
|
||||
|
||||
impl App {
|
||||
pub(super) fn enter_scenario_representation(&mut self, representation: Representation) -> bool {
|
||||
let Some(owner) = checked_owner(self.project(), representation) else {
|
||||
self.status =
|
||||
"This Scenario view is no longer available or has an incompatible owner.".into();
|
||||
return false;
|
||||
};
|
||||
self.layer = self.project().elements[&owner].layer();
|
||||
self.page = Page::Behavior;
|
||||
self.behavior.active_owner = Some(owner);
|
||||
match representation {
|
||||
Representation::Saved { diagram } => self.active_diagram = Some(diagram),
|
||||
Representation::Derived { .. } => {
|
||||
if self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id))
|
||||
{
|
||||
self.active_diagram = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.selected_node = None;
|
||||
if self
|
||||
.behavior
|
||||
.scenario
|
||||
.enter_representation(self.canvas_document, owner, representation)
|
||||
{
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Reconcile after navigation/adoption without selecting or applying a draft.
|
||||
/// A retained invalid Saved identity remains unavailable; it must not silently
|
||||
/// turn into a different automatic Scenario or another saved arrangement.
|
||||
pub(super) fn reconcile_scenario_workspace(&mut self) {
|
||||
if self.page == Page::Diagram
|
||||
&& !self.behavior.has_pending()
|
||||
&& let Some(diagram) = self
|
||||
.active_diagram
|
||||
.filter(|id| self.saved_scenario_diagram(*id))
|
||||
{
|
||||
self.enter_scenario_representation(Representation::Saved { diagram });
|
||||
}
|
||||
if self.page != Page::Behavior {
|
||||
self.behavior.scenario.leave();
|
||||
return;
|
||||
}
|
||||
if let Some(rep @ Representation::Saved { diagram }) =
|
||||
self.behavior.scenario.representation()
|
||||
{
|
||||
// The requested view is authoritative until explicit navigation
|
||||
// retires it. This also recovers the same view when Undo restores it.
|
||||
if let Some(owner) = checked_owner(self.project(), rep) {
|
||||
self.active_diagram = Some(diagram);
|
||||
self.layer = self.project().elements[&owner].layer();
|
||||
self.behavior.active_owner = Some(owner);
|
||||
self.behavior
|
||||
.scenario
|
||||
.enter_representation(self.canvas_document, owner, rep);
|
||||
} else if let Some(owner) = self.behavior.scenario.owner() {
|
||||
self.behavior
|
||||
.scenario
|
||||
.enter_representation(self.canvas_document, owner, rep);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some(owner) = behavior::selected_owner(self) else {
|
||||
self.behavior.scenario.leave();
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.project()
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
self.behavior.scenario.enter(self.canvas_document, owner);
|
||||
} else {
|
||||
self.behavior.scenario.leave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod adoption_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod owner_navigation_tests;
|
||||
@@ -1,264 +0,0 @@
|
||||
//! Independent adoption oracles: expected Projects are edited directly in the
|
||||
//! fixture, then admitted through the actual source editor/Document transaction.
|
||||
//! Navigation is checked separately from the expected authored mutation.
|
||||
use super::*;
|
||||
use crate::behavior::canvas_workspace::tests::Mounted;
|
||||
use iced::advanced::widget;
|
||||
use iced::widget::text_editor;
|
||||
use oa_core::language;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
owner: Id,
|
||||
same_layer: Id,
|
||||
other_layer: Id,
|
||||
view: Id,
|
||||
spare_view: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Original owner", None)
|
||||
.unwrap();
|
||||
let same_layer = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Other OA owner", None)
|
||||
.unwrap();
|
||||
let other_layer = project
|
||||
.add_element(Kind::Scenario, Layer::Sa, "SA owner", None)
|
||||
.unwrap();
|
||||
// A remaining topology makes silent fallback observably different from the
|
||||
// required unavailable Saved view after its owner is deleted.
|
||||
project
|
||||
.add_element(Kind::Process, Layer::Oa, "Remaining process", None)
|
||||
.unwrap();
|
||||
let mut make_view = |alias: &str, name: &str, owner| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
layout: None,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
};
|
||||
let view = make_view("retained_view", "Retained arrangement", owner);
|
||||
let spare_view = make_view("spare_view", "Spare arrangement", same_layer);
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(owner));
|
||||
choose(&mut app, view);
|
||||
assert_saved(&app, view, owner, Layer::Oa);
|
||||
assert!(!app.document.can_undo());
|
||||
Fixture {
|
||||
app,
|
||||
owner,
|
||||
same_layer,
|
||||
other_layer,
|
||||
view,
|
||||
spare_view,
|
||||
}
|
||||
}
|
||||
|
||||
fn choose(app: &mut App, id: Id) {
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id,
|
||||
label: "Previously captured picker item".into(),
|
||||
}));
|
||||
}
|
||||
|
||||
fn assert_saved(app: &App, view: Id, owner: Id, layer: Layer) {
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.layer, layer);
|
||||
assert_eq!(app.active_diagram, Some(view));
|
||||
let context = app
|
||||
.scenario_context()
|
||||
.expect("saved owner remains the visible context");
|
||||
assert_eq!(context.scenario, owner);
|
||||
assert_eq!(
|
||||
context.representation,
|
||||
Representation::Saved { diagram: view }
|
||||
);
|
||||
assert!(!context.visit.is_nil());
|
||||
}
|
||||
|
||||
fn adopt_from_source(app: &mut App, expected: &Project) {
|
||||
let text = language::format(expected);
|
||||
// Parser equality is a fixture precondition, not the navigation oracle.
|
||||
assert_eq!(language::parse(&text).unwrap(), *expected);
|
||||
let _ = app.update(Message::Page(Page::Text));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Paste(Arc::new(text)),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
let _ = app.update(Message::ApplySource);
|
||||
assert!(!app.source_dirty(), "{}", app.status);
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
expected,
|
||||
"actual source transaction must adopt exactly the fixture change"
|
||||
);
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
}
|
||||
|
||||
fn retarget(cross_layer: bool) {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let old_context = f.app.scenario_context().unwrap();
|
||||
let revision = f.app.document.revision();
|
||||
let target = if cross_layer {
|
||||
f.other_layer
|
||||
} else {
|
||||
f.same_layer
|
||||
};
|
||||
let layer = if cross_layer { Layer::Sa } else { Layer::Oa };
|
||||
let mut expected = before.decoded.project.clone();
|
||||
let view = expected.diagrams.get_mut(&f.view).unwrap();
|
||||
view.projection = ProjectionKind::Scenario { scenario: target };
|
||||
view.layer = layer;
|
||||
|
||||
adopt_from_source(&mut f.app, &expected);
|
||||
assert_saved(&f.app, f.view, target, layer);
|
||||
assert_ne!(f.app.scenario_context().unwrap().visit, old_context.visit);
|
||||
assert_eq!(
|
||||
f.app.document.revision(),
|
||||
revision + 1,
|
||||
"routing must not create a second transaction"
|
||||
);
|
||||
assert_eq!(
|
||||
f.app.project().diagrams[&f.spare_view],
|
||||
before.decoded.project.diagrams[&f.spare_view]
|
||||
);
|
||||
let adopted_bundle = f.app.document.bundle().clone();
|
||||
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert!(
|
||||
!f.app.document.can_undo(),
|
||||
"exactly one authored adoption must be undone"
|
||||
);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_saved(&f.app, f.view, target, layer);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &adopted_bundle);
|
||||
assert!(!f.app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_same_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
|
||||
retarget(false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_cross_layer_owner_retarget_undo_redo_keeps_requested_saved_uuid() {
|
||||
retarget(true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn source_deleted_owner_and_saved_view_mount_unavailable_instead_of_another_behavior() {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let mut expected = before.decoded.project.clone();
|
||||
assert!(expected.elements.remove(&f.owner).is_some());
|
||||
assert!(expected.diagrams.remove(&f.view).is_some());
|
||||
assert!(expected.elements.contains_key(&f.same_layer));
|
||||
assert!(
|
||||
expected
|
||||
.elements
|
||||
.values()
|
||||
.any(|e| e.kind() == Kind::Process)
|
||||
);
|
||||
adopt_from_source(&mut f.app, &expected);
|
||||
assert_eq!(
|
||||
f.app.behavior.scenario.representation(),
|
||||
Some(Representation::Saved { diagram: f.view })
|
||||
);
|
||||
let adopted = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let mut mounted = Mounted::new(&mut f.app).await;
|
||||
let geometry = mounted.geometry(&f.app);
|
||||
assert!(
|
||||
geometry
|
||||
.containers
|
||||
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
|
||||
);
|
||||
assert!(
|
||||
!geometry
|
||||
.containers
|
||||
.contains_key(&widget::Id::from("scenario-viewport"))
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &adopted.bundle);
|
||||
assert_eq!(
|
||||
f.app.document.revision(),
|
||||
revision,
|
||||
"mounting unavailable must remain passive"
|
||||
);
|
||||
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
let _ = f.app.update(Message::Redo);
|
||||
assert_eq!(
|
||||
f.app.behavior.scenario.representation(),
|
||||
Some(Representation::Saved { diagram: f.view })
|
||||
);
|
||||
mounted.settle(&mut f.app).await;
|
||||
assert!(
|
||||
mounted
|
||||
.geometry(&f.app)
|
||||
.containers
|
||||
.contains_key(&widget::Id::from("scenario-viewport-unavailable"))
|
||||
);
|
||||
assert_eq!(f.app.project(), &expected);
|
||||
assert_eq!(f.app.document.bundle(), &adopted.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_and_deleted_picker_ids_cannot_apply_an_unrelated_valid_property_draft() {
|
||||
let mut f = fixture();
|
||||
let mut expected = f.app.project().clone();
|
||||
expected.diagrams.remove(&f.spare_view).unwrap();
|
||||
adopt_from_source(&mut f.app, &expected);
|
||||
let _ = f.app.update(Message::Select(f.owner));
|
||||
let _ = f.app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Pending valid owner rename".into(),
|
||||
));
|
||||
assert!(f.app.draft_dirty());
|
||||
let before = f.app.document.snapshot();
|
||||
let revision = f.app.document.revision();
|
||||
let selected = f.app.selected;
|
||||
for invalid in [Id::nil(), f.spare_view] {
|
||||
choose(&mut f.app, invalid);
|
||||
assert_saved(&f.app, f.view, f.owner, Layer::Oa);
|
||||
assert_eq!(f.app.selected, selected);
|
||||
assert!(f.app.draft_dirty());
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
assert_eq!(f.app.document.revision(), revision);
|
||||
}
|
||||
// Establish that the retained draft really was valid, not merely refused by
|
||||
// the property validator: explicit Apply commits it in exactly one step.
|
||||
assert!(f.app.apply(), "{}", f.app.status);
|
||||
assert_eq!(
|
||||
f.app.project().elements[&f.owner].name,
|
||||
"Pending valid owner rename"
|
||||
);
|
||||
assert_eq!(f.app.document.revision(), revision + 1);
|
||||
let _ = f.app.update(Message::Undo);
|
||||
assert_eq!(f.app.project(), &before.decoded.project);
|
||||
assert_eq!(f.app.document.bundle(), &before.bundle);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
//! Explicit owner navigation must retire a retained Saved Scenario, including
|
||||
//! when selection was made on a different page. These are real App events.
|
||||
use super::*;
|
||||
use crate::behavior::scenario::CanvasEvent;
|
||||
|
||||
fn setup(kind: Kind) -> (App, Id, Id) {
|
||||
let mut project = Project::empty();
|
||||
let scenario = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Incident review", None)
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(kind, Layer::Oa, "Explicit destination", None)
|
||||
.unwrap();
|
||||
let view = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
view,
|
||||
Diagram {
|
||||
id: view,
|
||||
alias: "incident_saved".into(),
|
||||
name: "Incident saved arrangement".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario },
|
||||
layout: None,
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
let errors: Vec<_> = oa_core::validation::validate(&project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let mut app = App::with_project(project);
|
||||
let _ = app.update(Message::Select(scenario));
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: view,
|
||||
label: "Incident saved arrangement".into(),
|
||||
}));
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram: view }
|
||||
);
|
||||
assert_eq!(app.selected, scenario);
|
||||
(app, scenario, target)
|
||||
}
|
||||
|
||||
fn assert_destination(app: &App, target: Id) {
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, target);
|
||||
assert_eq!(app.behavior.active_owner, Some(target));
|
||||
assert_eq!(behavior::selected_owner(app), Some(target));
|
||||
assert_eq!(
|
||||
app.behavior.scenario.representation(),
|
||||
None,
|
||||
"explicit non-Scenario destination must not retain an overriding Saved request"
|
||||
);
|
||||
assert!(app.scenario_context().is_none());
|
||||
}
|
||||
|
||||
fn exercise(via_guide: bool) {
|
||||
for kind in [Kind::Process, Kind::StateMachine] {
|
||||
let (mut app, scenario, target) = setup(kind);
|
||||
assert_ne!(scenario, target);
|
||||
let held = app.scenario_context().unwrap();
|
||||
let accepted = app.document.snapshot();
|
||||
let drafts = app.source_workspace.bundle();
|
||||
let revision = app.document.revision();
|
||||
let dirty = (app.document.dirty(), app.dirty(), app.source_dirty());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
if via_guide {
|
||||
let _ = app.update(Message::Page(Page::Guide));
|
||||
assert_eq!(app.page, Page::Guide);
|
||||
assert!(app.scenario_context().is_none());
|
||||
}
|
||||
let _ = app.update(Message::Select(target));
|
||||
if via_guide {
|
||||
assert_eq!(
|
||||
app.page,
|
||||
Page::Guide,
|
||||
"explorer selection must not itself switch the page"
|
||||
);
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
}
|
||||
assert_destination(&app, target);
|
||||
let camera = app.camera_boundary();
|
||||
// The prior saved canvas cannot regain focus after either owner route.
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Focus(held)));
|
||||
assert_destination(&app, target);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), drafts);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(
|
||||
(app.document.dirty(), app.dirty(), app.source_dirty()),
|
||||
dirty
|
||||
);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_guide_selection_returns_to_explicit_process_or_state_owner() {
|
||||
exercise(true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_direct_behavior_selection_enters_explicit_process_or_state_owner() {
|
||||
exercise(false);
|
||||
}
|
||||
@@ -1,205 +0,0 @@
|
||||
use super::*;
|
||||
use crate::behavior::scenario::{CanvasEvent, ScrollArea};
|
||||
|
||||
fn saved(p: &mut Project, owner: Id, name: &str) -> Id {
|
||||
let id = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("view_{}", p.diagrams.len()),
|
||||
name: name.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
nodes: Default::default(),
|
||||
view_options: Default::default(),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn setup() -> (App, Id, Id, Id, Id) {
|
||||
let (mut p, [owner, callee, _], [reference, _, _]) =
|
||||
behavior::scenario::direct_tests::navigation_fixture();
|
||||
let a = saved(&mut p, owner, "Review A");
|
||||
let b = saved(&mut p, owner, "Review B");
|
||||
let mut app = App::with_project(p);
|
||||
app.select(owner);
|
||||
(app, a, b, reference, callee)
|
||||
}
|
||||
fn choose(app: &mut App, diagram: Id) {
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Captured view".into(),
|
||||
}));
|
||||
}
|
||||
fn event(app: &mut App, f: impl FnOnce(behavior::scenario::Context) -> CanvasEvent) {
|
||||
let context = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(f(context)));
|
||||
}
|
||||
#[test]
|
||||
fn saved_view_switches_are_distinct_passive_visits_and_preserve_actual_history() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let owner = app.selected;
|
||||
assert!(app.execute(Command::Rename(owner, "Previously edited caller".into())));
|
||||
let edited = app.document.snapshot();
|
||||
let edited_revision = app.document.revision();
|
||||
choose(&mut app, a);
|
||||
let first = app.scenario_context().unwrap();
|
||||
choose(&mut app, b);
|
||||
let second = app.scenario_context().unwrap();
|
||||
choose(&mut app, a);
|
||||
let third = app.scenario_context().unwrap();
|
||||
assert_eq!(first.scenario, owner);
|
||||
assert_eq!(first.representation, Representation::Saved { diagram: a });
|
||||
assert_eq!(second.representation, Representation::Saved { diagram: b });
|
||||
assert_ne!(first.visit, second.visit);
|
||||
assert_ne!(first.visit, third.visit);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, owner);
|
||||
assert_eq!(app.document.bundle(), &edited.bundle);
|
||||
assert_eq!(app.project(), &edited.decoded.project);
|
||||
assert_eq!(app.document.revision(), edited_revision);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &edited.bundle);
|
||||
assert_eq!(app.project(), &edited.decoded.project);
|
||||
}
|
||||
#[test]
|
||||
fn saved_view_return_rejects_old_passive_events_without_an_intermediate_frame() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let old = app.scenario_context().unwrap();
|
||||
choose(&mut app, b);
|
||||
choose(&mut app, a);
|
||||
let current = app.scenario_context().unwrap();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let before = app.document.snapshot();
|
||||
for stale in [
|
||||
CanvasEvent::Viewport(old, iced::Size::new(432., 321.)),
|
||||
CanvasEvent::Scrolled(old, ScrollArea::Page, iced::Vector::new(0., 800.)),
|
||||
CanvasEvent::FormShown(old, Id::new_v4()),
|
||||
CanvasEvent::Restored(
|
||||
old,
|
||||
Id::new_v4(),
|
||||
ScrollArea::Page,
|
||||
iced::Vector::new(0., 800.),
|
||||
),
|
||||
CanvasEvent::Camera(
|
||||
old,
|
||||
app.camera_epoch,
|
||||
behavior::canvas_workspace::Event::Pan(iced::Vector::new(90., 80.)),
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::ScenarioCanvas(stale));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
assert!(app.behavior.scenario.camera_boundary() == camera);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn saved_reference_back_restores_exact_view_with_a_fresh_visit() {
|
||||
let (mut app, a, _, reference, callee) = setup();
|
||||
choose(&mut app, a);
|
||||
let caller = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, reference));
|
||||
let target = app.scenario_context().unwrap();
|
||||
assert_eq!(target.scenario, callee);
|
||||
assert_eq!(
|
||||
target.representation,
|
||||
Representation::Derived { owner: callee }
|
||||
);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
event(&mut app, CanvasEvent::Back);
|
||||
let returned = app.scenario_context().unwrap();
|
||||
assert_eq!(returned.representation, caller.representation);
|
||||
assert_eq!(app.active_diagram, Some(a));
|
||||
assert_eq!(app.selected, reference);
|
||||
assert_ne!(returned.visit, caller.visit);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
#[test]
|
||||
fn saved_picker_applies_valid_old_draft_and_uuid_edit_follows_that_same_view() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let _ = app.update(Message::Select(a));
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::Field("/name".into(), "Renamed A".into()));
|
||||
choose(&mut app, b);
|
||||
assert_eq!(app.project().diagrams[&a].name, "Renamed A");
|
||||
assert_eq!(app.selected, b);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved { diagram: b }
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
choose(&mut app, a);
|
||||
let replacement = Id::new_v4();
|
||||
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
|
||||
choose(&mut app, a);
|
||||
assert!(!app.project().diagrams.contains_key(&a));
|
||||
assert_eq!(app.active_diagram, Some(replacement));
|
||||
assert_eq!(app.selected, replacement);
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Saved {
|
||||
diagram: replacement
|
||||
}
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn leaving_and_reentering_saved_view_preserves_source_and_retires_passive_visit() {
|
||||
let (mut app, a, _, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let old = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::Page(Page::Guide));
|
||||
assert!(app.scenario_context().is_none());
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
let fresh = app.scenario_context().unwrap();
|
||||
assert_eq!(fresh.representation, old.representation);
|
||||
assert_ne!(fresh.visit, old.visit);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arrangement_picker_rejects_previous_visit_and_switches_automatic_without_model_changes() {
|
||||
let (mut app, a, b, _, _) = setup();
|
||||
choose(&mut app, a);
|
||||
let stale = app.scenario_context().unwrap();
|
||||
choose(&mut app, b);
|
||||
let current = app.scenario_context().unwrap();
|
||||
let before = app.document.snapshot();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
stale,
|
||||
Representation::Saved { diagram: a },
|
||||
)));
|
||||
assert_eq!(app.scenario_context(), Some(current));
|
||||
event(&mut app, |context| {
|
||||
CanvasEvent::Representation(
|
||||
context,
|
||||
Representation::Derived {
|
||||
owner: context.scenario,
|
||||
},
|
||||
)
|
||||
});
|
||||
assert_eq!(
|
||||
app.scenario_context().unwrap().representation,
|
||||
Representation::Derived {
|
||||
owner: current.scenario
|
||||
}
|
||||
);
|
||||
assert_eq!(app.active_diagram, None);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
//! Admit spacing packets before ordinary navigation can retire a newer reveal.
|
||||
use super::*;
|
||||
use behavior::scenario::spacing::{Event, controller::Finish};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
impl App {
|
||||
pub(super) fn spacing_enabled(&self) -> bool {
|
||||
self.pending_diagram_reveal.is_none()
|
||||
&& !self.behavior.has_pending()
|
||||
&& !self.draft_dirty()
|
||||
&& !self.source_dirty()
|
||||
&& !self.source_rename.open()
|
||||
&& behavior::canvas_workspace::keyboard_enabled(self)
|
||||
}
|
||||
|
||||
pub(super) fn scenario_spacing(&mut self, event: Event) -> Task<Message> {
|
||||
let stamp = event.stamp();
|
||||
if self.scenario_context() != Some(stamp.context) || self.camera_epoch != stamp.camera_epoch
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let state = &self.behavior.scenario;
|
||||
let matches =
|
||||
state.spacing_token() == Some(event.token()) && state.spacing_stamp() == Some(stamp);
|
||||
if match event {
|
||||
Event::Begin { token, .. } => token.is_nil() || state.spacing_token().is_some(),
|
||||
_ => !matches,
|
||||
} {
|
||||
return Task::none();
|
||||
}
|
||||
if matches!(event, Event::Cancel { .. }) || !self.spacing_enabled() {
|
||||
if self.behavior.scenario.cancel_spacing() {
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::Begin { .. } => {
|
||||
let mut state = std::mem::take(&mut self.behavior.scenario);
|
||||
let result = state.begin_spacing(self.project(), event);
|
||||
self.behavior.scenario = state;
|
||||
if result.is_err() {
|
||||
self.status = "The drag could not start in this view. Try again.".into();
|
||||
}
|
||||
}
|
||||
Event::Move { screen, .. } => self.behavior.scenario.move_spacing(screen),
|
||||
Event::Finish { screen, .. } => {
|
||||
let finished = self.behavior.scenario.finish_spacing(screen);
|
||||
// Retire queued packets even for zero motion or a refused finish.
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
match finished {
|
||||
Ok(Finish::Click(target)) => self.select(target),
|
||||
Ok(Finish::Noop) => {}
|
||||
Ok(Finish::Commit {
|
||||
command,
|
||||
selected,
|
||||
scale,
|
||||
pan,
|
||||
}) => {
|
||||
// Document prepares and writes the source candidate before
|
||||
// committing its snapshot. Failure leaves camera/selection
|
||||
// and source/history at their accepted values.
|
||||
match self.document.execute(command) {
|
||||
Ok(_) => {
|
||||
self.selected = selected;
|
||||
self.selected_node = None;
|
||||
self.diagram_navigation_focus = None;
|
||||
self.behavior.scenario.accept_spacing_camera(scale, pan);
|
||||
self.sync();
|
||||
self.status = "Scenario spacing updated.".into();
|
||||
}
|
||||
Err(error) => self.status = format!("Spacing was not applied: {error}"),
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
self.status =
|
||||
"That position cannot be applied. The saved arrangement is unchanged."
|
||||
.into()
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Cancel { .. } => unreachable!(),
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
@@ -1,272 +0,0 @@
|
||||
use super::*;
|
||||
use behavior::scenario::{
|
||||
CanvasEvent,
|
||||
spacing::{
|
||||
Target,
|
||||
controller::tests::{fixture, packet},
|
||||
},
|
||||
};
|
||||
use iced::{Point, Size, Vector};
|
||||
|
||||
fn setup() -> (App, Id, Id) {
|
||||
let (document, owner, diagram, lane, message) = fixture();
|
||||
let mut app = App::with_project(document.project().clone());
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Diagram(Choice {
|
||||
id: diagram,
|
||||
label: "Saved".into(),
|
||||
}));
|
||||
let c = app.scenario_context().unwrap();
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
|
||||
c,
|
||||
Size::new(900., 600.),
|
||||
)));
|
||||
assert!(app.spacing_enabled());
|
||||
(app, lane, message)
|
||||
}
|
||||
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
|
||||
let event = packet(
|
||||
app.project(),
|
||||
&app.behavior.scenario,
|
||||
app.scenario_context().unwrap(),
|
||||
app.camera_epoch,
|
||||
target,
|
||||
);
|
||||
let Event::Begin {
|
||||
press_screen,
|
||||
scale,
|
||||
..
|
||||
} = event
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
|
||||
(event, press_screen, scale)
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Spacing(event)));
|
||||
}
|
||||
fn move_to(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Move {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen,
|
||||
},
|
||||
);
|
||||
}
|
||||
fn finish(app: &mut App, begin: Event, screen: Point) {
|
||||
send(
|
||||
app,
|
||||
Event::Finish {
|
||||
stamp: begin.stamp(),
|
||||
token: begin.token(),
|
||||
screen,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_batch_preview_is_immutable_and_one_commit_undo_redo_has_exact_source_parity() {
|
||||
let (mut app, lane, _) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let source = app.source_workspace.bundle();
|
||||
let selected = app.selected;
|
||||
let epoch = app.canvas_epoch;
|
||||
let (event, start, scale) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(40., 300.));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
finish(&mut app, event, start + Vector::new(40., 300.));
|
||||
assert_eq!(app.selected, lane);
|
||||
assert!(app.document.can_undo() && !app.document.can_redo());
|
||||
let accepted = app.document.snapshot();
|
||||
assert_ne!(accepted.bundle, original.bundle);
|
||||
let expected = behavior::scenario::spacing::controller::tests::expected(
|
||||
&original.decoded.project,
|
||||
match event.stamp().context.representation {
|
||||
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
|
||||
_ => unreachable!(),
|
||||
},
|
||||
Target::Lane(lane),
|
||||
40. / scale,
|
||||
);
|
||||
assert_eq!(app.project(), &expected);
|
||||
let _ = app.update(Message::ShortcutUndo(false));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(!app.document.can_undo() && app.document.can_redo());
|
||||
let _ = app.update(Message::ShortcutUndo(true));
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn click_selects_but_crossed_return_and_cancel_preserve_selection_camera_and_history() {
|
||||
let (mut app, lane, message) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, Target::Message(message));
|
||||
finish(&mut app, event, start + Vector::new(30., 2.));
|
||||
assert_eq!(app.selected, message);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
for cancel in [false, true] {
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(30., 0.));
|
||||
if cancel {
|
||||
send(
|
||||
&mut app,
|
||||
Event::Cancel {
|
||||
stamp: event.stamp(),
|
||||
token: event.token(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
finish(&mut app, event, start);
|
||||
}
|
||||
assert_eq!(app.selected, message);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_finish_after_new_view_and_forged_token_are_inert_before_reveal_reconciliation() {
|
||||
let (mut app, lane, _) = setup();
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
let held_epoch = app.canvas_epoch;
|
||||
send(
|
||||
&mut app,
|
||||
Event::Finish {
|
||||
stamp: event.stamp(),
|
||||
token: Id::new_v4(),
|
||||
screen: start + Vector::new(30., 0.),
|
||||
},
|
||||
);
|
||||
assert_eq!(app.canvas_epoch, held_epoch);
|
||||
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
|
||||
let owner = event.stamp().context.scenario;
|
||||
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
|
||||
event.stamp().context,
|
||||
behavior::scenario_layout::Representation::Derived { owner },
|
||||
)));
|
||||
let current = app.scenario_context();
|
||||
let epoch = app.canvas_epoch;
|
||||
let original = app.document.snapshot();
|
||||
finish(&mut app, event, start + Vector::new(50., 0.));
|
||||
assert_eq!(app.scenario_context(), current);
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_write_failure_cannot_commit_candidate_or_materialize_camera() {
|
||||
let (mut app, lane, _) = setup();
|
||||
let bundle = app.document.bundle().clone();
|
||||
let mut limits = oa_core::source_project::ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum();
|
||||
app.document = Document::from_bundle(bundle, limits).unwrap();
|
||||
app.sync();
|
||||
let original = app.document.snapshot();
|
||||
let selected = app.selected;
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(40., 0.));
|
||||
finish(&mut app, event, start + Vector::new(40., 0.));
|
||||
assert!(
|
||||
app.status.starts_with("Spacing was not applied:"),
|
||||
"{}",
|
||||
app.status
|
||||
);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_semantic_draft_and_source_edit_cancel_and_reject_held_finish() {
|
||||
for source in [false, true] {
|
||||
let (mut app, lane, _) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let selected = app.selected;
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(30., 0.));
|
||||
if source {
|
||||
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
|
||||
text_editor::Edit::Insert(' '),
|
||||
)));
|
||||
assert!(app.source_dirty());
|
||||
} else {
|
||||
let _ = app.update(Message::Behavior(behavior::Event::Scenario(
|
||||
behavior::scenario::Event::Start(
|
||||
event.stamp().context.scenario,
|
||||
behavior::scenario::Tool::Lifeline,
|
||||
),
|
||||
)));
|
||||
assert!(app.behavior.has_pending());
|
||||
}
|
||||
let draft = app.source_workspace.bundle();
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
finish(&mut app, event, start + Vector::new(30., 0.));
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), draft);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_finish_cannot_remove_new_same_view_deferred_reveal() {
|
||||
let (mut app, lane, message) = setup();
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
let behavior::scenario_layout::Representation::Saved { diagram } =
|
||||
event.stamp().context.representation
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let _ = app.update(Message::RevealInDiagram(diagram, message));
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
let epoch = app.canvas_epoch;
|
||||
let selected = app.selected;
|
||||
let visit = app.pending_scenario_reveal_visit;
|
||||
let original = app.document.snapshot();
|
||||
finish(&mut app, event, start + Vector::new(50., 0.));
|
||||
assert_eq!(app.canvas_epoch, epoch);
|
||||
assert_eq!(app.pending_scenario_reveal_visit, visit);
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_open_cancels_preview_and_captured_begin_stays_retired_after_close() {
|
||||
let (mut app, lane, _) = setup();
|
||||
let original = app.document.snapshot();
|
||||
let camera = app.behavior.scenario.camera_boundary();
|
||||
let (event, start, _) = begin(&mut app, Target::Lane(lane));
|
||||
move_to(&mut app, event, start + Vector::new(30., 0.));
|
||||
let _ = app.update(Message::Delete);
|
||||
assert!(app.delete_pending);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
finish(&mut app, event, start + Vector::new(30., 0.));
|
||||
let _ = app.update(Message::CancelDelete);
|
||||
send(&mut app, event);
|
||||
assert!(app.behavior.scenario.spacing_token().is_none());
|
||||
assert_eq!(app.project(), &original.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &original.bundle);
|
||||
assert!(camera == app.behavior.scenario.camera_boundary());
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
@@ -49,6 +49,7 @@ pub enum Event {
|
||||
Line(Id, Id, bool),
|
||||
Segment(Id, Id, bool),
|
||||
Details(Id, bool),
|
||||
PreviewScrolled(Id, Id, Id, f32),
|
||||
DetailSegment(Id, bool),
|
||||
ReadOnly(Id, text_editor::Action),
|
||||
Undo(Id),
|
||||
@@ -112,6 +113,7 @@ struct Form {
|
||||
line: usize,
|
||||
segment: usize,
|
||||
preview_destination: Id,
|
||||
preview_offset: f32,
|
||||
details: bool,
|
||||
detail_segment: usize,
|
||||
detail_editor: text_editor::Content,
|
||||
@@ -170,6 +172,17 @@ impl App {
|
||||
return false;
|
||||
};
|
||||
match event {
|
||||
Event::PreviewScrolled(invocation, approval, destination, offset) => {
|
||||
*invocation == form.invocation
|
||||
&& !form.details
|
||||
&& form
|
||||
.approval
|
||||
.as_ref()
|
||||
.is_some_and(|ready| ready.id == *approval)
|
||||
&& *destination == form.preview_destination
|
||||
&& offset.is_finite()
|
||||
&& *offset >= 0.
|
||||
}
|
||||
Event::Preview(invocation, generation) => {
|
||||
*invocation == form.invocation && *generation == form.generation
|
||||
}
|
||||
@@ -202,7 +215,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn rename_admission(&self) -> Result<(), String> {
|
||||
pub(super) fn rename_admission(&self) -> Result<(), String> {
|
||||
if self.page != Page::Text
|
||||
|| self.busy
|
||||
|| self.pending.is_some()
|
||||
@@ -435,11 +448,23 @@ impl App {
|
||||
}
|
||||
form.details = value;
|
||||
// Leaving or returning to the preview ends the old visit. Keep
|
||||
// its selected page and retained scroll; only retire delayed
|
||||
// operations, without scheduling another return-to-top.
|
||||
// its selected page and scroll; the Details widget replaces the
|
||||
// preview subtree, so restore the reported offset on return.
|
||||
form.preview_destination = Id::new_v4();
|
||||
form.detail_segment = 0;
|
||||
refresh_details(form);
|
||||
if !value && let Some(ready) = &form.approval {
|
||||
return iced::widget::operation::scroll_to(
|
||||
preview_scroll_id(form, ready.id),
|
||||
iced::widget::scrollable::AbsoluteOffset {
|
||||
x: None,
|
||||
y: Some(form.preview_offset),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Event::PreviewScrolled(_, _, _, offset) => {
|
||||
self.source_rename.form.as_mut().unwrap().preview_offset = offset;
|
||||
}
|
||||
Event::DetailSegment(_, next) => {
|
||||
let form = self.source_rename.form.as_mut().unwrap();
|
||||
@@ -516,6 +541,7 @@ impl App {
|
||||
line: 0,
|
||||
segment: 0,
|
||||
preview_destination: Id::new_v4(),
|
||||
preview_offset: 0.,
|
||||
details: false,
|
||||
detail_segment: 0,
|
||||
detail_editor: text_editor::Content::new(),
|
||||
@@ -953,6 +979,7 @@ fn input_id(invocation: Id) -> iced::widget::Id {
|
||||
// or changing a filter that leaves the page tuple unchanged (the ABA case).
|
||||
fn reset_preview_position(form: &mut Form) -> Task<Message> {
|
||||
form.preview_destination = Id::new_v4();
|
||||
form.preview_offset = 0.;
|
||||
if !form.details
|
||||
&& let Some(ready) = &form.approval
|
||||
{
|
||||
@@ -1118,7 +1145,15 @@ where
|
||||
pager(format!("Row segments {}/{} · both sides", form.segment + 1, segments.max(1)),
|
||||
(form.segment > 0).then(|| message(Event::Segment(invocation, preview_id, false))),
|
||||
(form.segment + 1 < segments).then(|| message(Event::Segment(invocation, preview_id, true)))),
|
||||
scrollable(displayed).id(preview_scroll_id(form, preview_id)).height(Fill).width(Fill).spacing(8),
|
||||
scrollable(displayed)
|
||||
.id(preview_scroll_id(form, preview_id))
|
||||
.on_scroll({
|
||||
let destination = form.preview_destination;
|
||||
move |viewport| message(Event::PreviewScrolled(
|
||||
invocation, preview_id, destination, viewport.absolute_offset().y,
|
||||
))
|
||||
})
|
||||
.height(Fill).width(Fill).spacing(8),
|
||||
].spacing(6).height(Fill).into()
|
||||
} else {
|
||||
scrollable(column![
|
||||
|
||||
@@ -4,6 +4,9 @@ use super::*;
|
||||
use crate::diagram_usage::{self as usage, Surface, Target as UsageTarget, Usage};
|
||||
use iced::{Size, Vector};
|
||||
|
||||
#[path = "navigation_union_tests/palette_union.rs"]
|
||||
mod palette_union;
|
||||
|
||||
fn model_id(number: u8) -> Id {
|
||||
Id::parse_str(&format!("00000000-0000-0000-0000-{number:012}")).unwrap()
|
||||
}
|
||||
@@ -356,7 +359,7 @@ fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arriv
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
|
||||
send(&mut app, Message::SourceSymbolsReady(source_ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(!app.source_symbols.open);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
send(&mut app, Message::DiagramUsagesReady(old_diagram));
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(new_token));
|
||||
assert!(app.diagram_usages.as_ref().unwrap().loading);
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! Interactions absent from the independently published navigation branch.
|
||||
use super::*;
|
||||
|
||||
fn assert_stale_navigation_message(message: Message) {
|
||||
let mut app = navigation_fixture();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
let status = app.status.clone();
|
||||
send(&mut app, message);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert_eq!(app.status, status);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_old_source_query_and_find_close_preserve_the_new_destination() {
|
||||
let mut previous = navigation_fixture();
|
||||
let query = previous.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(
|
||||
&mut previous,
|
||||
Message::SourceNavigation(nav::Event::Open(false)),
|
||||
);
|
||||
assert!(previous.source_navigation.open);
|
||||
let close = previous.source_find_close_message();
|
||||
assert!(previous.source_input_envelope(&close));
|
||||
assert_stale_navigation_message(query);
|
||||
assert_stale_navigation_message(close);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_source_query_and_checked_find_close_remain_live() {
|
||||
let mut app = navigation_fixture();
|
||||
let before = app.document.snapshot();
|
||||
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
|
||||
assert!(app.source_navigation.open);
|
||||
let close = app.source_find_close_message();
|
||||
send(&mut app, close.clone());
|
||||
assert!(!app.source_navigation.open);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
send(&mut app, close);
|
||||
assert_eq!(app.allocation_drag.epoch, epoch, "retired close is inert");
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
|
||||
send(&mut app, message);
|
||||
assert!(app.source_symbols.in_flight.is_some());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_old_chooser_context_and_every_token_variant_are_inert() {
|
||||
let mut previous = navigation_fixture();
|
||||
let open = previous.diagram_usage_message(model_id(2));
|
||||
send(&mut previous, open.clone());
|
||||
let (token, found) = finish_lookup(&mut previous);
|
||||
assert_stale_navigation_message(open);
|
||||
for event in [
|
||||
usage::Event::Close(token),
|
||||
usage::Event::Search(token, "old filter".into()),
|
||||
usage::Event::Page(token, 1),
|
||||
usage::Event::Move(token, 1),
|
||||
usage::Event::Activate(token),
|
||||
usage::Event::Destination(token, Some(found.surface)),
|
||||
usage::Event::Choose(token, found),
|
||||
] {
|
||||
assert_stale_navigation_message(Message::DiagramUsage(event));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_chooser_open_and_close_keep_their_boundary_and_worker_semantics() {
|
||||
let mut app = navigation_fixture();
|
||||
navigate(&mut app);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
let message = app.diagram_usage_message(model_id(2));
|
||||
send(&mut app, message);
|
||||
assert!(app.diagram_usages.is_some());
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.diagram_navigation_focus.is_none());
|
||||
assert!(app.allocation_drag.epoch > epoch);
|
||||
let completion = diagram_completion(&app);
|
||||
let token = completion.token;
|
||||
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
|
||||
assert!(app.diagram_usages.is_none());
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(token));
|
||||
send(&mut app, Message::DiagramUsagesReady(completion));
|
||||
assert!(app.diagram_lookup_in_flight.is_none());
|
||||
assert!(
|
||||
app.diagram_usages.is_none(),
|
||||
"completion does not reopen a closed chooser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_keyboard_notification_preserves_navigation_and_first_measurement() {
|
||||
let mut app = navigation_fixture();
|
||||
let before = app.document.snapshot();
|
||||
let notify = || Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary);
|
||||
send(&mut app, notify());
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
send(&mut app, notify());
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
first_measurement(&mut app, &found);
|
||||
send(&mut app, notify());
|
||||
assert!(app.diagram_focus(found.surface).is_some());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_rename_begin_cannot_retire_allocation_epoch_or_new_navigation() {
|
||||
let mut app = navigation_fixture();
|
||||
let captured = app.source_rename_begin();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Message::SourceRename(Event::Begin(captured)));
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn captured_behavior_toolbar_cannot_retire_a_new_documents_navigation() {
|
||||
let mut project = Project::empty();
|
||||
let scenario = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Prior scenario", None)
|
||||
.unwrap();
|
||||
let mut previous = App::with_project(project);
|
||||
previous.page = Page::Behavior;
|
||||
previous.behavior.active_owner = Some(scenario);
|
||||
previous
|
||||
.behavior
|
||||
.scenario
|
||||
.enter(previous.canvas_document, scenario);
|
||||
let scope = behavior::canvas_workspace::Scope::input_owner(&previous).unwrap();
|
||||
let captured = behavior::canvas_workspace::intent(
|
||||
scope,
|
||||
"Fit",
|
||||
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Zoom(
|
||||
previous.scenario_context().unwrap(),
|
||||
None,
|
||||
)),
|
||||
);
|
||||
|
||||
let mut app = navigation_fixture();
|
||||
let found = navigate(&mut app);
|
||||
let destination = navigation(&app);
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, captured);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
fn stale_compact_drawer_controls_preserve_current_navigation_and_evidence() {
|
||||
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
|
||||
for changed in 0..3 {
|
||||
let mut app = navigation_fixture();
|
||||
app.text_workspace.show_drawer(Some(DrawerTab::Status));
|
||||
let origin = app.text_drawer_context();
|
||||
let captured = [
|
||||
Message::TextWorkspace(TextEvent::ShowDrawer(origin, DrawerTab::Problems)),
|
||||
Message::TextWorkspace(TextEvent::HideDrawer(origin)),
|
||||
crate::views::captured_text_drawer_resize(&app),
|
||||
];
|
||||
match changed {
|
||||
0 => app.canvas_document = Id::new_v4(),
|
||||
1 => app.source_workspace.reset_navigation_session(),
|
||||
_ => app.text_workspace.show_drawer(Some(DrawerTab::Status)),
|
||||
}
|
||||
let found = navigate(&mut app);
|
||||
// Keep command availability true to isolate the stale owner check from
|
||||
// the page guard, while a newer measured navigation is still pending.
|
||||
app.page = Page::Text;
|
||||
assert!(app.source_navigation_available());
|
||||
let destination = navigation(&app);
|
||||
let allocation = app.allocation_boundary();
|
||||
let camera = app.camera_boundary();
|
||||
let allocation_epoch = app.allocation_drag.epoch;
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let drawer = app.text_workspace.drawer();
|
||||
let height = app.text_workspace.drawer_height();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let status = app.status.clone();
|
||||
for message in captured {
|
||||
send(&mut app, message);
|
||||
assert_eq!(navigation(&app), destination);
|
||||
assert!(app.allocation_boundary() == allocation);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert_eq!(app.allocation_drag.epoch, allocation_epoch);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.text_workspace.drawer(), drawer);
|
||||
assert_eq!(app.text_workspace.drawer_height(), height);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.status, status);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
app.page = Page::Diagram;
|
||||
first_measurement(&mut app, &found);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
fn current_compact_drawer_controls_and_mounted_resize_remain_live() {
|
||||
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
|
||||
let mut app = navigation_fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
navigate(&mut app);
|
||||
app.page = Page::Text;
|
||||
assert!(app.pending_diagram_reveal.is_some());
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
let show = Message::TextWorkspace(TextEvent::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Status,
|
||||
));
|
||||
send(&mut app, show);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status));
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert!(app.diagram_navigation_focus.is_none());
|
||||
assert!(app.allocation_drag.epoch > epoch);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
let height = app.text_workspace.drawer_height();
|
||||
let resize = crate::views::captured_text_drawer_resize(&app);
|
||||
send(&mut app, resize);
|
||||
assert_eq!(app.text_workspace.drawer_height(), height + 60.);
|
||||
assert!(app.allocation_drag.epoch > epoch);
|
||||
let epoch = app.allocation_drag.epoch;
|
||||
let hide = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
|
||||
send(&mut app, hide);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(app.allocation_drag.epoch > epoch);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_diagram_usages_command_uses_captured_query_and_existing_chooser() {
|
||||
let mut app = navigation_fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let command = app.text_command_message(crate::app::text_workspace::Command::ShowDiagramUsages);
|
||||
send(&mut app, command);
|
||||
let request = app
|
||||
.source_symbols
|
||||
.request
|
||||
.clone()
|
||||
.expect("actual source query started");
|
||||
assert_eq!(app.source_diagram_job, Some(request.job));
|
||||
let completion = source_completion(&app, request);
|
||||
send(&mut app, Message::SourceSymbolsReady(completion));
|
||||
let panel = app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.expect("existing guarded chooser opened");
|
||||
assert_eq!(panel.context.subject, model_id(2));
|
||||
assert_eq!(panel.context.document, app.canvas_document);
|
||||
assert_eq!(panel.context.revision, app.document.revision());
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(panel.token));
|
||||
assert!(!app.source_navigation_available());
|
||||
let (_, found) = finish_lookup(&mut app);
|
||||
assert_eq!(found.surface, usage::Surface::Saved(model_id(8)));
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
@@ -50,6 +50,7 @@ fn scroll_id(app: &App) -> widget::Id {
|
||||
struct Geometry {
|
||||
id: widget::Id,
|
||||
offset: Option<Vector>,
|
||||
bounds: Option<Rectangle>,
|
||||
}
|
||||
impl widget::Operation for Geometry {
|
||||
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
|
||||
@@ -58,13 +59,14 @@ impl widget::Operation for Geometry {
|
||||
fn scrollable(
|
||||
&mut self,
|
||||
id: Option<&widget::Id>,
|
||||
_: Rectangle,
|
||||
bounds: Rectangle,
|
||||
_: Rectangle,
|
||||
offset: Vector,
|
||||
_: &mut dyn widget::operation::Scrollable,
|
||||
) {
|
||||
if id == Some(&self.id) {
|
||||
self.offset = Some(offset);
|
||||
self.bounds = Some(bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +97,7 @@ impl Mounted {
|
||||
let mut geometry = Geometry {
|
||||
id: scroll_id(app),
|
||||
offset: None,
|
||||
bounds: None,
|
||||
};
|
||||
self.operate(app, &mut geometry);
|
||||
geometry.offset.expect("mounted preview scrollable").y
|
||||
@@ -114,6 +117,41 @@ impl Mounted {
|
||||
assert!(offset > 10., "fixture must really be scrolled");
|
||||
offset
|
||||
}
|
||||
fn wheel(&mut self, app: &App) -> Vec<Message> {
|
||||
let mut geometry = Geometry {
|
||||
id: scroll_id(app),
|
||||
offset: None,
|
||||
bounds: None,
|
||||
};
|
||||
self.operate(app, &mut geometry);
|
||||
let position = geometry.bounds.expect("visible preview bounds").center();
|
||||
let mut view = dialog_content(app, &self.editor);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
let mut shell = iced::advanced::Shell::new(&mut messages);
|
||||
view.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
|
||||
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -120. },
|
||||
}),
|
||||
Layout::new(&node),
|
||||
iced::mouse::Cursor::Available(position),
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&Rectangle::with_size(HOST),
|
||||
);
|
||||
assert!(
|
||||
shell.is_event_captured(),
|
||||
"preview must consume wheel input"
|
||||
);
|
||||
messages
|
||||
}
|
||||
async fn execute(&mut self, app: &App, task: Task<Message>) -> usize {
|
||||
let mut count = 0;
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
@@ -337,6 +375,7 @@ async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated
|
||||
let mut hidden = Geometry {
|
||||
id: old_preview.clone(),
|
||||
offset: None,
|
||||
bounds: None,
|
||||
};
|
||||
mounted.operate(&app, &mut hidden);
|
||||
assert!(
|
||||
@@ -355,8 +394,8 @@ async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated
|
||||
let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
||||
assert_eq!(
|
||||
mounted.execute(&app, show_preview).await,
|
||||
0,
|
||||
"returning does not force a scroll reset"
|
||||
1,
|
||||
"returning restores the reported offset in the new preview subtree"
|
||||
);
|
||||
let returned_visit = scroll_id(&app);
|
||||
assert_ne!(returned_visit, old_preview);
|
||||
@@ -383,3 +422,135 @@ async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn details_remount_restores_wheel_offset_before_any_rescroll() {
|
||||
for mount_details in [true, false] {
|
||||
let mut app = paged_fixture();
|
||||
let before = evidence(&app);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
let invocation = form.invocation;
|
||||
let approval = form.approval.as_ref().unwrap().id;
|
||||
let page = (form.file, form.hunk, form.line, form.segment);
|
||||
let mut mounted = Mounted::new(&app);
|
||||
let reports = mounted.wheel(&app);
|
||||
assert_eq!(reports.len(), 1, "one actual preview scroll report");
|
||||
let reported_offset = match &reports[0] {
|
||||
Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) => *offset,
|
||||
_ => panic!("expected an actual preview scroll report"),
|
||||
};
|
||||
assert!(reported_offset.is_finite() && reported_offset > 0.);
|
||||
let offset = mounted.offset(&app);
|
||||
// Iced reports fractional absolute offsets but exposes rounded display
|
||||
// translation to widget operations. Preserve both representations exactly.
|
||||
assert_eq!(reported_offset.round(), offset);
|
||||
assert!(
|
||||
offset > 10.,
|
||||
"real wheel input must move the overflowing fixture"
|
||||
);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let camera = app.camera_boundary();
|
||||
let allocation = app.allocation_boundary();
|
||||
for message in reports.iter().cloned() {
|
||||
let task = app.update(message);
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
}
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert!(app.allocation_boundary() == allocation);
|
||||
assert_eq!(evidence(&app), before);
|
||||
assert_eq!(
|
||||
app.source_rename.form.as_ref().unwrap().preview_offset,
|
||||
reported_offset
|
||||
);
|
||||
|
||||
let details = app.update(Message::SourceRename(Event::Details(invocation, true)));
|
||||
assert_eq!(mounted.execute(&app, details).await, 0);
|
||||
if mount_details {
|
||||
let mut hidden = Geometry {
|
||||
id: scroll_id(&app),
|
||||
offset: None,
|
||||
bounds: None,
|
||||
};
|
||||
mounted.operate(&app, &mut hidden);
|
||||
assert!(hidden.offset.is_none(), "Details replaced the Scrollable");
|
||||
}
|
||||
// A queued report from the hidden preview cannot overwrite its saved offset.
|
||||
let Message::SourceRename(Event::PreviewScrolled(
|
||||
old_invocation,
|
||||
old_approval,
|
||||
old_visit,
|
||||
_,
|
||||
)) = reports[0].clone()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let stale = Event::PreviewScrolled(old_invocation, old_approval, old_visit, offset + 100.);
|
||||
assert!(!app.source_rename_envelope(&stale));
|
||||
let task = app.update(Message::SourceRename(stale.clone()));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
|
||||
let returned = app.update(Message::SourceRename(Event::Details(invocation, false)));
|
||||
assert_eq!(mounted.execute(&app, returned).await, 1);
|
||||
// This assertion precedes any new scroll: the former test scrolled again here.
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
let form = app.source_rename.form.as_ref().unwrap();
|
||||
assert_eq!((form.file, form.hunk, form.line, form.segment), page);
|
||||
assert_eq!(form.approval.as_ref().unwrap().id, approval);
|
||||
assert_eq!(form.preview_offset, reported_offset);
|
||||
assert!(!app.source_rename_envelope(&stale));
|
||||
let task = app.update(Message::SourceRename(stale));
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
assert_eq!(mounted.offset(&app), offset);
|
||||
|
||||
let next = app.update(Message::SourceRename(Event::File(
|
||||
invocation, approval, true,
|
||||
)));
|
||||
assert_eq!(mounted.execute(&app, next).await, 1);
|
||||
assert_eq!(mounted.offset(&app), 0.);
|
||||
assert_eq!(app.source_rename.form.as_ref().unwrap().preview_offset, 0.);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn diagnostic_publication_during_rename_keeps_actual_preview_report_passive() {
|
||||
let mut app = paged_fixture();
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let before = evidence(&app);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let camera = app.camera_boundary();
|
||||
let allocation = app.allocation_boundary();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let mut mounted = Mounted::new(&app);
|
||||
let reports = mounted.wheel(&app);
|
||||
assert_eq!(reports.len(), 1);
|
||||
let Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) = &reports[0] else {
|
||||
panic!("actual wheel must publish the retained preview measurement");
|
||||
};
|
||||
assert!(offset.is_finite() && *offset > 0.);
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let current = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_ne!(current.generation(), publication.generation());
|
||||
assert_eq!(current.source(), publication.source());
|
||||
assert!(app.source_annotation_snapshot().is_some());
|
||||
assert!(
|
||||
!app.source_navigation_available(),
|
||||
"rename still owns modal input"
|
||||
);
|
||||
for message in reports.iter().cloned() {
|
||||
let task = app.update(message);
|
||||
assert_eq!(mounted.execute(&app, task).await, 0);
|
||||
}
|
||||
assert_eq!(
|
||||
app.source_rename.form.as_ref().unwrap().preview_offset,
|
||||
*offset
|
||||
);
|
||||
assert_eq!(mounted.offset(&app), offset.round());
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.camera_boundary() == camera);
|
||||
assert!(app.allocation_boundary() == allocation);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(evidence(&app), before);
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_hist
|
||||
assert_ne!(app.source_navigation.label(), "0 matches");
|
||||
let mut invalid = original_source.clone();
|
||||
invalid.files.insert(path.clone(), "?".into());
|
||||
app.source_diagnostics = source_workspace::lint(&invalid);
|
||||
app.publish_source_diagnostics(source_workspace::lint(&invalid));
|
||||
assert!(!app.source_diagnostics.is_empty());
|
||||
let version = app.source_version;
|
||||
app.lint_requested = Some(version);
|
||||
@@ -804,13 +804,25 @@ fn captured_escape(app: &App) -> Vec<Message> {
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let mut messages = Vec::new();
|
||||
let mut keyboard_boundaries = 0;
|
||||
for recipe in subscription::into_recipes(app.subscription()) {
|
||||
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
|
||||
let mut context = PollContext::from_waker(noop_waker_ref());
|
||||
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut context) {
|
||||
messages.push(message);
|
||||
if matches!(
|
||||
&message,
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
|
||||
) {
|
||||
keyboard_boundaries += 1;
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
keyboard_boundaries, 1,
|
||||
"each key has exactly one passive allocation notification"
|
||||
);
|
||||
messages
|
||||
}
|
||||
|
||||
@@ -827,7 +839,8 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
send(&mut app, Message::SourceRenameReady(ready.clone()));
|
||||
}
|
||||
app.source_navigation.open = true;
|
||||
app.source_symbols.open = true;
|
||||
app.text_workspace
|
||||
.show_drawer(Some(crate::app::text_workspace::DrawerTab::References));
|
||||
match other_modal {
|
||||
1 => app.hosted.visible = true,
|
||||
2 => {
|
||||
@@ -843,16 +856,18 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
let before = evidence(&app);
|
||||
let mut messages = captured_escape(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
if other_modal != 0 {
|
||||
assert!(app.source_rename.open(), "existing modal has priority");
|
||||
let mut messages = captured_escape(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
}
|
||||
assert!(!app.source_rename.open());
|
||||
assert!(
|
||||
app.source_symbols.open && app.source_navigation.open,
|
||||
app.text_workspace.drawer().is_some() && app.source_navigation.open,
|
||||
"rename Escape does not fall through"
|
||||
);
|
||||
if pending {
|
||||
@@ -861,7 +876,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
|
||||
assert!(!app.source_rename.open());
|
||||
}
|
||||
assert_eq!(evidence(&app), before);
|
||||
app.source_symbols.open = false;
|
||||
app.text_workspace.show_drawer(None);
|
||||
app.source_navigation.open = false;
|
||||
assert!(captured_escape(&app).is_empty());
|
||||
}
|
||||
|
||||
@@ -31,6 +31,44 @@ fn admission_notice(error: query::AdmissionError) -> String {
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
|
||||
match message {
|
||||
Message::SourceSymbols(context, event) => {
|
||||
context.session == self.source_workspace.session()
|
||||
&& context.path == self.source_workspace.active
|
||||
&& match event {
|
||||
nav::SymbolEvent::Choose { stamp, visit, .. } => {
|
||||
self.text_workspace.drawer()
|
||||
== Some(text_workspace::DrawerTab::References)
|
||||
&& self.source_navigation_available()
|
||||
&& stamp.snapshot == self.source_key()
|
||||
&& *visit == self.text_workspace.drawer_visit()
|
||||
&& self
|
||||
.source_symbols
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(|results| results.stamp == *stamp)
|
||||
&& self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.is_some_and(|cache| self.source_snapshot_matches(cache))
|
||||
}
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
Message::SourceFindClose(context, instance) => {
|
||||
context == &self.source_symbol_context()
|
||||
&& *instance == self.source_find_instance
|
||||
&& self.source_navigation.open
|
||||
&& self.source_navigation_available()
|
||||
}
|
||||
// The checked Find-close envelope is mapped inside update_inner.
|
||||
Message::SourceNavigation(nav::Event::Close) => false,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source_symbol_context(&self) -> nav::SymbolContext {
|
||||
nav::SymbolContext {
|
||||
session: self.source_workspace.session(),
|
||||
@@ -88,6 +126,20 @@ impl App {
|
||||
&& self.with_source_input(|input| snapshot.matches(input))
|
||||
}
|
||||
|
||||
/// Retained rows remain readable after departure, but are current only while
|
||||
/// their captured source bytes still match the complete workspace.
|
||||
pub fn source_results_current(&self) -> bool {
|
||||
self.source_symbols
|
||||
.results
|
||||
.as_ref()
|
||||
.is_some_and(|results| results.stamp.snapshot == self.source_key())
|
||||
&& self
|
||||
.source_symbols
|
||||
.cache
|
||||
.as_ref()
|
||||
.is_some_and(|cache| self.source_snapshot_matches(cache))
|
||||
}
|
||||
|
||||
pub fn source_navigation_available(&self) -> bool {
|
||||
self.page == Page::Text
|
||||
&& !self.source_rename.open()
|
||||
@@ -100,6 +152,7 @@ impl App {
|
||||
&& self.model_menu.is_none()
|
||||
&& self.diagram_usages.is_none()
|
||||
&& !self.reference_input_active()
|
||||
&& self.text_workspace.menu().is_none()
|
||||
&& self.architecture.is_none()
|
||||
&& !self.behavior.has_pending()
|
||||
}
|
||||
@@ -115,6 +168,12 @@ impl App {
|
||||
))
|
||||
}
|
||||
|
||||
/// Capture the same source-owned query used by the keyboard/menu adapter.
|
||||
pub(super) fn text_diagram_usages_message(&self) -> Option<Message> {
|
||||
self.source_navigation_available()
|
||||
.then(|| self.source_symbol_message(nav::SymbolEvent::DiagramUsages))
|
||||
}
|
||||
|
||||
pub fn source_reveal_ticket(&self) -> Option<Id> {
|
||||
self.source_symbols
|
||||
.reveal
|
||||
@@ -147,6 +206,10 @@ impl App {
|
||||
&& nav::exact_cursor(self.source_workspace.text(), cursor)
|
||||
{
|
||||
self.queue_source_reveal(cursor, false);
|
||||
// This ticket restores focus to the live editor, not a saved cursor.
|
||||
// Iced's Cursor omits Word/Line selection kind; moving back to that
|
||||
// lossy value would collapse an actual word/line selection.
|
||||
self.source_jump = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -176,13 +239,14 @@ impl App {
|
||||
if self.source_select_line && cursor.selection.is_none() {
|
||||
self.source_editor.perform(text_editor::Action::SelectLine);
|
||||
}
|
||||
return nav::interaction::guarded_focus(
|
||||
self.source_editor_id(),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
raw_interaction,
|
||||
);
|
||||
}
|
||||
Task::none()
|
||||
// A valid ticket with no jump is focus-only. All intentional navigation
|
||||
// still takes the cursor branch above, including explicit line selection.
|
||||
nav::interaction::guarded_focus(
|
||||
self.source_editor_id(),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
raw_interaction,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
|
||||
@@ -192,7 +256,8 @@ impl App {
|
||||
match event {
|
||||
nav::SymbolEvent::DiagramUsages => {
|
||||
if self.source_dirty() || !self.source_workspace.applied {
|
||||
self.source_symbols.open = true;
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
|
||||
return Task::none();
|
||||
}
|
||||
@@ -204,21 +269,28 @@ impl App {
|
||||
.map(|request| request.job);
|
||||
return task;
|
||||
}
|
||||
nav::SymbolEvent::Close => {
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
}
|
||||
nav::SymbolEvent::Back => {
|
||||
self.source_symbol_back();
|
||||
// A successful Back needs no empty results panel. Retain an
|
||||
// already open panel, or reveal an actionable return notice.
|
||||
self.source_symbols.open |= self.source_symbols.notice.is_some();
|
||||
if self.source_symbols.notice.is_some() {
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
let refs = self.text_workspace.references();
|
||||
return iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
nav::SymbolEvent::Choose {
|
||||
stamp,
|
||||
reference,
|
||||
index,
|
||||
..
|
||||
} => {
|
||||
let Some(results) = self.source_symbols.results.clone() else {
|
||||
return Task::none();
|
||||
@@ -256,7 +328,8 @@ impl App {
|
||||
}
|
||||
|
||||
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
|
||||
self.source_symbols.open = true;
|
||||
self.text_workspace
|
||||
.show_drawer(Some(text_workspace::DrawerTab::References));
|
||||
if !nav::interaction::is_current(
|
||||
&self.source_symbols.raw_interaction,
|
||||
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
|
||||
@@ -277,7 +350,7 @@ impl App {
|
||||
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
|
||||
Some(serial) => serial,
|
||||
None => {
|
||||
self.source_workspace.reset_navigation_session();
|
||||
self.reset_source_navigation_session();
|
||||
self.source_symbols.back.clear();
|
||||
self.source_symbols.cache = None;
|
||||
0
|
||||
@@ -317,9 +390,12 @@ impl App {
|
||||
};
|
||||
self.source_symbols.in_flight = Some(request.job);
|
||||
self.source_symbols.request = Some(request.clone());
|
||||
self.source_symbols.display_request = Some(request.clone());
|
||||
self.text_workspace
|
||||
.begin_references(request.stamp, request.path.clone());
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
Task::perform(
|
||||
let worker = Task::perform(
|
||||
async move {
|
||||
let worker_request = request.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -350,7 +426,14 @@ impl App {
|
||||
nav::Completion { request, result }
|
||||
},
|
||||
Message::SourceSymbolsReady,
|
||||
)
|
||||
);
|
||||
Task::batch([
|
||||
worker,
|
||||
iced::widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
iced::widget::scrollable::AbsoluteOffset { x: 0., y: 0. },
|
||||
),
|
||||
])
|
||||
}
|
||||
|
||||
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
|
||||
@@ -363,11 +446,11 @@ impl App {
|
||||
if diagram_query {
|
||||
self.source_diagram_job = None;
|
||||
}
|
||||
let wanted = self
|
||||
let wanted_navigation = self
|
||||
.source_symbols
|
||||
.request
|
||||
.take()
|
||||
.is_some_and(|current| current.job == request.job)
|
||||
.is_some_and(|current| current == request)
|
||||
&& request.interaction == self.source_symbols.interaction
|
||||
&& request.stamp.snapshot == self.source_key()
|
||||
&& nav::interaction::is_current(
|
||||
@@ -377,15 +460,34 @@ impl App {
|
||||
&& request.path == self.source_workspace.active
|
||||
&& request.cursor == self.source_editor.cursor()
|
||||
&& self.source_navigation_available();
|
||||
if !wanted {
|
||||
let wanted_display = self
|
||||
.source_symbols
|
||||
.display_request
|
||||
.as_ref()
|
||||
.is_some_and(|current| current == &request)
|
||||
&& request.stamp.snapshot == self.source_key();
|
||||
if !wanted_display {
|
||||
return Task::none();
|
||||
}
|
||||
match completion.result {
|
||||
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
|
||||
Ok((snapshot, results))
|
||||
if self.source_snapshot_matches(&snapshot)
|
||||
&& results.stamp == request.stamp
|
||||
&& results.action == request.action =>
|
||||
{
|
||||
self.source_symbols.cache = Some(snapshot);
|
||||
self.source_symbols.results = Some(results.clone());
|
||||
if diagram_query {
|
||||
if results.disposition == query::Disposition::Direct
|
||||
self.text_workspace.install_reference_files(
|
||||
results.stamp,
|
||||
results
|
||||
.definitions
|
||||
.iter()
|
||||
.map(|row| row.path.clone())
|
||||
.chain(results.references.iter().map(|row| row.path.clone())),
|
||||
);
|
||||
if diagram_query && wanted_navigation {
|
||||
if wanted_navigation
|
||||
&& results.disposition == query::Disposition::Direct
|
||||
&& results.scope.validated
|
||||
&& results.omitted_definitions == 0
|
||||
&& let [row] = results.definitions.as_slice()
|
||||
@@ -396,7 +498,7 @@ impl App {
|
||||
&& crate::diagram_usage::alias(self.project(), identity.id())
|
||||
== row.declaration.alias
|
||||
{
|
||||
self.source_symbols.open = false;
|
||||
self.text_workspace.show_drawer(None);
|
||||
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
|
||||
crate::diagram_usage::Context {
|
||||
document: self.canvas_document,
|
||||
@@ -408,7 +510,8 @@ impl App {
|
||||
self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into());
|
||||
return Task::none();
|
||||
}
|
||||
if results.disposition == query::Disposition::Direct
|
||||
if wanted_navigation
|
||||
&& results.disposition == query::Disposition::Direct
|
||||
&& let Some(row) = results.definitions.first()
|
||||
{
|
||||
self.source_symbol_jump(&row.path, row.position, row.end_position);
|
||||
@@ -443,6 +546,9 @@ impl App {
|
||||
Some("That source location is no longer available. Run navigation again.".into());
|
||||
return;
|
||||
}
|
||||
// The destination is admitted. Capture effective Word/Line ranges only
|
||||
// now, when this editor is actually about to leave its current position.
|
||||
let departure = nav::selection::capture(&mut self.source_editor);
|
||||
if let Some(buffer) = self
|
||||
.source_workspace
|
||||
.files
|
||||
@@ -452,7 +558,7 @@ impl App {
|
||||
session: self.source_workspace.session(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
generation: buffer.generation(),
|
||||
cursor: self.source_editor.cursor(),
|
||||
cursor: departure,
|
||||
};
|
||||
self.source_symbols
|
||||
.back
|
||||
@@ -462,8 +568,7 @@ impl App {
|
||||
}
|
||||
self.source_symbols.back.push(origin);
|
||||
}
|
||||
self.source_workspace
|
||||
.switch(path, self.source_editor.cursor());
|
||||
self.source_workspace.switch(path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
}
|
||||
@@ -478,7 +583,7 @@ impl App {
|
||||
return;
|
||||
};
|
||||
let unchanged = origin.generation == buffer.generation();
|
||||
let cursor = if unchanged {
|
||||
let mut cursor = if unchanged {
|
||||
origin.cursor
|
||||
} else if origin.path == self.source_workspace.active {
|
||||
self.source_editor.cursor()
|
||||
@@ -492,8 +597,11 @@ impl App {
|
||||
);
|
||||
return;
|
||||
}
|
||||
self.source_workspace
|
||||
.switch(&origin.path, self.source_editor.cursor());
|
||||
let departure = nav::selection::capture(&mut self.source_editor);
|
||||
if !unchanged && origin.path == self.source_workspace.active {
|
||||
cursor = departure;
|
||||
}
|
||||
self.source_workspace.switch(&origin.path, departure);
|
||||
self.show_source_file(false);
|
||||
self.queue_source_reveal(cursor, false);
|
||||
self.source_symbols.notice =
|
||||
@@ -506,3 +614,6 @@ impl App {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(all(test, debug_assertions))]
|
||||
mod focus_selection_tests;
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
//! The mounted CPU renderer and App use the same cosmic-text editor engine, but
|
||||
//! Iced's Content is parameterized by renderer and cannot be shared between them.
|
||||
//! We hit-test the initial click in the laid-out CPU widget, seed that raw caret
|
||||
//! in App, and relay the real subsequent SelectWord/SelectLine and editing actions.
|
||||
//! App's own Content is never replaced or cursor-normalized after selection. This
|
||||
//! tests the actual controller/content boundary without a window or GPU; native
|
||||
//! replay remains the check of the complete production renderer/layout path.
|
||||
use crate::app::Page;
|
||||
use crate::app::{App, Message};
|
||||
use crate::document::Document;
|
||||
use crate::source_navigation as nav;
|
||||
use crate::source_workspace::{LIMITS, Workspace};
|
||||
use crate::views::source_tests::FontLayout;
|
||||
use iced::advanced::{Layout, Shell, layout, widget as core_widget};
|
||||
use iced::futures::StreamExt;
|
||||
use iced::keyboard::{self, Key, Modifiers};
|
||||
use iced::widget::{button, container, row, text_editor, text_input};
|
||||
use iced::{Element, Event, Font, Size, mouse};
|
||||
use iced::{Point, Task, widget};
|
||||
use oa_core::{Project, source_project::SourceBundle};
|
||||
use std::{collections::BTreeMap, sync::Arc, sync::atomic::Ordering};
|
||||
|
||||
const SOURCE: &str = concat!(
|
||||
"// oa_contact tail\r\n",
|
||||
"arcadia 7;\r\n",
|
||||
"project model \"Selection\" id \"9e8f0000-0000-4000-8000-000000000091\" {}\r\n",
|
||||
);
|
||||
const HOST: Size = Size::new(620., 260.);
|
||||
|
||||
fn fixture() -> App {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
|
||||
};
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.page = Page::Text;
|
||||
app.show_source_file(false);
|
||||
app
|
||||
}
|
||||
|
||||
fn ui<'a>(
|
||||
app: &App,
|
||||
content: &'a text_editor::Content<FontLayout>,
|
||||
) -> Element<'a, Message, iced::Theme, FontLayout> {
|
||||
let find: Element<'a, Message, iced::Theme, FontLayout> = if app.source_navigation.open {
|
||||
row![
|
||||
text_input("Find", "")
|
||||
.id("source-find")
|
||||
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
|
||||
container(button("Close").on_press(app.source_find_close_message()))
|
||||
.id("selection-find-close"),
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
widget::Space::new().into()
|
||||
};
|
||||
// Column::push drops zero-height children. Keep both paths mounted even
|
||||
// while Find is hidden, as the production retained-slot layout does.
|
||||
widget::Column::from_vec(vec![
|
||||
container(find)
|
||||
.height(if app.source_navigation.open { 32. } else { 0. })
|
||||
.into(),
|
||||
widget::text_editor(content)
|
||||
.id(app.source_editor_id())
|
||||
.font(Font::MONOSPACE)
|
||||
.size(14)
|
||||
.padding(16)
|
||||
.height(iced::Fill)
|
||||
.on_action(Message::SourceEdit)
|
||||
.into(),
|
||||
])
|
||||
.width(iced::Fill)
|
||||
.height(iced::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
struct Mounted {
|
||||
content: text_editor::Content<FontLayout>,
|
||||
tree: core_widget::Tree,
|
||||
}
|
||||
|
||||
// Operations follow widget-provided traversal instead of assuming a fixed
|
||||
// relationship between layout nodes, transparent containers and state trees.
|
||||
struct Inspection {
|
||||
editor: core_widget::Id,
|
||||
focused: Option<bool>,
|
||||
close_bounds: Option<iced::Rectangle>,
|
||||
}
|
||||
|
||||
impl core_widget::Operation for Inspection {
|
||||
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
|
||||
operate(self);
|
||||
}
|
||||
|
||||
fn focusable(
|
||||
&mut self,
|
||||
id: Option<&core_widget::Id>,
|
||||
_: iced::Rectangle,
|
||||
state: &mut dyn core_widget::operation::Focusable,
|
||||
) {
|
||||
if id == Some(&self.editor) {
|
||||
assert!(
|
||||
self.focused.replace(state.is_focused()).is_none(),
|
||||
"duplicate editor ID"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn container(&mut self, id: Option<&core_widget::Id>, bounds: iced::Rectangle) {
|
||||
if id == Some(&core_widget::Id::new("selection-find-close")) {
|
||||
assert!(
|
||||
self.close_bounds.replace(bounds).is_none(),
|
||||
"duplicate Close target ID"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Mounted {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
content: text_editor::Content::with_text(SOURCE),
|
||||
tree: core_widget::Tree::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn events(&mut self, app: &App, events: &[(Event, mouse::Cursor)]) -> Vec<Message> {
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
let mut messages = Vec::new();
|
||||
for (event, cursor) in events {
|
||||
let mut shell = Shell::new(&mut messages);
|
||||
view.as_widget_mut().update(
|
||||
&mut self.tree,
|
||||
event,
|
||||
Layout::new(&node),
|
||||
*cursor,
|
||||
&FontLayout,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut shell,
|
||||
&iced::Rectangle::with_size(HOST),
|
||||
);
|
||||
if !matches!(event, Event::Mouse(mouse::Event::ButtonReleased(_))) {
|
||||
assert!(
|
||||
shell.is_event_captured(),
|
||||
"mounted input must be handled: {event:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
fn operation(&mut self, app: &App, mut op: Box<dyn core_widget::Operation>) {
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
loop {
|
||||
view.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&node),
|
||||
&FontLayout,
|
||||
op.as_mut(),
|
||||
);
|
||||
match op.finish() {
|
||||
core_widget::operation::Outcome::Chain(next) => op = next,
|
||||
core_widget::operation::Outcome::None => break,
|
||||
core_widget::operation::Outcome::Some(()) => panic!("focus does not emit messages"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn task(&mut self, app: &App, task: Task<Message>) {
|
||||
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
|
||||
while let Some(action) = actions.next().await {
|
||||
let iced_runtime::Action::Widget(op) = action else {
|
||||
panic!("expected a widget operation")
|
||||
};
|
||||
self.operation(app, op);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inspect(&mut self, app: &App) -> Inspection {
|
||||
let mut inspection = Inspection {
|
||||
editor: app.source_editor_id(),
|
||||
focused: None,
|
||||
close_bounds: None,
|
||||
};
|
||||
let mut view = ui(app, &self.content);
|
||||
self.tree.diff(view.as_widget());
|
||||
let node = view.as_widget_mut().layout(
|
||||
&mut self.tree,
|
||||
&FontLayout,
|
||||
&layout::Limits::new(Size::ZERO, HOST),
|
||||
);
|
||||
view.as_widget_mut().operate(
|
||||
&mut self.tree,
|
||||
Layout::new(&node),
|
||||
&FontLayout,
|
||||
&mut inspection,
|
||||
);
|
||||
inspection
|
||||
}
|
||||
|
||||
fn editor_focused(&mut self, app: &App) -> bool {
|
||||
self.inspect(app)
|
||||
.focused
|
||||
.expect("exactly one editor with the requested ID")
|
||||
}
|
||||
|
||||
fn close_message(&mut self, app: &App, escape: bool) -> Message {
|
||||
if escape {
|
||||
let event = Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Named(keyboard::key::Named::Escape),
|
||||
modified_key: Key::Named(keyboard::key::Named::Escape),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
assert!(
|
||||
self.events(app, &[(event, mouse::Cursor::Unavailable)])
|
||||
.is_empty()
|
||||
);
|
||||
// The existing subscription tests cover the captured-Escape bridge.
|
||||
// Deliver its App message after this actual input captured the key.
|
||||
Message::Escape
|
||||
} else {
|
||||
let center = self
|
||||
.inspect(app)
|
||||
.close_bounds
|
||||
.expect("the mounted Find Close target exists")
|
||||
.center();
|
||||
let cursor = mouse::Cursor::Available(center);
|
||||
let mut messages = self.events(
|
||||
app,
|
||||
&[
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
cursor,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
cursor,
|
||||
),
|
||||
],
|
||||
);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let close = messages.remove(0);
|
||||
assert!(matches!(close, Message::SourceFindClose(..)));
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
fn select(&mut self, app: &mut App, line: bool) {
|
||||
// One mounted update batch avoids delays between Iced's consecutive-click
|
||||
// timestamps; content actions are applied in their emitted order below.
|
||||
let at = mouse::Cursor::Available(Point::new(65., 25.));
|
||||
let events = (0..if line { 3 } else { 2 })
|
||||
.flat_map(|_| {
|
||||
[
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
at,
|
||||
),
|
||||
(
|
||||
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
at,
|
||||
),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let messages = self.events(app, &events);
|
||||
assert_eq!(messages.len(), if line { 3 } else { 2 });
|
||||
for (index, message) in messages.into_iter().enumerate() {
|
||||
let Message::SourceEdit(action) = message else {
|
||||
panic!("editor action")
|
||||
};
|
||||
if index == 0 {
|
||||
assert!(matches!(action, text_editor::Action::Click(_)));
|
||||
self.content.perform(action);
|
||||
let caret = self.content.cursor();
|
||||
assert_eq!(caret.position.line, 0);
|
||||
assert!(
|
||||
(4..13).contains(&caret.position.column),
|
||||
"click must hit inside oa_contact: {caret:?}"
|
||||
);
|
||||
// App's GPU-typed Content has no public layout API. Copy only the
|
||||
// initial hit-tested caret before any Word/Line selection exists.
|
||||
app.source_editor.move_to(caret);
|
||||
} else {
|
||||
assert_eq!(
|
||||
action,
|
||||
if index == 1 {
|
||||
text_editor::Action::SelectWord
|
||||
} else {
|
||||
text_editor::Action::SelectLine
|
||||
}
|
||||
);
|
||||
self.content.perform(action.clone());
|
||||
let _ = app.update(Message::SourceEdit(action));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
self.content.selection().as_deref(),
|
||||
Some(if line {
|
||||
"// oa_contact tail"
|
||||
} else {
|
||||
"oa_contact"
|
||||
})
|
||||
);
|
||||
assert_eq!(app.source_editor.selection(), self.content.selection());
|
||||
assert!(self.editor_focused(app));
|
||||
}
|
||||
|
||||
fn type_replacement(&mut self, app: &mut App) {
|
||||
let event = Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: Key::Character("x".into()),
|
||||
modified_key: Key::Character("x".into()),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: Modifiers::empty(),
|
||||
text: Some("x".into()),
|
||||
repeat: false,
|
||||
});
|
||||
let messages = self.events(app, &[(event, mouse::Cursor::Unavailable)]);
|
||||
assert_eq!(messages.len(), 1);
|
||||
let Message::SourceEdit(action) = messages.into_iter().next().unwrap() else {
|
||||
panic!("typed edit")
|
||||
};
|
||||
assert_eq!(
|
||||
action,
|
||||
text_editor::Action::Edit(text_editor::Edit::Insert('x'))
|
||||
);
|
||||
self.content.perform(action.clone());
|
||||
let _ = app.update(Message::SourceEdit(action));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn word_and_line_selection_survive_find_close_and_escape_then_replace_exact_bytes() {
|
||||
for line in [false, true] {
|
||||
for escape in [false, true] {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, line);
|
||||
let selected = app.source_editor.selection();
|
||||
let cursor = app.source_editor.cursor();
|
||||
// Word/Line selection has coincident raw endpoints. Merely checking
|
||||
// cursor equality after a move_to roundtrip cannot detect the defect.
|
||||
assert_eq!(cursor.selection, Some(cursor.position));
|
||||
let task = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, task).await;
|
||||
assert!(!mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.selection(), selected);
|
||||
let close = mounted.close_message(&app, escape);
|
||||
let _ = app.update(close);
|
||||
let ticket = app
|
||||
.source_reveal_ticket()
|
||||
.expect("guarded restoration ticket");
|
||||
let task = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, task).await;
|
||||
assert!(mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection(),
|
||||
selected,
|
||||
"selected bytes must survive focus restoration"
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), SOURCE);
|
||||
mounted.type_replacement(&mut app);
|
||||
let expected = SOURCE.replacen(selected.as_deref().unwrap(), "x", 1);
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(mounted.content.text(), expected);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mounted_direct_typing_control_and_old_cursor_roundtrip_discriminate_selection_loss() {
|
||||
for line in [false, true] {
|
||||
for old_roundtrip in [false, true] {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, line);
|
||||
let selected = app.source_editor.selection().unwrap();
|
||||
let interior = app.source_editor.cursor();
|
||||
if old_roundtrip {
|
||||
app.source_editor.move_to(interior);
|
||||
assert_ne!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
}
|
||||
mounted.type_replacement(&mut app);
|
||||
let expected = if old_roundtrip {
|
||||
let mut text = SOURCE.to_owned();
|
||||
text.insert(interior.position.column, 'x');
|
||||
text
|
||||
} else {
|
||||
SOURCE.replacen(&selected, "x", 1)
|
||||
};
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(mounted.content.text(), SOURCE.replacen(&selected, "x", 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_focus_ticket_and_later_raw_input_cannot_steal_focus_or_change_selected_bytes() {
|
||||
for later_raw_input in [false, true] {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, false);
|
||||
let selected = app.source_editor.selection();
|
||||
let open = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, open).await;
|
||||
let close = app.source_find_close_message();
|
||||
let _ = app.update(close);
|
||||
let ticket = app.source_reveal_ticket().unwrap();
|
||||
let focus = if later_raw_input {
|
||||
app.update(Message::SourceViewport(Some(ticket)))
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
let reopen = app.update(Message::SourceNavigation(nav::Event::Open(false)));
|
||||
mounted.task(&app, reopen).await;
|
||||
if later_raw_input {
|
||||
app.source_symbols
|
||||
.raw_interaction
|
||||
.fetch_add(1, Ordering::SeqCst);
|
||||
mounted.task(&app, focus).await;
|
||||
} else {
|
||||
let stale = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, stale).await;
|
||||
}
|
||||
assert!(app.source_navigation.open);
|
||||
assert!(!mounted.editor_focused(&app));
|
||||
assert_eq!(app.source_editor.selection(), selected);
|
||||
assert_eq!(app.source_editor.text(), SOURCE);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn explicit_cursor_navigation_still_moves_and_selects_requested_line() {
|
||||
let mut app = fixture();
|
||||
let mut mounted = Mounted::new();
|
||||
mounted.select(&mut app, false);
|
||||
let target = text_editor::Cursor {
|
||||
position: text_editor::Position { line: 1, column: 3 },
|
||||
selection: None,
|
||||
};
|
||||
// Clear the previous semantic selection as actual show_source_file does when
|
||||
// moving to another source buffer, then exercise intentional navigation.
|
||||
app.show_source_file(false);
|
||||
app.queue_source_reveal(target, true);
|
||||
let ticket = app.source_reveal_ticket().unwrap();
|
||||
let task = app.update(Message::SourceViewport(Some(ticket)));
|
||||
mounted.task(&app, task).await;
|
||||
assert_eq!(app.source_editor.cursor().position, target.position);
|
||||
assert_eq!(app.source_editor.selection().as_deref(), Some("arcadia 7;"));
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
use super::*;
|
||||
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
|
||||
use crate::source_workspace::Workspace;
|
||||
use oa_core::source_project::SourceBundle;
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Instant;
|
||||
|
||||
mod departure_selection;
|
||||
|
||||
const DEFINITIONS: &str = "defs/活動.arcadia";
|
||||
const USES: &str = "uses.arcadia";
|
||||
|
||||
@@ -35,6 +38,7 @@ fn fixture() -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.document = Document::from_bundle(fixture_bundle(), LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.page = Page::Text;
|
||||
@@ -48,6 +52,28 @@ fn fixture() -> App {
|
||||
app
|
||||
}
|
||||
|
||||
fn select_effective_pump(app: &mut App, line: bool) -> String {
|
||||
let source = app.source_workspace.text();
|
||||
let offset = source.find("source @pump").unwrap() + "source @pu".len();
|
||||
app.source_editor.move_to(text_editor::Cursor {
|
||||
position: position(source, offset),
|
||||
selection: None,
|
||||
});
|
||||
send(
|
||||
app,
|
||||
Message::SourceEdit(if line {
|
||||
text_editor::Action::SelectLine
|
||||
} else {
|
||||
text_editor::Action::SelectWord
|
||||
}),
|
||||
);
|
||||
let selected = app.source_editor.selection().unwrap();
|
||||
assert!(selected.contains("pump"));
|
||||
let raw = app.source_editor.cursor();
|
||||
assert_eq!(raw.selection, Some(raw.position));
|
||||
selected
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
// Dropping the returned Task does not poll its worker/lint future. Completion
|
||||
// below is deterministic and still traverses the real App message handler.
|
||||
@@ -59,6 +85,23 @@ fn symbol(app: &mut App, event: nav::SymbolEvent) {
|
||||
send(app, message);
|
||||
}
|
||||
|
||||
fn close_drawer(app: &mut App) {
|
||||
let message = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
|
||||
send(app, message);
|
||||
}
|
||||
|
||||
fn choice(app: &App, stamp: query::QueryStamp, reference: bool, index: usize) -> Message {
|
||||
app.source_symbol_message(nav::SymbolEvent::Choose {
|
||||
stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
reference,
|
||||
index,
|
||||
})
|
||||
}
|
||||
fn references_visible(app: &App) -> bool {
|
||||
app.text_workspace.drawer() == Some(DrawerTab::References)
|
||||
}
|
||||
|
||||
fn show(app: &mut App, path: &str) {
|
||||
send(app, Message::SourceFile(path.into()));
|
||||
reveal(app);
|
||||
@@ -97,6 +140,9 @@ fn begin(app: &mut App, action: query::Action) -> nav::Request {
|
||||
.clone()
|
||||
.expect("request admitted");
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp));
|
||||
assert!(references_visible(app));
|
||||
request
|
||||
}
|
||||
|
||||
@@ -163,14 +209,8 @@ fn valid_definition_and_reference_choice_keep_whole_model_and_source_unchanged()
|
||||
"references require a choice"
|
||||
);
|
||||
let second = uses.references[1].clone();
|
||||
symbol(
|
||||
&mut app,
|
||||
nav::SymbolEvent::Choose {
|
||||
stamp: uses.stamp,
|
||||
reference: true,
|
||||
index: 1,
|
||||
},
|
||||
);
|
||||
let message = choice(&app, uses.stamp, true, 1);
|
||||
send(&mut app, message);
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(
|
||||
@@ -211,14 +251,8 @@ fn detached_duplicate_requires_an_explicit_choice_even_when_included_source_is_v
|
||||
results.definitions[index].membership,
|
||||
query::Membership::Detached
|
||||
);
|
||||
symbol(
|
||||
&mut app,
|
||||
nav::SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
reference: false,
|
||||
index,
|
||||
},
|
||||
);
|
||||
let message = choice(&app, results.stamp, false, index);
|
||||
send(&mut app, message);
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_workspace.active, "detached.arcadia");
|
||||
assert_eq!(app.source_workspace.bundle(), loaded);
|
||||
@@ -266,14 +300,8 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() {
|
||||
.iter()
|
||||
.position(|row| row.declaration.alias == "pump")
|
||||
.unwrap();
|
||||
symbol(
|
||||
&mut app,
|
||||
nav::SymbolEvent::Choose {
|
||||
stamp: results.stamp,
|
||||
reference: false,
|
||||
index,
|
||||
},
|
||||
);
|
||||
let message = choice(&app, results.stamp, false, index);
|
||||
send(&mut app, message);
|
||||
reveal(&mut app);
|
||||
symbol(&mut app, nav::SymbolEvent::Back);
|
||||
reveal(&mut app);
|
||||
@@ -298,11 +326,12 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes() {
|
||||
fn completion_retains_current_display_but_rejects_navigation_after_context_changes() {
|
||||
for change in 0..7 {
|
||||
let mut app = fixture();
|
||||
let request = begin(&mut app, query::Action::Definition);
|
||||
let ready = completion(&app, request.clone());
|
||||
let expected = ready.result.as_ref().unwrap().1.clone();
|
||||
let accepted = app.document.snapshot();
|
||||
match change {
|
||||
0 => show(&mut app, "project.arcadia"),
|
||||
@@ -326,23 +355,38 @@ fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes
|
||||
let after = app.source_workspace.bundle();
|
||||
let active = app.source_workspace.active.clone();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let drawer = app.text_workspace.drawer();
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(
|
||||
app.source_symbols.in_flight.is_none(),
|
||||
"case {change}: release worker slot"
|
||||
);
|
||||
assert!(
|
||||
app.source_symbols.results.is_none(),
|
||||
"case {change}: stale result surfaced"
|
||||
if matches!(change, 1 | 2) {
|
||||
assert!(
|
||||
app.source_symbols.results.is_none(),
|
||||
"case {change}: changed source/session cannot own display"
|
||||
);
|
||||
} else {
|
||||
assert!(
|
||||
Arc::ptr_eq(app.source_symbols.results.as_ref().unwrap(), &expected),
|
||||
"case {change}: canceled navigation still owns current-source display"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.text_workspace.drawer(),
|
||||
drawer,
|
||||
"completion cannot select a tab"
|
||||
);
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.active, active);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), after);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_new_results() {
|
||||
let mut app = fixture();
|
||||
@@ -366,25 +410,41 @@ fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_ne
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_during_busy_hides_panel_and_completion_does_not_reopen_it() {
|
||||
fn close_during_busy_retains_hidden_completion_without_reopening_or_navigation() {
|
||||
let mut app = fixture();
|
||||
let request = begin(&mut app, query::Action::Definition);
|
||||
let ready = completion(&app, request.clone());
|
||||
assert!(app.source_symbols.open);
|
||||
symbol(&mut app, nav::SymbolEvent::Close);
|
||||
assert!(!app.source_symbols.open);
|
||||
let expected = ready.result.as_ref().unwrap().1.clone();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
assert!(references_visible(&app));
|
||||
close_drawer(&mut app);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(!app.source_symbols.open);
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&expected
|
||||
));
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let next = begin(&mut app, query::Action::References);
|
||||
assert_ne!(next.job, request.job);
|
||||
assert!(app.source_symbols.open);
|
||||
assert!(references_visible(&app));
|
||||
assert!(
|
||||
app.source_symbols.results.is_none(),
|
||||
"explicit new query replaces prior display ownership"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal() {
|
||||
let mut app = fixture();
|
||||
@@ -418,7 +478,7 @@ fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources() {
|
||||
fn escape_hides_results_and_cancels_navigation_but_retains_latest_display() {
|
||||
for pending in [false, true] {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
@@ -426,23 +486,42 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources
|
||||
let cursor = app.source_editor.cursor();
|
||||
let request = begin(&mut app, query::Action::References);
|
||||
let ready = completion(&app, request.clone());
|
||||
let expected = ready.result.as_ref().unwrap().1.clone();
|
||||
if !pending {
|
||||
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
|
||||
assert!(app.source_symbols.results.is_some());
|
||||
}
|
||||
let results = app.source_symbols.results.clone();
|
||||
app.source_navigation.open = true;
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
send(&mut app, Message::Escape);
|
||||
assert!(!app.source_symbols.open && !app.source_navigation.open);
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.notice.is_none());
|
||||
assert!(references_visible(&app) && !app.source_navigation.open);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
match (&results, &app.source_symbols.results) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("Find Escape must retain underlying results"),
|
||||
}
|
||||
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
reveal(&mut app);
|
||||
send(&mut app, Message::Escape);
|
||||
assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open);
|
||||
assert_eq!(app.source_symbols.results.is_some(), !pending);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
// Explicit Escape may restore editor focus; consume that ticket before
|
||||
// delivering completion so any later focus request is discriminated.
|
||||
reveal(&mut app);
|
||||
if pending {
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
}
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(!app.source_symbols.open);
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&expected
|
||||
));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
@@ -451,7 +530,6 @@ fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_dismisses_modal_before_underlying_source_results() {
|
||||
let mut app = fixture();
|
||||
@@ -465,14 +543,17 @@ fn escape_dismisses_modal_before_underlying_source_results() {
|
||||
app.source_reveal_ticket().is_none(),
|
||||
"modal dismissal must not focus the editor"
|
||||
);
|
||||
assert!(app.source_symbols.open);
|
||||
assert!(references_visible(&app));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
send(&mut app, Message::Escape);
|
||||
assert!(!app.source_symbols.open);
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
@@ -485,8 +566,8 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
|
||||
let origin = app.source_editor.cursor();
|
||||
finish(&mut app, query::Action::Definition);
|
||||
reveal(&mut app);
|
||||
symbol(&mut app, nav::SymbolEvent::Close);
|
||||
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
|
||||
close_drawer(&mut app);
|
||||
assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some());
|
||||
if changed {
|
||||
// Use the workspace edit API to give the origin a new generation.
|
||||
show(&mut app, USES);
|
||||
@@ -506,7 +587,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
|
||||
let raw = app.source_workspace.bundle();
|
||||
symbol(&mut app, nav::SymbolEvent::Back);
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(app.source_symbols.open, changed);
|
||||
assert_eq!(references_visible(&app), changed);
|
||||
assert_eq!(app.source_symbols.notice.is_some(), changed);
|
||||
reveal(&mut app);
|
||||
if !changed {
|
||||
@@ -518,7 +599,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
|
||||
}
|
||||
let mut app = fixture();
|
||||
symbol(&mut app, nav::SymbolEvent::Back);
|
||||
assert!(app.source_symbols.open && app.source_symbols.notice.is_some());
|
||||
assert!(references_visible(&app) && app.source_symbols.notice.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -529,12 +610,12 @@ fn find_only_captured_escape_closes_once_and_preserves_raw_query_and_selection()
|
||||
let cursor = app.source_editor.cursor();
|
||||
app.source_navigation.open = true;
|
||||
app.source_navigation.query = "送水 🚀".into();
|
||||
assert!(!app.source_symbols.open);
|
||||
assert!(!references_visible(&app));
|
||||
let mut messages = captured_input_subscription_messages(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!app.source_navigation.open && !app.source_symbols.open);
|
||||
assert!(!app.source_navigation.open && !references_visible(&app));
|
||||
assert_eq!(app.source_navigation.query, "送水 🚀");
|
||||
assert!(app.source_reveal_ticket().is_some());
|
||||
reveal(&mut app);
|
||||
@@ -776,8 +857,13 @@ async fn find_close_releases_removed_input_and_guarded_focus_respects_later_inpu
|
||||
|
||||
/// Feed the real App subscription recipes the status produced by a mounted
|
||||
/// TextInput. Poll only immediately available output: no timer wait, worker,
|
||||
/// file dialog or App task is executed by this helper.
|
||||
/// file dialog or App task is executed by this helper. Require the canvas keyboard
|
||||
/// notification separately, then return every command routed to an Escape owner.
|
||||
fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
input_subscription_messages(app, true)
|
||||
}
|
||||
|
||||
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
|
||||
use iced::advanced::{Layout, Shell, layout, subscription, widget};
|
||||
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
|
||||
use iced::keyboard::{self, key::Named};
|
||||
@@ -793,9 +879,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
input
|
||||
.as_widget_mut()
|
||||
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
if focused {
|
||||
tree.state
|
||||
.downcast_mut::<iced::widget::text_input::State<()>>()
|
||||
.focus();
|
||||
}
|
||||
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(Named::Escape),
|
||||
modified_key: keyboard::Key::Named(Named::Escape),
|
||||
@@ -817,7 +905,8 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
&mut shell,
|
||||
&iced::Rectangle::new(iced::Point::ORIGIN, size),
|
||||
);
|
||||
assert!(shell.is_event_captured());
|
||||
let captured = shell.is_event_captured();
|
||||
assert_eq!(captured, focused);
|
||||
assert!(input_messages.is_empty());
|
||||
assert!(
|
||||
!tree
|
||||
@@ -828,7 +917,11 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
let event = subscription::Event::Interaction {
|
||||
window: iced::window::Id::unique(),
|
||||
event,
|
||||
status: iced::event::Status::Captured,
|
||||
status: if captured {
|
||||
iced::event::Status::Captured
|
||||
} else {
|
||||
iced::event::Status::Ignored
|
||||
},
|
||||
};
|
||||
let runtime = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_time()
|
||||
@@ -836,6 +929,7 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
.unwrap();
|
||||
let _entered = runtime.enter();
|
||||
let mut messages = Vec::new();
|
||||
let mut keyboard_boundaries = 0;
|
||||
for recipe in subscription::into_recipes(app.subscription()) {
|
||||
let mut output = recipe.stream(Box::pin(stream::iter([event.clone()])));
|
||||
let mut context = Context::from_waker(noop_waker_ref());
|
||||
@@ -844,9 +938,20 @@ fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
|
||||
!matches!(message, Message::CheckpointTick),
|
||||
"timer must remain pending"
|
||||
);
|
||||
messages.push(message);
|
||||
if matches!(
|
||||
&message,
|
||||
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
|
||||
) {
|
||||
keyboard_boundaries += 1;
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
keyboard_boundaries, 1,
|
||||
"each key has exactly one passive allocation notification"
|
||||
);
|
||||
messages
|
||||
}
|
||||
|
||||
@@ -864,7 +969,9 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
|
||||
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
|
||||
}
|
||||
let results = app.source_symbols.results.clone();
|
||||
let expected = ready.result.as_ref().unwrap().1.clone();
|
||||
app.source_navigation.open = true;
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
match modal {
|
||||
0 => {}
|
||||
1 => app.hosted.visible = true,
|
||||
@@ -884,7 +991,12 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
|
||||
send(&mut app, messages.remove(0));
|
||||
if modal != 0 {
|
||||
assert!(!app.hosted.visible && app.file_prompt.is_none() && app.pending.is_none());
|
||||
assert!(app.source_symbols.open && app.source_navigation.open);
|
||||
assert!(references_visible(&app) && app.source_navigation.open);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(
|
||||
app.source_reveal_ticket().is_none(),
|
||||
"modal Escape cannot steal editor focus"
|
||||
);
|
||||
match (&results, &app.source_symbols.results) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
@@ -895,14 +1007,38 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
}
|
||||
assert!(!app.source_symbols.open && !app.source_navigation.open);
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.request.is_none());
|
||||
assert!(references_visible(&app) && !app.source_navigation.open);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
match (&results, &app.source_symbols.results) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
_ => panic!("Find Escape must retain underlying results"),
|
||||
}
|
||||
|
||||
assert_eq!(app.source_symbols.results.is_some(), !pending);
|
||||
assert!(app.source_reveal_ticket().is_some());
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
// One more actual Escape hides the remaining drawer. Both status
|
||||
// routes emit exactly one App message; no second bridge is needed.
|
||||
let mut messages = input_subscription_messages(&app, false);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!references_visible(&app) && !app.source_navigation.open);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
reveal(&mut app);
|
||||
if pending {
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
}
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&expected
|
||||
));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
@@ -919,7 +1055,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
|
||||
#[test]
|
||||
fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() {
|
||||
let mut app = fixture();
|
||||
app.source_symbols.open = true;
|
||||
app.text_workspace.show_drawer(Some(DrawerTab::References));
|
||||
let project = app.project().clone();
|
||||
assert!(
|
||||
app.behavior
|
||||
@@ -949,7 +1085,7 @@ fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_ro
|
||||
assert!(app.architecture.as_ref().unwrap().inline());
|
||||
// A retained Text panel must not add a second route to inline architecture.
|
||||
app.page = Page::Text;
|
||||
app.source_symbols.open = true;
|
||||
app.text_workspace.show_drawer(Some(DrawerTab::References));
|
||||
let messages = captured_input_subscription_messages(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(
|
||||
@@ -1169,7 +1305,16 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message])
|
||||
.collect();
|
||||
let results = app.source_symbols.results.clone();
|
||||
let cache = app.source_symbols.cache.clone();
|
||||
let panel = (app.source_symbols.open, app.source_symbols.notice.clone());
|
||||
let panel = (
|
||||
app.text_workspace.drawer(),
|
||||
app.source_symbols.notice.clone(),
|
||||
);
|
||||
let display_request = app.source_symbols.display_request.clone();
|
||||
let reference_presentation = (
|
||||
app.text_workspace.references().stamp(),
|
||||
app.text_workspace.drawer_visit(),
|
||||
app.text_workspace.references().scroll_y(),
|
||||
);
|
||||
let key = app.source_key();
|
||||
let active = app.source_workspace.active.clone();
|
||||
let cursor = app.source_editor.cursor();
|
||||
@@ -1234,9 +1379,21 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message])
|
||||
back
|
||||
);
|
||||
assert_eq!(
|
||||
(app.source_symbols.open, app.source_symbols.notice.clone()),
|
||||
(
|
||||
app.text_workspace.drawer(),
|
||||
app.source_symbols.notice.clone()
|
||||
),
|
||||
panel
|
||||
);
|
||||
assert_eq!(app.source_symbols.display_request, display_request);
|
||||
assert_eq!(
|
||||
(
|
||||
app.text_workspace.references().stamp(),
|
||||
app.text_workspace.drawer_visit(),
|
||||
app.text_workspace.references().scroll_y()
|
||||
),
|
||||
reference_presentation
|
||||
);
|
||||
match (&results, &app.source_symbols.results) {
|
||||
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
|
||||
(None, None) => {}
|
||||
@@ -1281,12 +1438,8 @@ fn stale_envelope_trial(reset: bool) {
|
||||
let old_messages = [
|
||||
app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)),
|
||||
app.source_symbol_message(nav::SymbolEvent::Back),
|
||||
app.source_symbol_message(nav::SymbolEvent::Close),
|
||||
app.source_symbol_message(nav::SymbolEvent::Choose {
|
||||
stamp: old_results.stamp,
|
||||
reference: false,
|
||||
index: 0,
|
||||
}),
|
||||
Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())),
|
||||
choice(&app, old_results.stamp, false, 0),
|
||||
];
|
||||
if reset {
|
||||
let project = app.project().clone();
|
||||
@@ -1346,9 +1499,9 @@ fn stale_envelope_trial(reset: bool) {
|
||||
// Replay once more with no busy-slot fallback and visible current results.
|
||||
assert_stale_envelopes_preserve_current(&mut app, &old_messages);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
symbol(&mut app, nav::SymbolEvent::Close);
|
||||
close_drawer(&mut app);
|
||||
assert_ne!(app.source_symbols.interaction, interaction);
|
||||
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some());
|
||||
let next = begin(&mut app, query::Action::References);
|
||||
assert_ne!(
|
||||
next.job, current.job,
|
||||
@@ -1501,12 +1654,332 @@ fn process_memory() -> Option<(usize, usize)> {
|
||||
(ok != 0).then_some((counters.working_set, counters.peak_working_set))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_definition_after_hide_or_tab_change_retains_data_without_reopening_or_focus() {
|
||||
for destination in [None, Some(DrawerTab::Problems), Some(DrawerTab::Status)] {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let request = begin(&mut app, query::Action::Definition);
|
||||
let ready = completion(&app, request.clone());
|
||||
let expected = ready.result.as_ref().unwrap().1.clone();
|
||||
assert_eq!(expected.disposition, query::Disposition::Direct);
|
||||
let event = destination.map_or(TextEvent::HideDrawer(app.text_drawer_context()), |tab| {
|
||||
TextEvent::ShowDrawer(app.text_drawer_context(), tab)
|
||||
});
|
||||
send(&mut app, Message::TextWorkspace(event));
|
||||
assert_eq!(app.text_workspace.drawer(), destination);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&expected
|
||||
));
|
||||
assert_eq!(app.text_workspace.drawer(), destination);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
{
|
||||
let captured = Message::TextWorkspace(TextEvent::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::References,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(references_visible(&app));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&expected
|
||||
));
|
||||
assert!(app.source_symbols.in_flight.is_none() && app.source_symbols.request.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_worker_with_changed_request_envelope_cannot_publish_or_navigate() {
|
||||
for changed in 0..7 {
|
||||
let mut app = fixture();
|
||||
let request = begin(&mut app, query::Action::Definition);
|
||||
let mut ready = completion(&app, request.clone());
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
match changed {
|
||||
0 => ready.request.stamp.serial += 1,
|
||||
1 => ready.request.path = DEFINITIONS.into(),
|
||||
2 => ready.request.offset += 1,
|
||||
3 => ready.request.cursor.selection = None,
|
||||
4 => ready.request.interaction = Id::new_v4(),
|
||||
5 => ready.request.raw_interaction += 1,
|
||||
_ => ready.request.action = query::Action::References,
|
||||
}
|
||||
assert_eq!(ready.request.job, request.job);
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(
|
||||
app.source_symbols.in_flight.is_none(),
|
||||
"worker completed in case {changed}"
|
||||
);
|
||||
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matching_old_worker_cannot_replace_a_newer_display_owner() {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let old = begin(&mut app, query::Action::Definition);
|
||||
let ready = completion(&app, old.clone());
|
||||
let mut latest = old.clone();
|
||||
latest.stamp.serial += 1;
|
||||
latest.job = Id::new_v4();
|
||||
// Isolate display ownership from both worker ownership and navigation intent.
|
||||
app.source_interaction();
|
||||
app.source_symbols.display_request = Some(latest.clone());
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&latest));
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_completion_checks_exact_bytes_even_when_snapshot_key_was_not_advanced() {
|
||||
let mut app = fixture();
|
||||
let request = begin(&mut app, query::Action::Definition);
|
||||
let ready = completion(&app, request.clone());
|
||||
let accepted = app.document.snapshot();
|
||||
{
|
||||
let captured = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
// Deliberately violate the normal edit API's key advancement: the exact
|
||||
// source witness must still reject stale bytes instead of trusting the key.
|
||||
app.source_workspace
|
||||
.files
|
||||
.get_mut(USES)
|
||||
.unwrap()
|
||||
.text
|
||||
.push_str("\r\n// changed bytes");
|
||||
assert_eq!(app.source_key(), request.stamp.snapshot);
|
||||
let changed = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), changed);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn popup_escape_has_no_duplicate_bridge_and_dismissal_preserves_underlying_find_and_drawer() {
|
||||
for line in [false, true] {
|
||||
for captured_find_escape in [false, true] {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let results = finish(&mut app, query::Action::References);
|
||||
let selected = select_effective_pump(&mut app, line);
|
||||
let cursor = app.source_editor.cursor();
|
||||
let group = TextEvent::ToggleFile {
|
||||
stamp: results.stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path: USES.into(),
|
||||
};
|
||||
send(&mut app, Message::TextWorkspace(group));
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
send(
|
||||
&mut app,
|
||||
Message::TextWorkspace(TextEvent::ReferenceScrolled {
|
||||
stamp: results.stamp,
|
||||
visit,
|
||||
y: 96.,
|
||||
}),
|
||||
);
|
||||
assert!(app.text_workspace.references().collapsed(USES));
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
|
||||
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
|
||||
app.source_navigation.query = "Retain Find 雨".into();
|
||||
let open = app.text_menu_message(iced::Point::new(30., 30.));
|
||||
send(&mut app, open);
|
||||
let menu = app.text_workspace_menu().expect("Text popup opened");
|
||||
assert!(
|
||||
captured_input_subscription_messages(&app).is_empty(),
|
||||
"Popup owns captured Escape; a bridge Escape would also close its underlying UI"
|
||||
);
|
||||
// The popup itself publishes this tokened dismissal on Escape.
|
||||
send(&mut app, menu.on_close);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
assert!(references_visible(&app) && app.source_navigation.open);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
|
||||
let mut next = input_subscription_messages(&app, captured_find_escape);
|
||||
assert_eq!(
|
||||
next.len(),
|
||||
1,
|
||||
"exactly one message for either Escape status"
|
||||
);
|
||||
assert!(matches!(next[0], Message::Escape));
|
||||
send(&mut app, next.remove(0));
|
||||
assert!(references_visible(&app) && !app.source_navigation.open);
|
||||
assert_eq!(
|
||||
app.text_workspace.drawer_visit(),
|
||||
visit,
|
||||
"closing Find does not replace the drawer visit"
|
||||
);
|
||||
assert_eq!(app.source_navigation.query, "Retain Find 雨");
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert!(app.text_workspace.references().collapsed(USES));
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
|
||||
assert!(app.source_reveal_ticket().is_some() && app.source_jump.is_none());
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
|
||||
let mut last = input_subscription_messages(&app, false);
|
||||
assert_eq!(last.len(), 1);
|
||||
assert!(matches!(last[0], Message::Escape));
|
||||
send(&mut app, last.remove(0));
|
||||
assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open);
|
||||
assert_ne!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert!(app.text_workspace.references().collapsed(USES));
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
|
||||
assert_eq!(app.source_navigation.query, "Retain Find 雨");
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
assert!(captured_input_subscription_messages(&app).is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_choices_cannot_replay_after_hide_or_references_problems_references() {
|
||||
for reference in [false, true] {
|
||||
for hide in [false, true] {
|
||||
let mut app = fixture();
|
||||
let results = finish(&mut app, query::Action::References);
|
||||
let index = usize::from(reference);
|
||||
let old = choice(&app, results.stamp, reference, index);
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let event = if hide {
|
||||
TextEvent::HideDrawer(app.text_drawer_context())
|
||||
} else {
|
||||
TextEvent::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems)
|
||||
};
|
||||
send(&mut app, Message::TextWorkspace(event));
|
||||
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert_stale_envelopes_preserve_current(&mut app, std::slice::from_ref(&old));
|
||||
{
|
||||
let captured = Message::TextWorkspace(TextEvent::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::References,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
|
||||
assert_eq!(
|
||||
app.source_key(),
|
||||
results.stamp.snapshot,
|
||||
"same source isolates the visit guard"
|
||||
);
|
||||
assert_stale_envelopes_preserve_current(&mut app, &[old]);
|
||||
let fresh = choice(&app, results.stamp, reference, index);
|
||||
send(&mut app, fresh);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"freshly captured reopened choice remains usable"
|
||||
);
|
||||
assert_eq!(app.source_symbols.back.len(), 1);
|
||||
reveal(&mut app);
|
||||
let (path, span) = if reference {
|
||||
let row = &results.references[index];
|
||||
(&row.path, row.span)
|
||||
} else {
|
||||
let row = &results.definitions[index];
|
||||
(&row.path, row.span)
|
||||
};
|
||||
assert_eq!(&app.source_workspace.active, path);
|
||||
assert_eq!(
|
||||
app.source_editor.cursor(),
|
||||
selection(&raw.files[path], span.start, span.end, false)
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combined_modal_completion_releases_worker_without_navigation_or_model_changes() {
|
||||
for modal in 0..5 {
|
||||
let mut app = fixture();
|
||||
let request = begin(&mut app, query::Action::Definition);
|
||||
let ready = completion(&app, request);
|
||||
let expected = ready.result.as_ref().unwrap().1.clone();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
@@ -1538,7 +2011,11 @@ fn combined_modal_completion_releases_worker_without_navigation_or_model_changes
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.in_flight.is_none(), "modal {modal}");
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
// Current-source display data may finish behind a modal; it grants no navigation.
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&expected
|
||||
));
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(app.source_reveal_ticket().is_none());
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
@@ -1589,6 +2066,7 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
|
||||
.move_to(selection(text, offset, offset + alias.len(), false));
|
||||
let request = begin(&mut app, query::Action::Definition);
|
||||
let ready = completion(&app, request);
|
||||
let expected = ready.result.as_ref().unwrap().1.clone();
|
||||
app.page = Page::Diagram;
|
||||
send(&mut app, Message::FieldReferenceOpen(open));
|
||||
assert!(app.reference_input_active(), "{}", app.status);
|
||||
@@ -1601,7 +2079,11 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
|
||||
let cursor = app.source_editor.cursor();
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
// Current-source display data may finish behind a modal; it grants no navigation.
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&expected
|
||||
));
|
||||
assert!(app.source_reveal_ticket().is_none());
|
||||
assert!(
|
||||
app.reference_input_active(),
|
||||
@@ -1611,3 +2093,31 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_key_changed_bytes_mark_retained_rows_stale_before_choice_boundaries() {
|
||||
for path in [USES, DEFINITIONS] {
|
||||
let mut app = fixture();
|
||||
let results = finish(&mut app, query::Action::References);
|
||||
assert!(app.source_results_current());
|
||||
#[cfg(debug_assertions)]
|
||||
assert!(!crate::views::captured_source_reference_stale_summary(&app));
|
||||
let captured = choice(&app, results.stamp, true, 0);
|
||||
let key = app.source_key();
|
||||
// Isolate byte authority from revision authority in both the displayed
|
||||
// and an inactive buffer. Views share this exact currentness decision.
|
||||
app.source_workspace
|
||||
.files
|
||||
.get_mut(path)
|
||||
.unwrap()
|
||||
.text
|
||||
.push_str("\r\n// exact bytes changed");
|
||||
assert_eq!(app.source_key(), key);
|
||||
assert!(!app.source_results_current());
|
||||
#[cfg(debug_assertions)]
|
||||
assert!(crate::views::captured_source_reference_stale_summary(&app));
|
||||
app.queue_source_reveal(app.source_editor.cursor(), false);
|
||||
assert!(app.source_reveal_ticket().is_some());
|
||||
assert_stale_envelopes_preserve_current(&mut app, &[captured]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn scoped_escape_retains_references_then_closes_them_without_losing_effective_selection() {
|
||||
for line in [false, true] {
|
||||
let mut app = fixture();
|
||||
let selected = select_effective_pump(&mut app, line);
|
||||
let results = finish(&mut app, query::Action::References);
|
||||
app.source_symbols.notice = Some("Keep this reference notice".into());
|
||||
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
|
||||
app.source_navigation.query = "retain 雪".into();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let revision = app.source_workspace.revision();
|
||||
|
||||
let mut messages = captured_input_subscription_messages(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.source_navigation.query, "retain 雪");
|
||||
assert!(references_visible(&app));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert_eq!(
|
||||
app.source_symbols.notice.as_deref(),
|
||||
Some("Keep this reference notice")
|
||||
);
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
|
||||
let mut messages = captured_input_subscription_messages(&app);
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(matches!(messages[0], Message::Escape));
|
||||
send(&mut app, messages.remove(0));
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert_eq!(
|
||||
app.source_symbols.notice.as_deref(),
|
||||
Some("Keep this reference notice")
|
||||
);
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
assert!(captured_input_subscription_messages(&app).is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_workspace.revision(), revision);
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
|
||||
let expected = if line {
|
||||
raw.files[USES].replacen(&selected, "x", 1)
|
||||
} else {
|
||||
raw.files[USES].replacen("source @pump", "source @x", 1)
|
||||
};
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
|
||||
);
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
send(&mut app, Message::SourceUndo);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
fn select_effective_pump(app: &mut App, line: bool) -> String {
|
||||
let source = app.source_workspace.text();
|
||||
let offset = source.find("source @pump").unwrap() + "source @pu".len();
|
||||
app.source_editor.move_to(text_editor::Cursor {
|
||||
position: position(source, offset),
|
||||
selection: None,
|
||||
});
|
||||
send(
|
||||
app,
|
||||
Message::SourceEdit(if line {
|
||||
text_editor::Action::SelectLine
|
||||
} else {
|
||||
text_editor::Action::SelectWord
|
||||
}),
|
||||
);
|
||||
let selected = app.source_editor.selection().unwrap();
|
||||
assert!(selected.contains("pump"));
|
||||
let raw = app.source_editor.cursor();
|
||||
assert_eq!(raw.selection, Some(raw.position));
|
||||
selected
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_selection_survives_file_roundtrip_and_definition_back() {
|
||||
for line in [false, true] {
|
||||
for definition in [false, true] {
|
||||
let mut app = fixture();
|
||||
let selected = select_effective_pump(&mut app, line);
|
||||
let accepted = app.document.snapshot();
|
||||
let bundle = app.source_workspace.bundle();
|
||||
let revision = app.source_workspace.revision();
|
||||
if definition {
|
||||
let results = finish(&mut app, query::Action::Definition);
|
||||
assert_eq!(results.disposition, query::Disposition::Direct);
|
||||
reveal(&mut app);
|
||||
assert_eq!(app.source_workspace.active, DEFINITIONS);
|
||||
symbol(&mut app, nav::SymbolEvent::Back);
|
||||
reveal(&mut app);
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
} else {
|
||||
show(&mut app, DEFINITIONS);
|
||||
show(&mut app, USES);
|
||||
}
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), bundle);
|
||||
assert_eq!(app.source_workspace.revision(), revision);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.source_workspace.undo(false) && !app.source_workspace.undo(true));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
let expected = if line {
|
||||
bundle.files[USES].replacen(&selected, "x", 1)
|
||||
} else {
|
||||
bundle.files[USES].replacen("source @pump", "source @x", 1)
|
||||
};
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
|
||||
);
|
||||
assert_eq!(app.source_workspace.text(), expected);
|
||||
assert_eq!(app.source_editor.text(), expected);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_selection_is_untouched_by_noop_missing_invalid_or_cancelled_navigation() {
|
||||
for variant in 0..4 {
|
||||
let mut app = fixture();
|
||||
let selected = select_effective_pump(&mut app, false);
|
||||
let raw_cursor = app.source_editor.cursor();
|
||||
let bundle = app.source_workspace.bundle();
|
||||
let revision = app.source_workspace.revision();
|
||||
let accepted = app.document.snapshot();
|
||||
match variant {
|
||||
0 => send(&mut app, Message::SourceFile(USES.into())),
|
||||
1 => send(&mut app, Message::SourceFile("missing.arcadia".into())),
|
||||
2 => app.source_symbol_jump(
|
||||
DEFINITIONS,
|
||||
query::Position {
|
||||
line: 999,
|
||||
byte_column: 0,
|
||||
},
|
||||
query::Position {
|
||||
line: 999,
|
||||
byte_column: 1,
|
||||
},
|
||||
),
|
||||
_ => {
|
||||
let request = begin(&mut app, query::Action::Definition);
|
||||
let ready = completion(&app, request);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
send(&mut app, Message::SourceFile(USES.into()));
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
reveal(&mut app);
|
||||
}
|
||||
}
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(
|
||||
app.source_editor.cursor(),
|
||||
raw_cursor,
|
||||
"live selection mode must not normalize"
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), bundle);
|
||||
assert_eq!(app.source_workspace.revision(), revision);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn changed_active_origin_back_keeps_current_effective_selection() {
|
||||
let mut app = fixture();
|
||||
let old_generation = app.source_workspace.files[USES].generation();
|
||||
let old_cursor = app.source_editor.cursor();
|
||||
let original = app.source_workspace.text().to_owned();
|
||||
app.source_editor
|
||||
.perform(text_editor::Action::Move(text_editor::Motion::Left));
|
||||
app.source_editor.move_to(text_editor::Cursor {
|
||||
position: text_editor::Position { line: 0, column: 3 },
|
||||
selection: None,
|
||||
});
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Insert('x'))),
|
||||
);
|
||||
let mut expected_edit = original;
|
||||
expected_edit.insert(3, 'x');
|
||||
assert_eq!(app.source_workspace.text(), expected_edit);
|
||||
let selected = select_effective_pump(&mut app, true);
|
||||
let bundle = app.source_workspace.bundle();
|
||||
let revision = app.source_workspace.revision();
|
||||
app.source_symbols.back.push(nav::HistoryEntry {
|
||||
session: app.source_workspace.session(),
|
||||
path: USES.into(),
|
||||
generation: old_generation,
|
||||
cursor: old_cursor,
|
||||
});
|
||||
symbol(&mut app, nav::SymbolEvent::Back);
|
||||
reveal(&mut app);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
assert!(
|
||||
app.source_symbols
|
||||
.notice
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("draft changed")
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), bundle);
|
||||
assert_eq!(app.source_workspace.revision(), revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_creation_preserves_outgoing_selection_and_undisplayed_entry_cursor() {
|
||||
let mut app = fixture();
|
||||
let selected = select_effective_pump(&mut app, true);
|
||||
let bundle = app.source_workspace.bundle();
|
||||
let entry_cursor = app.source_workspace.files[&bundle.entry].cursor;
|
||||
let accepted = app.document.snapshot();
|
||||
send(&mut app, Message::SourceNewPath("new.arcadia".into()));
|
||||
send(&mut app, Message::SourceCreateFile);
|
||||
assert_eq!(app.source_workspace.active, "new.arcadia");
|
||||
assert_eq!(
|
||||
app.source_workspace.files[&bundle.entry].cursor,
|
||||
entry_cursor
|
||||
);
|
||||
assert_eq!(app.source_workspace.files[USES].text, bundle.files[USES]);
|
||||
assert!(
|
||||
app.source_workspace.files[&bundle.entry]
|
||||
.text
|
||||
.contains("include \"new.arcadia\";")
|
||||
);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
show(&mut app, USES);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
select_effective_pump(&mut app, true);
|
||||
let raw = app.source_editor.cursor();
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceNewPath("../invalid.arcadia".into()),
|
||||
);
|
||||
send(&mut app, Message::SourceCreateFile);
|
||||
assert_eq!(app.source_workspace.active, USES);
|
||||
assert_eq!(app.source_editor.cursor(), raw);
|
||||
assert_eq!(
|
||||
app.source_editor.selection().as_deref(),
|
||||
Some(selected.as_str())
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,6 @@ impl App {
|
||||
if self.behavior.has_pending() {
|
||||
return;
|
||||
}
|
||||
self.behavior.scenario.forget_view();
|
||||
self.layer = layer;
|
||||
self.page = Page::Behavior;
|
||||
self.behavior.active_owner = Some(machine);
|
||||
@@ -115,6 +114,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
match event {
|
||||
Event::Remove(intent) => self.remove_from_diagram(intent),
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
&& size.height.is_finite()
|
||||
@@ -172,6 +172,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,6 +228,22 @@ impl App {
|
||||
let Some(context) = self.state_context() else {
|
||||
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 self.execute(plan.command) {
|
||||
|
||||
@@ -0,0 +1,686 @@
|
||||
//! Text-only presentation and captured command envelopes. Source/model authority
|
||||
//! remains in the existing App controllers; dispatch this envelope before the
|
||||
//! generic source_interaction invalidation in App::update.
|
||||
use super::{App, Message};
|
||||
use crate::{context_menu, source_navigation as nav};
|
||||
use iced::{
|
||||
Point, Task,
|
||||
widget::{self, text_editor::Cursor},
|
||||
};
|
||||
use nav::query::{QueryStamp, SnapshotKey};
|
||||
use oa_core::{Id, source_project::SourceLocation};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DrawerTab {
|
||||
References,
|
||||
Problems,
|
||||
Status,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Find,
|
||||
GoToLine,
|
||||
Definition,
|
||||
References,
|
||||
RenameAlias,
|
||||
Back,
|
||||
UndoText,
|
||||
RedoText,
|
||||
UndoModel,
|
||||
RedoModel,
|
||||
UndoRename,
|
||||
RevertFile,
|
||||
ShowProblems,
|
||||
ShowStatus,
|
||||
ShowDiagramUsages,
|
||||
}
|
||||
impl Command {
|
||||
fn shortcut(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::Find => Some("Ctrl F"),
|
||||
Self::GoToLine => Some("Ctrl G"),
|
||||
Self::Definition => Some("F12"),
|
||||
Self::References => Some("Shift F12"),
|
||||
Self::RenameAlias => Some("F2"),
|
||||
Self::Back => Some("Alt ←"),
|
||||
Self::UndoText => Some("Ctrl Z"),
|
||||
Self::RedoText => Some("Ctrl Y"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Find => "Find in file",
|
||||
Self::GoToLine => "Go to line",
|
||||
Self::Definition => "Go to definition",
|
||||
Self::References => "Find references",
|
||||
Self::RenameAlias => "Rename source alias",
|
||||
Self::Back => "Back in source",
|
||||
Self::UndoText => "Undo text edit",
|
||||
Self::RedoText => "Redo text edit",
|
||||
Self::UndoModel => "Undo model change",
|
||||
Self::RedoModel => "Redo model change",
|
||||
Self::UndoRename => "Undo source rename",
|
||||
Self::RevertFile => "Revert current file",
|
||||
Self::ShowProblems => "Source problems",
|
||||
Self::ShowStatus => "Source status",
|
||||
Self::ShowDiagramUsages => "Show diagram usages",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct CommandContext {
|
||||
document: Id,
|
||||
document_revision: u64,
|
||||
source: SnapshotKey,
|
||||
path: String,
|
||||
cursor: Cursor,
|
||||
interaction: Id,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandRequest {
|
||||
context: CommandContext,
|
||||
command: Command,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MenuRequest {
|
||||
context: CommandContext,
|
||||
anchor: Point,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DiagnosticPublication {
|
||||
generation: Id,
|
||||
source: SnapshotKey,
|
||||
}
|
||||
impl DiagnosticPublication {
|
||||
pub fn generation(self) -> Id {
|
||||
self.generation
|
||||
}
|
||||
pub fn source(self) -> SnapshotKey {
|
||||
self.source
|
||||
}
|
||||
pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget {
|
||||
DiagnosticTarget {
|
||||
publication: self,
|
||||
visit,
|
||||
location,
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiagnosticTarget {
|
||||
publication: DiagnosticPublication,
|
||||
visit: Id,
|
||||
location: SourceLocation,
|
||||
}
|
||||
|
||||
/// Authority captured by the rendered drawer, independent of its source rows.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct DrawerContext {
|
||||
pub document: Id,
|
||||
pub session: Id,
|
||||
pub visit: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
ShowDrawer(DrawerContext, DrawerTab),
|
||||
HideDrawer(DrawerContext),
|
||||
ResizeDrawer(DrawerContext, f32),
|
||||
ToggleFile {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
path: String,
|
||||
},
|
||||
ReferenceScrolled {
|
||||
stamp: QueryStamp,
|
||||
visit: Id,
|
||||
y: f32,
|
||||
},
|
||||
Command(CommandRequest),
|
||||
OpenMenu(MenuRequest),
|
||||
DismissMenu(Id),
|
||||
Invoke {
|
||||
token: Id,
|
||||
command: Command,
|
||||
},
|
||||
Diagnostic(DiagnosticTarget),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct FrozenEntry {
|
||||
command: Command,
|
||||
action: Option<Action>,
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
enum Action {
|
||||
Dispatch(Box<Message>),
|
||||
Drawer(DrawerTab),
|
||||
}
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Menu {
|
||||
token: Id,
|
||||
popup_token: u64,
|
||||
anchor: Point,
|
||||
context: CommandContext,
|
||||
entries: Vec<FrozenEntry>,
|
||||
}
|
||||
impl Menu {
|
||||
pub fn token(&self) -> Id {
|
||||
self.token
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ReferencePresentation {
|
||||
stamp: Option<QueryStamp>,
|
||||
origin: String,
|
||||
files: BTreeSet<String>,
|
||||
installed: bool,
|
||||
collapsed_files: BTreeSet<String>,
|
||||
scroll_y: f32,
|
||||
}
|
||||
impl Default for ReferencePresentation {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
stamp: None,
|
||||
origin: String::new(),
|
||||
files: BTreeSet::new(),
|
||||
installed: false,
|
||||
collapsed_files: BTreeSet::new(),
|
||||
scroll_y: 0.,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl ReferencePresentation {
|
||||
pub fn stamp(&self) -> Option<QueryStamp> {
|
||||
self.stamp
|
||||
}
|
||||
pub fn scroll_y(&self) -> f32 {
|
||||
self.scroll_y
|
||||
}
|
||||
pub fn collapsed(&self, path: &str) -> bool {
|
||||
self.collapsed_files.contains(path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct State {
|
||||
drawer: Option<DrawerTab>,
|
||||
drawer_visit: Id,
|
||||
drawer_height: f32,
|
||||
references: ReferencePresentation,
|
||||
menu: Option<Menu>,
|
||||
diagnostic_publication: Option<DiagnosticPublication>,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
drawer: None,
|
||||
drawer_visit: Id::new_v4(),
|
||||
drawer_height: 180.,
|
||||
references: ReferencePresentation::default(),
|
||||
menu: None,
|
||||
diagnostic_publication: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub fn drawer_visit(&self) -> Id {
|
||||
self.drawer_visit
|
||||
}
|
||||
pub fn reference_scroll_id(&self) -> widget::Id {
|
||||
widget::Id::from(format!("text-references-{}", self.drawer_visit))
|
||||
}
|
||||
pub fn drawer(&self) -> Option<DrawerTab> {
|
||||
self.drawer
|
||||
}
|
||||
pub fn drawer_height(&self) -> f32 {
|
||||
self.drawer_height
|
||||
}
|
||||
pub fn references(&self) -> &ReferencePresentation {
|
||||
&self.references
|
||||
}
|
||||
pub fn menu(&self) -> Option<&Menu> {
|
||||
self.menu.as_ref()
|
||||
}
|
||||
pub fn diagnostic_publication(&self) -> Option<DiagnosticPublication> {
|
||||
self.diagnostic_publication
|
||||
}
|
||||
/// Call for EVERY diagnostics replacement or clear, including equal bytes.
|
||||
pub fn publish_diagnostics(&mut self, source: SnapshotKey) -> DiagnosticPublication {
|
||||
let publication = DiagnosticPublication {
|
||||
generation: Id::new_v4(),
|
||||
source,
|
||||
};
|
||||
self.diagnostic_publication = Some(publication);
|
||||
publication
|
||||
}
|
||||
pub fn reset(&mut self) {
|
||||
*self = Self::default();
|
||||
}
|
||||
pub fn dismiss_menu(&mut self) {
|
||||
self.menu = None;
|
||||
}
|
||||
pub fn begin_references(&mut self, stamp: QueryStamp, origin: String) {
|
||||
self.references = ReferencePresentation {
|
||||
stamp: Some(stamp),
|
||||
origin,
|
||||
..ReferencePresentation::default()
|
||||
};
|
||||
self.show_drawer(Some(DrawerTab::References));
|
||||
}
|
||||
/// Install presentation groups only for the current completed query. Repeated
|
||||
/// delivery of the same completion cannot undo the user's group choices.
|
||||
pub fn install_reference_files(
|
||||
&mut self,
|
||||
stamp: QueryStamp,
|
||||
paths: impl IntoIterator<Item = String>,
|
||||
) {
|
||||
let refs = &mut self.references;
|
||||
if refs.stamp != Some(stamp) || refs.installed {
|
||||
return;
|
||||
}
|
||||
refs.files = paths.into_iter().collect();
|
||||
refs.collapsed_files = refs.files.clone();
|
||||
let expanded = if refs.files.contains(&refs.origin) {
|
||||
Some(refs.origin.clone())
|
||||
} else {
|
||||
refs.files.first().cloned()
|
||||
};
|
||||
if let Some(path) = expanded {
|
||||
refs.collapsed_files.remove(&path);
|
||||
}
|
||||
refs.installed = true;
|
||||
}
|
||||
pub(super) fn show_drawer(&mut self, drawer: Option<DrawerTab>) {
|
||||
self.drawer = drawer;
|
||||
self.drawer_visit = Id::new_v4();
|
||||
}
|
||||
fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer != Some(DrawerTab::References)
|
||||
|| self.drawer_visit != visit
|
||||
|| refs.stamp != Some(stamp)
|
||||
|| !refs.files.contains(&path)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if !refs.collapsed_files.remove(&path) {
|
||||
refs.collapsed_files.insert(path);
|
||||
}
|
||||
// Collapsing a group can replace a pressed row at the same position.
|
||||
self.drawer_visit = Id::new_v4();
|
||||
true
|
||||
}
|
||||
fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) {
|
||||
let refs = &mut self.references;
|
||||
if self.drawer == Some(DrawerTab::References)
|
||||
&& refs.stamp == Some(stamp)
|
||||
&& self.drawer_visit == visit
|
||||
&& y.is_finite()
|
||||
&& y >= 0.
|
||||
{
|
||||
refs.scroll_y = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn text_drawer_context(&self) -> DrawerContext {
|
||||
DrawerContext {
|
||||
document: self.canvas_document,
|
||||
session: self.source_workspace.session(),
|
||||
visit: self.text_workspace.drawer_visit(),
|
||||
}
|
||||
}
|
||||
fn text_command_context(&self) -> CommandContext {
|
||||
CommandContext {
|
||||
document: self.canvas_document,
|
||||
document_revision: self.document.revision(),
|
||||
source: self.source_key(),
|
||||
path: self.source_workspace.active.clone(),
|
||||
cursor: self.source_editor.cursor(),
|
||||
interaction: self.source_symbols.interaction,
|
||||
}
|
||||
}
|
||||
pub fn text_command_message(&self, command: Command) -> Message {
|
||||
Message::TextWorkspace(Event::Command(CommandRequest {
|
||||
context: self.text_command_context(),
|
||||
command,
|
||||
}))
|
||||
}
|
||||
pub fn text_menu_message(&self, anchor: Point) -> Message {
|
||||
Message::TextWorkspace(Event::OpenMenu(MenuRequest {
|
||||
context: self.text_command_context(),
|
||||
anchor,
|
||||
}))
|
||||
}
|
||||
pub fn text_workspace_menu(&self) -> Option<context_menu::Menu<Message>> {
|
||||
let menu = self.text_workspace.menu.as_ref()?;
|
||||
Some(context_menu::Menu {
|
||||
token: menu.popup_token,
|
||||
anchor: menu.anchor,
|
||||
entries: menu
|
||||
.entries
|
||||
.iter()
|
||||
.flat_map(|entry| {
|
||||
let mut rows = Vec::with_capacity(2);
|
||||
if matches!(entry.command, Command::UndoText | Command::ShowProblems) {
|
||||
rows.push(context_menu::Entry::separator());
|
||||
}
|
||||
rows.push(context_menu::Entry::Action {
|
||||
label: entry.command.label().into(),
|
||||
shortcut: entry.command.shortcut().map(str::to_owned),
|
||||
message: entry.action.as_ref().map(|_| {
|
||||
Message::TextWorkspace(Event::Invoke {
|
||||
token: menu.token(),
|
||||
command: entry.command,
|
||||
})
|
||||
}),
|
||||
});
|
||||
rows
|
||||
})
|
||||
.collect(),
|
||||
on_close: Message::TextWorkspace(Event::DismissMenu(menu.token())),
|
||||
})
|
||||
}
|
||||
pub fn text_diagnostic_message(&self, location: SourceLocation) -> Option<Message> {
|
||||
let publication = self.text_workspace.diagnostic_publication?;
|
||||
(publication.source == self.source_key()
|
||||
&& self.text_workspace.drawer() == Some(DrawerTab::Problems))
|
||||
.then(|| {
|
||||
Message::TextWorkspace(Event::Diagnostic(
|
||||
publication.target(self.text_workspace.drawer_visit(), location),
|
||||
))
|
||||
})
|
||||
}
|
||||
pub(crate) fn source_annotation_snapshot(
|
||||
&self,
|
||||
) -> Option<crate::source_annotations::Snapshot<'_>> {
|
||||
let publication = self.text_workspace.diagnostic_publication?;
|
||||
let buffer = self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&self.source_workspace.active)?;
|
||||
if publication.source != self.source_key() || buffer.text != self.source_snapshot.as_ref() {
|
||||
return None;
|
||||
}
|
||||
Some(crate::source_annotations::Snapshot {
|
||||
publication: publication.generation,
|
||||
source: self.source_snapshot.clone(),
|
||||
path: &self.source_workspace.active,
|
||||
diagnostics: &self.source_diagnostics,
|
||||
})
|
||||
}
|
||||
fn text_command_action(&self, command: Command) -> Option<Action> {
|
||||
// Menu has been taken before dispatch-time admission. This intentionally
|
||||
// uses navigation admission, not rename admission, for Find/text history.
|
||||
if !self.source_navigation_available() {
|
||||
return None;
|
||||
}
|
||||
let message =
|
||||
match command {
|
||||
Command::Find => Message::SourceNavigation(nav::Event::Open(false)),
|
||||
Command::GoToLine => Message::SourceNavigation(nav::Event::Open(true)),
|
||||
Command::Definition => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::Definition)),
|
||||
Command::References => self
|
||||
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::References)),
|
||||
Command::RenameAlias => {
|
||||
self.rename_admission().ok()?;
|
||||
self.source_rename_message()
|
||||
}
|
||||
Command::Back => {
|
||||
if !self
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.any(|entry| entry.session == self.source_workspace.session())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.source_symbol_message(nav::SymbolEvent::Back)
|
||||
}
|
||||
Command::UndoText => Message::SourceUndo,
|
||||
Command::RedoText => Message::SourceRedo,
|
||||
Command::UndoModel | Command::RedoModel => {
|
||||
if self.source_dirty() || self.draft_dirty() {
|
||||
return None;
|
||||
}
|
||||
if command == Command::UndoModel {
|
||||
if !self.document.can_undo() {
|
||||
return None;
|
||||
}
|
||||
Message::Undo
|
||||
} else {
|
||||
if !self.document.can_redo() {
|
||||
return None;
|
||||
}
|
||||
Message::Redo
|
||||
}
|
||||
}
|
||||
Command::UndoRename => self.source_rename_undo_message()?,
|
||||
Command::RevertFile => Message::RevertSource,
|
||||
Command::ShowProblems => return Some(Action::Drawer(DrawerTab::Problems)),
|
||||
Command::ShowStatus => return Some(Action::Drawer(DrawerTab::Status)),
|
||||
Command::ShowDiagramUsages => self.text_diagram_usages_message()?,
|
||||
};
|
||||
Some(Action::Dispatch(Box::new(message)))
|
||||
}
|
||||
pub(super) fn text_workspace_event(&mut self, event: Event) -> Task<Message> {
|
||||
match event {
|
||||
Event::OpenMenu(request) => {
|
||||
if request.context != self.text_command_context()
|
||||
|| !self.source_navigation_available()
|
||||
|| self.text_workspace.menu.is_some()
|
||||
|| !request.anchor.x.is_finite()
|
||||
|| !request.anchor.y.is_finite()
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
let Some(popup_token) = self.menu_sequence.checked_add(1) else {
|
||||
return Task::none();
|
||||
};
|
||||
self.text_presentation_boundary(Event::OpenMenu(request.clone()));
|
||||
self.source_interaction();
|
||||
let commands = [
|
||||
Command::Find,
|
||||
Command::GoToLine,
|
||||
Command::Definition,
|
||||
Command::References,
|
||||
Command::RenameAlias,
|
||||
Command::Back,
|
||||
Command::UndoText,
|
||||
Command::RedoText,
|
||||
Command::UndoRename,
|
||||
Command::UndoModel,
|
||||
Command::RedoModel,
|
||||
Command::RevertFile,
|
||||
Command::ShowProblems,
|
||||
Command::ShowStatus,
|
||||
Command::ShowDiagramUsages,
|
||||
];
|
||||
let entries = commands
|
||||
.into_iter()
|
||||
.filter_map(|command| {
|
||||
let action = self.text_command_action(command);
|
||||
if command == Command::ShowDiagramUsages && action.is_none() {
|
||||
None
|
||||
} else {
|
||||
Some(FrozenEntry { command, action })
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
self.menu_sequence = popup_token;
|
||||
self.text_workspace.menu = Some(Menu {
|
||||
token: Id::new_v4(),
|
||||
popup_token,
|
||||
anchor: request.anchor,
|
||||
context: self.text_command_context(),
|
||||
entries,
|
||||
});
|
||||
}
|
||||
Event::DismissMenu(token) => {
|
||||
if self
|
||||
.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.is_some_and(|menu| menu.token == token)
|
||||
{
|
||||
self.text_presentation_boundary(Event::DismissMenu(token));
|
||||
self.text_workspace.menu = None;
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::Invoke { token, command } => {
|
||||
if !self.text_workspace.menu.as_ref().is_some_and(|menu| {
|
||||
menu.token == token && menu.context == self.text_command_context()
|
||||
}) {
|
||||
return Task::none();
|
||||
}
|
||||
// Validate before closing; consume this invocation only once.
|
||||
// Never resolve a frozen row against a newly generated menu.
|
||||
let menu = self.text_workspace.menu.take().unwrap();
|
||||
let Some(action) = menu
|
||||
.entries
|
||||
.into_iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.and_then(|entry| entry.action)
|
||||
else {
|
||||
return Task::none();
|
||||
};
|
||||
if self.text_command_action(command).is_none() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
Event::Command(request) => {
|
||||
if request.context != self.text_command_context() {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(action) = self.text_command_action(request.command) {
|
||||
return self.dispatch_text_action(action);
|
||||
}
|
||||
}
|
||||
Event::ShowDrawer(context, tab) => {
|
||||
if context != self.text_drawer_context() || !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
self.text_presentation_boundary(Event::ShowDrawer(context, tab));
|
||||
return self.show_text_drawer(Some(tab));
|
||||
}
|
||||
Event::HideDrawer(context) => {
|
||||
if context != self.text_drawer_context() || !self.source_navigation_available() {
|
||||
return Task::none();
|
||||
}
|
||||
self.text_presentation_boundary(Event::HideDrawer(context));
|
||||
return self.show_text_drawer(None);
|
||||
}
|
||||
Event::ResizeDrawer(context, height) => {
|
||||
if context == self.text_drawer_context()
|
||||
&& self.source_navigation_available()
|
||||
&& height.is_finite()
|
||||
{
|
||||
// The view applies the measured editor-allocation bound.
|
||||
let height = height.clamp(180., 1200.);
|
||||
if height != self.text_workspace.drawer_height {
|
||||
self.text_presentation_boundary(Event::ResizeDrawer(context, height));
|
||||
self.source_interaction();
|
||||
self.text_workspace.drawer_height = height;
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::ToggleFile { stamp, visit, path } => {
|
||||
let captured = Event::ToggleFile {
|
||||
stamp,
|
||||
visit,
|
||||
path: path.clone(),
|
||||
};
|
||||
if self.source_navigation_available()
|
||||
&& self.text_workspace.toggle_file(stamp, visit, path)
|
||||
{
|
||||
self.text_presentation_boundary(captured);
|
||||
self.source_interaction();
|
||||
}
|
||||
}
|
||||
Event::ReferenceScrolled { stamp, visit, y } => {
|
||||
// Passive geometry must neither invalidate pending navigation
|
||||
// nor reset the editor identity, even while commands are blocked.
|
||||
self.text_workspace.reference_scrolled(stamp, visit, y);
|
||||
}
|
||||
Event::Diagnostic(target) => {
|
||||
if !self.source_navigation_available()
|
||||
|| self.text_workspace.drawer() != Some(DrawerTab::Problems)
|
||||
|| self.text_workspace.drawer_visit() != target.visit
|
||||
|| self.text_workspace.diagnostic_publication != Some(target.publication)
|
||||
|| target.publication.source != self.source_key()
|
||||
|| !self
|
||||
.source_workspace
|
||||
.files
|
||||
.get(&target.location.path)
|
||||
.is_some_and(|file| valid_span(&file.text, &target.location))
|
||||
{
|
||||
return Task::none();
|
||||
}
|
||||
self.text_presentation_boundary(Event::Diagnostic(target.clone()));
|
||||
self.source_interaction();
|
||||
self.jump_to_source(target.location);
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
fn dispatch_text_action(&mut self, action: Action) -> Task<Message> {
|
||||
match action {
|
||||
Action::Dispatch(message) => self.update(*message),
|
||||
Action::Drawer(tab) => {
|
||||
self.text_presentation_boundary(Event::ShowDrawer(self.text_drawer_context(), tab));
|
||||
self.show_text_drawer(Some(tab))
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Local Text presentation changes do not mutate a camera, document or route.
|
||||
/// After their own authority check, they still supersede pending navigation
|
||||
/// and allocation through the root's existing input boundary hooks. Source
|
||||
/// commands use update instead; passive measurements never enter this hook.
|
||||
fn text_presentation_boundary(&mut self, event: Event) {
|
||||
let message = Message::TextWorkspace(event);
|
||||
self.reconcile_pending_diagram_reveal(&message);
|
||||
self.allocation_before(&message);
|
||||
}
|
||||
|
||||
pub(super) fn show_text_drawer(&mut self, drawer: Option<DrawerTab>) -> Task<Message> {
|
||||
self.source_interaction();
|
||||
self.text_workspace.show_drawer(drawer);
|
||||
if drawer == Some(DrawerTab::References) {
|
||||
let refs = self.text_workspace.references();
|
||||
widget::operation::scroll_to(
|
||||
self.text_workspace.reference_scroll_id(),
|
||||
widget::scrollable::AbsoluteOffset {
|
||||
x: 0.,
|
||||
y: refs.scroll_y(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn valid_span(text: &str, location: &SourceLocation) -> bool {
|
||||
let span = location.span;
|
||||
span.start <= span.end
|
||||
&& span.end <= text.len()
|
||||
&& text.is_char_boundary(span.start)
|
||||
&& text.is_char_boundary(span.end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod lifecycle_tests;
|
||||
@@ -0,0 +1,611 @@
|
||||
//! Real lifecycle receipts, with all disk writes confined to unique temp roots.
|
||||
//! Returned App worker tasks are not polled; completions use the normal captured
|
||||
//! query input and are delivered deterministically through App::update.
|
||||
use super::*;
|
||||
use crate::app::{Page, read_snapshot, write_snapshot};
|
||||
use crate::document::Document;
|
||||
use crate::source_workspace::{LIMITS, Workspace};
|
||||
use oa_core::{
|
||||
Project,
|
||||
language::{Diagnostic, Span},
|
||||
source_project::{SourceBundle, SourceDiagnostic},
|
||||
validation::Severity,
|
||||
};
|
||||
use std::{collections::BTreeMap, path::Path, sync::Arc};
|
||||
|
||||
const VALID: &str = concat!(
|
||||
"arcadia 7;\r\n",
|
||||
"project model \"Lifecycle 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
|
||||
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
|
||||
"}\r\n",
|
||||
);
|
||||
|
||||
fn fixture() -> App {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), VALID.into())]),
|
||||
};
|
||||
let mut app = App::with_project(Project::empty());
|
||||
// with_project uses in-memory Home metadata and no recovery directory.
|
||||
assert!(app.recovery.directory.is_none());
|
||||
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
|
||||
app.source_workspace = Workspace::new(app.document.bundle(), true);
|
||||
app.selected = app.project().id;
|
||||
app.refresh_draft();
|
||||
app.page = Page::Text;
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn save(app: &mut App, path: &Path) {
|
||||
let receipt = write_snapshot(
|
||||
app.canvas_document,
|
||||
app.document.revision(),
|
||||
app.document.snapshot(),
|
||||
path.to_owned(),
|
||||
app.disk_project.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
send(app, Message::Saved(Ok(Some(receipt))));
|
||||
}
|
||||
fn reveal(app: &mut App) {
|
||||
if let Some(ticket) = app.source_reveal_ticket() {
|
||||
send(app, Message::SourceViewport(Some(ticket)));
|
||||
}
|
||||
}
|
||||
fn query(app: &mut App, action: nav::query::Action) -> nav::Completion {
|
||||
let message = app.source_symbol_message(nav::SymbolEvent::Query(action));
|
||||
send(app, message);
|
||||
let request = app.source_symbols.request.clone().expect("admitted query");
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
let snapshot = Arc::new(app.with_source_input(|input| {
|
||||
nav::query::admit_owned(input, nav::query::Limits::default())
|
||||
.unwrap()
|
||||
.capture()
|
||||
.unwrap()
|
||||
}));
|
||||
let results = Arc::new(
|
||||
snapshot
|
||||
.query(nav::query::Query {
|
||||
stamp: request.stamp,
|
||||
path: &request.path,
|
||||
byte_offset: request.offset,
|
||||
action,
|
||||
})
|
||||
.unwrap(),
|
||||
);
|
||||
nav::Completion {
|
||||
request,
|
||||
result: Ok((snapshot, results)),
|
||||
}
|
||||
}
|
||||
fn seed_navigation(app: &mut App, pending: bool) -> Option<nav::Completion> {
|
||||
let text = app.source_workspace.text();
|
||||
let start = text.find("oa_activity assess").unwrap() + "oa_activity ".len();
|
||||
let prefix = &text[..start];
|
||||
let position = widget::text_editor::Position {
|
||||
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
|
||||
column: prefix.rsplit_once('\n').unwrap().1.len(),
|
||||
};
|
||||
app.source_editor.move_to(Cursor {
|
||||
position,
|
||||
selection: None,
|
||||
});
|
||||
let ready = query(app, nav::query::Action::Definition);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
reveal(app);
|
||||
assert_eq!(app.source_symbols.back.len(), 1);
|
||||
let ready = query(app, nav::query::Action::References);
|
||||
send(app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.results.is_some() && app.source_symbols.cache.is_some());
|
||||
assert!(app.source_symbols.display_request.is_some());
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
if pending {
|
||||
Some(query(app, nav::query::Action::Definition))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
fn warning(app: &App) -> SourceDiagnostic {
|
||||
let source = app.source_workspace.text();
|
||||
let primary = source.find("model").unwrap();
|
||||
let related = source.find("assess").unwrap();
|
||||
SourceDiagnostic {
|
||||
path: app.source_workspace.active.clone(),
|
||||
diagnostic: Diagnostic {
|
||||
severity: Severity::Warning,
|
||||
code: "lifecycle_warning".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 2,
|
||||
column: 9,
|
||||
message: "Retained warning with a related source location".into(),
|
||||
span: Span {
|
||||
start: primary,
|
||||
end: primary + 5,
|
||||
},
|
||||
},
|
||||
related: vec![SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span {
|
||||
start: related,
|
||||
end: related + 6,
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn warning_messages(app: &App) -> [Message; 2] {
|
||||
let warning = &app.source_diagnostics[0];
|
||||
[
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: warning.path.clone(),
|
||||
span: warning.diagnostic.span,
|
||||
})
|
||||
.unwrap(),
|
||||
app.text_diagnostic_message(warning.related[0].clone())
|
||||
.unwrap(),
|
||||
]
|
||||
}
|
||||
fn assert_cleared(app: &App, job: Option<Id>) {
|
||||
assert_eq!(app.source_symbols.in_flight, job);
|
||||
assert!(app.source_symbols.request.is_none() && app.source_symbols.display_request.is_none());
|
||||
assert!(app.source_symbols.results.is_none() && app.source_symbols.cache.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.text_workspace.drawer().is_none() && app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.references().stamp().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and_related() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
let ready = seed_navigation(&mut app, true).unwrap();
|
||||
let job = ready.request.job;
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old = warning_messages(&app);
|
||||
let warnings = app.source_diagnostics.clone();
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let raw = app.source_workspace.bundle();
|
||||
save(&mut app, &second_path);
|
||||
assert_eq!(app.path.as_deref(), Some(second_path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, source.workspace);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(std::fs::read_to_string(&first_path).unwrap(), VALID);
|
||||
assert_eq!(std::fs::read_to_string(&second_path).unwrap(), VALID);
|
||||
assert_eq!(app.source_diagnostics, warnings);
|
||||
let current = app
|
||||
.text_workspace
|
||||
.diagnostic_publication()
|
||||
.expect("identity-only save retained current warnings");
|
||||
assert_eq!(current.source(), app.source_key());
|
||||
assert_ne!(current.generation(), publication.generation());
|
||||
assert_cleared(&app, Some(job));
|
||||
for message in old {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
for message in warning_messages(&app) {
|
||||
send(&mut app, message);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related warnings remain actionable"
|
||||
);
|
||||
reveal(&mut app);
|
||||
}
|
||||
let cursor = app.source_editor.cursor();
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_preserving_worker()
|
||||
{
|
||||
for pending in [false, true] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let path = directory
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("broken.arcadia");
|
||||
let invalid = format!(
|
||||
"{} include \"missing.arcadia\";\r\n}}\r\n",
|
||||
VALID.strip_suffix("}\r\n").unwrap()
|
||||
);
|
||||
std::fs::write(&path, &invalid).unwrap();
|
||||
let mut app = fixture();
|
||||
let ready = seed_navigation(&mut app, pending);
|
||||
let job = ready.as_ref().map(|ready| ready.request.job);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old = warning_messages(&app);
|
||||
let old_publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let old_source = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let open_menu = app.text_menu_message(Point::new(20., 20.));
|
||||
send(&mut app, open_menu);
|
||||
assert!(app.text_workspace.menu().is_some());
|
||||
let loaded = read_snapshot(path.clone()).unwrap();
|
||||
send(&mut app, Message::Loaded(Ok(Some(loaded))));
|
||||
assert!(!app.source_workspace.applied);
|
||||
assert_eq!(app.path.as_deref(), Some(path.as_path()));
|
||||
assert_ne!(app.source_key().workspace, old_source.workspace);
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_cleared(&app, job);
|
||||
assert!(
|
||||
app.source_diagnostics
|
||||
.iter()
|
||||
.any(|issue| issue.diagnostic.severity == Severity::Error)
|
||||
);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(publication.source(), app.source_key());
|
||||
assert_ne!(publication.generation(), old_publication.generation());
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let issue = app
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.find(|issue| {
|
||||
issue.path == app.source_workspace.active
|
||||
&& issue.diagnostic.span.end <= invalid.len()
|
||||
})
|
||||
.unwrap();
|
||||
let current = app
|
||||
.text_diagnostic_message(SourceLocation {
|
||||
path: issue.path.clone(),
|
||||
span: issue.diagnostic.span,
|
||||
})
|
||||
.unwrap();
|
||||
send(&mut app, current);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
reveal(&mut app);
|
||||
if let Some(ready) = ready {
|
||||
send(&mut app, Message::SourceSymbolsReady(ready));
|
||||
}
|
||||
assert!(app.source_symbols.in_flight.is_none());
|
||||
assert!(app.source_symbols.results.is_none());
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), invalid);
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), invalid);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canceled_or_failed_open_that_installs_nothing_retains_current_presentation_and_diagnostics() {
|
||||
for failed in [false, true] {
|
||||
let mut app = fixture();
|
||||
seed_navigation(&mut app, false);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
let results = app.source_symbols.results.clone().unwrap();
|
||||
let cache = app.source_symbols.cache.clone().unwrap();
|
||||
let display = app.source_symbols.display_request.clone();
|
||||
let back = app
|
||||
.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
(
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let key = app.source_key();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
let result = if failed {
|
||||
Err("Fixture read failed before installing a workspace".into())
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
send(&mut app, Message::Loaded(result));
|
||||
assert_eq!(app.source_key(), key);
|
||||
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
|
||||
assert_eq!(app.source_diagnostics, diagnostics);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.results.as_ref().unwrap(),
|
||||
&results
|
||||
));
|
||||
assert!(Arc::ptr_eq(
|
||||
app.source_symbols.cache.as_ref().unwrap(),
|
||||
&cache
|
||||
));
|
||||
assert_eq!(app.source_symbols.display_request, display);
|
||||
assert_eq!(
|
||||
app.source_symbols
|
||||
.back
|
||||
.iter()
|
||||
.map(|entry| (
|
||||
entry.session,
|
||||
entry.path.clone(),
|
||||
entry.generation,
|
||||
entry.cursor
|
||||
))
|
||||
.collect::<Vec<_>>(),
|
||||
back
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
|
||||
let first = tempfile::tempdir().unwrap();
|
||||
let second = tempfile::tempdir().unwrap();
|
||||
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
|
||||
let second_path = second
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.join("project.arcadia");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &first_path);
|
||||
app.publish_source_diagnostics(vec![warning(&app)]);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old = warning_messages(&app);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
let draft = format!("{VALID}// unsaved current draft\r\n");
|
||||
app.source_workspace
|
||||
.edit(draft.clone(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert_ne!(publication.source(), app.source_key());
|
||||
let accepted = app.document.snapshot();
|
||||
save(&mut app, &second_path);
|
||||
assert!(
|
||||
app.text_workspace.diagnostic_publication().is_none(),
|
||||
"identity-only reset cannot turn an old source span into a current warning"
|
||||
);
|
||||
{
|
||||
let captured = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: app.source_diagnostics[0].path.clone(),
|
||||
span: app.source_diagnostics[0].diagnostic.span,
|
||||
})
|
||||
.is_none()
|
||||
);
|
||||
for message in old {
|
||||
send(&mut app, message);
|
||||
}
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert_eq!(app.source_workspace.text(), draft);
|
||||
assert!(app.source_dirty());
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&second_path).unwrap(),
|
||||
VALID,
|
||||
"receipt records accepted bytes while the newer draft remains unsaved"
|
||||
);
|
||||
}
|
||||
|
||||
/// Use an actual pending-save journal and the real recovery worker. The optional
|
||||
/// reload error is injected only after recovery succeeded, so both receipt arms
|
||||
/// exercise the same matching target without relying on filesystem races.
|
||||
fn recover_warning_fixture(stale: bool, reload_error: bool) {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let root = directory.path().canonicalize().unwrap();
|
||||
let path = root.join("project.arcadia");
|
||||
let journal = root.join(".arcadia-save-project.arcadia.journal");
|
||||
let mut app = fixture();
|
||||
save(&mut app, &path);
|
||||
std::fs::create_dir(&journal).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("journal.json"),
|
||||
serde_json::to_vec(&serde_json::json!({
|
||||
"version": 1, "entry": "project.arcadia", "files": [{
|
||||
"path": "project.arcadia", "before": true, "write": true
|
||||
}]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(journal.join("0.before"), VALID).unwrap();
|
||||
std::fs::write(
|
||||
journal.join("0.after"),
|
||||
format!("{VALID}// interrupted save\r\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let disk = oa_core::source_files::load_project(&path, LIMITS).unwrap();
|
||||
app.pending_recovery = crate::app::recovery_target(&disk);
|
||||
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
|
||||
let pending = disk
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.find(|issue| issue.diagnostic.code == "pending_save")
|
||||
.unwrap();
|
||||
// Current-warning control includes newer unsaved bytes too. Recovery of an
|
||||
// applied workspace must retain its drafts, not reload over them.
|
||||
let draft = format!("{VALID}// retained draft\r\n");
|
||||
if !stale {
|
||||
app.source_workspace
|
||||
.edit(draft.clone(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
}
|
||||
let retained_warning = warning(&app);
|
||||
app.publish_source_diagnostics(vec![retained_warning.clone(), pending]);
|
||||
let show = Message::TextWorkspace(Event::ShowDrawer(
|
||||
app.text_drawer_context(),
|
||||
DrawerTab::Problems,
|
||||
));
|
||||
send(&mut app, show);
|
||||
let old = warning_messages(&app);
|
||||
let publication = app.text_workspace.diagnostic_publication().unwrap();
|
||||
if stale {
|
||||
// This is the existing workspace-key departure seam: drafts advance
|
||||
// the key while a previous diagnostic Vec can still await lint.
|
||||
app.source_workspace
|
||||
.edit(draft.clone(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert_ne!(publication.source(), app.source_key());
|
||||
} else {
|
||||
assert_eq!(publication.source(), app.source_key());
|
||||
}
|
||||
assert!(app.source_workspace.applied && app.source_workspace.has_edits());
|
||||
reveal(&mut app);
|
||||
let accepted = app.document.snapshot();
|
||||
let revision = app.document.revision();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let key = app.source_key();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
send(&mut app, Message::SourceRecover);
|
||||
assert!(
|
||||
app.busy,
|
||||
"normal recovery request admitted with retained applied drafts"
|
||||
);
|
||||
let mut receipt = crate::app::recover_snapshot(path.clone()).unwrap();
|
||||
assert_eq!(receipt.target, path);
|
||||
assert!(receipt.disk.is_ok());
|
||||
assert!(!journal.exists(), "real recovery consumed the journal");
|
||||
if reload_error {
|
||||
receipt.disk = Err("injected reload failure after successful recovery".into());
|
||||
}
|
||||
send(&mut app, Message::SourceRecovered(Ok(receipt)));
|
||||
assert!(!app.busy && app.pending_recovery.is_none());
|
||||
assert_eq!(app.source_diagnostics, vec![retained_warning]);
|
||||
assert_eq!(app.source_key(), key);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_workspace.text(), draft);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Problems));
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), VALID);
|
||||
assert!(app.status.contains(if reload_error {
|
||||
"files could not be reloaded"
|
||||
} else {
|
||||
"Current model and drafts are retained"
|
||||
}));
|
||||
if stale {
|
||||
assert_eq!(
|
||||
app.text_workspace.diagnostic_publication(),
|
||||
Some(publication)
|
||||
);
|
||||
assert!(app.source_annotation_snapshot().is_none());
|
||||
assert!(
|
||||
app.text_diagnostic_message(SourceLocation {
|
||||
path: app.source_diagnostics[0].path.clone(),
|
||||
span: app.source_diagnostics[0].diagnostic.span,
|
||||
})
|
||||
.is_none(),
|
||||
"recovery must not manufacture a fresh stale-warning action"
|
||||
);
|
||||
} else {
|
||||
let current = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(current.source(), key);
|
||||
assert_ne!(current.generation(), publication.generation());
|
||||
assert!(app.source_annotation_snapshot().is_some());
|
||||
}
|
||||
for message in old {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
if !stale {
|
||||
for message in warning_messages(&app) {
|
||||
send(&mut app, message);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"current primary and related remain live"
|
||||
);
|
||||
reveal(&mut app);
|
||||
}
|
||||
}
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_retains_current_warning_authority_with_drafts_and_reload_failure() {
|
||||
for reload_error in [false, true] {
|
||||
recover_warning_fixture(false, reload_error);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recovery_does_not_republish_stale_warnings_after_workspace_key_departure() {
|
||||
for reload_error in [false, true] {
|
||||
recover_warning_fixture(true, reload_error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,877 @@
|
||||
use super::*;
|
||||
use crate::app::Page;
|
||||
use oa_core::{Project, language::Span};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn fixture() -> App {
|
||||
let mut app = App::with_project(Project::empty());
|
||||
app.page = Page::Text;
|
||||
let diagnostics = app.source_diagnostics.clone();
|
||||
app.publish_source_diagnostics(diagnostics);
|
||||
assert!(app.source_navigation_available());
|
||||
app
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
// Exercise the real envelope path; worker/lint futures are deliberately not
|
||||
// polled here. The parent owns serialized compile/runtime verification.
|
||||
let _ = app.update(Message::TextWorkspace(event));
|
||||
}
|
||||
fn toggle(app: &App, stamp: QueryStamp, path: String) -> Event {
|
||||
Event::ToggleFile {
|
||||
stamp,
|
||||
visit: app.text_workspace.drawer_visit(),
|
||||
path,
|
||||
}
|
||||
}
|
||||
fn open(app: &mut App) -> Id {
|
||||
let message = app.text_menu_message(Point::new(10., 20.));
|
||||
let _ = app.update(message);
|
||||
app.text_workspace.menu().unwrap().token()
|
||||
}
|
||||
fn invoke(app: &mut App, token: Id, command: Command) {
|
||||
send(app, Event::Invoke { token, command });
|
||||
}
|
||||
fn enabled(app: &App, command: Command) -> bool {
|
||||
app.text_workspace
|
||||
.menu
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find(|entry| entry.command == command)
|
||||
.is_some_and(|entry| entry.action.is_some())
|
||||
}
|
||||
fn back_entry(app: &App) -> nav::HistoryEntry {
|
||||
nav::HistoryEntry {
|
||||
session: app.source_workspace.session(),
|
||||
path: app.source_workspace.active.clone(),
|
||||
generation: app.source_workspace.files[&app.source_workspace.active].generation(),
|
||||
cursor: app.source_editor.cursor(),
|
||||
}
|
||||
}
|
||||
fn stamp(app: &App, serial: u64) -> QueryStamp {
|
||||
QueryStamp {
|
||||
snapshot: app.source_key(),
|
||||
serial,
|
||||
}
|
||||
}
|
||||
fn location(app: &App, start: usize, end: usize) -> SourceLocation {
|
||||
SourceLocation {
|
||||
path: app.source_workspace.active.clone(),
|
||||
span: Span { start, end },
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_own_open_invalidation_does_not_stale_find_and_invoke_is_once() {
|
||||
let mut app = fixture();
|
||||
let old_interaction = app.source_symbols.interaction;
|
||||
let token = open(&mut app);
|
||||
assert_ne!(old_interaction, app.source_symbols.interaction);
|
||||
assert_eq!(
|
||||
app.text_workspace.menu.as_ref().unwrap().context,
|
||||
app.text_command_context()
|
||||
);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
let instance = app.source_find_instance;
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert_eq!(
|
||||
app.source_find_instance, instance,
|
||||
"replay must not reopen Find"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_entry_cannot_become_a_new_action_under_the_same_menu_token() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(!enabled(&app, Command::Back));
|
||||
app.source_symbols.back.push(back_entry(&app));
|
||||
assert!(
|
||||
!enabled(&app, Command::Back),
|
||||
"frozen presentation must remain disabled"
|
||||
);
|
||||
invoke(&mut app, token, Command::Back);
|
||||
assert_eq!(
|
||||
app.source_symbols.back.len(),
|
||||
1,
|
||||
"a newly enabled action cannot be substituted"
|
||||
);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enabled_entry_is_readmitted_at_dispatch_and_blocked_by_busy() {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find));
|
||||
app.busy = true;
|
||||
assert!(enabled(&app, Command::Find));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_dismiss_and_invoke_do_not_consume_a_newer_menu() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
let new = open(&mut app);
|
||||
send(&mut app, Event::DismissMenu(old));
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), new);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_source_caret_document_path_and_interaction_changes_refuse_stale_find() {
|
||||
for change in 0..6 {
|
||||
let mut app = fixture();
|
||||
let token = open(&mut app);
|
||||
match change {
|
||||
0 => {
|
||||
let value = format!("{}\n// changed", app.source_workspace.text());
|
||||
app.source_workspace.edit(value, app.source_editor.cursor());
|
||||
}
|
||||
1 => {
|
||||
let mut cursor = app.source_editor.cursor();
|
||||
cursor.position.column += 1;
|
||||
app.source_editor.move_to(cursor);
|
||||
}
|
||||
2 => app.canvas_document = Id::new_v4(),
|
||||
3 => app.source_workspace.active = "other.arcadia".into(),
|
||||
4 => app.source_symbols.interaction = Id::new_v4(),
|
||||
_ => {
|
||||
let mut bundle = app.document.bundle().clone();
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry)
|
||||
.unwrap()
|
||||
.push_str("\n// accepted revision");
|
||||
assert!(app.document.apply_bundle(bundle).unwrap());
|
||||
}
|
||||
}
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(!app.source_navigation.open, "stale context case {change}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_and_property_drafts_keep_find_and_text_undo_without_model_application() {
|
||||
let mut app = fixture();
|
||||
let accepted = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let pending_name = "Pending property draft";
|
||||
// Use the actual property input route before source edits coexist. Changing
|
||||
// Draft::value alone is overwritten by its authoritative raw input buffer.
|
||||
let _ = app.update(Message::Field("/name".into(), pending_name.into()));
|
||||
let assert_pending_property = |app: &App| {
|
||||
let draft = app.draft.as_ref().unwrap();
|
||||
assert!(draft.dirty());
|
||||
assert_eq!(draft.inputs["/name"].raw, pending_name);
|
||||
assert_eq!(draft.inputs["/name"].editor.text(), pending_name);
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap()["name"].as_str(),
|
||||
Some(pending_name)
|
||||
);
|
||||
};
|
||||
assert_pending_property(&app);
|
||||
let initial = app.source_workspace.text().to_owned();
|
||||
app.source_workspace
|
||||
.edit(format!("{initial}\n// draft"), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
assert!(app.source_dirty() && app.draft_dirty());
|
||||
let token = open(&mut app);
|
||||
assert!(enabled(&app, Command::Find) && enabled(&app, Command::UndoText));
|
||||
assert!(!enabled(&app, Command::RenameAlias) && !enabled(&app, Command::UndoModel));
|
||||
invoke(&mut app, token, Command::Find);
|
||||
assert!(app.source_navigation.open);
|
||||
assert_pending_property(&app);
|
||||
let token = open(&mut app);
|
||||
invoke(&mut app, token, Command::UndoText);
|
||||
assert_eq!(app.source_workspace.text(), initial);
|
||||
assert_pending_property(&app);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.document.bundle(), &accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_toolbar_request_cannot_retarget_after_source_change() {
|
||||
let mut app = fixture();
|
||||
let request = app.text_command_message(Command::Find);
|
||||
app.source_workspace
|
||||
.edit("// changed".into(), app.source_editor.cursor());
|
||||
let _ = app.update(request);
|
||||
assert!(!app.source_navigation.open);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn equal_diagnostic_republication_invalidates_primary_and_related_targets() {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let primary = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let related = app.text_diagnostic_message(location(&app, 1, 2)).unwrap();
|
||||
let previous = app.text_workspace.diagnostic_publication().unwrap();
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let current = app.text_workspace.diagnostic_publication().unwrap();
|
||||
assert_eq!(previous.source(), current.source());
|
||||
assert_ne!(previous.generation(), current.generation());
|
||||
for message in [primary, related] {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it() {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
app.source_interaction();
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.source_workspace
|
||||
.edit("// newer".into(), app.source_editor.cursor());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
assert!(app.text_diagnostic_message(location(&app, 0, 1)).is_none());
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostic_span_must_be_loaded_ordered_and_utf8_exact() {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
app.source_workspace
|
||||
.edit("a雨b".into(), app.source_editor.cursor());
|
||||
app.show_source_file(false);
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
let mut missing = location(&app, 0, 1);
|
||||
missing.path = "missing.arcadia".into();
|
||||
for target in [
|
||||
location(&app, 3, 2),
|
||||
location(&app, 0, 6),
|
||||
location(&app, 2, 4),
|
||||
location(&app, 1, 3),
|
||||
missing,
|
||||
] {
|
||||
let message = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_none());
|
||||
}
|
||||
let message = app.text_diagnostic_message(location(&app, 1, 4)).unwrap();
|
||||
let _ = app.update(message);
|
||||
assert!(app.source_symbols.reveal.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries() {
|
||||
let mut app = fixture();
|
||||
let first = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(first, "b/use.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["a/use.arcadia".into(), "b/use.arcadia".into()]);
|
||||
assert!(app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(!app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "a/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let old_scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let interaction = app.source_symbols.interaction;
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 123.,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_symbols.interaction, interaction,
|
||||
"scroll is passive"
|
||||
);
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Status);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: first,
|
||||
visit: old_visit,
|
||||
y: 0.,
|
||||
},
|
||||
);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
|
||||
assert_ne!(app.text_workspace.reference_scroll_id(), old_scroll_id);
|
||||
let second = stamp(&app, 2);
|
||||
app.text_workspace
|
||||
.begin_references(second, "absent.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(first, ["old.arcadia".into()]);
|
||||
app.text_workspace
|
||||
.install_reference_files(second, ["b/use.arcadia".into(), "a/use.arcadia".into()]);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 0.);
|
||||
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
let event = toggle(&app, first, "b/use.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() {
|
||||
let mut app = fixture();
|
||||
open(&mut app);
|
||||
app.text_workspace.reset();
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
assert!(app.text_workspace.diagnostic_publication().is_none());
|
||||
assert!(app.text_workspace.drawer().is_none());
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.text_workspace.drawer_height(), 180.);
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::MAX);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.text_workspace.drawer_height(), 1200.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "uses.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["uses.arcadia".into()]);
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let event = toggle(&app, current, "not-a-group.arcadia".into());
|
||||
send(&mut app, event);
|
||||
let old = QueryStamp {
|
||||
serial: 0,
|
||||
..current
|
||||
};
|
||||
let event = toggle(&app, old, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), 180.);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert!(app.source_symbols.request.is_some());
|
||||
let event = toggle(&app, current, "uses.arcadia".into());
|
||||
send(&mut app, event);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_ne!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(
|
||||
app.source_symbols.in_flight,
|
||||
Some(request.job),
|
||||
"intent does not release an occupied worker"
|
||||
);
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
{
|
||||
let captured = Event::ResizeDrawer(app.text_drawer_context(), 240.);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
{
|
||||
let captured = Event::HideDrawer(app.text_drawer_context());
|
||||
send(&mut app, captured);
|
||||
};
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_retained_group_can_expand_without_navigating_or_applying_drafts() {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
let path = app.source_workspace.active.clone();
|
||||
app.text_workspace.begin_references(current, path.clone());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, [path.clone()]);
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(app.text_workspace.references().collapsed(&path));
|
||||
let accepted = app.document.snapshot();
|
||||
let _ = app.update(Message::SourceEdit(widget::text_editor::Action::Edit(
|
||||
widget::text_editor::Edit::Insert(' '),
|
||||
)));
|
||||
assert_ne!(current.snapshot, app.source_key());
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let event = toggle(&app, current, path.clone());
|
||||
send(&mut app, event);
|
||||
assert!(!app.text_workspace.references().collapsed(&path));
|
||||
assert_eq!(app.source_workspace.active, path);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_symbols.back.is_empty());
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority() {
|
||||
let mut app = fixture();
|
||||
let old = open(&mut app);
|
||||
let sequence = app.text_workspace_menu().unwrap().token;
|
||||
app.reset(Project::empty(), None);
|
||||
assert!(app.text_workspace.menu().is_none());
|
||||
app.page = Page::Text;
|
||||
let fresh = open(&mut app);
|
||||
assert_ne!(old, fresh);
|
||||
assert!(app.text_workspace_menu().unwrap().token > sequence);
|
||||
invoke(&mut app, old, Command::Find);
|
||||
assert!(!app.source_navigation.open);
|
||||
assert_eq!(app.text_workspace.menu().unwrap().token(), fresh);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_references_problems() {
|
||||
for hide in [false, true] {
|
||||
let mut app = fixture();
|
||||
{
|
||||
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
let publication = app.text_workspace.diagnostic_publication();
|
||||
let targets = [location(&app, 0, 1), location(&app, 1, 2)];
|
||||
let old = targets
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|target| app.text_diagnostic_message(target).unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let history = (
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty(),
|
||||
);
|
||||
let event = if hide {
|
||||
Event::HideDrawer(app.text_drawer_context())
|
||||
} else {
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References)
|
||||
};
|
||||
send(&mut app, event);
|
||||
for reopened in [false, true] {
|
||||
if reopened {
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
} else {
|
||||
assert!(app.text_diagnostic_message(targets[0].clone()).is_none());
|
||||
}
|
||||
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
|
||||
assert_eq!(
|
||||
app.text_workspace.diagnostic_publication(),
|
||||
publication,
|
||||
"same list and source isolate the visit guard from publication freshness"
|
||||
);
|
||||
let context = app.text_command_context();
|
||||
let drawer = app.text_workspace.drawer();
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
for message in old.iter().cloned() {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(
|
||||
app.text_command_context(),
|
||||
context,
|
||||
"stale diagnostic must fail before interaction invalidation"
|
||||
);
|
||||
assert_eq!(app.text_workspace.drawer(), drawer);
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
app.document.dirty()
|
||||
),
|
||||
history
|
||||
);
|
||||
}
|
||||
}
|
||||
for target in targets {
|
||||
let fresh = app.text_diagnostic_message(target).unwrap();
|
||||
let _ = app.update(fresh);
|
||||
assert!(
|
||||
app.source_symbols.reveal.is_some(),
|
||||
"fresh primary and related targets remain usable"
|
||||
);
|
||||
app.source_interaction();
|
||||
}
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
|
||||
for replacement in 0..3 {
|
||||
let mut app = fixture();
|
||||
let current = stamp(&app, 1);
|
||||
app.text_workspace
|
||||
.begin_references(current, "a.arcadia".into());
|
||||
app.text_workspace
|
||||
.install_reference_files(current, ["a.arcadia".into(), "b.arcadia".into()]);
|
||||
let old = toggle(&app, current, "a.arcadia".into());
|
||||
let old_visit = app.text_workspace.drawer_visit();
|
||||
match replacement {
|
||||
0 => {
|
||||
{
|
||||
let captured = Event::HideDrawer(app.text_drawer_context());
|
||||
send(&mut app, captured);
|
||||
};
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
}
|
||||
1 => {
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
{
|
||||
let captured =
|
||||
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
|
||||
send(&mut app, captured);
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
let event = toggle(&app, current, "b.arcadia".into());
|
||||
send(&mut app, event);
|
||||
}
|
||||
}
|
||||
let visit = app.text_workspace.drawer_visit();
|
||||
assert_ne!(visit, old_visit);
|
||||
send(
|
||||
&mut app,
|
||||
Event::ReferenceScrolled {
|
||||
stamp: current,
|
||||
visit,
|
||||
y: 84.,
|
||||
},
|
||||
);
|
||||
let scroll_id = app.text_workspace.reference_scroll_id();
|
||||
let request = nav::Request {
|
||||
job: Id::new_v4(),
|
||||
interaction: app.source_symbols.interaction,
|
||||
raw_interaction: 0,
|
||||
stamp: current,
|
||||
path: app.source_workspace.active.clone(),
|
||||
offset: 0,
|
||||
cursor: app.source_editor.cursor(),
|
||||
action: nav::query::Action::References,
|
||||
};
|
||||
app.source_symbols.request = Some(request.clone());
|
||||
app.source_symbols.in_flight = Some(request.job);
|
||||
let groups =
|
||||
["a.arcadia", "b.arcadia"].map(|path| app.text_workspace.references().collapsed(path));
|
||||
let context = app.text_command_context();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
for _ in 0..2 {
|
||||
send(&mut app, old.clone());
|
||||
assert_eq!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_eq!(app.text_workspace.references().scroll_y(), 84.);
|
||||
assert_eq!(app.text_workspace.reference_scroll_id(), scroll_id);
|
||||
assert_eq!(app.text_workspace.references().stamp(), Some(current));
|
||||
assert_eq!(
|
||||
["a.arcadia", "b.arcadia"]
|
||||
.map(|path| app.text_workspace.references().collapsed(path)),
|
||||
groups,
|
||||
);
|
||||
assert_eq!(app.text_command_context(), context);
|
||||
assert_eq!(app.source_symbols.request.as_ref(), Some(&request));
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
}
|
||||
let fresh = toggle(&app, current, "a.arcadia".into());
|
||||
send(&mut app, fresh);
|
||||
assert_eq!(
|
||||
app.text_workspace.references().collapsed("a.arcadia"),
|
||||
!groups[0]
|
||||
);
|
||||
assert_ne!(app.text_workspace.drawer_visit(), visit);
|
||||
assert_ne!(app.source_symbols.interaction, context.interaction);
|
||||
assert!(app.source_symbols.request.is_none());
|
||||
assert_eq!(app.source_symbols.in_flight, Some(request.job));
|
||||
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
|
||||
assert!(app.source_symbols.back.is_empty());
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_snapshot_requires_exact_current_bytes_and_publication() {
|
||||
let mut app = fixture();
|
||||
let original_model = app.document.snapshot();
|
||||
let original_source = app.source_snapshot.clone();
|
||||
let publication = app.source_annotation_snapshot().unwrap().publication;
|
||||
let initial = app.source_annotation_snapshot().unwrap();
|
||||
assert!(Arc::ptr_eq(&initial.source, &original_source));
|
||||
assert_eq!(initial.path, app.source_workspace.active);
|
||||
assert!(std::ptr::eq(
|
||||
initial.diagnostics,
|
||||
app.source_diagnostics.as_slice()
|
||||
));
|
||||
|
||||
// Republish identical diagnostics: old cached geometry must get a new identity.
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
assert_ne!(
|
||||
app.source_annotation_snapshot().unwrap().publication,
|
||||
publication
|
||||
);
|
||||
|
||||
app.source_workspace
|
||||
.edit("// newer draft\n".into(), app.source_editor.cursor());
|
||||
assert!(
|
||||
app.source_annotation_snapshot().is_none(),
|
||||
"old publication after a real draft edit"
|
||||
);
|
||||
// A current source key alone must not bind new ranges to the old displayed text.
|
||||
app.publish_source_diagnostics(Vec::new());
|
||||
assert!(
|
||||
app.source_annotation_snapshot().is_none(),
|
||||
"displayed snapshot still contains old bytes"
|
||||
);
|
||||
app.show_source_file(false);
|
||||
let current = app.source_annotation_snapshot().unwrap();
|
||||
assert_eq!(current.source.as_ref(), "// newer draft\n");
|
||||
assert!(current.diagnostics.is_empty());
|
||||
assert!(Arc::ptr_eq(&original_model, &app.document.snapshot()));
|
||||
app.text_workspace.reset();
|
||||
assert!(app.source_annotation_snapshot().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn annotation_snapshot_tracks_real_file_round_trips_without_changing_model() {
|
||||
let mut project = Project::empty();
|
||||
let element = project
|
||||
.add_element(
|
||||
oa_core::Kind::Activity,
|
||||
oa_core::Layer::Oa,
|
||||
"Assess incident",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Text;
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
let model = app.document.snapshot();
|
||||
let entry = app.source_workspace.entry.clone();
|
||||
let fragment = app.document.compiled().index.declarations[&element]
|
||||
.path
|
||||
.clone();
|
||||
assert_ne!(entry, fragment);
|
||||
for path in [&entry, &fragment, &entry] {
|
||||
let _ = app.update(Message::SourceFile(path.clone()));
|
||||
let current = app.source_annotation_snapshot().unwrap();
|
||||
assert_eq!(current.path, path);
|
||||
assert_eq!(
|
||||
current.source.as_ref(),
|
||||
app.source_workspace.files[path].text
|
||||
);
|
||||
assert!(Arc::ptr_eq(¤t.source, &app.source_snapshot));
|
||||
assert!(Arc::ptr_eq(&model, &app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drawer_document_session_and_visit_guards_are_independently_required() {
|
||||
for component in 0..3 {
|
||||
for action in 0..3 {
|
||||
let mut app = fixture();
|
||||
app.text_workspace.show_drawer(Some(DrawerTab::Status));
|
||||
let mut captured = app.text_drawer_context();
|
||||
match component {
|
||||
0 => captured.document = Id::new_v4(),
|
||||
1 => captured.session = Id::new_v4(),
|
||||
_ => captured.visit = Id::new_v4(),
|
||||
}
|
||||
app.queue_source_reveal(app.source_editor.cursor(), false);
|
||||
let reveal = app.source_reveal_ticket();
|
||||
assert!(reveal.is_some());
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let cursor = app.source_editor.cursor();
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let current = app.text_drawer_context();
|
||||
let height = app.text_workspace.drawer_height();
|
||||
let event = match action {
|
||||
0 => Event::ShowDrawer(captured, DrawerTab::Problems),
|
||||
1 => Event::HideDrawer(captured),
|
||||
_ => Event::ResizeDrawer(captured, height + 60.),
|
||||
};
|
||||
send(&mut app, event);
|
||||
assert_eq!(app.text_drawer_context(), current);
|
||||
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Status));
|
||||
assert_eq!(app.text_workspace.drawer_height(), height);
|
||||
assert_eq!(app.source_reveal_ticket(), reveal);
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[test]
|
||||
fn actual_chooser_blocks_diagnostic_actions_and_mounted_hover_until_closed() {
|
||||
use oa_core::{language::Diagnostic, source_project::SourceDiagnostic, validation::Severity};
|
||||
for republish in [false, true] {
|
||||
let mut app = fixture();
|
||||
assert!(app.source_workspace.text().starts_with("arcadia"));
|
||||
app.publish_source_diagnostics(vec![SourceDiagnostic {
|
||||
path: app.source_workspace.active.clone(),
|
||||
diagnostic: Diagnostic {
|
||||
severity: Severity::Warning,
|
||||
code: "union".into(),
|
||||
message: "Union diagnostic details".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 1,
|
||||
column: 1,
|
||||
span: Span { start: 0, end: 7 },
|
||||
},
|
||||
related: Vec::new(),
|
||||
}]);
|
||||
let show = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
|
||||
send(&mut app, show);
|
||||
let old_target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap();
|
||||
let mut mounted = crate::source_annotations::AppMounted::new(
|
||||
app.source_annotation_snapshot().unwrap(),
|
||||
app.source_navigation_available(),
|
||||
app.source_symbols.raw_interaction.clone(),
|
||||
);
|
||||
mounted.assert_hover(true);
|
||||
let open = app.diagram_usage_message(app.project().id);
|
||||
let _ = app.update(open);
|
||||
let token = app
|
||||
.diagram_usages
|
||||
.as_ref()
|
||||
.expect("actual chooser opened")
|
||||
.token;
|
||||
assert!(!app.source_navigation_available());
|
||||
let accepted = app.document.snapshot();
|
||||
let raw = app.source_workspace.bundle();
|
||||
let cursor = app.source_editor.cursor();
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let reveal = app.source_reveal_ticket();
|
||||
if republish {
|
||||
app.publish_source_diagnostics(app.source_diagnostics.clone());
|
||||
}
|
||||
mounted.refresh(
|
||||
app.source_annotation_snapshot().unwrap(),
|
||||
app.source_navigation_available(),
|
||||
);
|
||||
mounted.assert_hover(false);
|
||||
let _ = app.update(old_target.clone());
|
||||
assert_eq!(app.source_symbols.interaction, interaction);
|
||||
assert_eq!(app.source_reveal_ticket(), reveal);
|
||||
assert_eq!(app.diagram_usages.as_ref().unwrap().token, token);
|
||||
assert_eq!(app.diagram_lookup_in_flight, Some(token));
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
let _ = app.update(Message::DiagramUsage(crate::diagram_usage::Event::Close(
|
||||
token,
|
||||
)));
|
||||
assert!(app.source_navigation_available());
|
||||
mounted.refresh(
|
||||
app.source_annotation_snapshot().unwrap(),
|
||||
app.source_navigation_available(),
|
||||
);
|
||||
mounted.assert_hover(true);
|
||||
if republish {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
let reveal = app.source_reveal_ticket();
|
||||
let _ = app.update(old_target);
|
||||
assert_eq!(
|
||||
app.source_symbols.interaction, interaction,
|
||||
"old publication stays stale after modal closes"
|
||||
);
|
||||
assert_eq!(app.source_reveal_ticket(), reveal);
|
||||
}
|
||||
let target = app.text_diagnostic_message(location(&app, 0, 7)).unwrap();
|
||||
let _ = app.update(target);
|
||||
assert!(
|
||||
app.source_reveal_ticket().is_some(),
|
||||
"fresh admitted location still navigates"
|
||||
);
|
||||
assert_eq!(app.source_workspace.bundle(), raw);
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
}
|
||||
}
|
||||
@@ -49,28 +49,11 @@ fn fixture(layer: Layer, kind: Kind) -> (App, Id, Id, Id) {
|
||||
p.diagrams.get_mut(&view).unwrap().name = "Unrelated architecture".into();
|
||||
p.place(view, reused, Position { x: 873., y: 421. });
|
||||
let mut app = App::with_project(p);
|
||||
let accepted = app.document.snapshot();
|
||||
// Enter through the actual navigation path: assigning Page/owner alone does
|
||||
// not create the representation and live visit required by canvas events.
|
||||
send(&mut app, Message::Select(root));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
assert_eq!(app.layer, layer);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, root);
|
||||
assert_eq!(app.behavior.active_owner, Some(root));
|
||||
if kind == Kind::Scenario {
|
||||
let context = app.scenario_context().unwrap();
|
||||
assert_eq!(context.document, app.canvas_document);
|
||||
assert_eq!(context.scenario, root);
|
||||
assert_eq!(
|
||||
context.representation,
|
||||
crate::behavior::scenario_layout::Representation::Derived { owner: root }
|
||||
);
|
||||
assert!(!context.visit.is_nil());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), accepted.bundle);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
app.layer = layer;
|
||||
app.page = Page::Behavior;
|
||||
app.selected = root;
|
||||
app.behavior.active_owner = Some(root);
|
||||
app.refresh_draft();
|
||||
(app, root, work, reused)
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,6 @@ pub(crate) mod creation;
|
||||
mod form_session;
|
||||
pub(crate) mod scenario;
|
||||
mod scenario_activation;
|
||||
#[cfg(test)]
|
||||
mod scenario_hit_tests;
|
||||
// Pure saved-geometry bridge; workspace integration is a separate checkpoint.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) mod scenario_layout;
|
||||
mod scenario_lifecycle;
|
||||
pub(crate) mod source_draft;
|
||||
pub(crate) mod state_canvas;
|
||||
@@ -64,18 +59,9 @@ mod scenario_scroll {
|
||||
process: bool,
|
||||
restore: Option<scenario::ScrollRestore>,
|
||||
}
|
||||
type Identity = (Id, Id, scenario_layout::Representation, Id);
|
||||
fn identity(context: scenario::Context) -> Identity {
|
||||
(
|
||||
context.document,
|
||||
context.scenario,
|
||||
context.representation,
|
||||
context.visit,
|
||||
)
|
||||
}
|
||||
type Report = Option<(bool, Identity, Vector)>;
|
||||
type Report = Option<(bool, Id, Id, Vector)>;
|
||||
struct ObservedScroll {
|
||||
identity: Identity,
|
||||
identity: (Id, Id),
|
||||
report: Report,
|
||||
restored: Option<Id>,
|
||||
}
|
||||
@@ -126,12 +112,8 @@ mod scenario_scroll {
|
||||
) {
|
||||
let restore = self.restore.filter(|request| {
|
||||
!self.process
|
||||
&& (
|
||||
request.document,
|
||||
request.owner,
|
||||
request.representation,
|
||||
request.visit,
|
||||
) == identity(self.context)
|
||||
&& (request.document, request.owner)
|
||||
== (self.context.document, self.context.scenario)
|
||||
&& tree.state.downcast_ref::<ObservedScroll>().restored != Some(request.token)
|
||||
});
|
||||
let receipt = restore.and_then(|request| {
|
||||
@@ -155,7 +137,12 @@ mod scenario_scroll {
|
||||
&mut offset,
|
||||
);
|
||||
if let Some(offset) = offset.0 {
|
||||
let next = Some((self.process, identity(self.context), offset));
|
||||
let next = Some((
|
||||
self.process,
|
||||
self.context.document,
|
||||
self.context.scenario,
|
||||
offset,
|
||||
));
|
||||
let state = tree.state.downcast_mut::<ObservedScroll>();
|
||||
if let Some(token) = receipt {
|
||||
state.restored = Some(token);
|
||||
@@ -199,7 +186,7 @@ mod scenario_scroll {
|
||||
}
|
||||
fn state(&self) -> widget::tree::State {
|
||||
widget::tree::State::new(ObservedScroll {
|
||||
identity: identity(self.context),
|
||||
identity: (self.context.document, self.context.scenario),
|
||||
report: None,
|
||||
restored: None,
|
||||
})
|
||||
@@ -208,7 +195,9 @@ mod scenario_scroll {
|
||||
vec![widget::Tree::new(self.content.as_widget())]
|
||||
}
|
||||
fn diff(&self, tree: &mut widget::Tree) {
|
||||
if tree.state.downcast_ref::<ObservedScroll>().identity != identity(self.context) {
|
||||
if tree.state.downcast_ref::<ObservedScroll>().identity
|
||||
!= (self.context.document, self.context.scenario)
|
||||
{
|
||||
// A new owner/document needs fresh real scroll state, not merely
|
||||
// cleared mirrored offsets. Edits and tool/form changes retain it.
|
||||
tree.state = self.state();
|
||||
@@ -497,25 +486,17 @@ mod scenario_scroll {
|
||||
}
|
||||
#[test]
|
||||
fn direct_scenario_restore_cannot_scroll_another_document_owner_or_process_view() {
|
||||
for mismatch in 0..5 {
|
||||
for mismatch in 0..3 {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
let mut request = scenario::ScrollRestore {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
representation: context.representation,
|
||||
visit: context.visit,
|
||||
token: Id::new_v4(),
|
||||
offset: Vector::new(0., 180.),
|
||||
};
|
||||
match mismatch {
|
||||
0 => request.document = Id::new_v4(),
|
||||
1 => request.owner = Id::new_v4(),
|
||||
3 => {
|
||||
request.representation = scenario_layout::Representation::Saved {
|
||||
diagram: Id::new_v4(),
|
||||
}
|
||||
}
|
||||
4 => request.visit = Id::new_v4(),
|
||||
_ => {}
|
||||
}
|
||||
let mut view = observer(600., context);
|
||||
@@ -602,7 +583,7 @@ mod scenario_scroll {
|
||||
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
|
||||
for process in [false, true] {
|
||||
let area = scenario::ScrollArea::Page;
|
||||
for change in 0..6 {
|
||||
for change in 0..4 {
|
||||
let context = scenario::direct_tests::context(Id::new_v4());
|
||||
let mut view = observer(600., context);
|
||||
view.process = process;
|
||||
@@ -631,12 +612,6 @@ mod scenario_scroll {
|
||||
next.revision += 1;
|
||||
next.epoch += 5;
|
||||
}
|
||||
4 => {
|
||||
next.representation = scenario_layout::Representation::Saved {
|
||||
diagram: Id::new_v4(),
|
||||
}
|
||||
}
|
||||
5 => next.visit = Id::new_v4(),
|
||||
_ => {}
|
||||
}
|
||||
view = observer(if change == 3 { 800. } else { 600. }, next);
|
||||
@@ -684,7 +659,7 @@ mod scenario_scroll {
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
actual,
|
||||
vec![if change < 2 || change >= 4 {
|
||||
vec![if change < 2 {
|
||||
Vector::ZERO
|
||||
} else {
|
||||
Vector::new(0., 180.)
|
||||
@@ -998,19 +973,6 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
return container(out).padding(16).width(Fill).height(Fill).into();
|
||||
}
|
||||
}
|
||||
// A requested saved view remains the destination even after its model is removed.
|
||||
// The shared display adapter reports unavailable instead of choosing another behavior.
|
||||
if matches!(
|
||||
app.behavior.scenario.representation(),
|
||||
Some(scenario_layout::Representation::Saved { .. })
|
||||
) && let Some(owner) = app.behavior.scenario.owner()
|
||||
&& !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == app.layer)
|
||||
{
|
||||
return scenario::view(app, owner, column![].spacing(6));
|
||||
}
|
||||
let choices = p
|
||||
.elements
|
||||
.values()
|
||||
@@ -1022,14 +984,7 @@ pub fn view(app: &App) -> Element<'_, Message> {
|
||||
label: format!("{} · {}", e.kind(), e.name),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let owner = if matches!(
|
||||
app.behavior.scenario.representation(),
|
||||
Some(scenario_layout::Representation::Saved { .. })
|
||||
) {
|
||||
app.behavior.scenario.owner()
|
||||
} else {
|
||||
selected_owner(app)
|
||||
};
|
||||
let owner = selected_owner(app);
|
||||
let selected = owner
|
||||
.and_then(|id| choices.iter().find(|c| c.id == id))
|
||||
.or(choices.first())
|
||||
@@ -1163,27 +1118,8 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Identifies the drawn primitive that won selection, not an authoring permission.
|
||||
/// In particular an activation and its message can share an occurrence UUID.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SceneHitRole {
|
||||
LifelineHeader,
|
||||
MessageBody,
|
||||
MessageLabel,
|
||||
Activation,
|
||||
LifecycleMarker,
|
||||
LifelineGuide,
|
||||
Box,
|
||||
OtherLine,
|
||||
}
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct SceneHit {
|
||||
id: Id,
|
||||
role: SceneHitRole,
|
||||
}
|
||||
struct BoxItem {
|
||||
id: Id,
|
||||
role: SceneHitRole,
|
||||
rect: Rectangle,
|
||||
title: String,
|
||||
detail: String,
|
||||
@@ -1216,7 +1152,6 @@ fn box_text_clip(
|
||||
|
||||
struct Line {
|
||||
id: Id,
|
||||
role: SceneHitRole,
|
||||
points: Vec<Point>,
|
||||
label: String,
|
||||
label_at: Point,
|
||||
@@ -1237,17 +1172,12 @@ struct Scene {
|
||||
boxes: Vec<BoxItem>,
|
||||
lines: Vec<Line>,
|
||||
size: Size,
|
||||
layout_origin: Point,
|
||||
selected: Id,
|
||||
scale: f32,
|
||||
camera_pan: iced::Vector,
|
||||
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
|
||||
spacing_stamp: Option<scenario::spacing::Stamp>,
|
||||
coordinate_input: bool,
|
||||
pending_placement: Option<(scenario::Slot, Option<Id>)>,
|
||||
lanes: BTreeMap<Id, f32>,
|
||||
// Exact render coordinates include unassigned lanes; do not derive from cards.
|
||||
render_lanes: BTreeMap<Id, f32>,
|
||||
slots: Vec<SequenceSlot>,
|
||||
rows: BTreeMap<Id, (Id, f32)>,
|
||||
activations: Vec<scenario_activation::Segment>,
|
||||
@@ -1271,10 +1201,6 @@ struct SequenceInput {
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
enum SequencePress {
|
||||
Spacing {
|
||||
stamp: scenario::spacing::Stamp,
|
||||
token: Id,
|
||||
},
|
||||
Occurrence {
|
||||
tool: scenario::Tool,
|
||||
slot: scenario::Slot,
|
||||
@@ -1295,49 +1221,28 @@ enum SequencePress {
|
||||
},
|
||||
}
|
||||
impl SequenceInput {
|
||||
fn cancel_spacing(&mut self) -> Option<Action<Message>> {
|
||||
let cancel = match self.press {
|
||||
Some(SequencePress::Spacing { stamp, token }) => Some(
|
||||
Action::publish(Message::ScenarioCanvas(scenario::CanvasEvent::Spacing(
|
||||
scenario::spacing::Event::Cancel { stamp, token },
|
||||
)))
|
||||
.and_capture(),
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
self.cancel();
|
||||
cancel
|
||||
}
|
||||
fn cancel(&mut self) {
|
||||
self.source = None;
|
||||
self.press = None;
|
||||
}
|
||||
}
|
||||
impl Scene {
|
||||
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
Self::sequence_geometry(p, owner, selected, None, None)
|
||||
fn removal_target(&self, owner: Id, id: Id) -> bool {
|
||||
id != owner
|
||||
&& (self.boxes.iter().any(|item| item.id == id)
|
||||
|| self.lines.iter().any(|item| item.id == id))
|
||||
}
|
||||
fn sequence_geometry(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
selected: Id,
|
||||
lane_override: Option<&BTreeMap<Id, f32>>,
|
||||
plan: Option<&scenario_layout::Plan>,
|
||||
) -> Self {
|
||||
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
camera_pan: iced::Vector::ZERO,
|
||||
boxes: vec![],
|
||||
lines: vec![],
|
||||
size: Size::new(700., 200.),
|
||||
layout_origin: Point::ORIGIN,
|
||||
selected,
|
||||
interaction: None,
|
||||
spacing_stamp: None,
|
||||
coordinate_input: true,
|
||||
pending_placement: None,
|
||||
lanes: BTreeMap::new(),
|
||||
render_lanes: BTreeMap::new(),
|
||||
slots: vec![],
|
||||
rows: BTreeMap::new(),
|
||||
activations: vec![],
|
||||
@@ -1408,32 +1313,12 @@ impl Scene {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
let mut lane_x = lanes
|
||||
let lane_x = lanes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (id, ..))| (*id, 160. + i as f32 * 240.))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
if let Some(values) = lane_override {
|
||||
for (id, x) in values {
|
||||
if let Some(lane) = lane_x.get_mut(id) {
|
||||
*lane = *x;
|
||||
}
|
||||
}
|
||||
}
|
||||
scene.render_lanes = lane_x.clone();
|
||||
scene.size.width = (lanes.len() as f32 * 240. + 80.).max(680.);
|
||||
let layout_left = if lane_override.is_some() {
|
||||
lane_x.values().fold(0_f32, |left, x| left.min(*x - 160.))
|
||||
} else {
|
||||
0.
|
||||
};
|
||||
scene.layout_origin.x = layout_left;
|
||||
if lane_override.is_some() {
|
||||
scene.size.width = lane_x
|
||||
.values()
|
||||
.fold(scene.size.width, |right, x| right.max(*x + 240.))
|
||||
- layout_left;
|
||||
}
|
||||
if scenario {
|
||||
scene.lanes = lane_x
|
||||
.iter()
|
||||
@@ -1449,26 +1334,8 @@ impl Scene {
|
||||
.collect();
|
||||
}
|
||||
let mut y = if scenario { 148. } else { 120. };
|
||||
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0, plan);
|
||||
if layout_left != 0. {
|
||||
for item in &mut scene.boxes {
|
||||
let body = scenario_layout::occurrence(p, item.id);
|
||||
if matches!(
|
||||
body,
|
||||
Some(
|
||||
OccurrenceBody::Branch { .. }
|
||||
| OccurrenceBody::ScenarioUse { .. }
|
||||
| OccurrenceBody::Control { .. }
|
||||
| OccurrenceBody::SequenceReference { .. }
|
||||
| OccurrenceBody::Exchange { .. }
|
||||
)
|
||||
) || (plan.is_none() && matches!(body, Some(OccurrenceBody::Fragment { .. })))
|
||||
{
|
||||
item.rect.x += layout_left;
|
||||
}
|
||||
}
|
||||
}
|
||||
scene.size.height = plan.map_or((y + 45.).max(210.), |plan| plan.height);
|
||||
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
|
||||
scene.size.height = (y + 45.).max(210.);
|
||||
if scenario {
|
||||
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
|
||||
p,
|
||||
@@ -1479,9 +1346,6 @@ impl Scene {
|
||||
scene.size.height,
|
||||
);
|
||||
scene.lifecycle.align_messages(&mut scene.lines);
|
||||
if let Some(plan) = plan {
|
||||
plan.align_message_anchors(&mut scene.lines);
|
||||
}
|
||||
scene.layout_activations(p, &lane_x);
|
||||
}
|
||||
for (id, name, detail) in lanes {
|
||||
@@ -1491,7 +1355,6 @@ impl Scene {
|
||||
0,
|
||||
Line {
|
||||
id,
|
||||
role: SceneHitRole::LifelineGuide,
|
||||
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
|
||||
label: String::new(),
|
||||
label_at: Point::ORIGIN,
|
||||
@@ -1501,24 +1364,14 @@ impl Scene {
|
||||
}
|
||||
scene.boxes.push(BoxItem {
|
||||
id,
|
||||
role: if scenario {
|
||||
SceneHitRole::LifelineHeader
|
||||
} else {
|
||||
SceneHitRole::Box
|
||||
},
|
||||
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
|
||||
title: if scenario {
|
||||
format!("Lane · {name}")
|
||||
} else {
|
||||
name
|
||||
},
|
||||
title: name,
|
||||
detail,
|
||||
marker: None,
|
||||
});
|
||||
}
|
||||
scene
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)] // Explicit complete plan replaces ambient geometry state.
|
||||
fn occurrences(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
@@ -1527,7 +1380,6 @@ impl Scene {
|
||||
lanes: &BTreeMap<Id, f32>,
|
||||
y: &mut f32,
|
||||
depth: usize,
|
||||
plan: Option<&scenario_layout::Plan>,
|
||||
) {
|
||||
let steps = p
|
||||
.elements
|
||||
@@ -1567,9 +1419,6 @@ impl Scene {
|
||||
}
|
||||
#[cfg(test)]
|
||||
scenario_lifecycle::render::apply_test_row_offset(*id, y);
|
||||
if let Some(plan) = plan {
|
||||
*y = plan.anchors[id].y;
|
||||
}
|
||||
if ordered {
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
@@ -1586,7 +1435,7 @@ impl Scene {
|
||||
OccurrenceBody::Fragment { operator, .. } => {
|
||||
let top = *y;
|
||||
*y += 55.;
|
||||
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
|
||||
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
|
||||
let rect = Rectangle::new(
|
||||
Point::new(18. + depth as f32 * 10., top),
|
||||
Size::new(
|
||||
@@ -1594,12 +1443,10 @@ impl Scene {
|
||||
(*y - top).max(95.),
|
||||
),
|
||||
);
|
||||
let rect = plan.map_or(rect, |plan| plan.frames[id]);
|
||||
self.boxes.insert(
|
||||
0,
|
||||
BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect,
|
||||
title: format!("{operator:?} · {}", e.name),
|
||||
detail: String::new(),
|
||||
@@ -1611,7 +1458,6 @@ impl Scene {
|
||||
OccurrenceBody::Branch { guard, .. } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(
|
||||
Point::new(36. + depth as f32 * 10., *y),
|
||||
Size::new(self.size.width - 85. - depth as f32 * 20., 43.),
|
||||
@@ -1628,7 +1474,7 @@ impl Scene {
|
||||
marker: Some(Kind::Occurrence),
|
||||
});
|
||||
*y += if scenario { 90. } else { 62. };
|
||||
self.occurrences(p, *id, scenario, lanes, y, depth + 1, plan);
|
||||
self.occurrences(p, *id, scenario, lanes, y, depth + 1);
|
||||
}
|
||||
OccurrenceBody::Exchange {
|
||||
exchange,
|
||||
@@ -1656,7 +1502,7 @@ impl Scene {
|
||||
source.and_then(|id| lanes.get(&id)),
|
||||
target.and_then(|id| lanes.get(&id)),
|
||||
) {
|
||||
let points = if source == target {
|
||||
let points = if a == b {
|
||||
vec![
|
||||
Point::new(*a, *y),
|
||||
Point::new(a + 75., *y),
|
||||
@@ -1695,7 +1541,6 @@ impl Scene {
|
||||
}
|
||||
self.lines.push(Line {
|
||||
id: *id,
|
||||
role: SceneHitRole::MessageBody,
|
||||
points,
|
||||
label: format!("{}. {kind}{}", index + 1, message_label),
|
||||
label_at: Point::new(a.min(*b) + 12., *y - 26.),
|
||||
@@ -1704,7 +1549,6 @@ impl Scene {
|
||||
} else {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(
|
||||
Point::new(65., *y - 20.),
|
||||
Size::new(self.size.width - 130., 65.),
|
||||
@@ -1727,7 +1571,6 @@ impl Scene {
|
||||
.unwrap_or(180.);
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(Point::new(x - 110., *y - 20.), Size::new(220., 70.)),
|
||||
title: e.name.clone(),
|
||||
detail: action
|
||||
@@ -1740,7 +1583,6 @@ impl Scene {
|
||||
OccurrenceBody::Control { kind } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(240., 70.)),
|
||||
title: format!("{kind:?} · {}", e.name),
|
||||
detail: "Contextual process control".into(),
|
||||
@@ -1751,7 +1593,6 @@ impl Scene {
|
||||
OccurrenceBody::SequenceReference { sequence } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(Point::new(65., *y - 20.), Size::new(280., 70.)),
|
||||
title: e.name.clone(),
|
||||
detail: sequence
|
||||
@@ -1764,7 +1605,6 @@ impl Scene {
|
||||
OccurrenceBody::ScenarioUse { scenario, bindings } => {
|
||||
self.boxes.push(BoxItem {
|
||||
id: *id,
|
||||
role: SceneHitRole::Box,
|
||||
rect: Rectangle::new(
|
||||
Point::new(65., *y - 20.),
|
||||
Size::new(self.size.width - 130., 70.),
|
||||
@@ -1787,10 +1627,6 @@ impl Scene {
|
||||
}
|
||||
if ordered {
|
||||
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
|
||||
if let Some(plan) = plan {
|
||||
self.sequence_bounds.insert(owner, plan.scopes[&owner]);
|
||||
*y = plan.scopes[&owner].1 + 42.;
|
||||
}
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -1830,21 +1666,11 @@ impl Scene {
|
||||
))
|
||||
}
|
||||
fn hit(&self, point: Point) -> Option<Id> {
|
||||
self.hit_item(point).map(|hit| hit.id)
|
||||
}
|
||||
|
||||
/// Preserve the semantic selection order and tolerance while retaining the
|
||||
/// primitive's identity. Future spacing admission must also validate the
|
||||
/// saved view, owner and core occurrence kind; a hit alone grants no edit.
|
||||
fn hit_item(&self, point: Point) -> Option<SceneHit> {
|
||||
if !point.x.is_finite() || !point.y.is_finite() {
|
||||
return None;
|
||||
}
|
||||
if let Some(id) = self.lifecycle.marker_at(point) {
|
||||
return Some(SceneHit {
|
||||
id,
|
||||
role: SceneHitRole::LifecycleMarker,
|
||||
});
|
||||
return Some(id);
|
||||
}
|
||||
self.boxes
|
||||
.iter()
|
||||
@@ -1854,10 +1680,7 @@ impl Scene {
|
||||
&& b.rect.contains(point)
|
||||
&& (b.marker != Some(Kind::Occurrence) || point.y < b.rect.y + 38.)
|
||||
})
|
||||
.map(|b| SceneHit {
|
||||
id: b.id,
|
||||
role: b.role,
|
||||
})
|
||||
.map(|b| b.id)
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
@@ -1867,51 +1690,26 @@ impl Scene {
|
||||
crate::diagram::distance_to_segment(point, p[0], p[1]) < 1.5
|
||||
})
|
||||
})
|
||||
.map(|line| SceneHit {
|
||||
id: line.id,
|
||||
role: line.role,
|
||||
})
|
||||
.map(|line| line.id)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.activations
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|b| b.rect.contains(point))
|
||||
.map(|b| SceneHit {
|
||||
id: b.call,
|
||||
role: SceneHitRole::Activation,
|
||||
})
|
||||
.map(|b| b.call)
|
||||
})
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
|
||||
.map(|line| SceneHit {
|
||||
id: line.id,
|
||||
role: if line.role == SceneHitRole::MessageBody {
|
||||
SceneHitRole::MessageLabel
|
||||
} else {
|
||||
SceneHitRole::OtherLine
|
||||
},
|
||||
})
|
||||
.map(|line| line.id)
|
||||
})
|
||||
// The generous message tolerance must not steal the exposed
|
||||
// shoulder of another call's execution when the view is zoomed out.
|
||||
.or_else(|| self.near_sequence_item(point))
|
||||
.or_else(|| {
|
||||
self.lifecycle
|
||||
.lane_at(point, self.scale)
|
||||
.map(|id| SceneHit {
|
||||
id,
|
||||
role: SceneHitRole::LifelineGuide,
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
self.lane_at(point).map(|id| SceneHit {
|
||||
id,
|
||||
role: SceneHitRole::LifelineGuide,
|
||||
})
|
||||
})
|
||||
.or_else(|| self.lifecycle.lane_at(point, self.scale))
|
||||
.or_else(|| self.lane_at(point))
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
@@ -1922,14 +1720,11 @@ impl Scene {
|
||||
< 8. / self.scale
|
||||
})
|
||||
})
|
||||
.map(|line| SceneHit {
|
||||
id: line.id,
|
||||
role: line.role,
|
||||
})
|
||||
.map(|line| line.id)
|
||||
})
|
||||
}
|
||||
|
||||
fn near_sequence_item(&self, point: Point) -> Option<SceneHit> {
|
||||
fn near_sequence_item(&self, point: Point) -> Option<Id> {
|
||||
if !self.scale.is_finite() || self.scale <= 0. {
|
||||
return None;
|
||||
}
|
||||
@@ -1943,32 +1738,17 @@ impl Scene {
|
||||
.windows(2)
|
||||
.map(|p| crate::diagram::distance_to_segment(point, p[0], p[1]))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
(
|
||||
SceneHit {
|
||||
id: line.id,
|
||||
role: line.role,
|
||||
},
|
||||
distance,
|
||||
)
|
||||
(line.id, distance)
|
||||
});
|
||||
// Compare broad message and lifecycle-marker tolerance together after
|
||||
// exact shapes, so dense minimum-Fit rows choose the nearer item.
|
||||
self.lifecycle
|
||||
.nearest_marker(point)
|
||||
.map(|(id, distance)| {
|
||||
(
|
||||
SceneHit {
|
||||
id,
|
||||
role: SceneHitRole::LifecycleMarker,
|
||||
},
|
||||
distance,
|
||||
)
|
||||
})
|
||||
.into_iter()
|
||||
.chain(messages)
|
||||
.filter(|(_, distance)| *distance <= 8. / self.scale)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.id.cmp(&b.0.id)))
|
||||
.map(|(hit, _)| hit)
|
||||
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
|
||||
.map(|(id, _)| id)
|
||||
}
|
||||
}
|
||||
impl canvas::Program<Message> for Scene {
|
||||
@@ -1996,33 +1776,15 @@ impl canvas::Program<Message> for Scene {
|
||||
// captured insertion slot belongs to the previous view, even if the
|
||||
// pointer itself did not move and the semantic context is unchanged.
|
||||
if state.bounds.is_some_and(|previous| previous != bounds) {
|
||||
let cancelled = state.cancel_spacing();
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
state.bounds = Some(bounds);
|
||||
if cancelled.is_some() {
|
||||
return cancelled;
|
||||
}
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if (!editable || !self.coordinate_input)
|
||||
&& let Some(cancelled) = state.cancel_spacing()
|
||||
{
|
||||
return Some(cancelled);
|
||||
if !editable {
|
||||
state.cancel();
|
||||
}
|
||||
// The viewport masks input outside its visible canvas bounds.
|
||||
let visible = cursor.position().is_some();
|
||||
if !visible
|
||||
&& matches!(state.press, Some(SequencePress::Spacing { .. }))
|
||||
&& matches!(
|
||||
event,
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::CursorMoved { .. }
|
||||
| mouse::Event::ButtonReleased(mouse::Button::Left)
|
||||
)
|
||||
)
|
||||
{
|
||||
return state.cancel_spacing();
|
||||
}
|
||||
if !visible
|
||||
&& matches!(
|
||||
event,
|
||||
@@ -2041,7 +1803,7 @@ impl canvas::Program<Message> for Scene {
|
||||
| SequencePress::Reorder { start, moved, .. } => {
|
||||
*moved |= position.distance(*start) >= 3.
|
||||
}
|
||||
SequencePress::Occurrence { .. } | SequencePress::Spacing { .. } => {}
|
||||
SequencePress::Occurrence { .. } => {}
|
||||
}
|
||||
}
|
||||
mouse::Cursor::Available(*position)
|
||||
@@ -2050,9 +1812,9 @@ impl canvas::Program<Message> for Scene {
|
||||
| canvas::Event::Window(iced::window::Event::Resized(_))
|
||||
| canvas::Event::Mouse(mouse::Event::CursorLeft)
|
||||
| canvas::Event::Window(iced::window::Event::Unfocused) => {
|
||||
let cancelled = state.cancel_spacing();
|
||||
state.cancel();
|
||||
state.pointer = None;
|
||||
return cancelled.or_else(|| Some(Action::request_redraw()));
|
||||
return Some(Action::request_redraw());
|
||||
}
|
||||
canvas::Event::Mouse(
|
||||
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
|
||||
@@ -2061,13 +1823,35 @@ impl canvas::Program<Message> for Scene {
|
||||
state.pointer = cursor.position();
|
||||
cursor
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete),
|
||||
modifiers,
|
||||
repeat: false,
|
||||
..
|
||||
}) if editable
|
||||
&& tool == CanvasTool::Select
|
||||
&& (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
|
||||
&& state.press.is_none()
|
||||
&& state.source.is_none() =>
|
||||
{
|
||||
let context = context?;
|
||||
if self.removal_target(context.scenario, self.selected) {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
|
||||
context,
|
||||
self.selected,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
..
|
||||
}) if state.source.is_some() || state.press.is_some() => {
|
||||
return state
|
||||
.cancel_spacing()
|
||||
.or_else(|| Some(Action::request_redraw().and_capture()));
|
||||
state.cancel();
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
_ => cursor,
|
||||
};
|
||||
@@ -2087,14 +1871,6 @@ impl canvas::Program<Message> for Scene {
|
||||
if !editable {
|
||||
return None;
|
||||
}
|
||||
if !self.coordinate_input {
|
||||
return self.hit(point).map(|id| {
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, id,
|
||||
)))
|
||||
.and_capture()
|
||||
});
|
||||
}
|
||||
if let CanvasTool::Message(_) = tool {
|
||||
let first = state.source.is_none();
|
||||
let (source, slot) = state.source.or_else(|| {
|
||||
@@ -2121,48 +1897,6 @@ impl canvas::Program<Message> for Scene {
|
||||
});
|
||||
return Some(Action::request_redraw().and_capture());
|
||||
}
|
||||
if matches!(
|
||||
context.representation,
|
||||
scenario_layout::Representation::Saved { .. }
|
||||
) {
|
||||
let hit = self.hit_item(point)?;
|
||||
let target = match hit.role {
|
||||
SceneHitRole::LifelineHeader => {
|
||||
Some(scenario::spacing::Target::Lane(hit.id))
|
||||
}
|
||||
SceneHitRole::MessageBody | SceneHitRole::MessageLabel => {
|
||||
Some(scenario::spacing::Target::Message(hit.id))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some((target, stamp)) = target.zip(self.spacing_stamp)
|
||||
&& stamp.context == context
|
||||
{
|
||||
let token = Id::new_v4();
|
||||
state.press = Some(SequencePress::Spacing { stamp, token });
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
scenario::spacing::Event::Begin {
|
||||
stamp,
|
||||
token,
|
||||
target,
|
||||
press_screen: cursor.position()?,
|
||||
canvas_bounds: bounds,
|
||||
scale: self.scale,
|
||||
pan: self.camera_pan,
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
// Saved spacing never silently changes the shared Scenario order.
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Select(
|
||||
context, hit.id,
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
if let Some(occurrence) = self
|
||||
.hit(point)
|
||||
.filter(|id| self.rows.contains_key(id))
|
||||
@@ -2192,22 +1926,6 @@ impl canvas::Program<Message> for Scene {
|
||||
let press = state.press.take()?;
|
||||
let context = context?;
|
||||
match press {
|
||||
SequencePress::Spacing { stamp, token } => {
|
||||
state.source = None;
|
||||
let event = if world.is_some() {
|
||||
scenario::spacing::Event::Finish {
|
||||
stamp,
|
||||
token,
|
||||
screen: cursor.position()?,
|
||||
}
|
||||
} else {
|
||||
scenario::spacing::Event::Cancel { stamp, token }
|
||||
};
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(event)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
SequencePress::Occurrence {
|
||||
tool: captured,
|
||||
slot,
|
||||
@@ -2299,18 +2017,6 @@ impl canvas::Program<Message> for Scene {
|
||||
Some(Action::request_redraw().and_capture())
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::CursorMoved { .. }) if context.is_some() => {
|
||||
if let Some(SequencePress::Spacing { stamp, token }) = state.press {
|
||||
return Some(
|
||||
Action::publish(Message::ScenarioCanvas(CanvasEvent::Spacing(
|
||||
scenario::spacing::Event::Move {
|
||||
stamp,
|
||||
token,
|
||||
screen: cursor.position()?,
|
||||
},
|
||||
)))
|
||||
.and_capture(),
|
||||
);
|
||||
}
|
||||
Some(Action::request_redraw())
|
||||
}
|
||||
_ => None,
|
||||
@@ -2369,7 +2075,7 @@ impl canvas::Program<Message> for Scene {
|
||||
.with_color(if activation.call == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
style::BOUNDARY
|
||||
})
|
||||
.with_width(if activation.call == self.selected {
|
||||
2.5
|
||||
@@ -2386,7 +2092,7 @@ impl canvas::Program<Message> for Scene {
|
||||
let color = if b.id == self.selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
style::BOUNDARY
|
||||
};
|
||||
if outline {
|
||||
f.stroke(
|
||||
@@ -2492,10 +2198,7 @@ impl canvas::Program<Message> for Scene {
|
||||
self.lifecycle
|
||||
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
|
||||
if let Some((context, tool, editable)) = self.interaction {
|
||||
let active = editable
|
||||
&& self.coordinate_input
|
||||
&& state.context == Some(context)
|
||||
&& state.tool == tool;
|
||||
let active = editable && state.context == Some(context) && state.tool == tool;
|
||||
let point = cursor.position_in(bounds).map(|p| {
|
||||
Point::new(
|
||||
(p.x - self.camera_pan.x) / self.scale,
|
||||
@@ -2511,12 +2214,7 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
});
|
||||
if editable
|
||||
&& self.coordinate_input
|
||||
&& tool == scenario::CanvasTool::Select
|
||||
&& matches!(
|
||||
context.representation,
|
||||
scenario_layout::Representation::Derived { .. }
|
||||
)
|
||||
&& let Some(handle) = self.reorder_handle()
|
||||
{
|
||||
f.fill_rectangle(handle.position(), handle.size(), style::TEAL);
|
||||
|
||||
@@ -10,8 +10,12 @@ pub use control::Control;
|
||||
pub use viewport::Viewport;
|
||||
|
||||
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
|
||||
!app.busy
|
||||
&& !app.hosted.visible
|
||||
!app.busy && keyboard_owner_enabled(app)
|
||||
}
|
||||
|
||||
/// Passive I/O suspends commands without changing the logical input owner.
|
||||
pub(crate) fn keyboard_owner_enabled(app: &crate::app::App) -> bool {
|
||||
!app.hosted.visible
|
||||
&& app.workflow.is_none()
|
||||
&& app.file_prompt.is_none()
|
||||
&& !app.delete_pending
|
||||
@@ -26,7 +30,6 @@ pub fn controls<'a>(
|
||||
keyboard: bool,
|
||||
scale: f32,
|
||||
details: Message,
|
||||
focus: Option<Message>,
|
||||
zoom: impl Fn(Option<f32>) -> Message,
|
||||
publish: impl Fn(Event) -> Message + Copy + 'a,
|
||||
) -> Element<'a, Message> {
|
||||
@@ -43,22 +46,25 @@ pub fn controls<'a>(
|
||||
publish,
|
||||
)
|
||||
};
|
||||
row![
|
||||
let controls = row![
|
||||
control("Details", "Details", Some(details)),
|
||||
tooltip(control("Canvas", "Canvas", focus),
|
||||
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
|
||||
tooltip::Position::Bottom),
|
||||
control("Fit", "Fit", Some(zoom(None))),
|
||||
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
|
||||
text(zoom_label(scale)).size(12),
|
||||
text(format!("{:.0}%", scale * 100.)).size(12),
|
||||
control("ZoomIn", "+", Some(zoom(Some(scale * 1.25)))),
|
||||
control("ActualSize", "100%", Some(zoom(Some(1.)))),
|
||||
].spacing(4).align_y(iced::Center).into()
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
tooltip(
|
||||
controls,
|
||||
container(text("F6: enter/leave canvas keyboard controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
|
||||
tooltip::Position::Bottom,
|
||||
).into()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Scope {
|
||||
pub(crate) scenario_view: Option<(super::scenario_layout::Representation, Id)>,
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub revision: u64,
|
||||
@@ -70,7 +76,6 @@ impl Scope {
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
@@ -79,7 +84,6 @@ impl Scope {
|
||||
Some(Self {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
scenario_view: None,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
})
|
||||
@@ -99,8 +103,8 @@ impl Scope {
|
||||
|
||||
pub fn control_id(self, name: &str) -> iced::advanced::widget::Id {
|
||||
iced::advanced::widget::Id::from(format!(
|
||||
"behavior-control:{}:{}:{}:{:?}:{name}",
|
||||
self.document, self.owner, self.revision, self.scenario_view
|
||||
"behavior-control:{}:{}:{}:{name}",
|
||||
self.document, self.owner, self.revision
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -124,7 +128,6 @@ pub enum Event {
|
||||
CancelGesture,
|
||||
Pan(Vector),
|
||||
Zoom { factor: f32, at: Point, base: f32 },
|
||||
Focus,
|
||||
Traverse(bool),
|
||||
ControlFocus(&'static str),
|
||||
ToggleFocus,
|
||||
@@ -134,20 +137,6 @@ pub enum Event {
|
||||
pub use focus::{focus_task, root as focus_root};
|
||||
/// Fold relative input in event order. The base scale is used only on leaving Fit.
|
||||
pub fn change(pan: &mut Vector, zoom: &mut Option<f32>, fit: &mut bool, event: Event) -> bool {
|
||||
change_with_minimum(pan, zoom, fit, event, 0.05)
|
||||
}
|
||||
|
||||
/// Scenario extents can require scales below the topology canvas's 5% floor.
|
||||
pub(super) fn change_with_minimum(
|
||||
pan: &mut Vector,
|
||||
zoom: &mut Option<f32>,
|
||||
fit: &mut bool,
|
||||
event: Event,
|
||||
minimum: f32,
|
||||
) -> bool {
|
||||
if !minimum.is_finite() || minimum <= 0. || minimum > 3. {
|
||||
return false;
|
||||
}
|
||||
match event {
|
||||
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
|
||||
let next = *pan + delta;
|
||||
@@ -167,10 +156,7 @@ pub(super) fn change_with_minimum(
|
||||
&& at.y.is_finite() =>
|
||||
{
|
||||
let old = if *fit { base } else { zoom.unwrap_or(base) };
|
||||
if !old.is_finite() || old <= 0. {
|
||||
return false;
|
||||
}
|
||||
let next = (old * factor).clamp(minimum, 3.);
|
||||
let next = (old * factor).clamp(0.05, 3.);
|
||||
if old == next {
|
||||
return false;
|
||||
}
|
||||
@@ -188,51 +174,6 @@ pub(super) fn change_with_minimum(
|
||||
}
|
||||
}
|
||||
|
||||
fn zoom_label(scale: f32) -> String {
|
||||
let percent = f64::from(scale) * 100.;
|
||||
if !percent.is_finite() || percent <= 0. {
|
||||
return "—".into();
|
||||
}
|
||||
if percent >= 1. {
|
||||
format!("{percent:.0}%")
|
||||
} else if percent >= 0.01 {
|
||||
format!("{percent:.3}%")
|
||||
} else {
|
||||
format!("{percent:.2e}%")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod camera_policy_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn topology_retains_five_percent_floor() {
|
||||
let (mut pan, mut zoom, mut fit) = (Vector::ZERO, Some(0.06), false);
|
||||
assert!(change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fit,
|
||||
Event::Zoom {
|
||||
factor: 0.1,
|
||||
at: Point::ORIGIN,
|
||||
base: 0.06,
|
||||
}
|
||||
));
|
||||
assert_eq!(zoom, Some(0.05));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_zoom_label_is_nonzero_and_changes_with_zoom() {
|
||||
assert_eq!(zoom_label(1.), "100%");
|
||||
assert_eq!(zoom_label(0.05), "5%");
|
||||
assert_eq!(zoom_label(0.00034193), "0.034%");
|
||||
assert_eq!(zoom_label(0.00034193 * 1.25), "0.043%");
|
||||
assert_eq!(zoom_label(1e-7), "1.00e-5%");
|
||||
assert_ne!(zoom_label(f32::from_bits(1)), "0%");
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
|
||||
pub fn palette<'a>(
|
||||
scope: Scope,
|
||||
|
||||
@@ -70,7 +70,6 @@ impl<T: Send + 'static> Operation<T> for Collect {
|
||||
return operation::Outcome::None;
|
||||
}
|
||||
let target = match self.event {
|
||||
Event::Focus => Some(canvas),
|
||||
Event::ControlFocus(name) => Some(self.scope.control_id(name)),
|
||||
Event::ToggleFocus if self.focused.is_none() => Some(canvas),
|
||||
Event::ToggleFocus | Event::Leave if self.focused.is_some() => None,
|
||||
|
||||
@@ -236,6 +236,9 @@ impl Mounted {
|
||||
}
|
||||
}
|
||||
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
|
||||
self.pixels_at(app, mouse::Cursor::Unavailable)
|
||||
}
|
||||
pub fn pixels_at(&mut self, app: &App, cursor: mouse::Cursor) -> Vec<u8> {
|
||||
use iced::advanced::renderer::Headless;
|
||||
let mut ui = UserInterface::build(
|
||||
app.view(),
|
||||
@@ -247,7 +250,7 @@ impl Mounted {
|
||||
let mut messages = vec![];
|
||||
let _ = ui.update(
|
||||
&[],
|
||||
mouse::Cursor::Unavailable,
|
||||
cursor,
|
||||
&mut self.renderer,
|
||||
&mut iced::advanced::clipboard::Null,
|
||||
&mut messages,
|
||||
@@ -262,7 +265,7 @@ impl Mounted {
|
||||
&iced::advanced::renderer::Style {
|
||||
text_color: crate::style::INK,
|
||||
},
|
||||
mouse::Cursor::Unavailable,
|
||||
cursor,
|
||||
);
|
||||
self.cache = ui.into_cache();
|
||||
Headless::screenshot(
|
||||
@@ -368,7 +371,6 @@ fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_input
|
||||
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
|
||||
use iced::widget::Space;
|
||||
let scope = Scope {
|
||||
scenario_view: None,
|
||||
document: Id::new_v4(),
|
||||
owner: Id::new_v4(),
|
||||
revision: 2,
|
||||
@@ -428,7 +430,6 @@ pub(crate) fn scope(app: &App) -> Scope {
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
@@ -437,7 +438,6 @@ pub(crate) fn scope(app: &App) -> Scope {
|
||||
Scope {
|
||||
document: context.document,
|
||||
owner: context.owner,
|
||||
scenario_view: None,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
}
|
||||
@@ -628,7 +628,7 @@ pub(crate) async fn retained_toolbar_boundaries(
|
||||
use iced::keyboard::key::Named;
|
||||
for boundary in ["Chooser", "Dirty", "Picker"] {
|
||||
let mut packets = vec![];
|
||||
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
|
||||
for name in ["Fit", tool, "Details"] {
|
||||
packets.push(capture_toolbar(mounted, app, name));
|
||||
}
|
||||
let lifetime = app.camera_epoch;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user