Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"
|
||||
|
||||
@@ -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![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1823,7 +1823,7 @@ 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});
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
|
||||
@@ -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,7 +9,6 @@ pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod routing;
|
||||
pub mod scenario_layout;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
pub mod source_files;
|
||||
@@ -19,9 +19,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;
|
||||
|
||||
@@ -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.
|
||||
@@ -1947,7 +1921,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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -930,18 +930,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 {
|
||||
@@ -1130,29 +1119,6 @@ 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,
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
@@ -1219,7 +1185,6 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &language::EditValue,
|
||||
before: Option<&language::EditValue>,
|
||||
after: &language::EditValue,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
use language::EditKind;
|
||||
@@ -1235,7 +1200,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 +1210,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])
|
||||
@@ -1298,7 +1263,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 +1286,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 +1298,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);
|
||||
}
|
||||
}
|
||||
@@ -1404,7 +1355,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 +1367,6 @@ impl SyntaxUpdate<'_> {
|
||||
self.old_aliases,
|
||||
self.legacy_value_paths,
|
||||
None,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1430,7 +1379,6 @@ impl SyntaxUpdate<'_> {
|
||||
self.new_aliases,
|
||||
self.legacy_value_paths,
|
||||
self.identity_origins,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1459,7 +1407,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 +1440,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 {
|
||||
@@ -1541,58 +1483,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],
|
||||
|
||||
@@ -68,11 +68,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 {
|
||||
|
||||
@@ -41,7 +41,6 @@ fn fixture() -> Fixture {
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,267 @@
|
||||
//! 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,
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+575
-687
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() {
|
||||
|
||||
@@ -78,6 +78,7 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
|
||||
editable: true,
|
||||
placing: false,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
|
||||
@@ -89,17 +90,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();
|
||||
|
||||
@@ -233,6 +233,7 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
|
||||
editable: true,
|
||||
placing: true,
|
||||
connection: None,
|
||||
allocation: None,
|
||||
};
|
||||
let mut state = crate::diagram::State::default();
|
||||
let action = canvas
|
||||
@@ -308,6 +309,7 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
|
||||
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 +323,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());
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -205,20 +270,30 @@ impl App {
|
||||
return task;
|
||||
}
|
||||
nav::SymbolEvent::Close => {
|
||||
self.source_symbols.open = false;
|
||||
self.source_symbols.results = None;
|
||||
self.source_symbols.notice = None;
|
||||
return self.show_text_drawer(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 +331,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 +353,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 +393,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 +429,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 +449,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 +463,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 +501,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 +513,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 +549,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 +561,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 +571,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 +586,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 +600,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 +617,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,18 @@ fn symbol(app: &mut App, event: nav::SymbolEvent) {
|
||||
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 +135,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 +204,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 +246,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 +295,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 +321,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 +350,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 +405,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);
|
||||
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));
|
||||
symbol(&mut app, nav::SymbolEvent::Close);
|
||||
assert!(!app.source_symbols.open);
|
||||
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 +473,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 +481,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 +525,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 +538,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()));
|
||||
}
|
||||
@@ -486,7 +562,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
|
||||
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());
|
||||
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 +582,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 +594,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 +605,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 +852,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 +874,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 +900,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 +912,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 +924,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 +933,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 +964,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 +986,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 +1002,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 +1050,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 +1080,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 +1300,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 +1374,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) => {}
|
||||
@@ -1282,11 +1434,7 @@ fn stale_envelope_trial(reset: bool) {
|
||||
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,
|
||||
}),
|
||||
choice(&app, old_results.stamp, false, 0),
|
||||
];
|
||||
if reset {
|
||||
let project = app.project().clone();
|
||||
@@ -1348,7 +1496,7 @@ fn stale_envelope_trial(reset: bool) {
|
||||
let interaction = app.source_symbols.interaction;
|
||||
symbol(&mut app, nav::SymbolEvent::Close);
|
||||
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 +1649,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 +2006,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 +2061,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 +2074,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 +2088,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()
|
||||
|
||||
@@ -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,9 +21,6 @@ pub(crate) mod creation;
|
||||
mod form_session;
|
||||
pub(crate) mod scenario;
|
||||
mod scenario_activation;
|
||||
// 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;
|
||||
@@ -62,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>,
|
||||
}
|
||||
@@ -124,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| {
|
||||
@@ -153,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);
|
||||
@@ -197,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,
|
||||
})
|
||||
@@ -206,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();
|
||||
@@ -495,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);
|
||||
@@ -600,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;
|
||||
@@ -629,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);
|
||||
@@ -682,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.)
|
||||
@@ -996,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()
|
||||
@@ -1020,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())
|
||||
@@ -1215,16 +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)>,
|
||||
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>,
|
||||
@@ -1274,29 +1227,22 @@ impl SequenceInput {
|
||||
}
|
||||
}
|
||||
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,
|
||||
coordinate_input: true,
|
||||
pending_placement: None,
|
||||
lanes: BTreeMap::new(),
|
||||
render_lanes: BTreeMap::new(),
|
||||
slots: vec![],
|
||||
rows: BTreeMap::new(),
|
||||
activations: vec![],
|
||||
@@ -1367,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()
|
||||
@@ -1408,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,
|
||||
@@ -1438,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 {
|
||||
@@ -1460,18 +1365,13 @@ impl Scene {
|
||||
scene.boxes.push(BoxItem {
|
||||
id,
|
||||
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,
|
||||
@@ -1480,7 +1380,6 @@ impl Scene {
|
||||
lanes: &BTreeMap<Id, f32>,
|
||||
y: &mut f32,
|
||||
depth: usize,
|
||||
plan: Option<&scenario_layout::Plan>,
|
||||
) {
|
||||
let steps = p
|
||||
.elements
|
||||
@@ -1520,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,
|
||||
@@ -1539,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(
|
||||
@@ -1547,7 +1443,6 @@ impl Scene {
|
||||
(*y - top).max(95.),
|
||||
),
|
||||
);
|
||||
let rect = plan.map_or(rect, |plan| plan.frames[id]);
|
||||
self.boxes.insert(
|
||||
0,
|
||||
BoxItem {
|
||||
@@ -1579,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,
|
||||
@@ -1607,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),
|
||||
@@ -1732,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,
|
||||
@@ -1811,17 +1702,7 @@ impl Scene {
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
.find(|line| {
|
||||
!line.label.is_empty()
|
||||
&& Rectangle::new(
|
||||
line.label_at,
|
||||
Size::new(
|
||||
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
|
||||
25.,
|
||||
),
|
||||
)
|
||||
.contains(point)
|
||||
})
|
||||
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
|
||||
.map(|line| line.id)
|
||||
})
|
||||
// The generous message tolerance must not steal the exposed
|
||||
@@ -1899,7 +1780,7 @@ impl canvas::Program<Message> for Scene {
|
||||
state.pointer = None;
|
||||
}
|
||||
state.bounds = Some(bounds);
|
||||
if !editable || !self.coordinate_input {
|
||||
if !editable {
|
||||
state.cancel();
|
||||
}
|
||||
// The viewport masks input outside its visible canvas bounds.
|
||||
@@ -1942,6 +1823,29 @@ 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),
|
||||
..
|
||||
@@ -1967,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(|| {
|
||||
@@ -2179,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
|
||||
@@ -2196,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(
|
||||
@@ -2302,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,
|
||||
@@ -2321,7 +2214,6 @@ impl canvas::Program<Message> for Scene {
|
||||
}
|
||||
});
|
||||
if editable
|
||||
&& self.coordinate_input
|
||||
&& tool == scenario::CanvasTool::Select
|
||||
&& let Some(handle) = self.reorder_handle()
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,6 +10,7 @@ use iced::{keyboard, mouse};
|
||||
struct Input {
|
||||
scope: Option<Scope>,
|
||||
focused: bool,
|
||||
focus_pending: bool,
|
||||
pointer: Option<Point>,
|
||||
pan: Option<(mouse::Button, Point)>,
|
||||
space: bool,
|
||||
@@ -21,9 +22,11 @@ impl operation::Focusable for Input {
|
||||
}
|
||||
fn focus(&mut self) {
|
||||
self.focused = true;
|
||||
self.focus_pending = false;
|
||||
}
|
||||
fn unfocus(&mut self) {
|
||||
self.focused = false;
|
||||
self.focus_pending = false;
|
||||
self.pan = None;
|
||||
self.space = false;
|
||||
}
|
||||
@@ -92,6 +95,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
state.focus_pending = false;
|
||||
}
|
||||
state.scope = Some(self.scope);
|
||||
tree.diff_children(std::slice::from_ref(&self.content));
|
||||
@@ -143,6 +147,11 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
return;
|
||||
}
|
||||
let state = tree.state.downcast_mut::<Input>();
|
||||
// A traversal operation is applied after this Iced event batch. Keep
|
||||
// its focus witness, but retire keyboard admission immediately.
|
||||
if state.focus_pending && matches!(event, Event::Keyboard(_)) {
|
||||
return;
|
||||
}
|
||||
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
|
||||
state.bounds = Some(layout.bounds());
|
||||
if changed_bounds || !self.keyboard {
|
||||
@@ -151,6 +160,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
}
|
||||
if !self.keyboard {
|
||||
state.focused = false;
|
||||
state.focus_pending = false;
|
||||
}
|
||||
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
|
||||
state.pointer = Some(*position);
|
||||
@@ -178,6 +188,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
state.focused = false;
|
||||
state.focus_pending = false;
|
||||
}
|
||||
Event::Mouse(mouse::Event::CursorLeft) => {
|
||||
state.pan = None;
|
||||
@@ -190,6 +201,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
cancel = true;
|
||||
}
|
||||
state.focused = inside && self.keyboard;
|
||||
state.focus_pending = false;
|
||||
if inside
|
||||
&& self.keyboard
|
||||
&& (*button == mouse::Button::Middle
|
||||
@@ -242,6 +254,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
repeat: false,
|
||||
..
|
||||
}) if self.keyboard && modifiers.is_empty() => {
|
||||
state.focus_pending = true;
|
||||
camera = Some(CameraEvent::ToggleFocus);
|
||||
capture = true;
|
||||
cancel = true;
|
||||
@@ -258,12 +271,14 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Tab) => {
|
||||
state.focus_pending = true;
|
||||
camera = Some(CameraEvent::Traverse(modifiers.shift()));
|
||||
capture = true;
|
||||
}
|
||||
Key::Named(Named::Escape) => {
|
||||
state.pan = None;
|
||||
state.space = false;
|
||||
state.focus_pending = true;
|
||||
camera = Some(CameraEvent::Leave);
|
||||
capture = true;
|
||||
}
|
||||
@@ -309,7 +324,9 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
|
||||
shell.request_redraw();
|
||||
return;
|
||||
}
|
||||
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
|
||||
if matches!(event, Event::Keyboard(_))
|
||||
&& (!state.focused || !self.keyboard || state.pan.is_some() || state.space)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let child_cursor = if inside {
|
||||
|
||||
@@ -893,14 +893,11 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Second scenario", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(p.clone());
|
||||
// Capture the context created by actual navigation, including its visit.
|
||||
// Merely assigning active_owner/selected does not mount a Scenario anymore.
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
app.page = Page::Behavior;
|
||||
app.layer = Layer::Oa;
|
||||
app.behavior.active_owner = Some(a);
|
||||
app.selected = a;
|
||||
let context = app.scenario_context().unwrap();
|
||||
assert_eq!(context.scenario, a);
|
||||
assert!(!context.visit.is_nil());
|
||||
let snapshot = app.document.snapshot();
|
||||
let source = app.document.bundle().clone();
|
||||
let begin = |context| scenario::CanvasEvent::BeginOccurrence {
|
||||
context,
|
||||
@@ -911,40 +908,23 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
|
||||
},
|
||||
lifeline: None,
|
||||
};
|
||||
let _ = app.update(Message::Select(b));
|
||||
let second = app.scenario_context().unwrap();
|
||||
assert_eq!(second.scenario, b);
|
||||
assert_ne!(second.visit, context.visit);
|
||||
app.behavior.active_owner = Some(b);
|
||||
app.selected = b;
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(context)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.project(), &p);
|
||||
|
||||
let _ = app.update(Message::Select(a));
|
||||
let before_layer_change = app.scenario_context().unwrap();
|
||||
assert_eq!(before_layer_change.scenario, a);
|
||||
assert_ne!(before_layer_change.visit, context.visit);
|
||||
let _ = app.update(Message::Layer(Layer::Sa));
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
assert!(app.scenario_context().is_none());
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(before_layer_change)));
|
||||
app.behavior.active_owner = Some(a);
|
||||
app.selected = a;
|
||||
app.layer = Layer::Sa;
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(context)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
|
||||
let _ = app.update(Message::Layer(Layer::Oa));
|
||||
let _ = app.update(Message::Select(a));
|
||||
app.layer = Layer::Oa;
|
||||
let current = app.scenario_context().unwrap();
|
||||
assert_eq!(current.scenario, a);
|
||||
assert_ne!(current.visit, before_layer_change.visit);
|
||||
// Returning to the same owner/layer must not revive either retired packet.
|
||||
for retired in [context, before_layer_change] {
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(retired)));
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
assert!(std::sync::Arc::ptr_eq(&snapshot, &app.document.snapshot()));
|
||||
let _ = app.update(Message::ScenarioCanvas(begin(current)));
|
||||
assert_eq!(app.behavior.pending_owner(), Some(a));
|
||||
assert_eq!(app.project(), &p);
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
assert!(!app.document.can_undo() && !app.document.can_redo());
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -694,9 +694,7 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
Picker::Owner => d.owner,
|
||||
Picker::Context => d.context,
|
||||
};
|
||||
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
|
||||
let title = match picker {
|
||||
Picker::Owner if staged_owner => "Or choose an existing participant",
|
||||
Picker::Owner => "Owned by participant",
|
||||
Picker::Context => "Scenario context · optional",
|
||||
};
|
||||
@@ -707,16 +705,13 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
|
||||
.padding(6)
|
||||
]
|
||||
.spacing(5);
|
||||
if let Some(id) = selected
|
||||
&& !staged_owner
|
||||
{
|
||||
if let Some(id) = selected {
|
||||
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
|
||||
} else if picker == Picker::Context {
|
||||
out = out.push(text("No context selected").size(12));
|
||||
}
|
||||
if options.is_empty() {
|
||||
out = out.push(text(match picker {
|
||||
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
|
||||
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
|
||||
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
|
||||
}).size(12));
|
||||
|
||||
@@ -4,14 +4,7 @@ use super::topology::{occurrence, unique_batch};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, text_input};
|
||||
use std::{cell::RefCell, collections::BTreeSet};
|
||||
mod camera;
|
||||
pub(crate) mod focus;
|
||||
use super::scenario_layout::{
|
||||
Representation,
|
||||
presentation::{self, ScenarioPresentation},
|
||||
};
|
||||
#[cfg(test)]
|
||||
mod presentation_tests;
|
||||
mod usage;
|
||||
mod workspace;
|
||||
pub(crate) use workspace::NAME_INPUT;
|
||||
@@ -74,12 +67,38 @@ impl std::fmt::Display for MessageStyle {
|
||||
})
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn removal_test_point(app: &App, owner: Id, id: Id) -> Point {
|
||||
let scene = super::Scene::sequence(app.project(), owner, id);
|
||||
let point = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|item| item.id == id && !item.title.is_empty())
|
||||
.map(|item| Point::new(item.rect.x + 20., item.rect.y + 20.))
|
||||
.or_else(|| {
|
||||
scene
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == id && !line.label.is_empty())
|
||||
.map(|line| line.label_at + iced::Vector::new(6., 8.))
|
||||
})
|
||||
.expect("rendered scenario target");
|
||||
let presentation = &app.behavior.scenario.presentation;
|
||||
Point::ORIGIN
|
||||
+ presentation.pan
|
||||
+ iced::Vector::new(point.x, point.y) * presentation.scale(scene.size)
|
||||
}
|
||||
|
||||
pub(crate) fn removal_target(p: &Project, owner: Id, id: Id) -> bool {
|
||||
super::selected_behavior(p, id) == Some(owner)
|
||||
&& super::Scene::sequence(p, owner, id).removal_target(owner, id)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Context {
|
||||
pub document: Id,
|
||||
pub scenario: Id,
|
||||
pub representation: Representation,
|
||||
pub visit: Id,
|
||||
pub revision: u64,
|
||||
pub epoch: u64,
|
||||
}
|
||||
@@ -112,13 +131,12 @@ pub enum CanvasEvent {
|
||||
FormShown(Context, Id),
|
||||
Zoom(Context, Option<f32>),
|
||||
Camera(Context, u64, super::canvas_workspace::Event),
|
||||
Focus(Context),
|
||||
Shelf(Context, Shelf),
|
||||
OpenUse(Context, Id),
|
||||
Back(Context),
|
||||
Choose(Context, CanvasTool),
|
||||
Representation(Context, Representation),
|
||||
Select(Context, Id),
|
||||
Remove(Context, Id),
|
||||
Begin {
|
||||
context: Context,
|
||||
slot: Slot,
|
||||
@@ -152,13 +170,12 @@ impl CanvasEvent {
|
||||
| Self::FormShown(context, _)
|
||||
| Self::Zoom(context, _)
|
||||
| Self::Camera(context, ..)
|
||||
| Self::Focus(context)
|
||||
| Self::Shelf(context, _)
|
||||
| Self::OpenUse(context, _)
|
||||
| Self::Back(context)
|
||||
| Self::Choose(context, _)
|
||||
| Self::Representation(context, _)
|
||||
| Self::Select(context, _)
|
||||
| Self::Remove(context, _)
|
||||
| Self::Begin { context, .. }
|
||||
| Self::BeginOccurrence { context, .. }
|
||||
| Self::Reorder { context, .. } => *context,
|
||||
@@ -229,7 +246,6 @@ const CALLER_LIMIT: usize = 32;
|
||||
pub(crate) struct Caller {
|
||||
document: Id,
|
||||
pub owner: Id,
|
||||
pub representation: Representation,
|
||||
pub occurrence: Id,
|
||||
target: Id,
|
||||
tool: CanvasTool,
|
||||
@@ -239,8 +255,6 @@ pub(crate) struct Caller {
|
||||
struct DetailsRestore {
|
||||
document: Id,
|
||||
owner: Id,
|
||||
representation: Representation,
|
||||
visit: Id,
|
||||
token: Id,
|
||||
offset: iced::Vector,
|
||||
}
|
||||
@@ -248,8 +262,6 @@ struct DetailsRestore {
|
||||
pub(super) struct ScrollRestore {
|
||||
pub document: Id,
|
||||
pub owner: Id,
|
||||
pub representation: Representation,
|
||||
pub visit: Id,
|
||||
pub token: Id,
|
||||
pub offset: iced::Vector,
|
||||
}
|
||||
@@ -307,7 +319,6 @@ impl State {
|
||||
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
|
||||
(
|
||||
self.canvas_tool,
|
||||
self.presentation.focused,
|
||||
self.presentation.shelf,
|
||||
self.presentation.viewport,
|
||||
self.presentation.pan,
|
||||
@@ -343,154 +354,43 @@ impl State {
|
||||
}
|
||||
|
||||
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
|
||||
self.enter_representation(document, owner, Representation::Derived { owner });
|
||||
}
|
||||
/// App validates requested identity with checked_owner before entering.
|
||||
pub(crate) fn enter_representation(
|
||||
&mut self,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
representation: Representation,
|
||||
) -> bool {
|
||||
if matches!(representation, Representation::Derived { owner: requested } if requested != owner)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let changed = self.presentation.identity != Some((document, owner))
|
||||
|| self.presentation.representation != Some(representation);
|
||||
if changed {
|
||||
if self.presentation.identity != Some((document, owner)) {
|
||||
self.presentation = workspace::Presentation {
|
||||
identity: Some((document, owner)),
|
||||
representation: Some(representation),
|
||||
focused: self.presentation.focused,
|
||||
fit: matches!(representation, Representation::Saved { .. }),
|
||||
..Default::default()
|
||||
};
|
||||
self.details_offset = iced::Vector::ZERO;
|
||||
self.canvas_tool = Some((owner, CanvasTool::Select));
|
||||
}
|
||||
if changed || self.presentation.visit.is_nil() {
|
||||
self.presentation.visit = Id::new_v4();
|
||||
self.restore = None;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
/// Suspend captured events while preserving this destination's camera state.
|
||||
pub(crate) fn leave(&mut self) {
|
||||
self.presentation.visit = Id::nil();
|
||||
self.restore = None;
|
||||
}
|
||||
pub(crate) fn forget_view(&mut self) {
|
||||
self.presentation.identity = None;
|
||||
self.presentation.representation = None;
|
||||
self.presentation.visit = Id::nil();
|
||||
self.restore = None;
|
||||
}
|
||||
pub(crate) fn representation(&self) -> Option<Representation> {
|
||||
self.presentation.representation
|
||||
}
|
||||
pub(crate) fn owner(&self) -> Option<Id> {
|
||||
self.presentation.identity.map(|(_, owner)| owner)
|
||||
}
|
||||
pub(crate) fn context(
|
||||
&self,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
revision: u64,
|
||||
epoch: u64,
|
||||
) -> Option<Context> {
|
||||
if self.presentation.identity != Some((document, owner)) || self.presentation.visit.is_nil()
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Context {
|
||||
document,
|
||||
scenario: owner,
|
||||
representation: self.representation()?,
|
||||
visit: self.presentation.visit,
|
||||
revision,
|
||||
epoch,
|
||||
})
|
||||
}
|
||||
pub(crate) fn display(
|
||||
&self,
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
selected: Id,
|
||||
) -> Option<ScenarioPresentation> {
|
||||
let (_, current_owner) = self.presentation.identity?;
|
||||
let representation = self.representation()?;
|
||||
(current_owner == owner).then(|| presentation::present(p, representation, selected))
|
||||
}
|
||||
pub(crate) fn coordinate_input(&self, p: &Project, context: Context) -> bool {
|
||||
self.context(
|
||||
context.document,
|
||||
context.scenario,
|
||||
context.revision,
|
||||
context.epoch,
|
||||
) == Some(context)
|
||||
&& self
|
||||
.display(p, context.scenario, context.scenario)
|
||||
.is_some_and(|shown| shown.capabilities().coordinate_input)
|
||||
}
|
||||
pub(crate) fn viewport(&mut self, size: Size) -> bool {
|
||||
self.presentation.viewport(size)
|
||||
}
|
||||
pub(crate) fn camera(
|
||||
&mut self,
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
event: super::canvas_workspace::Event,
|
||||
) -> bool {
|
||||
let Some(bounds) = self
|
||||
.display(project, owner, owner)
|
||||
.and_then(|shown| shown.bounds())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
|
||||
let p = &mut self.presentation;
|
||||
camera::change(
|
||||
&mut p.pan,
|
||||
&mut p.zoom,
|
||||
&mut p.fit,
|
||||
p.viewport,
|
||||
bounds,
|
||||
event,
|
||||
)
|
||||
super::canvas_workspace::change(&mut p.pan, &mut p.zoom, &mut p.fit, event)
|
||||
}
|
||||
pub(crate) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
self.presentation.zoom(scale)
|
||||
}
|
||||
pub(crate) fn camera_zoom(&mut self, p: &Project, owner: Id, zoom: Option<f32>) -> bool {
|
||||
let Some(bounds) = self
|
||||
.display(p, owner, owner)
|
||||
.and_then(|shown| shown.bounds())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let size = Scene::sequence(p, owner, owner).size;
|
||||
let old = self.presentation.scale(size);
|
||||
if let Some(next) = zoom {
|
||||
let current = self.presentation.camera(bounds);
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
self.camera(
|
||||
p,
|
||||
owner,
|
||||
super::canvas_workspace::Event::Zoom {
|
||||
factor: next / current.scale,
|
||||
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
||||
base: current.scale,
|
||||
},
|
||||
)
|
||||
self.camera(super::canvas_workspace::Event::Zoom {
|
||||
factor: next / old,
|
||||
at: Point::new(viewport.width / 2., viewport.height / 2.),
|
||||
base: old,
|
||||
})
|
||||
} else {
|
||||
self.zoom(None)
|
||||
self.zoom(None);
|
||||
let fitted = self.presentation.scale(size);
|
||||
self.zoom(Some(fitted));
|
||||
self.presentation.pan = iced::Vector::ZERO;
|
||||
true
|
||||
}
|
||||
}
|
||||
pub(crate) fn focused(&self) -> bool {
|
||||
self.presentation.focused
|
||||
}
|
||||
pub(crate) fn toggle_focus(&mut self) {
|
||||
self.presentation.focused = !self.presentation.focused;
|
||||
}
|
||||
pub(crate) fn toggle_shelf(&mut self, shelf: Shelf) {
|
||||
self.presentation.shelf = (self.presentation.shelf != Some(shelf)).then_some(shelf);
|
||||
}
|
||||
@@ -510,7 +410,6 @@ impl State {
|
||||
self.return_to.push(Caller {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
representation: context.representation,
|
||||
occurrence,
|
||||
target,
|
||||
tool: self.canvas_tool(context.scenario),
|
||||
@@ -525,7 +424,6 @@ impl State {
|
||||
fn valid_caller(p: &Project, context: Context, frame: &Caller) -> bool {
|
||||
frame.document == context.document
|
||||
&& frame.target == context.scenario
|
||||
&& presentation::checked_owner(p, frame.representation) == Some(frame.owner)
|
||||
&& referenced_scenario(p, frame.owner, frame.occurrence) == Some(frame.target)
|
||||
}
|
||||
pub(super) fn can_back(&self, p: &Project, context: Context) -> bool {
|
||||
@@ -542,23 +440,16 @@ impl State {
|
||||
None
|
||||
}
|
||||
pub(crate) fn restore_caller(&mut self, context: Context, frame: Caller) {
|
||||
if context.document != frame.document
|
||||
|| context.scenario != frame.owner
|
||||
|| context.representation != frame.representation
|
||||
{
|
||||
if context.document != frame.document || context.scenario != frame.owner {
|
||||
return;
|
||||
}
|
||||
self.canvas_tool = Some((frame.owner, frame.tool));
|
||||
self.details_offset = frame.details_offset;
|
||||
self.presentation = frame.presentation;
|
||||
self.presentation.identity = Some((context.document, context.scenario));
|
||||
self.presentation.representation = Some(frame.representation);
|
||||
self.presentation.visit = context.visit;
|
||||
self.restore = Some(DetailsRestore {
|
||||
document: context.document,
|
||||
owner: frame.owner,
|
||||
representation: frame.representation,
|
||||
visit: context.visit,
|
||||
token: Id::new_v4(),
|
||||
offset: frame.details_offset,
|
||||
});
|
||||
@@ -568,17 +459,13 @@ impl State {
|
||||
context: Context,
|
||||
ScrollArea::Page: ScrollArea,
|
||||
) -> Option<ScrollRestore> {
|
||||
let restore = self.restore.as_ref().filter(|r| {
|
||||
r.document == context.document
|
||||
&& r.owner == context.scenario
|
||||
&& r.representation == context.representation
|
||||
&& r.visit == context.visit
|
||||
})?;
|
||||
let restore = self
|
||||
.restore
|
||||
.as_ref()
|
||||
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
|
||||
Some(ScrollRestore {
|
||||
document: restore.document,
|
||||
owner: restore.owner,
|
||||
representation: restore.representation,
|
||||
visit: restore.visit,
|
||||
token: restore.token,
|
||||
offset: restore.offset,
|
||||
})
|
||||
@@ -591,11 +478,7 @@ impl State {
|
||||
offset: iced::Vector,
|
||||
) -> bool {
|
||||
if !self.restore.as_ref().is_some_and(|r| {
|
||||
r.document == context.document
|
||||
&& r.owner == context.scenario
|
||||
&& r.representation == context.representation
|
||||
&& r.visit == context.visit
|
||||
&& r.token == token
|
||||
r.document == context.document && r.owner == context.scenario && r.token == token
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
@@ -703,7 +586,7 @@ impl State {
|
||||
}
|
||||
created(command, draft.parent)
|
||||
}
|
||||
pub(super) fn canvas_tool(&self, owner: Id) -> CanvasTool {
|
||||
pub(crate) fn canvas_tool(&self, owner: Id) -> CanvasTool {
|
||||
self.canvas_tool
|
||||
.filter(|(id, _)| *id == owner)
|
||||
.map(|(_, tool)| tool)
|
||||
@@ -2068,13 +1951,11 @@ pub(crate) mod direct_tests {
|
||||
Context {
|
||||
document: Id::new_v4(),
|
||||
scenario: owner,
|
||||
representation: Representation::Derived { owner },
|
||||
visit: Id::new_v4(),
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
}
|
||||
}
|
||||
pub(super) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
|
||||
pub(crate) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
|
||||
let mut f = fixture();
|
||||
let p = &mut f.project;
|
||||
let machine = p
|
||||
@@ -3373,24 +3254,45 @@ mod tests {
|
||||
.iter()
|
||||
.any(|l| l.id == steps[1] && l.dashed && l.label.contains("reply"))
|
||||
);
|
||||
assert_eq!(scene.activations.len(), 1);
|
||||
let activation = &scene.activations[0];
|
||||
assert_eq!(activation.call, steps[0]);
|
||||
assert_eq!(activation.receiver, receiver);
|
||||
assert_eq!(activation.scope, owner);
|
||||
assert_eq!(
|
||||
scene.activations,
|
||||
vec![super::super::scenario_activation::Segment {
|
||||
call: steps[0],
|
||||
receiver,
|
||||
scope: owner,
|
||||
rect: Rectangle::new(
|
||||
Point::new(scene.lanes[&receiver] - 6., 148.),
|
||||
Size::new(12., 92.),
|
||||
),
|
||||
column: 0,
|
||||
finish: super::super::scenario_activation::Finish::Reply(steps[1]),
|
||||
}]
|
||||
activation.finish,
|
||||
scenario_activation::Finish::Reply(steps[1])
|
||||
);
|
||||
let call_line = scene.lines.iter().find(|line| line.id == steps[0]).unwrap();
|
||||
let reply_line = scene.lines.iter().find(|line| line.id == steps[1]).unwrap();
|
||||
assert_eq!(call_line.points.last().unwrap().y, 148.);
|
||||
assert_eq!(reply_line.points.first().unwrap().y, 240.);
|
||||
// Scene::draw paints this typed rectangle directly. Check its extent
|
||||
// against the actual message endpoints, without fixing a style width.
|
||||
let rect = activation.rect;
|
||||
assert!(
|
||||
[rect.x, rect.y, rect.width, rect.height]
|
||||
.iter()
|
||||
.all(|v| v.is_finite())
|
||||
);
|
||||
assert!(rect.width > 0. && rect.height > 0.);
|
||||
let call_line = scene.lines.iter().find(|l| l.id == steps[0]).unwrap();
|
||||
let reply_line = scene.lines.iter().find(|l| l.id == steps[1]).unwrap();
|
||||
let received = *call_line.points.last().unwrap();
|
||||
let returned = reply_line.points[0];
|
||||
assert_eq!(rect.y, received.y);
|
||||
assert_eq!(rect.y + rect.height, returned.y);
|
||||
assert_eq!(received.x, returned.x);
|
||||
assert!(received.x == rect.x || received.x == rect.x + rect.width);
|
||||
assert!(!scene.boxes.iter().any(|b| b.id == steps[0]));
|
||||
assert!(scene.open_calls.is_empty());
|
||||
let interior = rect.center();
|
||||
assert!(scene.lines.iter().filter(|l| !l.label.is_empty()).all(|l| {
|
||||
l.points.windows(2).all(|p| {
|
||||
crate::diagram::distance_to_segment(interior, p[0], p[1]) > 8. / scene.scale
|
||||
}) && !Rectangle::new(
|
||||
l.label_at,
|
||||
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 25.),
|
||||
)
|
||||
.contains(interior)
|
||||
}));
|
||||
assert_eq!(scene.hit(interior), Some(steps[0]));
|
||||
let mut reply = State::default();
|
||||
reply
|
||||
.update(result, Event::Start(owner, Tool::Message))
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
//! Scenario camera policy. Bounds are from the exact displayed presentation,
|
||||
//! including its origin and automatic fallback; no model geometry is authored.
|
||||
use crate::behavior::canvas_workspace::{self, Event};
|
||||
use iced::{Rectangle, Size, Vector};
|
||||
|
||||
pub(super) const MIN_SCALE: f32 = f32::from_bits(1);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Camera {
|
||||
pub(super) scale: f32,
|
||||
pub(super) pan: Vector,
|
||||
}
|
||||
|
||||
/// Fit never enlarges above 100%. Use f64 intermediates so a valid f32 origin
|
||||
/// and size cannot overflow while finding the center. Unrepresentable cameras
|
||||
/// are refused rather than silently clamped to an unrelated minimum scale.
|
||||
pub(super) fn fit(bounds: Rectangle, viewport: Size) -> Option<Camera> {
|
||||
if [
|
||||
bounds.x,
|
||||
bounds.y,
|
||||
bounds.width,
|
||||
bounds.height,
|
||||
viewport.width,
|
||||
viewport.height,
|
||||
]
|
||||
.iter()
|
||||
.any(|n| !n.is_finite())
|
||||
|| bounds.width < 0.
|
||||
|| bounds.height < 0.
|
||||
|| viewport.width <= 0.
|
||||
|| viewport.height <= 0.
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let width = f64::from(viewport.width);
|
||||
let height = f64::from(viewport.height);
|
||||
let scale = ((width - 2. * (width / 4.).min(8.)) / f64::from(bounds.width).max(1.))
|
||||
.min((height - 2. * (height / 4.).min(8.)) / f64::from(bounds.height).max(1.))
|
||||
.min(1.) as f32;
|
||||
if !scale.is_finite() || scale <= 0. {
|
||||
return None;
|
||||
}
|
||||
let x = width / 2. - (f64::from(bounds.x) + f64::from(bounds.width) / 2.) * f64::from(scale);
|
||||
let y = height / 2. - (f64::from(bounds.y) + f64::from(bounds.height) / 2.) * f64::from(scale);
|
||||
let pan = Vector::new(x as f32, y as f32);
|
||||
(pan.x.is_finite() && pan.y.is_finite()).then_some(Camera { scale, pan })
|
||||
}
|
||||
|
||||
pub(super) fn effective_camera(
|
||||
pan: Vector,
|
||||
zoom: Option<f32>,
|
||||
fitting: bool,
|
||||
viewport: Option<Size>,
|
||||
bounds: Rectangle,
|
||||
) -> Camera {
|
||||
if fitting && let Some(camera) = viewport.and_then(|size| fit(bounds, size)) {
|
||||
return camera;
|
||||
}
|
||||
Camera {
|
||||
scale: zoom.unwrap_or(1.),
|
||||
pan,
|
||||
}
|
||||
}
|
||||
|
||||
/// None preserves Fit as a mode, including before the first viewport arrives.
|
||||
pub(super) fn set_zoom(
|
||||
pan: &mut Vector,
|
||||
zoom: &mut Option<f32>,
|
||||
fitting: &mut bool,
|
||||
scale: Option<f32>,
|
||||
) -> bool {
|
||||
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
||||
return false;
|
||||
}
|
||||
let next = scale.map(|value| value.min(3.));
|
||||
let next_pan = if scale.is_none() { Vector::ZERO } else { *pan };
|
||||
let changed = *zoom != next || *fitting != scale.is_none() || *pan != next_pan;
|
||||
*zoom = next;
|
||||
*fitting = scale.is_none();
|
||||
*pan = next_pan;
|
||||
changed
|
||||
}
|
||||
|
||||
/// Materialize the visible fitted camera only when a camera gesture succeeds.
|
||||
/// A zero-motion or rejected event preserves the original mode and fields.
|
||||
pub(super) fn change(
|
||||
pan: &mut Vector,
|
||||
zoom: &mut Option<f32>,
|
||||
fitting: &mut bool,
|
||||
viewport: Option<Size>,
|
||||
bounds: Rectangle,
|
||||
event: Event,
|
||||
) -> bool {
|
||||
let camera = effective_camera(*pan, *zoom, *fitting, viewport, bounds);
|
||||
let (mut next_pan, mut next_zoom, mut next_fit) = (camera.pan, Some(camera.scale), false);
|
||||
let event = match event {
|
||||
Event::Zoom { base, .. } if !base.is_finite() || base <= 0. => return false,
|
||||
Event::Zoom { factor, at, .. } => Event::Zoom {
|
||||
factor,
|
||||
at,
|
||||
base: camera.scale,
|
||||
},
|
||||
event => event,
|
||||
};
|
||||
if !canvas_workspace::change_with_minimum(
|
||||
&mut next_pan,
|
||||
&mut next_zoom,
|
||||
&mut next_fit,
|
||||
event,
|
||||
MIN_SCALE,
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
*pan = next_pan;
|
||||
*zoom = next_zoom;
|
||||
*fitting = next_fit;
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use iced::Point;
|
||||
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle {
|
||||
x: -1_000_160.,
|
||||
y: -200.,
|
||||
width: 2_000_400.,
|
||||
height: 1_000.,
|
||||
}
|
||||
}
|
||||
fn near(actual: f32, expected: f32, tolerance: f32) {
|
||||
assert!(
|
||||
(actual - expected).abs() <= tolerance,
|
||||
"{actual} != {expected}"
|
||||
);
|
||||
}
|
||||
fn projected(camera: Camera, point: Point) -> Point {
|
||||
Point::new(
|
||||
point.x * camera.scale + camera.pan.x,
|
||||
point.y * camera.scale + camera.pan.y,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn negative_origin_fit_contains_independently_known_extremes() {
|
||||
let camera = fit(bounds(), Size::new(700., 500.)).unwrap();
|
||||
near(camera.scale, 0.0003419316, 1e-10);
|
||||
near(camera.pan.x, 349.98633, 0.0001);
|
||||
near(
|
||||
projected(camera, Point::new(-1_000_160., -200.)).x,
|
||||
8.,
|
||||
0.0001,
|
||||
);
|
||||
near(
|
||||
projected(camera, Point::new(1_000_240., 800.)).x,
|
||||
692.,
|
||||
0.0001,
|
||||
);
|
||||
near(projected(camera, Point::new(40., 300.)).y, 250., 0.0001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fit_remains_active_across_first_and_changed_viewport() {
|
||||
let (mut pan, mut zoom, mut fitting) = (Vector::new(50., 80.), Some(0.8), false);
|
||||
assert!(set_zoom(&mut pan, &mut zoom, &mut fitting, None));
|
||||
let retained = effective_camera(pan, zoom, fitting, Some(Size::new(1200., 500.)), bounds());
|
||||
let actual = effective_camera(pan, zoom, fitting, Some(Size::new(500., 500.)), bounds());
|
||||
assert!(actual.scale < retained.scale);
|
||||
near(
|
||||
projected(actual, Point::new(-1_000_160., -200.)).x,
|
||||
8.,
|
||||
0.0001,
|
||||
);
|
||||
near(
|
||||
projected(actual, Point::new(1_000_240., 800.)).x,
|
||||
492.,
|
||||
0.0001,
|
||||
);
|
||||
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() {
|
||||
let viewport = Some(Size::new(700., 500.));
|
||||
let start = fit(bounds(), viewport.unwrap()).unwrap();
|
||||
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
|
||||
let at = Point::new(210., 180.);
|
||||
let world = Point::new(
|
||||
(at.x - start.pan.x) / start.scale,
|
||||
(at.y - start.pan.y) / start.scale,
|
||||
);
|
||||
for factor in [1.25, 0.8] {
|
||||
let prior_scale = zoom.unwrap_or(start.scale);
|
||||
assert!(change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fitting,
|
||||
viewport,
|
||||
bounds(),
|
||||
Event::Zoom {
|
||||
factor,
|
||||
at,
|
||||
base: start.scale,
|
||||
}
|
||||
));
|
||||
let camera = effective_camera(pan, zoom, fitting, viewport, bounds());
|
||||
near(camera.scale, prior_scale * factor, 1e-10);
|
||||
assert!(camera.scale < 0.001);
|
||||
near(projected(camera, world).x, at.x, 0.0001);
|
||||
near(projected(camera, world).y, at.y, 0.0001);
|
||||
}
|
||||
near(zoom.unwrap(), start.scale, 1e-10);
|
||||
near(pan.x, start.pan.x, 0.0001);
|
||||
assert!(!fitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() {
|
||||
let viewport = Some(Size::new(700., 500.));
|
||||
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
|
||||
for event in [
|
||||
Event::Pan(Vector::ZERO),
|
||||
Event::Zoom {
|
||||
factor: 1.,
|
||||
at: Point::ORIGIN,
|
||||
base: 1.,
|
||||
},
|
||||
] {
|
||||
assert!(!change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fitting,
|
||||
viewport,
|
||||
bounds(),
|
||||
event
|
||||
));
|
||||
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
|
||||
}
|
||||
let start = fit(bounds(), viewport.unwrap()).unwrap();
|
||||
assert!(change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fitting,
|
||||
viewport,
|
||||
bounds(),
|
||||
Event::Pan(Vector::new(20., -30.))
|
||||
));
|
||||
assert_eq!(pan, start.pan + Vector::new(20., -30.));
|
||||
assert_eq!(zoom, Some(start.scale));
|
||||
assert!(!fitting);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_inputs_are_passive_and_large_finite_bounds_do_not_overflow_center() {
|
||||
assert!(fit(bounds(), Size::new(f32::NAN, 500.)).is_none());
|
||||
let extreme = Rectangle {
|
||||
x: f32::MAX,
|
||||
y: 0.,
|
||||
width: f32::MAX,
|
||||
height: 100.,
|
||||
};
|
||||
let camera = fit(extreme, Size::new(700., 500.)).unwrap();
|
||||
assert!(camera.scale > 0. && camera.scale < 1e-30 && camera.pan.x.is_finite());
|
||||
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
|
||||
assert!(!set_zoom(&mut pan, &mut zoom, &mut fitting, Some(f32::NAN)));
|
||||
assert!(!change(
|
||||
&mut pan,
|
||||
&mut zoom,
|
||||
&mut fitting,
|
||||
Some(Size::new(700., 500.)),
|
||||
bounds(),
|
||||
Event::Zoom {
|
||||
factor: 1.25,
|
||||
at: Point::ORIGIN,
|
||||
base: f32::NAN
|
||||
}
|
||||
));
|
||||
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,6 @@ use std::collections::BTreeMap;
|
||||
pub(crate) struct Stamp {
|
||||
document: ModelId,
|
||||
owner: ModelId,
|
||||
representation: super::Representation,
|
||||
visit: ModelId,
|
||||
revision: u64,
|
||||
session: ModelId,
|
||||
mount: ModelId,
|
||||
@@ -22,8 +20,6 @@ impl Stamp {
|
||||
Self {
|
||||
document: context.document,
|
||||
owner: context.scenario,
|
||||
representation: context.representation,
|
||||
visit: context.visit,
|
||||
revision: context.revision,
|
||||
session,
|
||||
mount,
|
||||
@@ -32,12 +28,10 @@ impl Stamp {
|
||||
}
|
||||
pub(crate) fn name_id(stamp: Stamp) -> Id {
|
||||
Id::from(format!(
|
||||
"{}:{}:{}:{:?}:{}:{}:{}:{}",
|
||||
"{}:{}:{}:{}:{}:{}",
|
||||
super::NAME_INPUT,
|
||||
stamp.document,
|
||||
stamp.owner,
|
||||
stamp.representation,
|
||||
stamp.visit,
|
||||
stamp.revision,
|
||||
stamp.session,
|
||||
stamp.mount
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
//! Controller identity and actual presentation input, independent of App routing.
|
||||
use super::*;
|
||||
use oa_core::{Diagram, DiagramLayout, ProjectionKind, ScenarioLanePosition, ScenarioLayout};
|
||||
|
||||
fn saved(p: &mut Project, owner: Id, lanes: &[(Id, f32)]) -> Representation {
|
||||
let id = Id::new_v4();
|
||||
p.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("saved_{}", p.diagrams.len()),
|
||||
name: "Saved scenario".into(),
|
||||
description: String::new(),
|
||||
layer: p.elements[&owner].layer(),
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: lanes
|
||||
.iter()
|
||||
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
|
||||
.collect(),
|
||||
messages: vec![],
|
||||
})),
|
||||
},
|
||||
);
|
||||
let errors: Vec<_> = oa_core::validation::validate(p)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == oa_core::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
Representation::Saved { diagram: id }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_visits_distinguish_same_owner_roundtrips_and_suspended_return_without_editing() {
|
||||
let mut f = direct_tests::fixture();
|
||||
let a = saved(&mut f.project, f.owner, &[(f.sender, -500.)]);
|
||||
let b = saved(&mut f.project, f.owner, &[(f.sender, 900.)]);
|
||||
let original = f.project.clone();
|
||||
let document = Id::new_v4();
|
||||
let mut state = State::default();
|
||||
assert!(state.enter_representation(document, f.owner, a));
|
||||
let first = state.context(document, f.owner, 7, 9).unwrap();
|
||||
assert!(!state.enter_representation(document, f.owner, a));
|
||||
assert_eq!(state.context(document, f.owner, 7, 9), Some(first));
|
||||
state.presentation.pan = iced::Vector::new(23., -17.);
|
||||
state.leave();
|
||||
assert_eq!(state.representation(), Some(a));
|
||||
assert_eq!(state.owner(), Some(f.owner));
|
||||
assert!(state.context(document, f.owner, 7, 9).is_none());
|
||||
assert!(state.enter_representation(document, f.owner, a));
|
||||
let returned = state.context(document, f.owner, 7, 9).unwrap();
|
||||
assert_ne!(returned.visit, first.visit);
|
||||
assert_eq!(state.presentation.pan, iced::Vector::new(23., -17.));
|
||||
assert!(state.enter_representation(document, f.owner, b));
|
||||
let middle = state.context(document, f.owner, 7, 9).unwrap();
|
||||
assert!(state.enter_representation(document, f.owner, a));
|
||||
let last = state.context(document, f.owner, 7, 9).unwrap();
|
||||
assert_ne!(last.visit, returned.visit);
|
||||
assert_ne!(last.visit, middle.visit);
|
||||
assert!(!state.coordinate_input(&f.project, first));
|
||||
assert!(state.coordinate_input(&f.project, last));
|
||||
let session = Id::new_v4();
|
||||
let mount = Id::new_v4();
|
||||
assert_ne!(
|
||||
focus::Stamp::new(first, session, mount),
|
||||
focus::Stamp::new(last, session, mount)
|
||||
);
|
||||
assert_eq!(f.project, original);
|
||||
state.forget_view();
|
||||
assert_eq!(state.representation(), None);
|
||||
assert_eq!(state.owner(), None);
|
||||
assert!(state.context(document, f.owner, 7, 9).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_caller_restores_exact_representation_with_fresh_visit_and_rejects_deleted_view() {
|
||||
let (mut project, [a, b, _], [reference, _, _]) = direct_tests::navigation_fixture();
|
||||
let view = saved(&mut project, a, &[]);
|
||||
let original = project.clone();
|
||||
let document = Id::new_v4();
|
||||
let mut state = State::default();
|
||||
state.enter_representation(document, a, view);
|
||||
let initial = state.context(document, a, 0, 0).unwrap();
|
||||
state.presentation.pan = iced::Vector::new(11., 29.);
|
||||
state.details_offset = iced::Vector::new(0., 71.);
|
||||
assert_eq!(state.open_use(&project, initial, reference).unwrap(), b);
|
||||
state.enter(document, b);
|
||||
let callee = state.context(document, b, 0, 0).unwrap();
|
||||
let frame = state.back(&project, callee).unwrap();
|
||||
assert_eq!(frame.representation, view);
|
||||
state.enter_representation(document, a, frame.representation);
|
||||
let returned = state.context(document, a, 0, 0).unwrap();
|
||||
state.restore_caller(returned, frame);
|
||||
assert_ne!(returned.visit, initial.visit);
|
||||
assert_eq!(state.representation(), Some(view));
|
||||
assert_eq!(state.presentation.pan, iced::Vector::new(11., 29.));
|
||||
assert!(state.scroll_restore(initial, ScrollArea::Page).is_none());
|
||||
assert_eq!(
|
||||
state
|
||||
.scroll_restore(returned, ScrollArea::Page)
|
||||
.unwrap()
|
||||
.offset,
|
||||
iced::Vector::new(0., 71.)
|
||||
);
|
||||
assert_eq!(project, original);
|
||||
state.open_use(&project, returned, reference).unwrap();
|
||||
state.enter(document, b);
|
||||
let Representation::Saved { diagram } = view else {
|
||||
unreachable!()
|
||||
};
|
||||
project.diagrams.remove(&diagram);
|
||||
let callee = state.context(document, b, 0, 0).unwrap();
|
||||
assert!(state.back(&project, callee).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestures() {
|
||||
let mut f = direct_tests::fixture();
|
||||
// Equal authored lane centers are structurally valid, but cannot render a distinct-endpoint call.
|
||||
let rep = saved(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
&[(f.sender, 900.), (f.receiver, 900.)],
|
||||
);
|
||||
let original = f.project.clone();
|
||||
let mut state = State::default();
|
||||
state.enter_representation(Id::new_v4(), f.owner, rep);
|
||||
let document = state.presentation.identity.unwrap().0;
|
||||
let context = state.context(document, f.owner, 0, 0).unwrap();
|
||||
let shown = state.display(&f.project, f.owner, f.call).unwrap();
|
||||
assert!(matches!(
|
||||
shown.status(),
|
||||
presentation::PresentationStatus::Fallback { .. }
|
||||
));
|
||||
assert!(!state.coordinate_input(&f.project, context));
|
||||
let automatic = presentation::present(
|
||||
&f.project,
|
||||
Representation::Derived { owner: f.owner },
|
||||
f.call,
|
||||
);
|
||||
let automatic = automatic.scene().unwrap();
|
||||
assert_eq!(shown.scene().unwrap().render_lanes, automatic.render_lanes);
|
||||
assert_eq!(shown.scene().unwrap().activations, automatic.activations);
|
||||
assert!(!automatic.activations.is_empty());
|
||||
let mut scene = shown.into_scene().unwrap();
|
||||
scene.coordinate_input = false;
|
||||
let line = scene.lines.iter().find(|line| line.id == f.call).unwrap();
|
||||
let point = Point::new(
|
||||
(line.points[0].x + line.points.last().unwrap().x) / 2.,
|
||||
line.points[0].y,
|
||||
);
|
||||
assert_eq!(scene.hit(point), Some(f.call));
|
||||
let bounds = Rectangle::with_size(Size::new(1600., 1000.));
|
||||
for tool in [
|
||||
CanvasTool::Select,
|
||||
CanvasTool::Message(MessageStyle::CallReply),
|
||||
CanvasTool::Add(Tool::Reuse),
|
||||
] {
|
||||
scene.interaction = Some((context, tool, true));
|
||||
let mut input = SequenceInput::default();
|
||||
let mut messages = vec![];
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: point },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved {
|
||||
position: Point::new(point.x, point.y + 100.),
|
||||
},
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
if let Some(message) = canvas::Program::update(
|
||||
&scene,
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds,
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.and_then(|action| action.into_inner().0)
|
||||
{
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
assert_eq!(messages.len(), 1);
|
||||
assert!(
|
||||
matches!(messages[0], Message::ScenarioCanvas(CanvasEvent::Select(c, id)) if c == context && id == f.call)
|
||||
);
|
||||
assert!(input.press.is_none() && input.source.is_none());
|
||||
}
|
||||
// A gesture already captured in a Ready frame is cancelled when capability is revoked.
|
||||
scene.coordinate_input = true;
|
||||
scene.interaction = Some((context, CanvasTool::Select, true));
|
||||
let mut input = SequenceInput::default();
|
||||
let _ = canvas::Program::update(
|
||||
&scene,
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(point),
|
||||
);
|
||||
assert!(input.press.is_some());
|
||||
scene.coordinate_input = false;
|
||||
let result = canvas::Program::update(
|
||||
&scene,
|
||||
&mut input,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
|
||||
bounds,
|
||||
mouse::Cursor::Available(point),
|
||||
);
|
||||
assert!(result.and_then(|action| action.into_inner().0).is_none());
|
||||
assert!(input.press.is_none());
|
||||
assert_eq!(f.project, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_camera_fits_actual_negative_origin_and_tiny_scale_without_authored_mutation() {
|
||||
let mut f = direct_tests::fixture();
|
||||
let rep = saved(
|
||||
&mut f.project,
|
||||
f.owner,
|
||||
&[(f.sender, -1_000_000.), (f.receiver, 1_000_000.)],
|
||||
);
|
||||
let original = f.project.clone();
|
||||
let mut state = State::default();
|
||||
state.enter_representation(Id::new_v4(), f.owner, rep);
|
||||
state.viewport(Size::new(700., 500.));
|
||||
assert!(
|
||||
state.presentation.fit,
|
||||
"new saved views await their real viewport in Fit mode"
|
||||
);
|
||||
assert!(
|
||||
!state.camera_zoom(&f.project, f.owner, None),
|
||||
"already-active Fit is idempotent"
|
||||
);
|
||||
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
|
||||
assert!(matches!(
|
||||
shown.status(),
|
||||
presentation::PresentationStatus::Ready
|
||||
));
|
||||
let bounds = shown.bounds().unwrap();
|
||||
assert!(bounds.x < -1_000_000. && bounds.x + bounds.width > 1_000_000.);
|
||||
let fitted = state.presentation.camera(bounds);
|
||||
assert!(fitted.scale > 0. && fitted.scale < 0.001);
|
||||
let left = bounds.x * fitted.scale + fitted.pan.x;
|
||||
let right = (bounds.x + bounds.width) * fitted.scale + fitted.pan.x;
|
||||
assert!((left - 8.).abs() < 0.001 && (right - 692.).abs() < 0.001);
|
||||
assert!(state.camera(
|
||||
&f.project,
|
||||
f.owner,
|
||||
super::super::canvas_workspace::Event::Pan(iced::Vector::new(13., -7.))
|
||||
));
|
||||
let moved = state.presentation.camera(bounds);
|
||||
assert_eq!(moved.scale, fitted.scale);
|
||||
assert_eq!(moved.pan, fitted.pan + iced::Vector::new(13., -7.));
|
||||
assert!(!state.presentation.fit);
|
||||
assert_eq!(f.project, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_saved_identity_remains_unavailable_and_never_falls_into_derived_geometry() {
|
||||
let mut f = direct_tests::fixture();
|
||||
let rep = saved(&mut f.project, f.owner, &[]);
|
||||
let mut state = State::default();
|
||||
let document = Id::new_v4();
|
||||
state.enter_representation(document, f.owner, rep);
|
||||
let Representation::Saved { diagram } = rep else {
|
||||
unreachable!()
|
||||
};
|
||||
f.project.diagrams.remove(&diagram);
|
||||
let original = f.project.clone();
|
||||
let shown = state.display(&f.project, f.owner, f.owner).unwrap();
|
||||
assert_eq!(shown.requested(), rep);
|
||||
assert!(matches!(
|
||||
shown.status(),
|
||||
presentation::PresentationStatus::Unavailable { .. }
|
||||
));
|
||||
assert!(shown.scene().is_none() && shown.bounds().is_none());
|
||||
assert_eq!(state.owner(), Some(f.owner));
|
||||
assert!(state.context(document, f.owner, 0, 0).is_some());
|
||||
assert!(!state.camera_zoom(&f.project, f.owner, None));
|
||||
assert_eq!(f.project, original);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Navigation is derived from the same opaque-reference scene that is painted.
|
||||
use super::*;
|
||||
use crate::behavior::scenario_layout::{Representation, presentation};
|
||||
use crate::diagram_usage::{Surface, Target, Usage};
|
||||
|
||||
/// The renderer collapses whitespace before shortening. Require at least one
|
||||
@@ -23,14 +22,7 @@ fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
|
||||
start < retained
|
||||
}
|
||||
|
||||
fn usages(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
subject: Id,
|
||||
surface: Surface,
|
||||
bounds: Rectangle,
|
||||
scene: &Scene,
|
||||
) -> Vec<Usage> {
|
||||
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
|
||||
let mut result = Vec::new();
|
||||
let mut add = |id, bounds, role: &str| {
|
||||
// Activation bars share the occurrence identity with a labelled message;
|
||||
@@ -41,7 +33,7 @@ fn usages(
|
||||
{
|
||||
result.push(Usage {
|
||||
subject,
|
||||
surface,
|
||||
surface: Surface::Scenario(owner),
|
||||
target: Target::ScenarioObject(id),
|
||||
role: role.into(),
|
||||
bounds,
|
||||
@@ -145,15 +137,15 @@ fn usages(
|
||||
}
|
||||
}
|
||||
}
|
||||
if subject == owner || subject == surface.id() {
|
||||
if subject == owner {
|
||||
result.insert(
|
||||
0,
|
||||
Usage {
|
||||
subject,
|
||||
surface,
|
||||
surface: Surface::Scenario(owner),
|
||||
target: Target::Whole,
|
||||
role: "scenario".into(),
|
||||
bounds,
|
||||
bounds: Rectangle::new(Point::ORIGIN, scene.size),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -161,29 +153,15 @@ fn usages(
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Membership comes from the complete presentation chosen for this exact view,
|
||||
/// including automatic fallback geometry while retaining its Saved identity.
|
||||
pub(crate) fn representation_usages(
|
||||
&self,
|
||||
p: &Project,
|
||||
representation: Representation,
|
||||
subject: Id,
|
||||
) -> Vec<Usage> {
|
||||
let shown = presentation::present(p, representation, subject);
|
||||
let (Some(owner), Some(bounds), Some(scene)) =
|
||||
(shown.owner(), shown.bounds(), shown.scene())
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let surface = match representation {
|
||||
Representation::Derived { owner } => Surface::Scenario(owner),
|
||||
Representation::Saved { diagram } => Surface::Saved(diagram),
|
||||
};
|
||||
usages(p, owner, subject, surface, bounds, scene)
|
||||
}
|
||||
|
||||
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
|
||||
self.representation_usages(p, Representation::Derived { owner }, subject)
|
||||
if !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
return vec![];
|
||||
}
|
||||
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_usage(
|
||||
@@ -193,24 +171,12 @@ impl State {
|
||||
owner: Id,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
self.reveal_representation_usage(
|
||||
p,
|
||||
document,
|
||||
owner,
|
||||
Representation::Derived { owner },
|
||||
target,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn reveal_representation_usage(
|
||||
&mut self,
|
||||
p: &Project,
|
||||
document: Id,
|
||||
owner: Id,
|
||||
representation: Representation,
|
||||
target: &Target,
|
||||
) -> bool {
|
||||
if self.draft.is_some() || presentation::checked_owner(p, representation) != Some(owner) {
|
||||
if self.draft.is_some()
|
||||
|| !p
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|e| e.kind() == Kind::Scenario)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let id = match target {
|
||||
@@ -219,7 +185,7 @@ impl State {
|
||||
_ => return false,
|
||||
};
|
||||
let Some(usage) = self
|
||||
.representation_usages(p, representation, id)
|
||||
.diagram_usages(p, owner, id)
|
||||
.into_iter()
|
||||
.find(|u| &u.target == target)
|
||||
else {
|
||||
@@ -227,29 +193,11 @@ impl State {
|
||||
};
|
||||
// Preserve an existing mounted viewport across enter's owner reset.
|
||||
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
|
||||
if !viewport.width.is_finite()
|
||||
|| !viewport.height.is_finite()
|
||||
|| viewport.width <= 0.
|
||||
|| viewport.height <= 0.
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let (zoom, pan) = if matches!(target, Target::Whole) {
|
||||
let Some(camera) = super::camera::fit(usage.bounds, viewport) else {
|
||||
return false;
|
||||
};
|
||||
(None, camera.pan)
|
||||
} else {
|
||||
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
|
||||
if !zoom.is_finite() || !pan.x.is_finite() || !pan.y.is_finite() {
|
||||
return false;
|
||||
}
|
||||
(Some(zoom), pan)
|
||||
};
|
||||
self.enter_representation(document, owner, representation);
|
||||
self.enter(document, owner);
|
||||
self.presentation.viewport = Some(viewport);
|
||||
self.canvas_tool = Some((owner, CanvasTool::Select));
|
||||
self.presentation.zoom(zoom);
|
||||
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
|
||||
self.presentation.zoom(Some(zoom));
|
||||
self.presentation.pan = pan;
|
||||
true
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody)
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
|
||||
if !steps.contains(&id) {
|
||||
steps.push(id);
|
||||
}
|
||||
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut()
|
||||
&& !steps.contains(&id)
|
||||
{
|
||||
steps.push(id);
|
||||
}
|
||||
id
|
||||
}
|
||||
|
||||
@@ -13,15 +13,13 @@ pub enum Shelf {
|
||||
#[derive(Clone, Default)]
|
||||
pub(super) struct Presentation {
|
||||
pub(super) identity: Option<(Id, Id)>,
|
||||
pub(super) representation: Option<Representation>,
|
||||
pub(super) visit: Id,
|
||||
pub(super) viewport: Option<Size>,
|
||||
pub(super) focused: bool,
|
||||
pub(super) shelf: Option<Shelf>,
|
||||
pub(super) pan: iced::Vector,
|
||||
pub(super) zoom: Option<f32>,
|
||||
pub(super) fit: bool,
|
||||
}
|
||||
const MIN_SCALE: f32 = 0.05;
|
||||
impl Presentation {
|
||||
pub(super) fn viewport(&mut self, size: Size) -> bool {
|
||||
if !size.width.is_finite()
|
||||
@@ -36,26 +34,29 @@ impl Presentation {
|
||||
true
|
||||
}
|
||||
pub(super) fn zoom(&mut self, scale: Option<f32>) -> bool {
|
||||
camera::set_zoom(&mut self.pan, &mut self.zoom, &mut self.fit, scale)
|
||||
if scale.is_some_and(|value| !value.is_finite() || value <= 0.) {
|
||||
return false;
|
||||
}
|
||||
let next = scale.map(|value| value.clamp(MIN_SCALE, 3.));
|
||||
let changed = self.zoom != next || self.fit != scale.is_none();
|
||||
self.zoom = next;
|
||||
self.fit = scale.is_none();
|
||||
changed
|
||||
}
|
||||
pub(super) fn camera(&self, bounds: Rectangle) -> camera::Camera {
|
||||
camera::effective_camera(self.pan, self.zoom, self.fit, self.viewport, bounds)
|
||||
fn fit_scale(&self, scene: Size) -> Option<f32> {
|
||||
let viewport = self.viewport?;
|
||||
// Fit uses the actual mounted viewport with a small visual gutter.
|
||||
Some(
|
||||
((viewport.width - 16.).max(1.) / scene.width.max(1.))
|
||||
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
|
||||
)
|
||||
}
|
||||
// Legacy tests call size-only scale; actual display always passes full bounds.
|
||||
#[cfg(test)]
|
||||
pub(super) fn scale(&self, size: Size) -> f32 {
|
||||
self.camera(Rectangle::new(Point::ORIGIN, size)).scale
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
struct ArrangementChoice {
|
||||
representation: Representation,
|
||||
label: String,
|
||||
}
|
||||
impl std::fmt::Display for ArrangementChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.label)
|
||||
pub(super) fn scale(&self, scene: Size) -> f32 {
|
||||
if self.fit {
|
||||
self.fit_scale(scene).unwrap_or(1.).clamp(MIN_SCALE, 1.)
|
||||
} else {
|
||||
self.zoom.unwrap_or(1.)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,160 +68,62 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
.draft
|
||||
.as_ref()
|
||||
.is_some_and(|draft| draft.owner == owner);
|
||||
let Some(context) = app.scenario_context() else {
|
||||
return column![
|
||||
out,
|
||||
text("Scenario view is unavailable."),
|
||||
button("Open selected source").on_press(Message::SourceForSelection)
|
||||
]
|
||||
.spacing(8)
|
||||
.into();
|
||||
};
|
||||
let context = app.scenario_context().expect("visible scenario context");
|
||||
let tool = state.canvas_tool(owner);
|
||||
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
|
||||
let shown = presentation::present(p, context.representation, app.selected);
|
||||
let capabilities = shown.capabilities();
|
||||
let coordinate_input = clean && capabilities.coordinate_input;
|
||||
let notice = display_notice(p, &shown);
|
||||
let camera_state = shown.bounds().map(|bounds| presentation.camera(bounds));
|
||||
let scale = camera_state.map_or(1., |camera| camera.scale);
|
||||
let mut scene = Scene::sequence(p, owner, app.selected);
|
||||
let scale = presentation.scale(scene.size);
|
||||
scene.scale = scale;
|
||||
scene.camera_pan = presentation.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.pending_placement = state.pending_placement(owner);
|
||||
let scope = super::super::canvas_workspace::Scope {
|
||||
document: context.document,
|
||||
owner,
|
||||
revision: context.revision,
|
||||
epoch: app.camera_epoch,
|
||||
scenario_view: Some((context.representation, context.visit)),
|
||||
};
|
||||
let keyboard = clean
|
||||
&& capabilities.geometry_navigation
|
||||
&& super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
|
||||
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
|
||||
let canvas: Element<'a, Message> = if let Some(mut scene) = shown.into_scene() {
|
||||
let camera_state = camera_state.expect("displayed Scene has complete bounds");
|
||||
scene.scale = camera_state.scale;
|
||||
scene.camera_pan = camera_state.pan;
|
||||
scene.interaction = Some((context, tool, clean));
|
||||
scene.coordinate_input = capabilities.coordinate_input;
|
||||
scene.pending_placement = coordinate_input
|
||||
.then(|| state.pending_placement(owner))
|
||||
.flatten();
|
||||
container(
|
||||
sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
canvas::Canvas::new(scene).width(Fill).height(Fill),
|
||||
scope,
|
||||
scale,
|
||||
keyboard,
|
||||
camera,
|
||||
)
|
||||
.pan_background(tool == CanvasTool::Select || !capabilities.coordinate_input),
|
||||
)
|
||||
.key((
|
||||
context.document,
|
||||
format!("{:?}", context.representation),
|
||||
context.visit,
|
||||
))
|
||||
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.id("scenario-viewport")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into()
|
||||
} else {
|
||||
container(text("No supported canvas geometry is available. Inspect the model or open its source to repair this view."))
|
||||
.id("scenario-viewport-unavailable").padding(16).width(Fill).height(Fill).style(style::card).into()
|
||||
};
|
||||
let mut navigation = row![
|
||||
out.width(Fill),
|
||||
super::super::canvas_workspace::Control::new(
|
||||
button(
|
||||
text(if presentation.focused {
|
||||
"Restore panels"
|
||||
} else {
|
||||
"Focus canvas"
|
||||
})
|
||||
.size(12)
|
||||
)
|
||||
.padding(6)
|
||||
.on_press(super::super::canvas_workspace::intent(
|
||||
let canvas = container(
|
||||
sensor(
|
||||
super::super::canvas_workspace::Viewport::new(
|
||||
canvas::Canvas::new(scene).width(Fill).height(Fill),
|
||||
scope,
|
||||
"Panels",
|
||||
Message::ScenarioCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
scope,
|
||||
"Panels",
|
||||
keyboard,
|
||||
Some(super::super::canvas_workspace::intent(
|
||||
scope,
|
||||
"Panels",
|
||||
Message::ScenarioCanvas(CanvasEvent::Focus(context))
|
||||
)),
|
||||
camera
|
||||
scale,
|
||||
keyboard,
|
||||
camera,
|
||||
)
|
||||
.pan_background(tool == CanvasTool::Select),
|
||||
)
|
||||
]
|
||||
.spacing(6);
|
||||
.key((context.document, owner))
|
||||
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
|
||||
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
|
||||
)
|
||||
.id("scenario-viewport")
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.style(style::card)
|
||||
.into();
|
||||
let mut navigation = row![out.width(Fill)].spacing(6);
|
||||
if state.can_back(p, context) {
|
||||
navigation =
|
||||
navigation.push(button(text("Back").size(12)).padding(6).on_press_maybe(
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
|
||||
));
|
||||
}
|
||||
let mut header = column![navigation, notice].spacing(4);
|
||||
let saved_views = p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|diagram| {
|
||||
presentation::checked_owner(
|
||||
p,
|
||||
Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
) == Some(owner)
|
||||
})
|
||||
.map(|diagram| ArrangementChoice {
|
||||
representation: Representation::Saved {
|
||||
diagram: diagram.id,
|
||||
},
|
||||
label: short(&diagram.name, 64),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !saved_views.is_empty() {
|
||||
let mut choices = vec![ArrangementChoice {
|
||||
representation: Representation::Derived { owner },
|
||||
label: "Automatic arrangement".into(),
|
||||
}];
|
||||
choices.extend(saved_views);
|
||||
let selected = choices
|
||||
.iter()
|
||||
.find(|choice| choice.representation == context.representation)
|
||||
.cloned();
|
||||
header = header.push(
|
||||
pick_list(choices, selected, move |choice: ArrangementChoice| {
|
||||
Message::ScenarioCanvas(CanvasEvent::Representation(context, choice.representation))
|
||||
})
|
||||
.placeholder("Choose arrangement")
|
||||
.text_size(12)
|
||||
.padding(5)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if capabilities.geometry_navigation {
|
||||
header = header.push(super::super::canvas_workspace::controls(
|
||||
let header = column![
|
||||
navigation,
|
||||
super::super::canvas_workspace::controls(
|
||||
scope,
|
||||
keyboard,
|
||||
scale,
|
||||
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Camera(
|
||||
context,
|
||||
scope.epoch,
|
||||
super::super::canvas_workspace::Event::Focus,
|
||||
))),
|
||||
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
|
||||
camera,
|
||||
));
|
||||
}
|
||||
camera
|
||||
)
|
||||
]
|
||||
.spacing(4);
|
||||
let entries = [
|
||||
("S", "Select / reorder occurrences", CanvasTool::Select),
|
||||
(
|
||||
@@ -289,10 +192,9 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
glyph,
|
||||
label,
|
||||
choice == tool,
|
||||
(clean && (choice == CanvasTool::Select || capabilities.coordinate_input))
|
||||
.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
|
||||
context, choice,
|
||||
))),
|
||||
)
|
||||
}),
|
||||
);
|
||||
@@ -639,100 +541,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
|
||||
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
|
||||
}
|
||||
|
||||
fn display_notice<'a>(p: &Project, shown: &ScenarioPresentation) -> Element<'a, Message> {
|
||||
use super::super::scenario_layout::{Diagnostic, presentation::PresentationStatus};
|
||||
let title = match shown.requested() {
|
||||
Representation::Saved { diagram } => p.diagrams.get(&diagram).map_or_else(
|
||||
|| "Unavailable saved Scenario view".into(),
|
||||
|view| format!("Saved view · {}", view.name),
|
||||
),
|
||||
Representation::Derived { owner } => format!("Automatic view · {}", p.named(owner)),
|
||||
};
|
||||
let mut notice = column![text(title).size(14)].spacing(4);
|
||||
match shown.status() {
|
||||
PresentationStatus::Ready => {
|
||||
for diagnostic in shown.diagnostics().iter().take(3) {
|
||||
let explanation = match diagnostic {
|
||||
Diagnostic::SourceOrderCrossing { before, after, .. } => format!(
|
||||
"Saved spacing places '{}' below '{}'. The Scenario order is unchanged.",
|
||||
p.named(*before),
|
||||
p.named(*after)
|
||||
),
|
||||
Diagnostic::FrameExtendsPastFollowingAnchor { frame, following } => format!(
|
||||
"Saved spacing makes '{}' overlap '{}'. Both keep their saved positions.",
|
||||
p.named(*frame),
|
||||
p.named(*following)
|
||||
),
|
||||
};
|
||||
notice = notice.push(text(explanation).size(12));
|
||||
}
|
||||
if shown.diagnostics().len() > 3 {
|
||||
notice = notice.push(
|
||||
text(format!(
|
||||
"{} additional spacing notices are retained.",
|
||||
shown.diagnostics().len() - 3
|
||||
))
|
||||
.size(12),
|
||||
);
|
||||
}
|
||||
}
|
||||
PresentationStatus::Fallback { requested_issues } => {
|
||||
notice = notice.push(text("Showing the complete automatic arrangement because the saved positions cannot currently be displayed. Saved positions are preserved.").size(12));
|
||||
if let Some(issue) = requested_issues.first() {
|
||||
notice = notice.push(text(geometry_problem(p, issue)).size(12));
|
||||
}
|
||||
notice = notice.push(text("Selection, navigation and Fit remain available. Canvas insertion and reordering are unavailable until the saved positions are repaired.").size(12));
|
||||
}
|
||||
PresentationStatus::Unavailable { .. } => {
|
||||
notice = notice.push(text("This view is unavailable. Its identity or automatic geometry cannot be resolved; the model and source remain available for inspection and repair.").size(12));
|
||||
}
|
||||
}
|
||||
if !shown.latent_messages().is_empty() {
|
||||
notice = notice.push(text(format!("{} unfinished message position(s) are retained until their endpoints are complete.", shown.latent_messages().len())).size(12));
|
||||
}
|
||||
notice = notice.push(button("Open selected source").on_press(Message::SourceForSelection));
|
||||
container(notice)
|
||||
.id("scenario-display-notice")
|
||||
.padding(6)
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn geometry_problem(p: &Project, issue: &super::super::scenario_layout::GeometryIssue) -> String {
|
||||
use super::super::scenario_layout::GeometryIssue;
|
||||
match issue {
|
||||
GeometryIssue::TypedActivationCrossingNotSupported { call, event } => format!(
|
||||
"The position of '{}' conflicts with the execution of '{}'.",
|
||||
p.named(*event),
|
||||
p.named(*call)
|
||||
),
|
||||
GeometryIssue::CoincidentDistinctEndpoints(message) => format!(
|
||||
"The distinct endpoints of '{}' have the same horizontal position.",
|
||||
p.named(*message)
|
||||
),
|
||||
GeometryIssue::TypedActivationOutsideLifetime { call, .. }
|
||||
| GeometryIssue::TypedActivationOutsideScope { call, .. } => format!(
|
||||
"The position of '{}' falls outside its supported execution interval.",
|
||||
p.named(*call)
|
||||
),
|
||||
GeometryIssue::FixedFrameHeaderWouldMove { frame } => format!(
|
||||
"The saved spacing would move the fixed header of '{}'.",
|
||||
p.named(*frame)
|
||||
),
|
||||
GeometryIssue::OperandScopeOutsideEnclosingInterval { operand, .. } => format!(
|
||||
"The spacing of '{}' extends outside its enclosing frame.",
|
||||
p.named(*operand)
|
||||
),
|
||||
GeometryIssue::LifecycleGeometryNotSupported { .. }
|
||||
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
|
||||
| GeometryIssue::LifetimeInversionNotSupported { .. } => {
|
||||
"The saved spacing cannot preserve the participant lifetime geometry.".into()
|
||||
}
|
||||
_ => "These saved positions are outside the geometry currently supported by the canvas."
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn sequence_action<'a>(
|
||||
title: &str,
|
||||
message: Message,
|
||||
|
||||
@@ -12,6 +12,15 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
|
||||
let _ = app.update(Message::Page(crate::app::Page::Behavior));
|
||||
app.window_size = Size::new(1100., 700.);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let geometry = mounted.geometry(&app);
|
||||
let scope = canvas_workspace::tests::scope(&app);
|
||||
for removed in ["Canvas", "Panels"] {
|
||||
assert!(!geometry.containers.contains_key(&scope.control_id(removed)));
|
||||
}
|
||||
assert!(
|
||||
!canvas_workspace::tests::focused(&mut mounted, &app, "viewport"),
|
||||
"direct editing needs no activation or initial keyboard focus grab"
|
||||
);
|
||||
for size in [Size::new(1100., 700.), Size::new(1440., 900.)] {
|
||||
app.window_size = size;
|
||||
mounted.settle(&mut app).await;
|
||||
@@ -48,28 +57,8 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
|
||||
mounted.pan(&mut app, start, start + delta).await;
|
||||
let diff = app.behavior.scenario.presentation.pan - next_pan;
|
||||
assert!(diff.x.hypot(diff.y) < 0.01);
|
||||
// Iced operations report palette children in content coordinates.
|
||||
// The preceding Action focus may have scrolled this retained palette.
|
||||
let geometry = mounted.geometry(&app);
|
||||
let scope = canvas_workspace::Scope::current(&app).unwrap();
|
||||
let (palette, offset) = geometry.scrolls[&scope.control_id("palette-scroll")];
|
||||
let tool = geometry.bounds("behavior-tool:A").center() - offset;
|
||||
assert!(
|
||||
palette.contains(tool),
|
||||
"visible Action at {tool:?}: {palette:?}"
|
||||
);
|
||||
let chosen = mounted.click(&mut app, tool).await;
|
||||
assert!(
|
||||
chosen.iter().any(|message| matches!(
|
||||
canvas_workspace::tests::intent_action(message),
|
||||
Message::ScenarioCanvas(CanvasEvent::Choose(_, CanvasTool::Add(Tool::Action)))
|
||||
)),
|
||||
"Action palette click at scale {scale}, offset {offset:?}: {chosen:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.canvas_tool(f.owner),
|
||||
CanvasTool::Add(Tool::Action)
|
||||
);
|
||||
let tool = mounted.geometry(&app).bounds("behavior-tool:A").center();
|
||||
mounted.click(&mut app, tool).await;
|
||||
let messages = mounted.click(&mut app, bounds.position() + desired).await;
|
||||
assert!(
|
||||
messages.iter().any(
|
||||
@@ -77,7 +66,7 @@ async fn production_scenario_canvas_keeps_bounds_draft_identity_and_camera_throu
|
||||
tool: Tool::Action, slot, lifeline: Some(line), ..
|
||||
}) if *slot == expected && *line == f.sender)
|
||||
),
|
||||
"scale {scale}, expected slot {expected:?}: {messages:?}"
|
||||
"scale {scale}: {messages:?}"
|
||||
);
|
||||
let session = app.behavior.scenario.form_session().unwrap();
|
||||
let invocation = app.behavior.scenario.draft.as_ref().unwrap().invocation;
|
||||
@@ -1051,7 +1040,9 @@ fn scenario_workspace_viewport_events_are_passive_guarded_and_do_not_apply_raw_f
|
||||
}
|
||||
app.busy = false;
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
let epoch = app.canvas_epoch;
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.8)));
|
||||
assert_ne!(app.canvas_epoch, epoch);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
|
||||
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
|
||||
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
|
||||
@@ -1352,23 +1343,12 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
let snapshot = app.document.snapshot();
|
||||
let source = app.document.bundle().clone();
|
||||
for boundary in ["Fit", "A", "Details", "Panels", "Bounds"] {
|
||||
for boundary in ["Fit", "A", "Details", "ZoomIn", "Bounds"] {
|
||||
let toolbar = capture_toolbar(&mut mounted, &app, "Fit");
|
||||
let bounds = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
let point = bounds.position() + iced::Vector::new(95., 90.);
|
||||
let lifetime = app.camera_epoch;
|
||||
// Fit stores no pan offset. A real drag starts from the displayed fitted
|
||||
// camera, then materializes it before applying its ordered screen deltas.
|
||||
let shown = app
|
||||
.behavior
|
||||
.scenario
|
||||
.display(app.project(), f.owner, app.selected)
|
||||
.unwrap();
|
||||
let before_camera = app
|
||||
.behavior
|
||||
.scenario
|
||||
.presentation
|
||||
.camera(shown.bounds().unwrap());
|
||||
let pan = app.behavior.scenario.presentation.pan;
|
||||
let first = point + iced::Vector::new(13., 9.);
|
||||
let last = first + iced::Vector::new(17., -3.);
|
||||
let emitted = mounted
|
||||
@@ -1383,21 +1363,7 @@ async fn production_scenario_camera_external_controls_bounds_and_retained_deltas
|
||||
mouse::Cursor::Available(last),
|
||||
)
|
||||
.await;
|
||||
let expected_pan = before_camera.pan + (last - point);
|
||||
let actual_pan = app.behavior.scenario.presentation.pan;
|
||||
let difference = actual_pan - expected_pan;
|
||||
assert!(
|
||||
difference.x.hypot(difference.y) < 0.001,
|
||||
"{boundary}: displayed pan {actual_pan:?} must follow {expected_pan:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.zoom,
|
||||
Some(before_camera.scale)
|
||||
);
|
||||
assert!(
|
||||
!app.behavior.scenario.presentation.fit,
|
||||
"drag materializes the visible Fit camera"
|
||||
);
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, pan + (last - point));
|
||||
assert_eq!(
|
||||
app.camera_epoch, lifetime,
|
||||
"own ordered deltas retain their lifetime"
|
||||
@@ -1576,10 +1542,7 @@ async fn production_scenario_keyboard_enters_activates_draws_focus_and_returns_t
|
||||
)));
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO);
|
||||
assert!(focused(&mut mounted, &app, "Fit"));
|
||||
tab_to(&mut mounted, &mut app, "Canvas").await;
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
|
||||
.await;
|
||||
tab_to(&mut mounted, &mut app, "viewport").await;
|
||||
assert!(focused(&mut mounted, &app, "viewport"));
|
||||
mounted
|
||||
.batch(&mut app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
|
||||
@@ -1805,19 +1768,14 @@ fn scenario_workspace_fit_uses_measured_inner_viewport_and_rejects_nonfinite_inp
|
||||
assert!(!presentation.viewport(Size::new(484., 204.)));
|
||||
let scale = presentation.scale(graph);
|
||||
assert!(graph.width * scale <= 468. && graph.height * scale <= 188.);
|
||||
assert!(scale > camera::MIN_SCALE);
|
||||
assert!(scale > MIN_SCALE);
|
||||
for bad in [f32::NAN, f32::INFINITY, 0., -1.] {
|
||||
assert!(!presentation.zoom(Some(bad)));
|
||||
assert!(!presentation.viewport(Size::new(bad, 100.)));
|
||||
assert!(!presentation.viewport(Size::new(100., bad)));
|
||||
assert_eq!(presentation.scale(graph), scale);
|
||||
}
|
||||
let tall = Rectangle::with_size(Size::new(900., 10000.));
|
||||
let camera = presentation.camera(tall);
|
||||
assert!(camera.scale < 0.05 && camera.scale > 0.);
|
||||
assert!((camera.scale - 188. / 10000.).abs() < 1e-8);
|
||||
assert!((tall.height * camera.scale + camera.pan.y - 196.).abs() < 1e-4);
|
||||
assert!((camera.pan.y - 8.).abs() < 1e-4);
|
||||
assert_eq!(presentation.scale(Size::new(900., 10000.)), MIN_SCALE);
|
||||
assert!(presentation.zoom(Some(1.25)));
|
||||
presentation.viewport(Size::new(200., 100.));
|
||||
assert_eq!(
|
||||
@@ -1899,7 +1857,6 @@ async fn scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
|
||||
});
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Shelf(ctx, Shelf::Selection));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
let mut mounted = Mounted::new(&mut app).await;
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(0.75)));
|
||||
let canvas = mounted.geometry(&app).bounds("scenario-viewport");
|
||||
@@ -1942,7 +1899,13 @@ async fn scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after
|
||||
mounted.settle(&mut app).await;
|
||||
assert_eq!(app.behavior.scenario.presentation.pan, Vector::ZERO);
|
||||
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, Some(2.)));
|
||||
navigation_event(&mut app, CanvasEvent::Focus);
|
||||
navigation_event(&mut app, |ctx| {
|
||||
CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Action))
|
||||
});
|
||||
assert_eq!(
|
||||
app.behavior.scenario.canvas_tool(b),
|
||||
CanvasTool::Add(Tool::Action)
|
||||
);
|
||||
let _ = app.update(Message::Command(Command::Rename(
|
||||
reference,
|
||||
"Renamed caller use".into(),
|
||||
@@ -1960,7 +1923,6 @@ async fn scenario_workspace_open_and_back_restore_owner_and_mounted_camera_after
|
||||
app.behavior.scenario.canvas_tool(a),
|
||||
CanvasTool::Message(MessageStyle::Timer)
|
||||
);
|
||||
assert!(app.behavior.scenario.focused());
|
||||
assert_eq!(
|
||||
app.behavior.scenario.presentation.shelf,
|
||||
Some(Shelf::Selection)
|
||||
|
||||
@@ -1,430 +0,0 @@
|
||||
//! Unwired single-line scenario label experiment. No renderer or schema is changed.
|
||||
//!
|
||||
//! Iced 0.14.0 WGPU `geometry::Frame::fill_text` shapes positive uniform-scale
|
||||
//! canvas text at `size * zoom`; its `text::prepare` uses graphics text::Cache.
|
||||
//! This adapter uses that same allocator/font system/attributes, not a character
|
||||
//! width estimate. Canvas Text::max_width is wrapping, so it stays infinite.
|
||||
//! Experimental parity requires settled fonts and a fresh renderer/cache after
|
||||
//! font loading. Iced's WGPU text cache key omits FontSystem::Version, so merely
|
||||
//! remeasuring here cannot repair its cached glyph buffers after dynamic loading.
|
||||
//! A RenderSession is caller attestation of that lifecycle, not its enforcement.
|
||||
//! No fonts may be loaded between measurement, guarded access and actual draw.
|
||||
//!
|
||||
//! Integration seam: after lifecycle/activation endpoint alignment, a caller
|
||||
//! supplies a usable text width in world units and retains one cache per live
|
||||
//! labelled occurrence across Scene rebuilds. Paint, hit testing, navigation and extents
|
||||
//! consume the returned Label. Position/color do not invalidate measurement.
|
||||
//! This does NOT choose a self-call, reversed-arrow or obstacle corridor policy.
|
||||
//! It measures layout advances, not raster ink; overhang/clip/activation-border
|
||||
//! acceptance still requires the real WGPU evaluator. The old 32-grapheme/225px
|
||||
//! renderer is deliberately untouched for a later baseline comparison.
|
||||
use iced::advanced::{
|
||||
graphics::text as graphics_text,
|
||||
text::{Alignment, LineHeight, Shaping},
|
||||
};
|
||||
use iced::{Color, Font, Pixels, Point, Rectangle, Size, widget::canvas::Text};
|
||||
use std::{collections::VecDeque, ops::Range, sync::Arc};
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
const MAX_SOURCE_BYTES: usize = 64 * 1024;
|
||||
const MAX_GRAPHEMES: usize = 4096;
|
||||
const MAX_ENTRIES: usize = 2;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Style {
|
||||
pub font: Font,
|
||||
pub size: Pixels,
|
||||
pub line_height: LineHeight,
|
||||
pub shaping: Shaping,
|
||||
}
|
||||
|
||||
impl Default for Style {
|
||||
fn default() -> Self {
|
||||
let text = Text::default();
|
||||
Self {
|
||||
font: text.font,
|
||||
size: Pixels(12.),
|
||||
line_height: text.line_height,
|
||||
shaping: text.shaping,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Style {
|
||||
fn scaled(self, zoom: f32) -> (f32, f32) {
|
||||
let size = Pixels(self.size.0 * zoom);
|
||||
let line_height = match self.line_height {
|
||||
LineHeight::Absolute(value) => LineHeight::Absolute(Pixels(value.0 * zoom)),
|
||||
LineHeight::Relative(value) => LineHeight::Relative(value),
|
||||
};
|
||||
(size.0, line_height.to_absolute(size).0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit experimental contract: the caller finishes loading fonts and starts
|
||||
/// a fresh renderer/cache BEFORE capturing this session, then keeps fonts fixed.
|
||||
/// This module cannot inspect or reset the actual renderer's text cache.
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct RenderSession {
|
||||
version: graphics_text::Version,
|
||||
identity: Arc<()>,
|
||||
}
|
||||
|
||||
impl RenderSession {
|
||||
pub fn after_fonts_settled_and_renderer_reset() -> Result<Self, Error> {
|
||||
let fonts = graphics_text::font_system()
|
||||
.read()
|
||||
.map_err(|_| Error::FontSystemUnavailable)?;
|
||||
Ok(Self {
|
||||
version: fonts.version(),
|
||||
identity: Arc::new(()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Label {
|
||||
source: String,
|
||||
displayed: String,
|
||||
/// Original-label bytes represented before any added ellipsis. Includes
|
||||
/// collapsed whitespace, but `retains_any` never counts whitespace as text.
|
||||
retained_source: Range<usize>,
|
||||
/// World-space layout advances, at origin; not glyph-ink containment.
|
||||
bounds: Rectangle,
|
||||
style: Style,
|
||||
measured_session: RenderSession,
|
||||
measured_zoom: f32,
|
||||
}
|
||||
|
||||
impl Label {
|
||||
fn check(&self, session: &RenderSession, zoom: f32) -> Result<(), Error> {
|
||||
if !Arc::ptr_eq(&self.measured_session.identity, &session.identity) {
|
||||
return Err(Error::StaleRenderSession);
|
||||
}
|
||||
if !zoom.is_finite() || zoom != self.measured_zoom {
|
||||
return Err(Error::StaleZoom);
|
||||
}
|
||||
let fonts = graphics_text::font_system()
|
||||
.read()
|
||||
.map_err(|_| Error::FontSystemUnavailable)?;
|
||||
if fonts.version() != self.measured_session.version || fonts.version() != session.version {
|
||||
return Err(Error::FontSetChanged);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn displayed(&self, session: &RenderSession, zoom: f32) -> Result<&str, Error> {
|
||||
self.check(session, zoom)?;
|
||||
Ok(&self.displayed)
|
||||
}
|
||||
|
||||
pub fn retained_source(
|
||||
&self,
|
||||
session: &RenderSession,
|
||||
zoom: f32,
|
||||
) -> Result<Range<usize>, Error> {
|
||||
self.check(session, zoom)?;
|
||||
Ok(self.retained_source.clone())
|
||||
}
|
||||
|
||||
pub fn retains_any(
|
||||
&self,
|
||||
session: &RenderSession,
|
||||
zoom: f32,
|
||||
field: Range<usize>,
|
||||
) -> Result<bool, Error> {
|
||||
self.check(session, zoom)?;
|
||||
if field.start > field.end || self.source.get(field.clone()).is_none() {
|
||||
return Ok(false);
|
||||
}
|
||||
let end = field.end.min(self.retained_source.end);
|
||||
Ok(field.start < end
|
||||
&& self
|
||||
.source
|
||||
.get(field.start..end)
|
||||
.is_some_and(|text| text.chars().any(|c| !c.is_whitespace())))
|
||||
}
|
||||
|
||||
/// Validate finite world position and rectangle endpoints only. Camera
|
||||
/// transforms and raster bounds remain a separate integration contract.
|
||||
pub fn bounds_at(
|
||||
&self,
|
||||
session: &RenderSession,
|
||||
zoom: f32,
|
||||
position: Point,
|
||||
) -> Result<Rectangle, Error> {
|
||||
self.check(session, zoom)?;
|
||||
translated_bounds(position, self.bounds.size())
|
||||
}
|
||||
|
||||
/// Paint under the same positive uniform zoom used for `Cache::resolve`.
|
||||
/// No per-label wrapping or implicit text maximum is introduced.
|
||||
/// The returned Text must be consumed immediately in this session/zoom;
|
||||
/// retaining raw Text past another font load/zoom change bypasses the guard.
|
||||
pub fn canvas_text(
|
||||
&self,
|
||||
session: &RenderSession,
|
||||
zoom: f32,
|
||||
position: Point,
|
||||
color: Color,
|
||||
) -> Result<Text, Error> {
|
||||
self.bounds_at(session, zoom, position)?;
|
||||
Ok(Text {
|
||||
content: self.displayed.clone(),
|
||||
position,
|
||||
color,
|
||||
size: self.style.size,
|
||||
line_height: self.style.line_height,
|
||||
font: self.style.font,
|
||||
shaping: self.style.shaping,
|
||||
max_width: f32::INFINITY,
|
||||
..Text::default()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(super) enum Error {
|
||||
InvalidMetrics,
|
||||
SourceBudget,
|
||||
GraphemeBudget,
|
||||
FontSystemUnavailable,
|
||||
InvalidMeasurement,
|
||||
InvalidPosition,
|
||||
StaleZoom,
|
||||
StaleRenderSession,
|
||||
FontSetChanged,
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
source: String,
|
||||
style: Style,
|
||||
zoom: f32,
|
||||
width: f32,
|
||||
label: Arc<Label>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
/// Two recent resolutions for ONE live labelled occurrence. A global small LRU
|
||||
/// would thrash when a larger Scene is rebuilt in order. The integration owner
|
||||
/// must retain these by occurrence identity and prune on removal/document change.
|
||||
pub(super) struct Cache {
|
||||
version: Option<graphics_text::Version>,
|
||||
session: Option<Arc<()>>,
|
||||
entries: VecDeque<Entry>,
|
||||
#[cfg(test)]
|
||||
allocations: usize,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
/// `width` excludes any caller-chosen backplate padding. Zero is valid.
|
||||
/// Cache ownership must survive redraws; constructing one per Scene defeats
|
||||
/// reuse. Changed fonts invalidate the SESSION: the caller must establish
|
||||
/// a fresh renderer and a new settled session, not just remeasure this cache.
|
||||
/// Error handling/fallback for these explicit experiment budgets is an
|
||||
/// integration decision; never silently paint the old truncated contract.
|
||||
pub fn resolve(
|
||||
&mut self,
|
||||
session: &RenderSession,
|
||||
source: &str,
|
||||
width: f32,
|
||||
zoom: f32,
|
||||
style: Style,
|
||||
) -> Result<Arc<Label>, Error> {
|
||||
let (size, line_height) = style.scaled(zoom);
|
||||
if !width.is_finite()
|
||||
|| width < 0.
|
||||
|| !zoom.is_finite()
|
||||
|| zoom <= 0.
|
||||
|| !size.is_finite()
|
||||
|| size <= 0.
|
||||
|| !line_height.is_finite()
|
||||
|| line_height <= 0.
|
||||
|| !(width * zoom).is_finite()
|
||||
|| !(size / zoom).is_finite()
|
||||
|| !(line_height / zoom).is_finite()
|
||||
{
|
||||
return Err(Error::InvalidMetrics);
|
||||
}
|
||||
if source.len() > MAX_SOURCE_BYTES {
|
||||
return Err(Error::SourceBudget);
|
||||
}
|
||||
let mut fonts = graphics_text::font_system()
|
||||
.write()
|
||||
.map_err(|_| Error::FontSystemUnavailable)?;
|
||||
if session.version != fonts.version() {
|
||||
return Err(Error::FontSetChanged);
|
||||
}
|
||||
if self.version != Some(fonts.version())
|
||||
|| self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_none_or(|identity| !Arc::ptr_eq(identity, &session.identity))
|
||||
{
|
||||
self.entries.clear();
|
||||
self.version = Some(fonts.version());
|
||||
self.session = Some(session.identity.clone());
|
||||
}
|
||||
if let Some(index) = self.entries.iter().position(|entry| {
|
||||
entry.source == source
|
||||
&& entry.style == style
|
||||
&& entry.zoom == zoom
|
||||
&& entry.width == width
|
||||
}) {
|
||||
let entry = self.entries.remove(index).unwrap();
|
||||
let label = entry.label.clone();
|
||||
self.entries.push_back(entry);
|
||||
return Ok(label);
|
||||
}
|
||||
let normalized = Normalized::new(source);
|
||||
let cuts: Vec<usize> = normalized
|
||||
.text
|
||||
.grapheme_indices(true)
|
||||
.map(|(i, _)| i)
|
||||
.chain(std::iter::once(normalized.text.len()))
|
||||
.collect();
|
||||
if cuts.len().saturating_sub(1) > MAX_GRAPHEMES {
|
||||
return Err(Error::GraphemeBudget);
|
||||
}
|
||||
// Candidate buffers are temporary. Only the resolved strings/metrics
|
||||
// survive, bounded by MAX_ENTRIES; renderer caching remains independent.
|
||||
let mut shapes = graphics_text::Cache::new();
|
||||
let mut measure = |content: &str| -> Result<Size, Error> {
|
||||
if content.is_empty() {
|
||||
return Ok(Size::ZERO);
|
||||
}
|
||||
#[cfg(test)]
|
||||
{
|
||||
self.allocations += 1;
|
||||
}
|
||||
let (_, entry) = shapes.allocate(
|
||||
fonts.raw(),
|
||||
graphics_text::cache::Key {
|
||||
content,
|
||||
size,
|
||||
line_height,
|
||||
font: style.font,
|
||||
bounds: Size::INFINITE,
|
||||
shaping: style.shaping,
|
||||
align_x: Alignment::Default,
|
||||
},
|
||||
);
|
||||
if entry.buffer.layout_runs().count() != 1
|
||||
|| !entry.min_bounds.width.is_finite()
|
||||
|| !entry.min_bounds.height.is_finite()
|
||||
{
|
||||
return Err(Error::InvalidMeasurement);
|
||||
}
|
||||
world_size(entry.min_bounds, zoom)
|
||||
};
|
||||
let full = measure(&normalized.text)?;
|
||||
let (displayed, retained, measured) = if full.width <= width {
|
||||
(normalized.text.clone(), normalized.text.len(), full)
|
||||
} else {
|
||||
let ellipsis = measure("…")?;
|
||||
if ellipsis.width > width {
|
||||
(String::new(), 0, Size::ZERO)
|
||||
} else {
|
||||
// Every accepted candidate is actually shaped and fits. Search
|
||||
// is bounded/logarithmic; contextual shaping is not universally
|
||||
// monotone, so this does NOT promise the globally longest prefix.
|
||||
// Full content is always checked first and never needlessly cut.
|
||||
let mut best = ("…".to_owned(), 0, ellipsis);
|
||||
let (mut low, mut high) = (1, cuts.len().saturating_sub(1));
|
||||
while low < high {
|
||||
let middle = low + (high - low) / 2;
|
||||
let candidate = format!("{}…", &normalized.text[..cuts[middle]]);
|
||||
let measured = measure(&candidate)?;
|
||||
if measured.width <= width {
|
||||
best = (candidate, cuts[middle], measured);
|
||||
low = middle + 1;
|
||||
} else {
|
||||
high = middle;
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
};
|
||||
let label = Arc::new(Label {
|
||||
source: source.into(),
|
||||
displayed,
|
||||
retained_source: 0..normalized.source_end(retained),
|
||||
bounds: Rectangle::with_size(measured),
|
||||
style,
|
||||
measured_session: session.clone(),
|
||||
measured_zoom: zoom,
|
||||
});
|
||||
if self.entries.len() == MAX_ENTRIES {
|
||||
self.entries.pop_front();
|
||||
}
|
||||
self.entries.push_back(Entry {
|
||||
source: source.into(),
|
||||
style,
|
||||
zoom,
|
||||
width,
|
||||
label: label.clone(),
|
||||
});
|
||||
Ok(label)
|
||||
}
|
||||
}
|
||||
|
||||
fn world_size(scaled: Size, zoom: f32) -> Result<Size, Error> {
|
||||
let world = Size::new(scaled.width / zoom, scaled.height / zoom);
|
||||
if !world.width.is_finite()
|
||||
|| !world.height.is_finite()
|
||||
|| world.width < 0.
|
||||
|| world.height < 0.
|
||||
{
|
||||
return Err(Error::InvalidMeasurement);
|
||||
}
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
fn translated_bounds(position: Point, size: Size) -> Result<Rectangle, Error> {
|
||||
if !position.x.is_finite()
|
||||
|| !position.y.is_finite()
|
||||
|| !(position.x + size.width).is_finite()
|
||||
|| !(position.y + size.height).is_finite()
|
||||
{
|
||||
return Err(Error::InvalidPosition);
|
||||
}
|
||||
Ok(Rectangle::new(position, size))
|
||||
}
|
||||
|
||||
struct Normalized {
|
||||
text: String,
|
||||
ends: Vec<(usize, usize)>,
|
||||
}
|
||||
impl Normalized {
|
||||
fn new(source: &str) -> Self {
|
||||
let mut result = Self {
|
||||
text: String::new(),
|
||||
ends: vec![(0, 0)],
|
||||
};
|
||||
let mut separator = false;
|
||||
for (start, c) in source.char_indices() {
|
||||
if c.is_whitespace() {
|
||||
separator = !result.text.is_empty();
|
||||
continue;
|
||||
}
|
||||
if separator {
|
||||
result.text.push(' ');
|
||||
result.ends.push((result.text.len(), start));
|
||||
separator = false;
|
||||
}
|
||||
result.text.push(c);
|
||||
result.ends.push((result.text.len(), start + c.len_utf8()));
|
||||
}
|
||||
result
|
||||
}
|
||||
fn source_end(&self, normalized_end: usize) -> usize {
|
||||
self.ends
|
||||
.binary_search_by_key(&normalized_end, |(end, _)| *end)
|
||||
.map(|index| self.ends[index].1)
|
||||
.expect("grapheme cuts are character boundaries")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scenario_label/tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,231 +0,0 @@
|
||||
//! Unwired corridor-policy candidate for the measured scenario-label experiment.
|
||||
//!
|
||||
//! The caller supplies a finite structural label band AFTER endpoint/activation
|
||||
//! alignment, with padding already removed from its edges. Blockers include
|
||||
//! padded activation/card/header bounds over the complete text band, including
|
||||
//! stroke/ink clearance. This function does not infer a self-message corridor,
|
||||
//! alter scene extents, change zoom, or establish raster-ink containment.
|
||||
use iced::Rectangle;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct InvalidGeometry;
|
||||
|
||||
fn edges(rect: Rectangle) -> Result<(f32, f32, f32, f32), InvalidGeometry> {
|
||||
let right = rect.x + rect.width;
|
||||
let bottom = rect.y + rect.height;
|
||||
if [rect.x, rect.y, rect.width, rect.height, right, bottom]
|
||||
.into_iter()
|
||||
.any(|value| !value.is_finite())
|
||||
|| rect.width < 0.
|
||||
|| rect.height < 0.
|
||||
{
|
||||
return Err(InvalidGeometry);
|
||||
}
|
||||
Ok((rect.x, right, rect.y, bottom))
|
||||
}
|
||||
|
||||
/// Select one connected clear interval; never assemble text across blockers.
|
||||
/// Prefer greater width, then proximity to the previous label anchor, then the
|
||||
/// leftmost interval. Input order has no influence. A zero-area band has no
|
||||
/// corridor. Touching the padded vertical band is conservatively obstructing;
|
||||
/// a zero-area blocker has no area and must receive stroke padding upstream.
|
||||
pub(super) fn widest_clear(
|
||||
band: Rectangle,
|
||||
blockers: &[Rectangle],
|
||||
preferred_anchor: f32,
|
||||
) -> Result<Option<Rectangle>, InvalidGeometry> {
|
||||
let (left, right, top, bottom) = edges(band)?;
|
||||
if !preferred_anchor.is_finite() {
|
||||
return Err(InvalidGeometry);
|
||||
}
|
||||
let mut blocked = Vec::new();
|
||||
for &blocker in blockers {
|
||||
let (x0, x1, y0, y1) = edges(blocker)?;
|
||||
if blocker.width > 0. && blocker.height > 0. && y1 >= top && y0 <= bottom {
|
||||
let interval = (x0.max(left), x1.min(right));
|
||||
if interval.0 < interval.1 {
|
||||
blocked.push(interval);
|
||||
}
|
||||
}
|
||||
}
|
||||
if band.width == 0. || band.height == 0. {
|
||||
return Ok(None);
|
||||
}
|
||||
blocked.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.total_cmp(&b.1)));
|
||||
let mut best: Option<Rectangle> = None;
|
||||
let mut consider = |start: f32, end: f32| {
|
||||
if start >= end {
|
||||
return;
|
||||
}
|
||||
let candidate = Rectangle {
|
||||
x: start,
|
||||
width: contained_width(start, end),
|
||||
..band
|
||||
};
|
||||
// Use f64 only for comparison distance: subtracting two admitted f32
|
||||
// world coordinates can overflow even though each rectangle is finite.
|
||||
let distance = |x: f32| (f64::from(x) - f64::from(preferred_anchor)).abs();
|
||||
if best.is_none_or(|old| {
|
||||
candidate.width > old.width
|
||||
|| (candidate.width == old.width
|
||||
&& (distance(candidate.x) < distance(old.x)
|
||||
|| (distance(candidate.x) == distance(old.x) && candidate.x < old.x)))
|
||||
}) {
|
||||
best = Some(candidate);
|
||||
}
|
||||
};
|
||||
let mut cursor = left;
|
||||
for (start, end) in blocked {
|
||||
consider(cursor, start);
|
||||
cursor = cursor.max(end);
|
||||
}
|
||||
consider(cursor, right);
|
||||
Ok(best)
|
||||
}
|
||||
|
||||
/// Finite f32 endpoints can subtract to infinity or round across a blocker.
|
||||
/// Compute in f64, cap to finite f32, and round inward if nearest rounding
|
||||
/// overshot the admitted interval's end.
|
||||
fn contained_width(start: f32, end: f32) -> f32 {
|
||||
let exact = f64::from(end) - f64::from(start);
|
||||
let mut width = exact.min(f64::from(f32::MAX)) as f32;
|
||||
if f64::from(start) + f64::from(width) > f64::from(end) {
|
||||
width = width.next_down();
|
||||
}
|
||||
width
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rect(x: f32, y: f32, width: f32, height: f32) -> Rectangle {
|
||||
Rectangle {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_message_does_not_cross_an_intermediate_execution() {
|
||||
let band = rect(20., 100., 700., 22.);
|
||||
let blockers = [rect(250., 80., 16., 80.), rect(490., 90., 28., 140.)];
|
||||
let result = widest_clear(band, &blockers, band.x).unwrap().unwrap();
|
||||
assert_eq!(result, rect(20., 100., 230., 22.));
|
||||
for blocker in blockers {
|
||||
assert!(result.x + result.width <= blocker.x || result.x >= blocker.x + blocker.width);
|
||||
}
|
||||
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_unsorted_blockers_merge_and_widest_gap_wins() {
|
||||
let band = rect(-100., 10., 600., 20.);
|
||||
let blockers = [
|
||||
rect(50., 0., 80., 60.),
|
||||
rect(0., 5., 70., 30.),
|
||||
rect(450., 0., 100., 60.),
|
||||
];
|
||||
let expected = Some(rect(130., 10., 320., 20.));
|
||||
assert_eq!(widest_clear(band, &blockers, -100.).unwrap(), expected);
|
||||
let mut reversed = blockers;
|
||||
reversed.reverse();
|
||||
assert_eq!(widest_clear(band, &reversed, -100.).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_padded_band_and_touching_boundaries_are_obstructed() {
|
||||
let band = rect(0., 100., 200., 20.);
|
||||
// The first blocker ends at the band top: checking only a baseline
|
||||
// would miss it. The second starts at the band bottom.
|
||||
let blockers = [rect(0., 70., 60., 30.), rect(150., 120., 50., 30.)];
|
||||
assert_eq!(
|
||||
widest_clear(band, &blockers, 0.).unwrap(),
|
||||
Some(rect(60., 100., 90., 20.))
|
||||
);
|
||||
let outside = [rect(0., 70., 200., 29.), rect(0., 121., 200., 30.)];
|
||||
assert_eq!(widest_clear(band, &outside, 0.).unwrap(), Some(band));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ties_keep_the_nearest_anchor_then_choose_leftmost() {
|
||||
let band = rect(0., 0., 300., 20.);
|
||||
let blockers = [rect(100., 0., 100., 20.)];
|
||||
assert_eq!(
|
||||
widest_clear(band, &blockers, 190.).unwrap(),
|
||||
Some(rect(200., 0., 100., 20.))
|
||||
);
|
||||
assert_eq!(
|
||||
widest_clear(band, &blockers, 100.).unwrap(),
|
||||
Some(rect(0., 0., 100., 20.))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caller_defined_self_and_reversed_corridors_keep_their_geometry() {
|
||||
// Direction and self-loop policy are explicit caller inputs. Neither
|
||||
// a zero endpoint gap nor screen width can silently supply a corridor.
|
||||
for band in [rect(420., 40., 180., 22.), rect(-400., 90., 350., 22.)] {
|
||||
assert_eq!(widest_clear(band, &[], band.x).unwrap(), Some(band));
|
||||
}
|
||||
assert_eq!(
|
||||
widest_clear(rect(420., 40., 0., 22.), &[], 420.).unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
widest_clear(rect(420., 40., 180., 0.), &[], 420.).unwrap(),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
widest_clear(rect(0., 0., 100., 20.), &[rect(-10., -10., 200., 50.)], 0.).unwrap(),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_overflowing_geometry_never_enters_a_corridor() {
|
||||
let band = rect(0., 0., 100., 20.);
|
||||
for invalid in [
|
||||
rect(f32::NAN, 0., 10., 20.),
|
||||
rect(0., 0., -1., 20.),
|
||||
rect(f32::MAX, 0., f32::MAX, 20.),
|
||||
rect(0., f32::MAX, 10., f32::MAX),
|
||||
] {
|
||||
assert_eq!(widest_clear(invalid, &[], 0.), Err(InvalidGeometry));
|
||||
assert_eq!(widest_clear(band, &[invalid], 0.), Err(InvalidGeometry));
|
||||
}
|
||||
assert_eq!(widest_clear(band, &[], f32::INFINITY), Err(InvalidGeometry));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_band_cannot_reconstruct_an_infinite_width() {
|
||||
let band = rect(f32::from_bits(0xfe79636e), 0., f32::MAX, 20.);
|
||||
let right = band.x + band.width;
|
||||
assert!(right.is_finite());
|
||||
assert!(
|
||||
(right - band.x).is_infinite(),
|
||||
"counterexample must exercise f32 subtraction overflow"
|
||||
);
|
||||
let result = widest_clear(band, &[], 0.).unwrap().unwrap();
|
||||
assert!(result.width.is_finite() && result.width > 0.);
|
||||
assert!(result.x + result.width <= right);
|
||||
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(right));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructed_width_rounds_inward_at_a_blocker() {
|
||||
let band = rect(-999_948.562_5, 0., 1_000_000., 20.);
|
||||
let blocker = rect(2.1, 0., 10., 20.);
|
||||
assert!(
|
||||
band.x + (blocker.x - band.x) > blocker.x,
|
||||
"counterexample must cross the blocker under naive reconstruction"
|
||||
);
|
||||
let result = widest_clear(band, &[blocker], 0.).unwrap().unwrap();
|
||||
assert_eq!(result.x, band.x);
|
||||
assert!(result.width > 999_000.);
|
||||
assert!(result.x + result.width <= blocker.x);
|
||||
assert!(f64::from(result.x) + f64::from(result.width) <= f64::from(blocker.x));
|
||||
}
|
||||
}
|
||||
@@ -1,487 +0,0 @@
|
||||
//! Unexecuted evaluator candidates; the module is intentionally not wired yet.
|
||||
use super::*;
|
||||
use iced::advanced::{
|
||||
graphics::text::Paragraph,
|
||||
text::{Paragraph as _, Text as ParagraphText, Wrapping},
|
||||
};
|
||||
|
||||
// Independent paragraph construction: no candidate cache, normalization, search,
|
||||
// or character-width estimate is used by this measurement oracle.
|
||||
fn paragraph_size(content: &str, zoom: f32, style: Style) -> Size {
|
||||
let size = Pixels(style.size.0 * zoom);
|
||||
let line_height = match style.line_height {
|
||||
LineHeight::Relative(factor) => LineHeight::Relative(factor),
|
||||
LineHeight::Absolute(pixels) => LineHeight::Absolute(Pixels(pixels.0 * zoom)),
|
||||
};
|
||||
let paragraph = Paragraph::with_text(ParagraphText {
|
||||
content,
|
||||
bounds: Size::INFINITE,
|
||||
size,
|
||||
line_height,
|
||||
font: style.font,
|
||||
align_x: Alignment::Default,
|
||||
align_y: iced::alignment::Vertical::Top,
|
||||
shaping: style.shaping,
|
||||
wrapping: Wrapping::default(),
|
||||
});
|
||||
assert_eq!(paragraph.buffer().layout_runs().count(), 1);
|
||||
Size::new(paragraph.min_width() / zoom, paragraph.min_height() / zoom)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adequate_corridor_reveals_full_realistic_names_and_distinguishing_suffixes() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style::default();
|
||||
for text in [
|
||||
"1. call · Request valve repair · Technician service conversation",
|
||||
"2. reply · Return pressure after repair · Technician service conversation",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel A",
|
||||
"1. call · Request redundant hydraulic pressure calibration channel B",
|
||||
] {
|
||||
let wide = cache.resolve(&session, text, 1600., 1., style).unwrap();
|
||||
assert_eq!(wide.displayed(&session, 1.).unwrap(), text);
|
||||
assert_eq!(wide.retained_source(&session, 1.).unwrap(), 0..text.len());
|
||||
assert_eq!(wide.bounds.size(), paragraph_size(text, 1., style));
|
||||
let narrow = cache.resolve(&session, text, 80., 1., style).unwrap();
|
||||
assert_ne!(narrow.displayed, text);
|
||||
assert!(narrow.bounds.width <= 80.);
|
||||
assert_eq!(
|
||||
narrow.bounds.size(),
|
||||
paragraph_size(&narrow.displayed, 1., style)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalized_visible_prefix_maps_to_original_bytes_without_claiming_hidden_operation() {
|
||||
let raw = " \t1. call · Pump\r\n · Operation: Need\u{301}le ";
|
||||
let expected = "1. call · Pump · Operation: Need\u{301}le";
|
||||
let n = Normalized::new(raw);
|
||||
assert_eq!(n.text, expected);
|
||||
let prefix = "1. call · Pump ";
|
||||
assert_eq!(n.source_end(prefix.len()), raw.find("· Operation").unwrap());
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let wide = cache
|
||||
.resolve(&session, raw, 1600., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(wide.displayed, expected);
|
||||
assert_eq!(wide.retained_source.end, raw.trim_end().len());
|
||||
let field = raw.find("Need").unwrap()..raw.trim_end().len();
|
||||
assert!(wide.retains_any(&session, 1., field.clone()).unwrap());
|
||||
assert!(
|
||||
!wide.retains_any(&session, 1., 0..2).unwrap(),
|
||||
"normalized whitespace is not a visible field"
|
||||
);
|
||||
let small = cache
|
||||
.resolve(&session, raw, 70., 1., Style::default())
|
||||
.unwrap();
|
||||
assert!(!small.retains_any(&session, 1., field).unwrap());
|
||||
assert!(small.retained_source.end < raw.find("Operation").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grapheme_prefixes_and_zoom_match_actual_paragraph_layout() {
|
||||
let text = "call · 戻り値 圧力 e\u{301} 👩🔬 safety valve verification";
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for zoom in [0.66, 1., 1.75] {
|
||||
let label = cache
|
||||
.resolve(&session, text, 105., zoom, Style::default())
|
||||
.unwrap();
|
||||
assert!(label.bounds.width <= 105.);
|
||||
assert_eq!(
|
||||
label.bounds.size(),
|
||||
paragraph_size(&label.displayed, zoom, Style::default())
|
||||
);
|
||||
assert!(
|
||||
text.grapheme_indices(true)
|
||||
.any(|(i, _)| i == label.retained_source.end)
|
||||
|| label.retained_source.end == text.len()
|
||||
);
|
||||
let painted = label
|
||||
.canvas_text(&session, zoom, Point::new(400., 90.), Color::BLACK)
|
||||
.unwrap();
|
||||
assert_eq!(painted.content, label.displayed);
|
||||
assert!(painted.max_width.is_infinite());
|
||||
assert_eq!(
|
||||
label
|
||||
.bounds_at(&session, zoom, painted.position)
|
||||
.unwrap()
|
||||
.size(),
|
||||
label.bounds.size()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_and_too_narrow_labels_never_claim_source_visibility() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for text in ["", " \t\n", "Operation: Pump"] {
|
||||
let label = cache
|
||||
.resolve(&session, text, 0., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(label.displayed, "");
|
||||
assert_eq!(label.retained_source, 0..0);
|
||||
assert_eq!(label.bounds.size(), Size::ZERO);
|
||||
assert!(!label.retains_any(&session, 1., 0..text.len()).unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redraws_reuse_resolution_but_width_zoom_and_style_are_dependencies() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification with a descriptive operation label";
|
||||
let style = Style::default();
|
||||
let a = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
let measured = cache.allocations;
|
||||
let b = cache.resolve(&session, text, 100., 1., style).unwrap();
|
||||
assert!(Arc::ptr_eq(&a, &b));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
for (width, zoom, style) in [
|
||||
(120., 1., style),
|
||||
(100., 0.66, style),
|
||||
(
|
||||
100.,
|
||||
1.,
|
||||
Style {
|
||||
size: Pixels(14.),
|
||||
..style
|
||||
},
|
||||
),
|
||||
] {
|
||||
let other = cache.resolve(&session, text, width, zoom, style).unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &other));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_is_remeasured_for_a_fresh_settled_render_session() {
|
||||
let mut cache = Cache::default();
|
||||
let old_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Pressure verification";
|
||||
let a = cache
|
||||
.resolve(&old_session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
assert!(Arc::ptr_eq(
|
||||
&a,
|
||||
&cache
|
||||
.resolve(&old_session, text, 100., 1., Style::default())
|
||||
.unwrap()
|
||||
));
|
||||
assert_eq!(cache.allocations, measured);
|
||||
// A fresh renderer session invalidates a still-resident entry. This does
|
||||
// not mutate global fonts or purport to test dynamic WGPU font loading.
|
||||
let new_session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let refreshed = cache
|
||||
.resolve(&new_session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(
|
||||
&cache.entries.front().unwrap().label,
|
||||
&refreshed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resident_entry_remeasures_after_local_font_epoch_invalidation() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let text = "call · Resident pressure label";
|
||||
let a = cache
|
||||
.resolve(&session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &a));
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
// Exercise the local memo epoch branch while a is still resident. No
|
||||
// intervening width/zoom/style resolution can cause incidental eviction.
|
||||
// This is not an actual font-load or renderer-cache acceptance test.
|
||||
cache.version = None;
|
||||
let refreshed = cache
|
||||
.resolve(&session, text, 100., 1., Style::default())
|
||||
.unwrap();
|
||||
assert!(!Arc::ptr_eq(&a, &refreshed));
|
||||
assert!(cache.allocations > measured);
|
||||
assert_eq!(cache.entries.len(), 1);
|
||||
assert!(Arc::ptr_eq(
|
||||
&cache.entries.front().unwrap().label,
|
||||
&refreshed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retained_artifact_rejects_other_zoom_and_render_session_for_every_consumer() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let label = cache
|
||||
.resolve(&session, "Operation: Pressure", 200., 1., Style::default())
|
||||
.unwrap();
|
||||
let other = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (context, zoom, expected) in [
|
||||
(&session, 0.66, Error::StaleZoom),
|
||||
(&other, 1., Error::StaleRenderSession),
|
||||
] {
|
||||
assert_eq!(label.displayed(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(label.retained_source(context, zoom).unwrap_err(), expected);
|
||||
assert_eq!(
|
||||
label.retains_any(context, zoom, 0..9).unwrap_err(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
label.bounds_at(context, zoom, Point::ORIGIN).unwrap_err(),
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.canvas_text(context, zoom, Point::ORIGIN, Color::BLACK)
|
||||
.unwrap_err(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
label
|
||||
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_scaled_metrics_cannot_escape_as_infinite_world_dimensions() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let style = Style {
|
||||
size: Pixels(2.),
|
||||
line_height: LineHeight::Relative(f32::MAX),
|
||||
..Style::default()
|
||||
};
|
||||
// At zoom 0.5 the scaled size=1 and line height=MAX are finite. The
|
||||
// required world line height is not; reject before passing it to shaping.
|
||||
let scaled = style.scaled(0.5);
|
||||
assert!(scaled.0.is_finite() && scaled.1.is_finite());
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&session, "call", 100., 0.5, style)
|
||||
.unwrap_err(),
|
||||
Error::InvalidMetrics
|
||||
);
|
||||
assert_eq!(cache.allocations, 0);
|
||||
assert!(cache.entries.is_empty());
|
||||
// Independently exercise validation after division for actual measured
|
||||
// dimensions: finite scaled width/height do not imply finite world values.
|
||||
assert_eq!(
|
||||
world_size(Size::new(f32::MAX, 1.), 0.5),
|
||||
Err(Error::InvalidMeasurement)
|
||||
);
|
||||
assert_eq!(
|
||||
world_size(Size::new(1., f32::MAX), 0.5),
|
||||
Err(Error::InvalidMeasurement)
|
||||
);
|
||||
assert_eq!(
|
||||
world_size(Size::new(20., 8.), 0.5).unwrap(),
|
||||
Size::new(40., 16.)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_metrics_and_explicit_experiment_budgets_fail_without_a_cached_candidate() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
for (width, zoom) in [(f32::NAN, 1.), (-1., 1.), (100., 0.), (100., f32::INFINITY)] {
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&session, "call", width, zoom, Style::default())
|
||||
.unwrap_err(),
|
||||
Error::InvalidMetrics
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(
|
||||
&session,
|
||||
&"x".repeat(MAX_SOURCE_BYTES + 1),
|
||||
100.,
|
||||
1.,
|
||||
Style::default()
|
||||
)
|
||||
.unwrap_err(),
|
||||
Error::SourceBudget
|
||||
);
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(
|
||||
&session,
|
||||
&"x".repeat(MAX_GRAPHEMES + 1),
|
||||
100.,
|
||||
1.,
|
||||
Style::default()
|
||||
)
|
||||
.unwrap_err(),
|
||||
Error::GraphemeBudget
|
||||
);
|
||||
assert!(cache.entries.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_translation_rejects_nonfinite_world_positions_and_endpoints() {
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let label = cache
|
||||
.resolve(&session, "call", 100., 1., Style::default())
|
||||
.unwrap();
|
||||
for position in [Point::new(f32::NAN, 0.), Point::new(0., f32::INFINITY)] {
|
||||
assert_eq!(
|
||||
label.bounds_at(&session, 1., position).unwrap_err(),
|
||||
Error::InvalidPosition
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.canvas_text(&session, 1., position, Color::BLACK)
|
||||
.unwrap_err(),
|
||||
Error::InvalidPosition
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
translated_bounds(Point::new(f32::MAX, 0.), Size::new(f32::MAX, 1.)),
|
||||
Err(Error::InvalidPosition)
|
||||
);
|
||||
assert_eq!(
|
||||
translated_bounds(Point::new(0., f32::MAX), Size::new(1., f32::MAX)),
|
||||
Err(Error::InvalidPosition)
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.bounds_at(&session, 1., Point::new(-20., -10.))
|
||||
.unwrap()
|
||||
.position(),
|
||||
Point::new(-20., -10.)
|
||||
);
|
||||
}
|
||||
|
||||
/// Must run as the ONLY selected test in a dedicated, fresh test process.
|
||||
/// The ignored marker keeps this global-font mutation out of normal parallel
|
||||
/// desktop tests. See the evidence directory's exact executable runner.
|
||||
#[test]
|
||||
#[ignore = "mutates the global font database; run only this exact test in a fresh process"]
|
||||
fn actual_font_load_rejects_retained_label_and_old_session() {
|
||||
use graphics_text::cosmic_text::fontdb;
|
||||
use std::borrow::Cow;
|
||||
|
||||
let mut cache = Cache::default();
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let source = "Operation: Pressure";
|
||||
let label = cache
|
||||
.resolve(&session, source, 200., 1., Style::default())
|
||||
.unwrap();
|
||||
assert_eq!(label.displayed(&session, 1.).unwrap(), source);
|
||||
assert!(
|
||||
label
|
||||
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
|
||||
.is_ok()
|
||||
);
|
||||
let measured = cache.allocations;
|
||||
assert!(measured > 0);
|
||||
assert!(Arc::ptr_eq(&cache.entries.front().unwrap().label, &label));
|
||||
|
||||
let (version_before, version_after, faces_before, faces_after) = {
|
||||
let mut fonts = graphics_text::font_system().write().unwrap();
|
||||
let face = fonts
|
||||
.raw()
|
||||
.db()
|
||||
.faces()
|
||||
.find(|face| {
|
||||
face.families
|
||||
.iter()
|
||||
.any(|(family, _)| family == "Iced-Icons")
|
||||
})
|
||||
.expect("Iced 0.14 embeds and loads its known Iced-Icons.ttf face")
|
||||
.id;
|
||||
let bytes = fonts
|
||||
.raw()
|
||||
.db()
|
||||
.with_face_data(face, |data, _| data.to_vec())
|
||||
.expect("the built-in binary font has accessible bytes");
|
||||
assert!(!bytes.is_empty());
|
||||
// load_font increments Version even for invalid bytes. Independently
|
||||
// establish actual face admission so this cannot pass with invalid data.
|
||||
let mut probe = fontdb::Database::new();
|
||||
probe.load_font_data(bytes.clone());
|
||||
let admitted = probe
|
||||
.faces()
|
||||
.filter(|face| {
|
||||
face.families
|
||||
.iter()
|
||||
.any(|(family, _)| family == "Iced-Icons")
|
||||
})
|
||||
.count();
|
||||
assert!(
|
||||
admitted > 0,
|
||||
"the extracted bytes parse as the expected real font"
|
||||
);
|
||||
let before = fonts.version();
|
||||
assert_eq!(before, session.version);
|
||||
let count = fonts.raw().db().faces().count();
|
||||
// Owned bytes intentionally bypass Borrowed-address deduplication.
|
||||
// This is an actual valid duplicate-face load, not a claim the chosen
|
||||
// face or rendered metrics change; the stale-epoch rule is the oracle.
|
||||
fonts.load_font(Cow::Owned(bytes));
|
||||
(
|
||||
before,
|
||||
fonts.version(),
|
||||
count,
|
||||
fonts.raw().db().faces().count(),
|
||||
)
|
||||
};
|
||||
assert_ne!(version_before, version_after);
|
||||
assert!(
|
||||
faces_after > faces_before,
|
||||
"actual font admission must accompany the version increment"
|
||||
);
|
||||
assert_eq!(
|
||||
label.displayed(&session, 1.).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label.retained_source(&session, 1.).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label.retains_any(&session, 1., 0..9).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label.bounds_at(&session, 1., Point::ORIGIN).unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
label
|
||||
.canvas_text(&session, 1., Point::ORIGIN, Color::BLACK)
|
||||
.unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
cache
|
||||
.resolve(&session, source, 200., 1., Style::default())
|
||||
.unwrap_err(),
|
||||
Error::FontSetChanged
|
||||
);
|
||||
assert_eq!(
|
||||
cache.allocations, measured,
|
||||
"an invalid session must not remeasure and imply renderer repair"
|
||||
);
|
||||
println!(
|
||||
"Valid Iced-Icons load: faces {faces_before}->{faces_after}, font Version {version_before:?}->{version_after:?}; old label and session rejected"
|
||||
);
|
||||
// Do not create a fresh session or claim dynamic WGPU cache recovery here.
|
||||
// Process exit is the isolation boundary; no other test may run afterward.
|
||||
}
|
||||
@@ -1,816 +0,0 @@
|
||||
//! Pure complete Scenario geometry. No persistence, gesture or fallback policy.
|
||||
//! The only renderer is Scene::sequence_geometry; this module resolves its inputs.
|
||||
use super::*;
|
||||
use oa_core::{DiagramLayout, ScenarioLayout};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) mod presentation;
|
||||
|
||||
type Candidate = BTreeMap<Id, f32>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AnchorRole {
|
||||
MessageSend,
|
||||
MessageReceive,
|
||||
Observation,
|
||||
FrameStart,
|
||||
OperandStart,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(super) struct Anchor {
|
||||
pub(super) y: f32,
|
||||
role: AnchorRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct MessageAnchors {
|
||||
send_y: f32,
|
||||
receive_y: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Diagnostic {
|
||||
SourceOrderCrossing { scope: Id, before: Id, after: Id },
|
||||
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum GeometryIssue {
|
||||
UnknownAnchor(Id),
|
||||
LifecycleGeometryNotSupported { lifeline: Id, scope: Id },
|
||||
InvalidRepresentation(Id),
|
||||
InvalidCoordinate(Id),
|
||||
DuplicateCoordinate(Id),
|
||||
ExpandedAddressNotSupported(Id),
|
||||
CoincidentDistinctEndpoints(Id),
|
||||
TypedActivationOutsideScope { call: Id, scope: Id },
|
||||
TypedActivationOutsideLifetime { call: Id, receiver: Id },
|
||||
TypedActivationCrossingNotSupported { call: Id, event: Id },
|
||||
NonFinite(Id),
|
||||
CompoundMoveNotImplemented(Id),
|
||||
FixedFrameHeaderWouldMove { frame: Id },
|
||||
OperandScopeOutsideEnclosingInterval { operand: Id, fragment: Id },
|
||||
LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id },
|
||||
LifetimeInversionNotSupported { create: Id, delete: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct Plan {
|
||||
pub(super) anchors: BTreeMap<Id, Anchor>,
|
||||
messages: BTreeMap<Id, MessageAnchors>,
|
||||
pub(super) frames: BTreeMap<Id, Rectangle>,
|
||||
pub(super) scopes: BTreeMap<Id, (f32, f32)>,
|
||||
pub(super) height: f32,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
pub(super) fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match p.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. } => Some(body),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn role(body: &OccurrenceBody) -> AnchorRole {
|
||||
match body {
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
} => AnchorRole::MessageReceive,
|
||||
OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend,
|
||||
OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart,
|
||||
OccurrenceBody::Branch { .. } => AnchorRole::OperandStart,
|
||||
_ => AnchorRole::Observation,
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(p: &Project, id: Id, owner: Id) -> bool {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
for _ in 0..p.elements.len() {
|
||||
match current {
|
||||
Some(parent) if parent == owner => return true,
|
||||
Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn depth(p: &Project, id: Id) -> usize {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
let mut result = 0;
|
||||
while let Some(parent) = current {
|
||||
result += 1;
|
||||
if result > p.elements.len() {
|
||||
break;
|
||||
}
|
||||
current = p.elements.get(&parent).and_then(|e| e.parent);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
||||
let x = a.x.min(b.x);
|
||||
let y = a.y.min(b.y);
|
||||
Rectangle::new(
|
||||
Point::new(x, y),
|
||||
Size::new(
|
||||
(a.x + a.width).max(b.x + b.width) - x,
|
||||
(a.y + a.height).max(b.y + b.height) - y,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Actual named shapes/message paths supply footprint measurements; activation
|
||||
/// bars are intentionally excluded because current Scene derives them later.
|
||||
fn footprints(
|
||||
scene: &Scene,
|
||||
anchors: &BTreeMap<Id, Anchor>,
|
||||
seed: &BTreeMap<Id, Anchor>,
|
||||
) -> BTreeMap<Id, Rectangle> {
|
||||
let mut out = BTreeMap::new();
|
||||
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
|
||||
let Some(anchor) = anchors.get(&item.id) else {
|
||||
continue;
|
||||
};
|
||||
let mut rect = item.rect;
|
||||
rect.y += anchor.y - seed[&item.id].y;
|
||||
out.insert(item.id, rect);
|
||||
}
|
||||
for line in &scene.lines {
|
||||
let Some(anchor) = anchors.get(&line.id) else {
|
||||
continue;
|
||||
};
|
||||
let delta = anchor.y - seed[&line.id].y;
|
||||
let mut rect = line.label_bounds();
|
||||
rect.y += delta;
|
||||
for point in &line.points {
|
||||
rect = union(
|
||||
rect,
|
||||
Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO),
|
||||
);
|
||||
}
|
||||
out.entry(line.id)
|
||||
.and_modify(|old| *old = union(*old, rect))
|
||||
.or_insert(rect);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn include_ancestors(p: &Project, id: Id, rect: Rectangle, covered: &mut BTreeMap<Id, Rectangle>) {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
for _ in 0..p.elements.len() {
|
||||
let Some(parent) = current else {
|
||||
break;
|
||||
};
|
||||
covered
|
||||
.entry(parent)
|
||||
.and_modify(|old| *old = union(*old, rect))
|
||||
.or_insert(rect);
|
||||
current = p.elements.get(&parent).and_then(|e| e.parent);
|
||||
}
|
||||
}
|
||||
|
||||
fn coverage(p: &Project, footprints: &BTreeMap<Id, Rectangle>) -> BTreeMap<Id, Rectangle> {
|
||||
let mut covered = BTreeMap::new();
|
||||
for (&id, &rect) in footprints {
|
||||
include_ancestors(p, id, rect, &mut covered);
|
||||
}
|
||||
covered
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
/// Authored endpoint values are primary inputs, not deltas reconstructed from
|
||||
/// rounded rows. Lifecycle alignment owns x and marker attachment; this final
|
||||
/// y alignment precedes typed activation derivation and preserves send bits.
|
||||
pub(super) fn align_message_anchors(&self, lines: &mut [Line]) {
|
||||
for line in lines {
|
||||
let Some(message) = self.messages.get(&line.id) else {
|
||||
continue;
|
||||
};
|
||||
let previous_send = line.points.first().map_or(message.send_y, |point| point.y);
|
||||
line.label_at.y += message.send_y - previous_send;
|
||||
let last = line.points.len().saturating_sub(1);
|
||||
for (index, point) in line.points.iter_mut().enumerate() {
|
||||
point.y = if index == last || (last == 3 && index == 2) {
|
||||
message.receive_y
|
||||
} else {
|
||||
message.send_y
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
baseline: &Scene,
|
||||
desired: &Candidate,
|
||||
) -> Result<Self, GeometryIssue> {
|
||||
let mut seed: BTreeMap<_, _> = baseline
|
||||
.rows
|
||||
.iter()
|
||||
.map(|(id, (_, y))| {
|
||||
(
|
||||
*id,
|
||||
Anchor {
|
||||
y: *y,
|
||||
role: role(occurrence(p, *id).expect("Scene occurrence")),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Operand headers are real Scene boxes, but are not entries in rows.
|
||||
for item in &baseline.boxes {
|
||||
if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) {
|
||||
seed.insert(
|
||||
item.id,
|
||||
Anchor {
|
||||
y: item.rect.y,
|
||||
role: AnchorRole::OperandStart,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut anchors = seed.clone();
|
||||
for (&id, &y) in desired {
|
||||
let anchor = anchors
|
||||
.get_mut(&id)
|
||||
.ok_or(GeometryIssue::UnknownAnchor(id))?;
|
||||
if !y.is_finite() {
|
||||
return Err(GeometryIssue::NonFinite(id));
|
||||
}
|
||||
if matches!(
|
||||
anchor.role,
|
||||
AnchorRole::FrameStart | AnchorRole::OperandStart
|
||||
) && y != anchor.y
|
||||
{
|
||||
return Err(GeometryIssue::CompoundMoveNotImplemented(id));
|
||||
}
|
||||
anchor.y = y;
|
||||
}
|
||||
let mut messages = BTreeMap::new();
|
||||
for line in &baseline.lines {
|
||||
if let (Some(anchor), Some(first), Some(last)) = (
|
||||
anchors.get(&line.id),
|
||||
line.points.first(),
|
||||
line.points.last(),
|
||||
) {
|
||||
let message = if desired.contains_key(&line.id) {
|
||||
let self_message = matches!(occurrence(p, line.id), Some(OccurrenceBody::Exchange {
|
||||
sender: Some(sender), receiver, message_kind, ..
|
||||
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
|
||||
let lifecycle = matches!(
|
||||
occurrence(p, line.id),
|
||||
Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
})
|
||||
);
|
||||
if self_message && lifecycle {
|
||||
MessageAnchors {
|
||||
send_y: anchor.y - 24.,
|
||||
receive_y: anchor.y,
|
||||
}
|
||||
} else {
|
||||
MessageAnchors {
|
||||
send_y: anchor.y,
|
||||
receive_y: if self_message {
|
||||
anchor.y + 24.
|
||||
} else {
|
||||
anchor.y
|
||||
},
|
||||
}
|
||||
}
|
||||
} else {
|
||||
MessageAnchors {
|
||||
send_y: first.y,
|
||||
receive_y: last.y,
|
||||
}
|
||||
};
|
||||
messages.insert(line.id, message);
|
||||
}
|
||||
}
|
||||
// Track actual semantic lifecycle order per receiver and scope. In particular,
|
||||
// a later recreation must not be compared with an earlier deletion.
|
||||
for element in p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.id == owner || inside(p, e.id, owner))
|
||||
{
|
||||
let Some(steps) = element.steps() else {
|
||||
continue;
|
||||
};
|
||||
let mut last = BTreeMap::new();
|
||||
for id in steps {
|
||||
if let (
|
||||
Some(message),
|
||||
Some(OccurrenceBody::Exchange {
|
||||
receiver: Some(lane),
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
}),
|
||||
) = (messages.get(id), occurrence(p, *id))
|
||||
&& let Some((previous, previous_y)) =
|
||||
last.insert(*lane, (*id, message.receive_y))
|
||||
&& previous_y > message.receive_y
|
||||
{
|
||||
return Err(GeometryIssue::LifetimeInversionNotSupported {
|
||||
create: previous,
|
||||
delete: *id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let old_footprints = footprints(baseline, &seed, &seed);
|
||||
let mut drawn = footprints(baseline, &anchors, &seed);
|
||||
let old_coverage = coverage(p, &old_footprints);
|
||||
let mut new_coverage = coverage(p, &drawn);
|
||||
let mut frames = BTreeMap::new();
|
||||
let mut containers: Vec<_> = seed
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. })))
|
||||
.collect();
|
||||
containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id)));
|
||||
for id in containers {
|
||||
let original = old_footprints[&id];
|
||||
let mut rect = original;
|
||||
if let Some(covered) = new_coverage.get(&id).copied() {
|
||||
// Preserve automatic extents and their existing descendant padding.
|
||||
// Upward envelope growth would move the painted frame header
|
||||
// while its fixed FrameStart/row/slot remained at the seed.
|
||||
// Reject that unsupported compound change, not mere overlap.
|
||||
if covered.y < rect.y {
|
||||
return Err(GeometryIssue::FixedFrameHeaderWouldMove { frame: id });
|
||||
}
|
||||
let old_covered = old_coverage[&id];
|
||||
let padding =
|
||||
(original.y + original.height - old_covered.y - old_covered.height).max(0.);
|
||||
// Preserve padding before the footprint itself reaches the old
|
||||
// frame edge: operand scopes already retain their own padding.
|
||||
rect.height = rect
|
||||
.height
|
||||
.max(covered.y + covered.height + padding - rect.y);
|
||||
}
|
||||
frames.insert(id, rect);
|
||||
drawn.insert(id, rect);
|
||||
include_ancestors(p, id, rect, &mut new_coverage);
|
||||
}
|
||||
let mut scopes = baseline.sequence_bounds.clone();
|
||||
for (&scope, bounds) in &mut scopes {
|
||||
if let (Some(old), Some(new)) = (
|
||||
old_coverage.get(&scope).copied(),
|
||||
new_coverage.get(&scope).copied(),
|
||||
) {
|
||||
let top_padding = (old.y - bounds.0).max(0.);
|
||||
let bottom_padding = (bounds.1 - old.y - old.height).max(0.);
|
||||
if new.y < bounds.0 {
|
||||
bounds.0 = new.y - top_padding;
|
||||
}
|
||||
bounds.1 = bounds.1.max(new.y + new.height + bottom_padding);
|
||||
}
|
||||
}
|
||||
let tail = baseline.size.height - baseline.sequence_bounds[&owner].1;
|
||||
let height = baseline.size.height.max(scopes[&owner].1 + tail);
|
||||
for (&operand, &(start, end)) in &scopes {
|
||||
if !matches!(occurrence(p, operand), Some(OccurrenceBody::Branch { .. })) {
|
||||
continue;
|
||||
}
|
||||
let fragment = p.elements[&operand].parent.expect("operand fragment");
|
||||
let caller = p.elements[&fragment]
|
||||
.parent
|
||||
.expect("fragment caller sequence");
|
||||
let enclosing_frame = frames[&fragment];
|
||||
let (caller_start, caller_end) = if caller == owner {
|
||||
(85., height - 20.)
|
||||
} else {
|
||||
let (a, b) = scopes[&caller];
|
||||
(a, if a == b { a + 26. } else { b })
|
||||
};
|
||||
let enclosing_start = anchors[&fragment].y.max(caller_start);
|
||||
let enclosing_end = (enclosing_frame.y + enclosing_frame.height).min(caller_end);
|
||||
let effective_end = if start == end { start + 26. } else { end };
|
||||
// Actual lifecycle traversal cannot enter an operand that escapes
|
||||
// this enclosing interval. This guards bridge feasibility only;
|
||||
// it is not a generic event-spacing or sibling-ordering policy.
|
||||
if start < enclosing_start || effective_end > enclosing_end {
|
||||
return Err(GeometryIssue::OperandScopeOutsideEnclosingInterval {
|
||||
operand,
|
||||
fragment,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (&id, message) in &messages {
|
||||
if matches!(
|
||||
occurrence(p, id),
|
||||
Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
})
|
||||
) {
|
||||
let scope = p.elements[&id].parent.expect("lifecycle sequence owner");
|
||||
// Match Rendering::new's effective scope, not the insertion
|
||||
// scope: it overrides the root span to 85..height-20 and gives
|
||||
// empty non-root scopes the existing 26px lifetime allowance.
|
||||
let (start, end) = if scope == owner {
|
||||
(85., height - 20.)
|
||||
} else if let Some(&(start, end)) = scopes.get(&scope) {
|
||||
(start, if start == end { start + 26. } else { end })
|
||||
} else {
|
||||
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
|
||||
occurrence: id,
|
||||
scope,
|
||||
});
|
||||
};
|
||||
if message.receive_y < start || message.receive_y > end {
|
||||
return Err(GeometryIssue::LifecycleAnchorOutsideRenderScope {
|
||||
occurrence: id,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut diagnostics = Vec::new();
|
||||
for element in p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.id == owner || inside(p, e.id, owner))
|
||||
{
|
||||
if let Some(steps) = element.steps() {
|
||||
for pair in steps.windows(2) {
|
||||
if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) {
|
||||
if a.y > b.y {
|
||||
diagnostics.push(Diagnostic::SourceOrderCrossing {
|
||||
scope: element.id,
|
||||
before: pair[0],
|
||||
after: pair[1],
|
||||
});
|
||||
}
|
||||
if let Some(frame) = frames.get(&pair[0])
|
||||
&& frame.y + frame.height > b.y
|
||||
{
|
||||
diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor {
|
||||
frame: pair[0],
|
||||
following: pair[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
anchors,
|
||||
messages,
|
||||
frames,
|
||||
scopes,
|
||||
height,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// An appearance identity, never inferred from selection or from the owner alone.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Representation {
|
||||
Derived { owner: Id },
|
||||
Saved { diagram: Id },
|
||||
}
|
||||
|
||||
/// Complete immutable geometry produced from one Project borrow. Scene is retained
|
||||
/// with its plan so a caller cannot accidentally render this plan against another
|
||||
/// Project or representation. Document/revision freshness belongs to that caller.
|
||||
pub(super) struct ResolvedScenarioPlan {
|
||||
pub(super) representation: Representation,
|
||||
pub(super) owner: Id,
|
||||
pub(super) lanes: BTreeMap<Id, f32>,
|
||||
pub(super) plan: Plan,
|
||||
pub(super) latent_messages: Vec<Id>,
|
||||
pub(super) bounds: Rectangle,
|
||||
pub(super) scene: Scene,
|
||||
}
|
||||
|
||||
pub(super) fn resolve(
|
||||
p: &Project,
|
||||
representation: Representation,
|
||||
selected: Id,
|
||||
) -> Result<ResolvedScenarioPlan, Vec<GeometryIssue>> {
|
||||
let (owner, layout) = match representation {
|
||||
Representation::Derived { owner } => (owner, None),
|
||||
Representation::Saved { diagram } => {
|
||||
let view = p
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
|
||||
let owner = view
|
||||
.projection
|
||||
.scenario()
|
||||
.ok_or_else(|| vec![GeometryIssue::InvalidRepresentation(diagram)])?;
|
||||
if p.elements
|
||||
.get(&owner)
|
||||
.is_none_or(|e| e.layer() != view.layer)
|
||||
{
|
||||
return Err(vec![GeometryIssue::InvalidRepresentation(diagram)]);
|
||||
}
|
||||
let layout = view
|
||||
.layout
|
||||
.as_ref()
|
||||
.map(|DiagramLayout::Scenario(layout)| layout);
|
||||
(owner, layout)
|
||||
}
|
||||
};
|
||||
if p.elements
|
||||
.get(&owner)
|
||||
.is_none_or(|e| e.kind() != Kind::Scenario)
|
||||
{
|
||||
return Err(vec![GeometryIssue::InvalidRepresentation(owner)]);
|
||||
}
|
||||
let empty = ScenarioLayout::default();
|
||||
let layout = layout.unwrap_or(&empty);
|
||||
let mut lanes = BTreeMap::new();
|
||||
let mut issues = Vec::new();
|
||||
for lane in &layout.lanes {
|
||||
if p.elements
|
||||
.get(&lane.lifeline)
|
||||
.is_none_or(|e| e.kind() != Kind::Lifeline || e.parent != Some(owner))
|
||||
{
|
||||
issues.push(GeometryIssue::UnknownAnchor(lane.lifeline));
|
||||
}
|
||||
if !lane.x.is_finite() || lane.x.abs() > oa_core::MAX_COORDINATE {
|
||||
issues.push(GeometryIssue::InvalidCoordinate(lane.lifeline));
|
||||
}
|
||||
if lanes.insert(lane.lifeline, lane.x).is_some() {
|
||||
issues.push(GeometryIssue::DuplicateCoordinate(lane.lifeline));
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
// The actual automatic traversal runs first, with authored lane centers already
|
||||
// applied. It supplies every untouched anchor, shape, insertion scope and label.
|
||||
let baseline = Scene::sequence_geometry(
|
||||
p,
|
||||
owner,
|
||||
selected,
|
||||
(!lanes.is_empty()).then_some(&lanes),
|
||||
None,
|
||||
);
|
||||
let mut desired = BTreeMap::new();
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut latent_messages = Vec::new();
|
||||
let line_ids: std::collections::BTreeSet<_> =
|
||||
baseline.lines.iter().map(|line| line.id).collect();
|
||||
for message in &layout.messages {
|
||||
let id = message.address.occurrence;
|
||||
if !seen.insert(id) {
|
||||
issues.push(GeometryIssue::DuplicateCoordinate(id));
|
||||
}
|
||||
if !message.address.references.is_empty() {
|
||||
issues.push(GeometryIssue::ExpandedAddressNotSupported(id));
|
||||
}
|
||||
if !message.send_y.is_finite() || message.send_y.abs() > oa_core::MAX_COORDINATE {
|
||||
issues.push(GeometryIssue::InvalidCoordinate(id));
|
||||
}
|
||||
let Some(OccurrenceBody::Exchange {
|
||||
sender,
|
||||
receiver,
|
||||
message_kind,
|
||||
..
|
||||
}) = occurrence(p, id)
|
||||
else {
|
||||
issues.push(GeometryIssue::UnknownAnchor(id));
|
||||
continue;
|
||||
};
|
||||
if !baseline.rows.contains_key(&id) {
|
||||
issues.push(GeometryIssue::UnknownAnchor(id));
|
||||
continue;
|
||||
}
|
||||
if !line_ids.contains(&id) {
|
||||
latent_messages.push(id);
|
||||
continue;
|
||||
}
|
||||
// Legacy lifecycle rows denote receipt, whereas the saved field denotes send.
|
||||
let row = if sender.is_some()
|
||||
&& sender == receiver
|
||||
&& matches!(message_kind, MessageKind::Create | MessageKind::Delete)
|
||||
{
|
||||
message.send_y + 24.
|
||||
} else {
|
||||
message.send_y
|
||||
};
|
||||
desired.insert(id, row);
|
||||
}
|
||||
let actual_lanes = &baseline.render_lanes;
|
||||
// A coincident pair is not a self message. Do not invent an arrow or lane order.
|
||||
for line in &baseline.lines {
|
||||
if let Some(OccurrenceBody::Exchange {
|
||||
sender: Some(sender),
|
||||
receiver,
|
||||
message_kind,
|
||||
..
|
||||
}) = occurrence(p, line.id)
|
||||
{
|
||||
let target = if matches!(message_kind, MessageKind::Timer { .. }) {
|
||||
Some(*sender)
|
||||
} else {
|
||||
*receiver
|
||||
};
|
||||
if target.is_some_and(|target| target != *sender)
|
||||
&& target
|
||||
.is_some_and(|target| actual_lanes.get(sender) == actual_lanes.get(&target))
|
||||
{
|
||||
issues.push(GeometryIssue::CoincidentDistinctEndpoints(line.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
let mut plan = Plan::build(p, owner, &baseline, &desired).map_err(|issue| vec![issue])?;
|
||||
// Keep the schema's exact send value; send+24 is performed once. In particular,
|
||||
// a self Create/Delete receive row must never round-trip back into saved send.
|
||||
for message in &layout.messages {
|
||||
let id = message.address.occurrence;
|
||||
if let Some(anchors) = plan.messages.get_mut(&id) {
|
||||
let self_message = matches!(occurrence(p, id), Some(OccurrenceBody::Exchange {
|
||||
sender: Some(sender), receiver, message_kind, ..
|
||||
}) if matches!(message_kind, MessageKind::Timer { .. }) || *receiver == Some(*sender));
|
||||
*anchors = MessageAnchors {
|
||||
send_y: message.send_y,
|
||||
receive_y: if self_message {
|
||||
message.send_y + 24.
|
||||
} else {
|
||||
message.send_y
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
// Typed bars require a positive receiver interval. This is a narrow renderer
|
||||
// limitation, not a global ban on reordered/negative asynchronous anchors.
|
||||
for (&id, message) in &plan.messages {
|
||||
match occurrence(p, id) {
|
||||
Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Call,
|
||||
..
|
||||
}) => {
|
||||
let scope = baseline.rows[&id].0;
|
||||
let (start, end) = if scope == owner {
|
||||
(85., plan.height - 20.)
|
||||
} else {
|
||||
plan.scopes[&scope]
|
||||
};
|
||||
if desired.contains_key(&id) && (message.send_y < start || message.receive_y >= end)
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationOutsideScope { call: id, scope });
|
||||
}
|
||||
}
|
||||
Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
reply_to: Some(call),
|
||||
..
|
||||
}) => {
|
||||
if let Some(start) = plan.messages.get(call)
|
||||
&& (desired.contains_key(&id) || desired.contains_key(call))
|
||||
&& message.send_y <= start.receive_y
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call: *call,
|
||||
event: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
for segment in &baseline.activations {
|
||||
if baseline
|
||||
.rows
|
||||
.get(&segment.call)
|
||||
.is_none_or(|(scope, _)| *scope != segment.scope)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Some(start) = plan.messages.get(&segment.call) else {
|
||||
continue;
|
||||
};
|
||||
let event = match segment.finish {
|
||||
scenario_activation::Finish::Reply(event)
|
||||
| scenario_activation::Finish::Terminated(event) => Some(event),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(event) = event
|
||||
&& (desired.contains_key(&event) || desired.contains_key(&segment.call))
|
||||
&& plan.anchors[&event].y <= start.receive_y
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call: segment.call,
|
||||
event,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
let scene = Scene::sequence_geometry(
|
||||
p,
|
||||
owner,
|
||||
selected,
|
||||
(!lanes.is_empty()).then_some(&lanes),
|
||||
Some(&plan),
|
||||
);
|
||||
// Compare the actual projector, whose cursor rules include sibling scopes and
|
||||
// lifecycle/fragment events. Retain existing incomplete-model uncertainty;
|
||||
// reject only newly missing geometry introduced by this presentation.
|
||||
let previous_missing = baseline.lifecycle.missing_geometry();
|
||||
for (lifeline, scope) in scene
|
||||
.lifecycle
|
||||
.missing_geometry()
|
||||
.difference(&previous_missing)
|
||||
{
|
||||
issues.push(GeometryIssue::LifecycleGeometryNotSupported {
|
||||
lifeline: *lifeline,
|
||||
scope: *scope,
|
||||
});
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
for segment in &baseline.activations {
|
||||
let Some(seed) = baseline
|
||||
.lines
|
||||
.iter()
|
||||
.find(|line| line.id == segment.call)
|
||||
.and_then(|line| line.points.last())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if segment.rect.y != seed.y || baseline.rows[&segment.call].0 != segment.scope {
|
||||
continue;
|
||||
}
|
||||
let receive = plan.messages[&segment.call].receive_y;
|
||||
if !desired.is_empty()
|
||||
&& !scene.activations.iter().any(|current| {
|
||||
current.call == segment.call
|
||||
&& current.scope == segment.scope
|
||||
&& current.rect.y == receive
|
||||
})
|
||||
{
|
||||
issues.push(GeometryIssue::TypedActivationOutsideLifetime {
|
||||
call: segment.call,
|
||||
receiver: segment.receiver,
|
||||
});
|
||||
}
|
||||
}
|
||||
if !issues.is_empty() {
|
||||
return Err(issues);
|
||||
}
|
||||
let bounds = structural_bounds(&scene);
|
||||
let all_lanes = scene.render_lanes.clone();
|
||||
Ok(ResolvedScenarioPlan {
|
||||
representation,
|
||||
owner,
|
||||
lanes: all_lanes,
|
||||
plan,
|
||||
latent_messages,
|
||||
bounds,
|
||||
scene,
|
||||
})
|
||||
}
|
||||
|
||||
fn structural_bounds(scene: &Scene) -> Rectangle {
|
||||
let mut bounds = Rectangle::new(scene.layout_origin, scene.size);
|
||||
for item in &scene.boxes {
|
||||
bounds = union(bounds, item.rect);
|
||||
}
|
||||
for line in &scene.lines {
|
||||
if !line.label.is_empty() {
|
||||
bounds = union(bounds, line.label_bounds());
|
||||
}
|
||||
for point in &line.points {
|
||||
// Arrow wings extend beyond the centerline in either direction.
|
||||
bounds = union(
|
||||
bounds,
|
||||
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for segment in &scene.activations {
|
||||
bounds = union(bounds, segment.rect);
|
||||
}
|
||||
for point in &scene.open_calls {
|
||||
bounds = union(
|
||||
bounds,
|
||||
Rectangle::new(Point::new(point.x - 8., point.y - 8.), Size::new(16., 16.)),
|
||||
);
|
||||
}
|
||||
if let Some(lifetime) = scene.lifecycle.bounds() {
|
||||
bounds = union(bounds, lifetime);
|
||||
}
|
||||
bounds
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
//! Display policy for one requested Scenario representation, without authoring.
|
||||
//! The resolver remains the sole renderer. Freshness and editor admission belong
|
||||
//! to the caller; a capability here never bypasses those guards.
|
||||
use super::{Diagnostic, GeometryIssue, Representation, ResolvedScenarioPlan, resolve};
|
||||
use crate::behavior::Scene;
|
||||
use iced::Rectangle;
|
||||
use oa_core::{DiagramLayout, Id, Kind, OccurrenceBody, Project, SharedBody};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PresentationStatus {
|
||||
Ready,
|
||||
Fallback {
|
||||
requested_issues: Vec<GeometryIssue>,
|
||||
},
|
||||
Unavailable {
|
||||
requested_issues: Vec<GeometryIssue>,
|
||||
automatic_issues: Vec<GeometryIssue>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct PresentationCapabilities {
|
||||
/// Actual geometry exists for inspection, Fit and geometry-based navigation.
|
||||
pub(crate) geometry_navigation: bool,
|
||||
/// Still subject to document, gesture, editor and view-visit admission.
|
||||
pub(crate) coordinate_input: bool,
|
||||
}
|
||||
|
||||
pub(crate) struct ScenarioPresentation {
|
||||
requested: Representation,
|
||||
owner: Option<Id>,
|
||||
status: PresentationStatus,
|
||||
geometry: Option<ResolvedScenarioPlan>,
|
||||
// Saved metadata must survive a complete automatic fallback.
|
||||
latent_messages: Vec<Id>,
|
||||
}
|
||||
|
||||
impl ScenarioPresentation {
|
||||
pub(crate) fn requested(&self) -> Representation {
|
||||
self.requested
|
||||
}
|
||||
pub(crate) fn owner(&self) -> Option<Id> {
|
||||
self.owner
|
||||
}
|
||||
pub(crate) fn status(&self) -> &PresentationStatus {
|
||||
&self.status
|
||||
}
|
||||
pub(crate) fn geometry_representation(&self) -> Option<Representation> {
|
||||
self.geometry
|
||||
.as_ref()
|
||||
.map(|geometry| geometry.representation)
|
||||
}
|
||||
pub(crate) fn bounds(&self) -> Option<Rectangle> {
|
||||
self.geometry.as_ref().map(|geometry| geometry.bounds)
|
||||
}
|
||||
/// Diagnostics describe the actual displayed geometry. Requested hard failures
|
||||
/// are retained separately in status(), never presented as automatic warnings.
|
||||
pub(crate) fn diagnostics(&self) -> &[Diagnostic] {
|
||||
self.geometry
|
||||
.as_ref()
|
||||
.map_or(&[], |geometry| &geometry.plan.diagnostics)
|
||||
}
|
||||
pub(crate) fn latent_messages(&self) -> &[Id] {
|
||||
&self.latent_messages
|
||||
}
|
||||
pub(crate) fn capabilities(&self) -> PresentationCapabilities {
|
||||
PresentationCapabilities {
|
||||
geometry_navigation: self.geometry.is_some(),
|
||||
coordinate_input: matches!(self.status, PresentationStatus::Ready),
|
||||
}
|
||||
}
|
||||
pub(in crate::behavior) fn scene(&self) -> Option<&Scene> {
|
||||
self.geometry.as_ref().map(|geometry| &geometry.scene)
|
||||
}
|
||||
pub(in crate::behavior) fn into_scene(self) -> Option<Scene> {
|
||||
self.geometry.map(|geometry| geometry.scene)
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a structurally accepted Project for passive display. This is not a
|
||||
/// second model validator: identity and resolver-reported structural failures
|
||||
/// cannot be converted into a more permissive automatic representation.
|
||||
pub(crate) fn present(
|
||||
project: &Project,
|
||||
requested: Representation,
|
||||
selected: Id,
|
||||
) -> ScenarioPresentation {
|
||||
let owner = checked_owner(project, requested);
|
||||
let Some(owner_id) = owner else {
|
||||
let id = match requested {
|
||||
Representation::Derived { owner } => owner,
|
||||
Representation::Saved { diagram } => diagram,
|
||||
};
|
||||
return ScenarioPresentation {
|
||||
requested,
|
||||
owner: None,
|
||||
geometry: None,
|
||||
latent_messages: vec![],
|
||||
status: PresentationStatus::Unavailable {
|
||||
requested_issues: vec![GeometryIssue::InvalidRepresentation(id)],
|
||||
automatic_issues: vec![],
|
||||
},
|
||||
};
|
||||
};
|
||||
match resolve(project, requested, selected) {
|
||||
Ok(geometry) => ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
status: PresentationStatus::Ready,
|
||||
latent_messages: geometry.latent_messages.clone(),
|
||||
geometry: Some(geometry),
|
||||
},
|
||||
Err(requested_issues) => {
|
||||
if matches!(requested, Representation::Derived { .. })
|
||||
|| requested_issues.iter().any(structural_failure)
|
||||
{
|
||||
return ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
geometry: None,
|
||||
latent_messages: vec![],
|
||||
status: PresentationStatus::Unavailable {
|
||||
requested_issues,
|
||||
automatic_issues: vec![],
|
||||
},
|
||||
};
|
||||
}
|
||||
match resolve(
|
||||
project,
|
||||
Representation::Derived { owner: owner_id },
|
||||
selected,
|
||||
) {
|
||||
Ok(geometry) => {
|
||||
let latent_messages =
|
||||
saved_latent_messages(project, requested, &geometry.scene);
|
||||
ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
status: PresentationStatus::Fallback { requested_issues },
|
||||
geometry: Some(geometry),
|
||||
latent_messages,
|
||||
}
|
||||
}
|
||||
Err(automatic_issues) => ScenarioPresentation {
|
||||
requested,
|
||||
owner,
|
||||
geometry: None,
|
||||
latent_messages: vec![],
|
||||
status: PresentationStatus::Unavailable {
|
||||
requested_issues,
|
||||
automatic_issues,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn checked_owner(project: &Project, requested: Representation) -> Option<Id> {
|
||||
let owner = match requested {
|
||||
Representation::Derived { owner } => owner,
|
||||
Representation::Saved { diagram } => {
|
||||
let view = project.diagrams.get(&diagram)?;
|
||||
let owner = view.projection.scenario()?;
|
||||
if view.id != diagram || project.elements.get(&owner)?.layer() != view.layer {
|
||||
return None;
|
||||
}
|
||||
owner
|
||||
}
|
||||
};
|
||||
project
|
||||
.elements
|
||||
.get(&owner)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario)
|
||||
.then_some(owner)
|
||||
}
|
||||
|
||||
fn structural_failure(issue: &GeometryIssue) -> bool {
|
||||
// These violate the existing identity/address/finite-coordinate admission
|
||||
// contract. Geometry support failures below do not invalidate stored intent.
|
||||
match issue {
|
||||
GeometryIssue::InvalidRepresentation(_)
|
||||
| GeometryIssue::UnknownAnchor(_)
|
||||
| GeometryIssue::InvalidCoordinate(_)
|
||||
| GeometryIssue::DuplicateCoordinate(_)
|
||||
| GeometryIssue::ExpandedAddressNotSupported(_)
|
||||
| GeometryIssue::NonFinite(_) => true,
|
||||
GeometryIssue::LifecycleGeometryNotSupported { .. }
|
||||
| GeometryIssue::CoincidentDistinctEndpoints(_)
|
||||
| GeometryIssue::TypedActivationOutsideScope { .. }
|
||||
| GeometryIssue::TypedActivationOutsideLifetime { .. }
|
||||
| GeometryIssue::TypedActivationCrossingNotSupported { .. }
|
||||
| GeometryIssue::CompoundMoveNotImplemented(_)
|
||||
| GeometryIssue::FixedFrameHeaderWouldMove { .. }
|
||||
| GeometryIssue::OperandScopeOutsideEnclosingInterval { .. }
|
||||
| GeometryIssue::LifecycleAnchorOutsideRenderScope { .. }
|
||||
| GeometryIssue::LifetimeInversionNotSupported { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn saved_latent_messages(project: &Project, requested: Representation, scene: &Scene) -> Vec<Id> {
|
||||
let Representation::Saved { diagram } = requested else {
|
||||
return vec![];
|
||||
};
|
||||
let Some(DiagramLayout::Scenario(layout)) = project.diagrams[&diagram].layout.as_ref() else {
|
||||
return vec![];
|
||||
};
|
||||
let lines: BTreeSet<_> = scene.lines.iter().map(|line| line.id).collect();
|
||||
layout
|
||||
.messages
|
||||
.iter()
|
||||
.filter_map(|message| {
|
||||
let id = message.address.occurrence;
|
||||
(message.address.references.is_empty()
|
||||
&& scene.rows.contains_key(&id)
|
||||
&& !lines.contains(&id)
|
||||
&& matches!(
|
||||
project
|
||||
.elements
|
||||
.get(&id)
|
||||
.and_then(|element| element.shared()),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
})
|
||||
))
|
||||
.then_some(id)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{
|
||||
Command, Diagram, OccurrenceEndpoint, ProjectionKind, ScenarioLanePosition, ScenarioLayout,
|
||||
ScenarioMessagePosition, language, validation,
|
||||
};
|
||||
|
||||
fn fixture() -> (Project, Id) {
|
||||
let project = language::parse(FIXTURE).unwrap();
|
||||
assert_admitted(&project);
|
||||
let owner = named(&project, "conversation");
|
||||
(project, owner)
|
||||
}
|
||||
fn named(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
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 save(project: &mut Project, owner: Id, layout: Option<ScenarioLayout>) -> Id {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: format!("view_{}", project.diagrams.len()),
|
||||
name: "Saved conversation".into(),
|
||||
description: String::new(),
|
||||
layer: project.elements[&owner].layer(),
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: layout.map(DiagramLayout::Scenario),
|
||||
},
|
||||
);
|
||||
id
|
||||
}
|
||||
fn layout(messages: &[(Id, f32)], lanes: &[(Id, f32)]) -> ScenarioLayout {
|
||||
ScenarioLayout {
|
||||
lanes: lanes
|
||||
.iter()
|
||||
.map(|&(lifeline, x)| ScenarioLanePosition { lifeline, x })
|
||||
.collect(),
|
||||
messages: messages
|
||||
.iter()
|
||||
.map(|&(occurrence, send_y)| ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
occurrence,
|
||||
references: vec![],
|
||||
},
|
||||
send_y,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
fn same_geometry(a: &Scene, b: &Scene) {
|
||||
assert!(
|
||||
!a.activations.is_empty(),
|
||||
"fallback must include real typed executions"
|
||||
);
|
||||
assert_eq!(a.activations, b.activations);
|
||||
assert_eq!(a.lifecycle, b.lifecycle);
|
||||
assert_eq!(a.rows, b.rows);
|
||||
assert_eq!(a.sequence_bounds, b.sequence_bounds);
|
||||
assert_eq!(a.render_lanes, b.render_lanes);
|
||||
assert_eq!(a.lanes, b.lanes);
|
||||
assert_eq!(a.open_calls, b.open_calls);
|
||||
assert_eq!((a.size, a.layout_origin), (b.size, b.layout_origin));
|
||||
let boxes = |scene: &Scene| {
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.map(|item| {
|
||||
(
|
||||
item.id,
|
||||
item.rect,
|
||||
item.title.clone(),
|
||||
item.detail.clone(),
|
||||
item.marker,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let lines = |scene: &Scene| {
|
||||
scene
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
(
|
||||
line.id,
|
||||
line.points.clone(),
|
||||
line.label.clone(),
|
||||
line.label_at,
|
||||
line.dashed,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let slots = |scene: &Scene| {
|
||||
scene
|
||||
.slots
|
||||
.iter()
|
||||
.map(|slot| {
|
||||
(
|
||||
slot.slot.parent,
|
||||
slot.slot.before,
|
||||
slot.y,
|
||||
slot.label.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(boxes(a), boxes(b));
|
||||
assert_eq!(lines(a), lines(b));
|
||||
assert_eq!(slots(a), slots(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_saved_layouts_remain_saved_ready_representations() {
|
||||
let (mut project, owner) = fixture();
|
||||
let absent = save(&mut project, owner, None);
|
||||
let empty = save(&mut project, owner, Some(ScenarioLayout::default()));
|
||||
assert_admitted(&project);
|
||||
let automatic = present(&project, Representation::Derived { owner }, owner);
|
||||
assert_eq!(automatic.status(), &PresentationStatus::Ready);
|
||||
for diagram in [absent, empty] {
|
||||
let requested = Representation::Saved { diagram };
|
||||
let displayed = present(&project, requested, owner);
|
||||
assert_eq!(displayed.requested(), requested);
|
||||
assert_eq!(displayed.geometry_representation(), Some(requested));
|
||||
assert_eq!(displayed.owner(), Some(owner));
|
||||
assert_eq!(displayed.status(), &PresentationStatus::Ready);
|
||||
assert!(displayed.capabilities().coordinate_input);
|
||||
assert_eq!(displayed.bounds(), automatic.bounds());
|
||||
same_geometry(displayed.scene().unwrap(), automatic.scene().unwrap());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_crossing_diagnostic_keeps_authored_geometry_and_latent_identity() {
|
||||
let (mut project, owner) = fixture();
|
||||
let first = named(&project, "first");
|
||||
let second = named(&project, "second");
|
||||
let left = named(&project, "left");
|
||||
let unfinished = named(&project, "unfinished");
|
||||
let diagram = save(
|
||||
&mut project,
|
||||
owner,
|
||||
Some(layout(
|
||||
&[(first, 1000.), (unfinished, 2000.)],
|
||||
&[(left, 900.)],
|
||||
)),
|
||||
);
|
||||
assert_admitted(&project);
|
||||
let shown = present(&project, Representation::Saved { diagram }, first);
|
||||
assert_eq!(shown.status(), &PresentationStatus::Ready);
|
||||
assert_eq!(shown.scene().unwrap().rows[&first].1, 1000.);
|
||||
assert_eq!(shown.scene().unwrap().rows[&second].1, 240.);
|
||||
assert_eq!(shown.scene().unwrap().render_lanes[&left], 900.);
|
||||
assert!(
|
||||
shown
|
||||
.diagnostics()
|
||||
.contains(&Diagnostic::SourceOrderCrossing {
|
||||
scope: owner,
|
||||
before: first,
|
||||
after: second
|
||||
})
|
||||
);
|
||||
assert_eq!(shown.latent_messages(), &[unfinished]);
|
||||
assert_eq!(shown.scene().unwrap().rows[&unfinished].1, 516.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hard_failure_uses_complete_automatic_geometry_but_retains_each_saved_request_and_latent_notice()
|
||||
{
|
||||
let (mut project, owner) = fixture();
|
||||
let call = named(&project, "call");
|
||||
let reply = named(&project, "reply");
|
||||
let unfinished = named(&project, "unfinished");
|
||||
let left = named(&project, "left");
|
||||
let right = named(&project, "right");
|
||||
let authored = layout(
|
||||
&[(reply, 300.), (unfinished, 2000.)],
|
||||
&[(left, 900.), (right, -500.)],
|
||||
);
|
||||
let a = save(&mut project, owner, Some(authored.clone()));
|
||||
let b = save(&mut project, owner, Some(authored));
|
||||
assert_admitted(&project);
|
||||
let before = project.clone();
|
||||
let automatic = present(&project, Representation::Derived { owner }, call);
|
||||
let control = automatic.scene().unwrap();
|
||||
assert_eq!(control.render_lanes[&left], 160.);
|
||||
assert_eq!(control.render_lanes[&right], 400.);
|
||||
assert_eq!(control.rows[&reply].1, 424.);
|
||||
assert_eq!(control.activations.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
control.activations[0].rect.y,
|
||||
control.activations[0].rect.height
|
||||
),
|
||||
(356., 68.)
|
||||
);
|
||||
for diagram in [a, b] {
|
||||
let request = Representation::Saved { diagram };
|
||||
let shown = present(&project, request, call);
|
||||
assert_eq!(shown.requested(), request);
|
||||
assert_eq!(
|
||||
shown.geometry_representation(),
|
||||
Some(Representation::Derived { owner })
|
||||
);
|
||||
assert_eq!(
|
||||
shown.status(),
|
||||
&PresentationStatus::Fallback {
|
||||
requested_issues: vec![GeometryIssue::TypedActivationCrossingNotSupported {
|
||||
call,
|
||||
event: reply
|
||||
}],
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
shown.capabilities(),
|
||||
PresentationCapabilities {
|
||||
geometry_navigation: true,
|
||||
coordinate_input: false
|
||||
}
|
||||
);
|
||||
assert_eq!(shown.latent_messages(), &[unfinished]);
|
||||
assert!(shown.diagnostics().is_empty());
|
||||
assert_eq!(shown.bounds(), automatic.bounds());
|
||||
same_geometry(shown.scene().unwrap(), control);
|
||||
}
|
||||
assert_eq!(project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_or_incompatible_identity_and_structural_coordinate_errors_are_unavailable() {
|
||||
let (mut project, owner) = fixture();
|
||||
let left = named(&project, "left");
|
||||
let diagram = save(&mut project, owner, None);
|
||||
let missing = Id::new_v4();
|
||||
for request in [
|
||||
Representation::Saved { diagram: missing },
|
||||
Representation::Derived { owner: left },
|
||||
] {
|
||||
let shown = present(&project, request, owner);
|
||||
assert_eq!(shown.requested(), request);
|
||||
assert_eq!(shown.owner(), None);
|
||||
assert!(
|
||||
matches!(shown.status(), PresentationStatus::Unavailable { automatic_issues, .. } if automatic_issues.is_empty())
|
||||
);
|
||||
assert!(shown.scene().is_none() && shown.bounds().is_none());
|
||||
assert_eq!(
|
||||
shown.capabilities(),
|
||||
PresentationCapabilities {
|
||||
geometry_navigation: false,
|
||||
coordinate_input: false
|
||||
}
|
||||
);
|
||||
}
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
ProjectionKind::Scenario { scenario: missing };
|
||||
assert!(matches!(
|
||||
present(&project, Representation::Saved { diagram }, owner).status(),
|
||||
PresentationStatus::Unavailable { .. }
|
||||
));
|
||||
// Deliberately invalid-model control, not a warning-admitted geometry failure.
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
ProjectionKind::Scenario { scenario: owner };
|
||||
project.diagrams.get_mut(&diagram).unwrap().layout = Some(DiagramLayout::Scenario(layout(
|
||||
&[],
|
||||
&[(left, oa_core::MAX_COORDINATE + 1.)],
|
||||
)));
|
||||
assert!(
|
||||
validation::validate(&project)
|
||||
.iter()
|
||||
.any(|issue| issue.severity == validation::Severity::Error)
|
||||
);
|
||||
let before = project.clone();
|
||||
let shown = present(&project, Representation::Saved { diagram }, owner);
|
||||
assert!(
|
||||
matches!(shown.status(), PresentationStatus::Unavailable { requested_issues, automatic_issues }
|
||||
if requested_issues == &[GeometryIssue::InvalidCoordinate(left)] && automatic_issues.is_empty())
|
||||
);
|
||||
assert!(shown.scene().is_none());
|
||||
assert_eq!(project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_fallback_preserves_exact_document_source_and_one_step_undo_redo() {
|
||||
let (mut project, owner) = fixture();
|
||||
let first = named(&project, "first");
|
||||
let reply = named(&project, "reply");
|
||||
let view = save(&mut project, owner, Some(layout(&[(reply, 300.)], &[])));
|
||||
assert_admitted(&project);
|
||||
let mut document = Document::from_project(project, Default::default()).unwrap();
|
||||
let initial_project = document.project().clone();
|
||||
let initial_source = document.bundle().clone();
|
||||
document
|
||||
.execute(Command::Rename(first, "An intentional prior edit".into()))
|
||||
.unwrap();
|
||||
let accepted_project = document.project().clone();
|
||||
let accepted_source = document.bundle().clone();
|
||||
let revision = document.revision();
|
||||
{
|
||||
let shown = present(
|
||||
document.project(),
|
||||
Representation::Saved { diagram: view },
|
||||
first,
|
||||
);
|
||||
assert!(matches!(
|
||||
shown.status(),
|
||||
PresentationStatus::Fallback { .. }
|
||||
));
|
||||
assert!(shown.scene().is_some());
|
||||
}
|
||||
assert_eq!(document.project(), &accepted_project);
|
||||
assert_eq!(document.bundle(), &accepted_source);
|
||||
assert_eq!(document.revision(), revision);
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.project(), &initial_project);
|
||||
assert_eq!(document.bundle(), &initial_source);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.project(), &accepted_project);
|
||||
assert_eq!(document.bundle(), &accepted_source);
|
||||
}
|
||||
|
||||
const FIXTURE: &str = r#"arcadia 7;
|
||||
project display_probe "Scenario display probe" id "70000000-0000-4000-8000-000000000001" {
|
||||
oa_activity source "Source" id "70000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity target "Target" id "70000000-0000-4000-8000-000000000003" {}
|
||||
interaction exchange "Repeated exchange" id "70000000-0000-4000-8000-000000000004" { source @source; target @target; }
|
||||
oa_scenario conversation "Conversation" id "70000000-0000-4000-8000-000000000005" { viewpoint OperationalActivity; steps [@first, @second, @call, @reply, @unfinished]; }
|
||||
oa_lifeline left "Left" id "70000000-0000-4000-8000-000000000006" { parent @conversation; represented @source; }
|
||||
oa_lifeline right "Right" id "70000000-0000-4000-8000-000000000007" { parent @conversation; represented @target; }
|
||||
oa_occurrence first "First" id "70000000-0000-4000-8000-000000000008" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence second "Second" id "70000000-0000-4000-8000-000000000009" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver @right; message_kind Asynchronous; }; }; }
|
||||
oa_occurrence call "Call" id "70000000-0000-4000-8000-00000000000a" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Call; }; }; }
|
||||
oa_occurrence reply "Reply" id "70000000-0000-4000-8000-00000000000b" { parent @conversation; body { Exchange { exchange none; sender @right; receiver @right; message_kind Reply; reply_to @call; }; }; }
|
||||
oa_occurrence unfinished "Unfinished" id "70000000-0000-4000-8000-00000000000c" { parent @conversation; body { Exchange { exchange @exchange; sender @left; receiver none; message_kind Asynchronous; }; }; }
|
||||
}
|
||||
"#;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,7 +31,6 @@ struct EventMarker {
|
||||
capsule: Rectangle,
|
||||
name: String,
|
||||
sender_x: Option<f32>,
|
||||
self_message: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
@@ -90,7 +89,7 @@ impl Rendering {
|
||||
continue;
|
||||
};
|
||||
for event in &lane.markers {
|
||||
let sender_id = project
|
||||
let sender_x = project
|
||||
.elements
|
||||
.get(&event.occurrence)
|
||||
.and_then(|e| e.shared())
|
||||
@@ -102,7 +101,7 @@ impl Rendering {
|
||||
..
|
||||
},
|
||||
..
|
||||
} => Some(*sender),
|
||||
} => lanes.get(sender).copied(),
|
||||
_ => None,
|
||||
});
|
||||
markers.push(EventMarker {
|
||||
@@ -114,8 +113,7 @@ impl Rendering {
|
||||
Size::new(140., 28.),
|
||||
),
|
||||
name: project.named(lane.lifeline).to_owned(),
|
||||
sender_x: sender_id.and_then(|id| lanes.get(&id).copied()),
|
||||
self_message: sender_id == Some(lane.lifeline),
|
||||
sender_x,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -140,7 +138,7 @@ impl Rendering {
|
||||
};
|
||||
let x = marker.center.x;
|
||||
let y = marker.center.y;
|
||||
let self_message = marker.self_message;
|
||||
let self_message = sender == x;
|
||||
let end_x = match marker.event.kind {
|
||||
MarkerKind::Birth if sender < x => marker.capsule.x,
|
||||
MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
|
||||
@@ -168,48 +166,6 @@ impl Rendering {
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometry loss is distinct from warning-admitted semantic uncertainty.
|
||||
/// The saved bridge compares these exact projector diagnostics with baseline.
|
||||
pub(in crate::behavior) fn missing_geometry(&self) -> std::collections::BTreeSet<(Id, Id)> {
|
||||
self.lifetimes
|
||||
.iter()
|
||||
.flat_map(|lane| {
|
||||
lane.uncertainties
|
||||
.iter()
|
||||
.filter(|issue| issue.reason == super::UncertaintyReason::MissingGeometry)
|
||||
.map(|issue| (lane.lifeline, issue.scope))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Complete structural lifetime bounds, including the birth capsule and death mark.
|
||||
pub(in crate::behavior) fn bounds(&self) -> Option<Rectangle> {
|
||||
let mut bounds = self.span.and_then(|span| {
|
||||
self.lanes
|
||||
.values()
|
||||
.map(|x| {
|
||||
Rectangle::new(
|
||||
Point::new(*x, span.start),
|
||||
Size::new(0., span.end - span.start),
|
||||
)
|
||||
})
|
||||
.reduce(crate::behavior::scenario_layout::union)
|
||||
});
|
||||
for marker in &self.markers {
|
||||
let rect = match marker.event.kind {
|
||||
MarkerKind::Birth => marker.capsule,
|
||||
MarkerKind::Death => Rectangle::new(
|
||||
Point::new(marker.center.x - 10., marker.center.y - 10.),
|
||||
Size::new(20., 20.),
|
||||
),
|
||||
};
|
||||
bounds = Some(bounds.map_or(rect, |old| {
|
||||
crate::behavior::scenario_layout::union(old, rect)
|
||||
}));
|
||||
}
|
||||
bounds
|
||||
}
|
||||
|
||||
pub(in crate::behavior) fn alive_spans(
|
||||
&self,
|
||||
lifeline: Id,
|
||||
@@ -284,9 +240,9 @@ impl Rendering {
|
||||
..Stroke::default()
|
||||
.with_width(1.)
|
||||
.with_color(if id == selected {
|
||||
Color::from_rgb8(148, 190, 187)
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::from_rgb8(214, 222, 222)
|
||||
style::BOUNDARY
|
||||
})
|
||||
},
|
||||
);
|
||||
@@ -306,7 +262,7 @@ impl Rendering {
|
||||
if lifetime.lifeline == selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
Color::from_rgb8(91, 113, 113)
|
||||
style::BOUNDARY
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -205,7 +205,7 @@ fn real_scene_long_header_title_and_detail_keep_names_and_have_bounded_clips() {
|
||||
f.project.elements.get_mut(&f.work).unwrap().name = name.clone();
|
||||
let scene = f.scene();
|
||||
let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
|
||||
assert_eq!(header.title, format!("Lane · {name}"));
|
||||
assert_eq!(header.title, name);
|
||||
assert_eq!(header.detail, name);
|
||||
assert_eq!(header.rect.size(), Size::new(204., 70.));
|
||||
assert_eq!(scene.hit(header.rect.center()), Some(f.receiver));
|
||||
|
||||
@@ -443,8 +443,8 @@ fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without
|
||||
let birth = f.message(f.owner, MessageKind::Create);
|
||||
let c1 = f.message(f.owner, MessageKind::Call);
|
||||
let c2 = f.message(f.owner, MessageKind::Call);
|
||||
let r2 = f.reply(f.owner, c2);
|
||||
let r1 = f.reply(f.owner, c1);
|
||||
f.reply(f.owner, c2);
|
||||
f.reply(f.owner, c1);
|
||||
let death = f.message(f.owner, MessageKind::Delete);
|
||||
let document = f.document();
|
||||
let before = document.snapshot();
|
||||
@@ -454,15 +454,6 @@ fn test_only_upstream_row_offsets_update_lifetime_segments_and_endpoints_without
|
||||
});
|
||||
assert_eq!(moved.rows[&birth].1, baseline.rows[&birth].1 + 60.);
|
||||
assert_eq!(moved.rows[&death].1, baseline.rows[&death].1 + 60.);
|
||||
for scene in [&baseline, &moved] {
|
||||
assert_eq!(scene.activations.len(), 2);
|
||||
for (call, reply) in [(c1, r1), (c2, r2)] {
|
||||
assert!(scene.activations.iter().any(|segment| {
|
||||
segment.call == call
|
||||
&& segment.finish == Finish::Reply(reply)
|
||||
}));
|
||||
}
|
||||
}
|
||||
for (a, b) in baseline.activations.iter().zip(&moved.activations) {
|
||||
assert_eq!(
|
||||
(a.call, a.receiver, a.scope, a.column, a.finish),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user