Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu 3753b4fd05 Sync agent instructions from main 2026-09-13 11:40:47 +03:00
sorokin.oleg.edu b4b860f277 Record Text workspace publication and exact Git readback 2026-09-12 22:05:07 -07:00
sorokin.oleg.edu 2df7f580aa Make the Text workspace compact and preserve editing context
Replace the oversized header and reference rail with stable editor slots and a retained supporting drawer. Bind diagnostic, result and menu actions to their original publication and visit; preserve effective selections on navigation and dismiss Actions, Find and the drawer one surface per Escape.

Retain exact Windows source captures, eight final gates (871 desktop passes), independent reviews, native selection/rename/recovery trials and the original failures. All 526 tested inputs and staged evidence were byte-checked before commit. Combined preview integration and packaging remain with issue 27; Linux remains paused.
2026-09-12 22:00:36 -07:00
sorokin.oleg.edu 6a244fb21c Preserve live Text selection when restoring Find focus
Keep existing reveal tickets and raw-input guards while separating focus restoration from cursor movement. This separable production patch is extracted from the Text workspace union verified by mounted red/green, full Windows gates and native replacement trials. The CBC-only intermediate tree was not separately built; the following contribution records exact tested union inputs and four-test integration guidance.
2026-09-12 21:54:16 -07:00
sorokin.oleg.edu cbc49a7802 Reset alias preview scroll for each explicit destination 2026-09-12 15:51:09 -07:00
sorokin.oleg.edu 4686d05003 Record completed native Text alias rename verification 2026-09-12 15:37:10 -07:00
sorokin.oleg.edu dfb6102ba5 Add guarded Text alias rename with complete preview and editor reuse 2026-09-12 15:01:08 -07:00
sorokin.oleg.edu be7a8cd8d7 Publish verified navigation Windows prerelease checkpoint 2026-09-12 03:58:54 -07:00
sorokin.oleg.edu f5e1c734da Record independent package and native Windows navigation acceptance 2026-09-12 03:51:26 -07:00
sorokin.oleg.edu 9613cf71aa Sync agent instructions from main 2026-09-12 13:43:44 +03:00
sorokin.oleg.edu 08350bb3f4 Sync agent instructions from main 2026-09-12 13:40:27 +03:00
sorokin.oleg.edu 36c105f3cd Sync agent instructions from main 2026-09-12 13:16:07 +03:00
sorokin.oleg.edu c5ea82ed50 Preserve navigation Windows package preparation and exact source provenance 2026-09-11 14:24:43 -07:00
sorokin.oleg.edu 8cf64cedb2 Verify repair and recovery save state in native Windows workflows 2026-09-11 14:12:09 -07:00
sorokin.oleg.edu 33c6d83e9b Bind repaired source save state to its actual destination 2026-09-11 13:43:37 -07:00
sorokin.oleg.edu 1e63dd69ed Choose guarded worker preparation for Text alias rename 2026-09-11 13:42:07 -07:00
sorokin.oleg.edu 00a21d3322 Record corrected navigation trial and alias transaction measurements 2026-09-11 13:34:48 -07:00
sorokin.oleg.edu eeeaa12627 Fix native Text navigation layout and focus regressions 2026-09-11 12:44:17 -07:00
sorokin.oleg.edu f4e426ae2a Preserve preliminary alias-rename preview timing and unchanged inputs 2026-09-11 12:14:38 -07:00
sorokin.oleg.edu fb16c4200f Record Text alias rename proposal and updated navigation acceptance scope 2026-09-11 12:08:32 -07:00
sorokin.oleg.edu 2dedde29e9 Preserve initial navigation native trial and confirmed interaction defects 2026-09-11 12:07:30 -07:00
sorokin.oleg.edu 8e00d3ce4f Record matching navigation GUI build and focused coordination entrypoints 2026-09-11 11:34:00 -07:00
sorokin.oleg.edu 36db9356cc Add bounded definition and reference navigation for current source drafts
Preserve loaded draft bytes and model history, reject stale workspace and input events, and keep captured Escape under modal priority. Verified 181 focused Windows tests and strict desktop Clippy on exact inputs4; earlier full737 scope and component/native preparation evidence are archived separately. Native navigation acceptance remains pending.
2026-09-11 11:23:20 -07:00
sorokin.oleg.edu fb24def2f5 Record verified contextual Windows prerelease and Gitea handoff 2026-09-11 10:14:53 -07:00
sorokin.oleg.edu b70bf1df45 Sync agent instructions from main 2026-09-11 19:52:14 +03:00
sorokin.oleg.edu b2c3e54001 Verify Windows contextual authoring and preserve postboot evidence 2026-09-11 09:04:30 -07:00
sorokin.oleg.edu ca53f574e5 Save reboot checkpoint with source layout WIP and native evidence 2026-09-11 06:42:46 -07:00
sorokin.oleg.edu c4bad4b8de Preserve complete OA scenario native history and reopen evidence 2026-09-11 06:26:18 -07:00
sorokin.oleg.edu d95b1ab178 Preserve published standalone CLI release records 2026-09-11 06:20:22 -07:00
sorokin.oleg.edu df6f595626 Stage explicit prerequisite sources inside behavior creation forms 2026-09-11 06:09:29 -07:00
sorokin.oleg.edu 07eb3cd12b Sync agent instructions from main 2026-09-11 15:55:38 +03:00
sorokin.oleg.edu 27df4a3945 Package standalone source CLI with verified runtime and notices 2026-09-11 05:48:05 -07:00
sorokin.oleg.edu 6a610616e7 Publish verified Windows authoring prerelease evidence 2026-09-11 05:43:28 -07:00
sorokin.oleg.edu 01b76a1e89 Add source-first CLI with guarded writes and reviewed evidence 2026-09-11 05:14:13 -07:00
sorokin.oleg.edu 2ec72d4241 Verify native behavior creation recovery and source authoring contracts 2026-09-11 04:21:56 -07:00
sorokin.oleg.edu 4bb4aaf3dc Add named behavior creation and verified textual authoring examples 2026-09-11 01:54:55 -07:00
sorokin.oleg.edu d114a271c5 Refresh scenario viewport measurement when its identity changes 2026-09-11 01:21:24 -07:00
sorokin.oleg.edu 3978bbe23c Keep scenario authoring visible and restore exact caller context 2026-09-10 12:03:17 -07:00
Codex 81668f94ed Seed optional history values from explicit constructors 2026-09-10 11:52:36 -07:00
sorokin.oleg.edu ee97fcb1e3 Record updated usability objective and integration evidence 2026-09-10 11:40:45 -07:00
sorokin.oleg.edu b98f135799 Add portable Linux packaging and verify distribution notices 2026-09-10 11:38:10 -07:00
sorokin.oleg.edu a70de55ecd Integrate nullable stored-history constructor correction 2026-09-10 11:25:59 -07:00
sorokin.oleg.edu 31b7dc3e15 Restore inline naming focus after recovery 2026-09-10 11:25:06 -07:00
sorokin.oleg.edu 057edddef8 Integrate process, scenario and typed history authoring checkpoint 2026-09-10 11:14:43 -07:00
sorokin.oleg.edu b24a97360f Record release evidence and coordinated integration work 2026-09-10 11:13:11 -07:00
sorokin.oleg.edu 7d1df4f369 Fix inline architecture naming and captured Escape cancellation
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
2026-09-10 11:10:24 -07:00
4039 changed files with 364959 additions and 525507 deletions
+53 -53
View File
@@ -15,77 +15,77 @@
# Exact source/recovery oracles and archived ledgers retain their measured bytes.
# Keep ordinary text diffs; only disable checkout/check-in newline conversion.
PROJECT_GOAL.md -text whitespace=cr-at-eol
harness/fixtures/release1-native/** -text
harness/fixtures/interface-operation-probe/** -text
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
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/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-context-20260911/** -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/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/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
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-native-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-reopen-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-native-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-reboot-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-layout-reboot-20260911/run-gates2.ps1 whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
harness/experiments/scenario-native-local-20260911/** -text whitespace=cr-at-eol
harness/experiments/scenario-native-local-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
harness/experiments/scenario-native-local-20260911/**/*.arcadia whitespace=-blank-at-eol,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.
harness/experiments/wsl-capacity-20260911/** -text whitespace=cr-at-eol
harness/experiments/wsl-postboot-9e8r6t-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-postboot-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-verified-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-layout-full-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
scripts/windows-notices/Fira-OFL.txt -text
scripts/windows-notices/FiraSans-Regular-NOTICE.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
# Preserve the pinned Iced dependency bytes and upstream manifest.
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-recovery-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-machine-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-lifeline-v2-20260911/** -text whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-lifeline-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-machine-v2-20260911/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-source-reopen-20260911/launch-method.ps1 whitespace=-blank-at-eof,cr-at-eol
# Raw compact.exe output includes CR-CR-LF and incidental trailing spaces.
harness/experiments/wsl-capacity-20260911/*.log -whitespace
harness/experiments/contextual-prerelease-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-prerelease-publication-20260911/** -text whitespace=cr-at-eol
harness/history/2026-09-11-state-before-gitea-migration/** -text whitespace=cr-at-eol
harness/experiments/contextual-prerelease-20260911/native/**/*.arcadia whitespace=-blank-at-eol,-blank-at-eof,cr-at-eol
harness/experiments/contextual-prerelease-20260911/producer/package*.log whitespace=-blank-at-eof,cr-at-eol
harness/experiments/contextual-prerelease-20260911/producer/source-push.log -whitespace
harness/experiments/source-alias-rename-native-20260912/** -text whitespace=cr-at-eol
harness/experiments/source-alias-rename-completed-20260912/** -text whitespace=cr-at-eol
harness/experiments/source-alias-rename-refresh-20260912/** -text whitespace=cr-at-eol
harness/experiments/source-alias-rename-native-closure-20260912/** -text whitespace=cr-at-eol
# Preserve Text-workspace input, native and review evidence at its recorded bytes.
harness/experiments/text-workspace-initial-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-workspace-initial-review-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-find-selection-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-workspace-native2-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-workspace-followup-review-20260913/** -text whitespace=cr-at-eol
harness/experiments/text-workspace-native3-20260913/** -text whitespace=cr-at-eol
# These checksum-bound baseline/expected fixtures deliberately end with blank lines.
harness/experiments/text-workspace-native2-20260913/fixtures/**/*.arcadia whitespace=-blank-at-eof,cr-at-eol
# Captured process streams retain intentional blank lines and diagnostic spacing.
harness/experiments/text-find-selection-20260913/**/*.log -whitespace
harness/experiments/text-workspace-initial-20260913/**/*.log -whitespace
harness/experiments/text-workspace-native2-20260913/**/*.log -whitespace
harness/experiments/text-workspace-native3-20260913/**/*.log -whitespace
Generated
+2 -23
View File
@@ -1567,6 +1567,8 @@ 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",
@@ -2105,25 +2107,6 @@ 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"
@@ -2170,9 +2153,6 @@ name = "oa-core"
version = "0.2.0"
dependencies = [
"libc",
"num-bigint",
"num-integer",
"num-traits",
"same-file",
"serde",
"serde_json",
@@ -2196,7 +2176,6 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"unicode-segmentation",
]
[[package]]
-4
View File
@@ -1,11 +1,7 @@
[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"
+2 -2
View File
@@ -222,8 +222,8 @@ pub fn prepare(entry: &Path, operation: Operation) -> Result<Prepared, Box<Repor
Box::new(report.clone())
})?;
let raw = snapshot.reachable_bundle();
// Keep discovery/IO failures that lint cannot recover from the bundle alone.
// Recompute source diagnostics below, against the same original bytes.
// The loader may include warnings from stabilized compilation. Only its
// discovery/IO diagnostics are authoritative on the original bytes.
let mut diagnostics: Vec<_> = snapshot
.diagnostics
.iter()
-3
View File
@@ -12,9 +12,6 @@ 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"
@@ -1,27 +0,0 @@
//! Read and validate a saved acceptance fixture without rewriting its source.
//! cargo run --locked -p oa-core --example attachment_snapshot -- input.arcadia output.json
use oa_core::{persistence, validate_candidate};
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let bytes = serde_json::to_vec_pretty(&project)?;
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.write_all(b"\n")?;
output.sync_all()?;
println!(
"Validated {} objects and {} views; saved a complete Project snapshot",
project.identities().len(),
project.diagrams.len()
);
Ok(())
}
-103
View File
@@ -1,103 +0,0 @@
//! 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;
@@ -1,107 +0,0 @@
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
}
}
@@ -1,469 +0,0 @@
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)
}
@@ -1,32 +0,0 @@
[
{
"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"
}
]
@@ -1,240 +0,0 @@
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)
}
@@ -1,378 +0,0 @@
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(),
))
}
}
@@ -1,804 +0,0 @@
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());
}
+15 -49
View File
@@ -3,8 +3,6 @@
mod semantic_source;
#[path = "migration_v2/source.rs"]
mod v2_source;
pub(crate) use crate::source_defaults::Mode as DeclarationFormat;
use crate::source_defaults::{self, Context as DefaultContext};
use crate::*;
use serde_json::{Map, Value};
use std::{collections::BTreeMap, fmt};
@@ -647,34 +645,25 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
source.len()
}
/// Canonical output uses persistent aliases and explicit identities, omitting
/// only values supplied by source defaults. Typed nested records are ordinary
/// language fields; JSON documents are not embedded.
/// Canonical output uses persistent aliases and explicit identities. Typed
/// nested records are ordinary language fields; JSON documents are not embedded.
pub fn format(project: &Project) -> String {
let mut out = std::format!(
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n",
"arcadia {SCHEMA_VERSION};\nproject {} {} id \"{}\" {{\n description {};\n\n",
project.alias,
quote(&project.name),
project.id,
quote(&project.description)
);
if !project.description.is_empty() {
out.push_str(&std::format!(
" description {};\n",
quote(&project.description)
));
}
out.push('\n');
for (_, declaration) in format_declarations(project, DeclarationFormat::Compact) {
for (_, declaration) in format_declarations(project) {
out.push_str(&declaration);
}
out.push_str("}\n");
out
}
/// Shared typed writer for canonical output and lossless editing. Complete edit
/// comparisons retain defaults; emitted declarations and value fragments apply
/// only the omission rules valid in their selected source context.
pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) -> Vec<(Id, String)> {
/// The same typed declaration writer is used by flat and modular source output.
pub(crate) fn format_declarations(project: &Project) -> Vec<(Id, String)> {
let aliases = project
.identities()
.into_iter()
@@ -686,7 +675,6 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
let mut fields = payload(&serde_json::to_value(&e.body).unwrap());
fields.insert("description".into(), Value::String(e.description.clone()));
fields.insert("parent".into(), serde_json::to_value(e.parent).unwrap());
let fields = source_defaults::fields(DefaultContext::Element(e.kind()), &fields, mode);
write_declaration(
&mut out,
&element_word(e.kind(), e.layer()),
@@ -702,7 +690,6 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
let mut out = String::new();
let mut fields = payload(&serde_json::to_value(&r.body).unwrap());
fields.insert("description".into(), Value::String(r.description.clone()));
let fields = source_defaults::fields(DefaultContext::Relation(r.kind()), &fields, mode);
write_declaration(
&mut out,
&relation_word(r.kind()),
@@ -718,7 +705,6 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
let mut out = String::new();
let mut fields = record_fields(d);
fields.remove("nodes");
let fields = source_defaults::fields(DefaultContext::Diagram, &fields, mode);
write_declaration(
&mut out,
"diagram",
@@ -733,7 +719,6 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
let mut out = String::new();
let mut fields = record_fields(n);
fields.insert("diagram".into(), Value::String(d.id.to_string()));
let fields = source_defaults::fields(DefaultContext::Node, &fields, mode);
write_declaration(&mut out, "node", &n.alias, None, n.id, &fields, &aliases);
declarations.push((n.id, out));
}
@@ -746,7 +731,7 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
&a.alias,
Some(&a.name),
a.id,
&source_defaults::fields(DefaultContext::Artifact, &record_fields(a), mode),
&record_fields(a),
&aliases,
);
declarations.push((a.id, out));
@@ -759,7 +744,7 @@ pub(crate) fn format_declarations(project: &Project, mode: DeclarationFormat) ->
&t.alias,
Some(&t.name),
t.id,
&source_defaults::fields(DefaultContext::Trace, &record_fields(t), mode),
&record_fields(t),
&aliases,
);
declarations.push((t.id, out));
@@ -1757,9 +1742,7 @@ pub(crate) fn resolve_declarations(
*viewpoint = ScenarioViewpoint::MixedLegacy;
}
let mut body = serde_json::to_value(initial).unwrap();
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Element(kind))
} else if legacy {
let defaults = if legacy {
crate::persistence::v3::legacy_defaults(kind)
.and_then(|value| value.as_object().cloned())
.unwrap_or_else(|| payload(&body))
@@ -1816,12 +1799,7 @@ pub(crate) fn resolve_declarations(
let mut body =
serde_json::to_value(RelationBody::new(kind, Id::nil(), Id::nil()))
.unwrap();
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Relation(kind))
} else {
payload(&body)
};
let merged = merge_fields(defaults, fields).map_err(&conversion_error)?;
let merged = merge_fields(payload(&body), fields).map_err(&conversion_error)?;
replace_payload(&mut body, merged);
let body = serde_json::from_value(body)
.map_err(|e| conversion_error(e.to_string()))?;
@@ -1846,10 +1824,7 @@ pub(crate) fn resolve_declarations(
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Diagram)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
record.insert("nodes".into(), serde_json::json!({}));
@@ -1864,10 +1839,7 @@ pub(crate) fn resolve_declarations(
return Err(conversion_error("Missing required field 'element'.".into()));
}
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Node)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, false);
let node: NodePlacement = serde_json::from_value(Value::Object(record))
@@ -1879,10 +1851,7 @@ pub(crate) fn resolve_declarations(
return Err(conversion_error("Missing required field 'owner'.".into()));
}
let defaults = serde_json::json!({"description":"","owner":null,"relation":"References","uri":"","revision":null,"fragment":null});
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Artifact)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let artifact = serde_json::from_value(Value::Object(record))
@@ -1898,10 +1867,7 @@ pub(crate) fn resolve_declarations(
}
}
let defaults = serde_json::json!({"description":"","oa":null,"sa":null,"kind":"Refinement","baseline":null});
let defaults = if project.schema_version >= 7 {
source_defaults::parser_fields(DefaultContext::Trace)
} else { defaults.as_object().unwrap().clone() };
let mut record = merge_fields(defaults, fields)
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
let trace = if project.schema_version < 5 {
-2
View File
@@ -1,5 +1,4 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod analysis_numeric;
pub mod data;
pub mod language;
pub mod model;
@@ -11,7 +10,6 @@ pub mod references;
pub mod routing;
pub mod semantic;
pub mod session;
mod source_defaults;
pub mod source_files;
pub mod source_project;
pub mod topology_context;
-34
View File
@@ -1502,45 +1502,11 @@ pub struct Diagram {
#[serde(deny_unknown_fields)]
pub struct EdgeRoute {
pub edge: Id,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_attachment: Option<EdgeAttachment>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_attachment: Option<EdgeAttachment>,
#[serde(default)]
pub waypoints: Vec<Position>,
#[serde(default)]
pub label: Option<EdgeLabel>,
}
/// Presentation preference on one end of a connection. Real ports take precedence.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeAttachment {
pub side: PortAnchorSide,
pub offset: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EdgeEnd {
Source,
Target,
}
impl EdgeRoute {
pub fn attachment(&self, end: EdgeEnd) -> Option<EdgeAttachment> {
match end {
EdgeEnd::Source => self.source_attachment,
EdgeEnd::Target => self.target_attachment,
}
}
pub fn set_attachment(&mut self, end: EdgeEnd, attachment: Option<EdgeAttachment>) {
match end {
EdgeEnd::Source => self.source_attachment = attachment,
EdgeEnd::Target => self.target_attachment = attachment,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct EdgeLabel {
+38 -132
View File
@@ -1,12 +1,7 @@
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
//! callers share this derived geometry for drawing, picking, fitting and export.
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
use crate::{
Diagram, EdgeAttachment, EdgeEnd, Id, PortAnchorSide, Position, Project, Relation,
RelationBody, RelationKind, Size,
};
mod attachments;
pub use attachments::{attachment_from_point, attachment_point, semantic_port_endpoint};
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
use std::fmt;
@@ -46,7 +41,6 @@ pub struct RoutedEdge {
// Derived solver state, never persisted. A preview must inherit the same
// remaining work budget as this edge had in stable-ID settled traversal.
search_budget_before: usize,
attachments: [Option<EdgeAttachment>; 2],
}
#[derive(Debug, Clone, PartialEq)]
@@ -179,7 +173,6 @@ pub fn association_end_geometry(
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RouteWarning {
CoincidentAttachments,
PinInsideObstacle { pin: usize, obstacle: Id },
NoClearPath,
SearchBudgetExhausted,
@@ -188,9 +181,6 @@ pub enum RouteWarning {
impl fmt::Display for RouteWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::CoincidentAttachments => f.write_str(
"The two attachments coincide; move either end to separate the loop tips.",
),
Self::PinInsideObstacle { pin, .. } => {
write!(f, "Bend {} is inside a card or its clearance", pin + 1)
}
@@ -384,14 +374,7 @@ struct Endpoint {
point: Position,
stub: Position,
}
fn endpoints(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
port: Option<Id>,
view: &ProjectedView,
attachment: Option<EdgeAttachment>,
) -> Vec<Endpoint> {
fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Vec<Endpoint> {
if let Some(port) = port.and_then(|id| {
view.ports
.iter()
@@ -406,18 +389,26 @@ fn endpoints(
),
}];
}
if let Some(attachment) = attachment {
return vec![attachments::endpoint(project, diagram, node, attachment)];
}
[
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
let r = Rect::node(node);
let mid = pos((r.left + r.right) / 2.0, (r.top + r.bottom) / 2.0);
vec![
Endpoint {
point: pos(r.left, mid.y),
stub: pos(r.left - PORT_STUB, mid.y),
},
Endpoint {
point: pos(r.right, mid.y),
stub: pos(r.right + PORT_STUB, mid.y),
},
Endpoint {
point: pos(mid.x, r.top),
stub: pos(mid.x, r.top - PORT_STUB),
},
Endpoint {
point: pos(mid.x, r.bottom),
stub: pos(mid.x, r.bottom + PORT_STUB),
},
]
.into_iter()
.map(|side| attachments::endpoint(project, diagram, node, EdgeAttachment { side, offset: 0.5 }))
.collect()
}
fn ancestors(id: Id, nodes: &BTreeMap<Id, &ProjectedNode>, out: &mut BTreeSet<Id>) {
@@ -516,12 +507,6 @@ fn route_diagram_impl(
.is_some_and(|(source, target)| source != edge.source || target != edge.target);
let custom_suspended = promoted;
let route = if promoted { None } else { stored };
let attachments = [EdgeEnd::Source, EdgeEnd::Target].map(|end| {
semantic_port_endpoint(project, edge.id, end)
.is_none()
.then(|| route.and_then(|r| r.attachment(end)))
.flatten()
});
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
@@ -532,10 +517,7 @@ fn route_diagram_impl(
});
if let Some(cached) = cached {
budget = cached.search_budget_before;
if cached.pins == pins
&& cached.attachments == attachments
&& cached.custom_suspended == custom_suspended
{
if cached.pins == pins && cached.custom_suspended == custom_suspended {
// Label-only editing cannot change the connection beneath the
// pointer, including a settled fallback with routing warnings.
for &point in &cached.points {
@@ -561,22 +543,9 @@ fn route_diagram_impl(
}
let (points, pin_lengths, failure) = route_edge(
edge,
&endpoints(
project,
diagram,
source,
semantic_port_endpoint(project, edge.id, EdgeEnd::Source).or(edge.source_port),
view,
attachments[0],
),
&endpoints(
project,
diagram,
target,
semantic_port_endpoint(project, edge.id, EdgeEnd::Target).or(edge.target_port),
view,
attachments[1],
),
source,
target,
view,
&pins,
&obstacles,
&previous,
@@ -605,7 +574,6 @@ fn route_diagram_impl(
warnings,
custom_suspended,
search_budget_before,
attachments,
},
);
}
@@ -711,46 +679,6 @@ struct RoutingSpace<'a> {
xs: Vec<f32>,
ys: Vec<f32>,
}
fn coincident_detour(a: Endpoint, obstacles: &[Rect]) -> [Position; 3] {
let direction = |delta: f32| if delta == 0. { 0. } else { delta.signum() };
let normal = pos(
direction(a.stub.x - a.point.x),
direction(a.stub.y - a.point.y),
);
let tangent = pos(-normal.y, normal.x);
let candidates: Vec<_> = [36., 72., 144., 288.]
.into_iter()
.flat_map(|extent| {
[-1., 1.].into_iter().map(move |side| {
[
pos(a.stub.x + normal.x * extent, a.stub.y + normal.y * extent),
pos(
a.stub.x + (normal.x + tangent.x * side) * extent,
a.stub.y + (normal.y + tangent.y * side) * extent,
),
pos(
a.stub.x + tangent.x * side * extent,
a.stub.y + tangent.y * side * extent,
),
]
})
})
.collect();
// These are computed loop guards, not authored hard pins. Prefer a clear
// orientation/size before asking the bounded router to connect them.
candidates
.iter()
.find(|p| clear_path(&[a.stub, p[0], p[1], p[2], a.stub], obstacles))
.or_else(|| {
candidates.iter().find(|p| {
p.iter()
.all(|point| !obstacles.iter().any(|r| r.contains(*point)))
})
})
.copied()
.unwrap_or(candidates[0])
}
impl<'a> RoutingSpace<'a> {
fn new(obstacles: &'a [Rect]) -> Self {
Self {
@@ -764,41 +692,24 @@ impl<'a> RoutingSpace<'a> {
#[allow(clippy::too_many_arguments)]
fn route_edge(
edge: &ProjectedEdge,
source_ends: &[Endpoint],
target_ends: &[Endpoint],
source: &ProjectedNode,
target: &ProjectedNode,
view: &ProjectedView,
pins: &[Position],
obstacles: &[Rect],
previous: &[Segment],
budget: &mut usize,
) -> EdgePath {
// Coincident authored tips are valid presentation, including on self loops.
// Preserve them and add an external detour; never index an empty candidate
// list or turn the connection into an out-and-back degenerate stub.
let coincident = edge.source == edge.target
&& source_ends.len() == 1
&& target_ends.len() == 1
&& source_ends[0].point == target_ends[0].point
&& source_ends[0].stub == target_ends[0].stub;
let authored_pin_count = pins.len();
let mut loop_pins = Vec::new();
if coincident {
let a = source_ends[0];
loop_pins.extend_from_slice(pins);
loop_pins.extend(coincident_detour(a, obstacles));
}
let pins = if coincident { &loop_pins[..] } else { pins };
let space = RoutingSpace::new(obstacles);
let mut candidates = Vec::new();
for &a in source_ends {
for &b in target_ends {
for a in endpoints(source, edge.source_port, view) {
for b in endpoints(target, edge.target_port, view) {
// A real self-relation must leave and return at distinct anchors.
// Reusing one anchor makes the cheapest path a degenerate stub.
if edge.source == edge.target
&& edge.source_port.is_none()
&& edge.target_port.is_none()
&& a.point == b.point
&& a.stub == b.stub
&& !coincident
{
continue;
}
@@ -818,9 +729,10 @@ fn route_edge(
// Try all side combinations cheaply before spending the bounded grid budget.
for search in [false, true] {
for &(estimate, a, b) in &candidates {
if best.as_ref().is_some_and(|(cost, _, _)| {
estimate + manhattan(a.point, a.stub) + manhattan(b.point, b.stub) > *cost
}) {
if best
.as_ref()
.is_some_and(|(cost, _, _)| estimate + 2.0 * PORT_STUB > *cost)
{
continue;
}
if obstacles
@@ -836,7 +748,7 @@ fn route_edge(
let mut occupied = previous.to_vec();
record_segments(&mut occupied, &points);
let mut pin_lengths = Vec::new();
let mut distance = manhattan(a.point, a.stub);
let mut distance = PORT_STUB;
let mut start = a.stub;
let mut failed = false;
for (index, end) in pins
@@ -880,20 +792,15 @@ fn route_edge(
break;
}
}
if let Some((_, points, mut pin_lengths)) = best {
pin_lengths.truncate(authored_pin_count);
return (
points,
pin_lengths,
coincident.then_some(RouteWarning::CoincidentAttachments),
);
if let Some((_, points, pin_lengths)) = best {
return (points, pin_lengths, None);
}
// Recoverable fallback retains every hard pin, in order, and the actual port
// leads. Its warning is deliberately visible to consumers.
let (_, a, b) = candidates[0];
let mut points = vec![a.point, a.stub];
let mut pin_lengths = Vec::new();
let mut distance = manhattan(a.point, a.stub);
let mut distance = PORT_STUB;
let mut start = a.stub;
for (index, end) in pins
.iter()
@@ -926,7 +833,6 @@ fn route_edge(
start = end;
}
points.push(b.point);
pin_lengths.truncate(authored_pin_count);
(
points,
pin_lengths,
-160
View File
@@ -1,160 +0,0 @@
//! One boundary definition shared by persisted routing and attachment gestures.
use super::*;
use crate::{EdgeAttachment, EdgeEnd, Kind, PortAnchorSide};
/// Semantic authority, independent of whether projection currently exposes a port.
pub fn semantic_port_endpoint(project: &Project, edge: Id, end: EdgeEnd) -> Option<Id> {
let endpoint = if let Some(relation) = project.relations.get(&edge) {
match (&relation.body, end) {
(RelationBody::FunctionalExchange { source_port, .. }, EdgeEnd::Source) => *source_port,
(RelationBody::FunctionalExchange { target_port, .. }, EdgeEnd::Target) => *target_port,
(_, EdgeEnd::Source) => relation.source(),
(_, EdgeEnd::Target) => relation.target(),
}
} else {
let trace = project.traces.get(&edge)?;
match end {
EdgeEnd::Source => trace.oa,
EdgeEnd::Target => trace.sa,
}
};
project
.elements
.get(&endpoint)
.filter(|e| e.kind() == Kind::Port)
.map(|_| endpoint)
}
/// Intersect the chosen cardinal scan line with the painted shape. Offset is
/// measured over the complete projected bounding side, including its corners.
/// Terminate's X uses its outermost stroke intersection rather than a contour.
pub fn attachment_point(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: &EdgeAttachment,
) -> Position {
let vertical = matches!(
attachment.side,
PortAnchorSide::Left | PortAnchorSide::Right
);
let (extent, depth) = if vertical {
(node.size.height, node.size.width)
} else {
(node.size.width, node.size.height)
};
let along = attachment.offset.clamp(0., 1.) * extent;
let t = 2. * attachment.offset.clamp(0., 1.) - 1.;
let kind = diagram
.projection
.state_machine()
.and_then(|_| project.elements.get(&node.element))
.map(|e| e.kind());
let inset = match kind {
Some(Kind::Choice) => depth * 0.5 * t.abs(),
Some(Kind::Terminate) => {
depth
* attachment
.offset
.clamp(0., 1.)
.min(1. - attachment.offset.clamp(0., 1.))
}
Some(
Kind::Initial
| Kind::Junction
| Kind::Final
| Kind::ShallowHistory
| Kind::DeepHistory
| Kind::EntryPoint
| Kind::ExitPoint,
) => depth * 0.5 * (1. - (1. - t * t).max(0.).sqrt()),
Some(Kind::Region | Kind::Fork | Kind::Join) => 0.,
_ => {
let radius = 10_f32.min(extent / 2.).min(depth / 2.);
let corner = (radius - along.min(extent - along)).max(0.);
radius - (radius * radius - corner * corner).max(0.).sqrt()
}
};
match attachment.side {
PortAnchorSide::Left => pos(node.position.x + inset, node.position.y + along),
PortAnchorSide::Right => pos(
node.position.x + node.size.width - inset,
node.position.y + along,
),
PortAnchorSide::Top => pos(node.position.x + along, node.position.y + inset),
PortAnchorSide::Bottom => pos(
node.position.x + along,
node.position.y + node.size.height - inset,
),
}
}
/// Choose a cardinal bounding side, then map its fraction onto the painted
/// contour. Contour distances cannot distinguish the sides of a circle at its
/// extrema: Right/0 and Top/0.5 denote the same point but different lead directions.
/// Hysteresis is in diagram units, capped for small symbols. Only UI snapping
/// rounds to hundredths: source/API values retain their complete precision.
pub fn attachment_from_point(
node: &ProjectedNode,
cursor: Position,
previous: Option<PortAnchorSide>,
hysteresis: f32,
) -> EdgeAttachment {
let candidate = |side| EdgeAttachment {
side,
offset: projection::port_anchor_offset(node, side, cursor),
};
// Measure side intent at the box, not from a distant overshoot: Euclidean
// distance differences converge outside a small symbol and would otherwise
// let hysteresis retain a perpendicular side indefinitely.
let boundary_cursor = pos(
cursor
.x
.clamp(node.position.x, node.position.x + node.size.width),
cursor
.y
.clamp(node.position.y, node.position.y + node.size.height),
);
let distance = |side| {
length(
projection::port_anchor_position(node, side, candidate(side).offset),
boundary_cursor,
)
};
let mut side = [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
]
.into_iter()
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
.unwrap();
let hysteresis = hysteresis
.max(0.)
.min(node.size.width.min(node.size.height) * 0.2);
if let Some(old) = previous
&& distance(old) <= distance(side) + hysteresis
{
side = old;
}
let mut attachment = candidate(side);
attachment.offset = (attachment.offset * 100.).round() / 100.;
attachment
}
pub(super) fn endpoint(
project: &Project,
diagram: &Diagram,
node: &ProjectedNode,
attachment: EdgeAttachment,
) -> Endpoint {
let point = attachment_point(project, diagram, node, &attachment);
let stub = match attachment.side {
PortAnchorSide::Left => pos(node.position.x - PORT_STUB, point.y),
PortAnchorSide::Right => pos(node.position.x + node.size.width + PORT_STUB, point.y),
PortAnchorSide::Top => pos(point.x, node.position.y - PORT_STUB),
PortAnchorSide::Bottom => pos(point.x, node.position.y + node.size.height + PORT_STUB),
};
Endpoint { point, stub }
}
-399
View File
@@ -1,399 +0,0 @@
//! Ordinary source defaults shared by current parsing and declaration rendering.
//!
//! These are source omission defaults, not graphical creation defaults. The
//! policy operates on complete typed declaration maps. Required named fields
//! and selected constructor payloads remain distinct from omission defaults.
//! Historical versions use their existing frozen parser paths. Semantic baseline
//! history is never traversed or normalized here.
use crate::{Kind, RelationKind};
use serde_json::{Map, Value, json};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Context {
Element(Kind),
Relation(RelationKind),
Diagram,
Node,
Artifact,
Trace,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Mode {
CompleteForEdit,
Compact,
/// Internal lossless-edit fragments: retain every top-level value while
/// compacting its selected constructor's optional nested fields.
CompactValues,
}
/// Top-level field inventory for the current ordinary reader. Callers must
/// check required fields before merging; null entries only admit their names.
/// This never supplies defaults inside a selected constructor.
pub(crate) fn parser_fields(context: Context) -> Map<String, Value> {
let mut fields = declaration_defaults(context);
let required: &[&str] = match context {
Context::Element(kind) => {
fields.remove("description");
fields.remove("parent");
if kind == Kind::Port {
&["contract"]
} else {
&[]
}
}
Context::Relation(kind) => {
fields.remove("description");
match kind {
RelationKind::ContextualLink => &["owner", "source", "target", "purpose"],
RelationKind::FunctionalExchange => {
&["source", "target", "source_port", "target_port"]
}
_ => &["source", "target"],
}
}
Context::Diagram => &[],
Context::Node => &["element"],
Context::Artifact => &["owner"],
Context::Trace => &["oa", "sa"],
};
for name in required {
fields.insert((*name).into(), Value::Null);
}
fields
}
pub(crate) fn fields(
context: Context,
input: &Map<String, Value>,
mode: Mode,
) -> Map<String, Value> {
let mut result = input.clone();
restore_serialization_skips(context, &mut result);
if mode != Mode::CompleteForEdit {
compact_nested(context, &mut result);
}
if mode == Mode::Compact {
// Compact a composite and its declared default by the same nested rules
// before whole-field comparison. This also makes compact input stable:
// e.g. String{minimum_length:0} still denotes the default definition.
let mut defaults = declaration_defaults(context);
compact_nested(context, &mut defaults);
omit_defaults(&mut result, &defaults);
}
result
}
fn restore_serialization_skips(context: Context, fields: &mut Map<String, Value>) {
match context {
Context::Element(Kind::Occurrence) => {
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
exchange
.entry("operation".to_owned())
.or_insert(Value::Null);
}
}
Context::Diagram => {
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes.iter_mut().filter_map(Value::as_object_mut) {
route
.entry("source_attachment".to_owned())
.or_insert(Value::Null);
route
.entry("target_attachment".to_owned())
.or_insert(Value::Null);
}
}
}
Context::Node => {
fields.entry("port_anchors".to_owned()).or_insert(json!([]));
}
Context::Trace => {
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
baseline.entry("semantic".to_owned()).or_insert(Value::Null);
}
}
_ => {}
}
}
fn declaration_defaults(context: Context) -> Map<String, Value> {
let mut defaults = match context {
Context::Element(kind) => element_defaults(kind),
Context::Relation(kind) => relation_defaults(kind),
Context::Diagram => object(json!({
"description": "", "layer": "Oa", "projection": "Overview",
"routes": [], "view_options": {
"hidden_edges": [], "relation_kinds": "All",
"work_categories": "All", "participant_categories": "All",
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
}
})),
Context::Node => object(json!({
"position": {"x": 0.0, "y": 0.0},
"size": {"width": 224.0, "height": 94.0},
"collapsed": false, "port_anchors": []
})),
Context::Artifact => object(json!({
"description": "", "relation": "References", "uri": "",
"revision": null, "fragment": null
})),
Context::Trace => object(json!({
"description": "", "kind": "Refinement", "baseline": null
})),
};
match context {
Context::Element(_) => {
defaults.insert("description".into(), json!(""));
defaults.insert("parent".into(), Value::Null);
}
Context::Relation(_) => {
defaults.insert("description".into(), json!(""));
}
_ => {}
}
defaults
}
fn element_defaults(kind: Kind) -> Map<String, Value> {
if let Some(state_kind) = kind.state_kind() {
// The declaration selects this discriminator; no GUI factory is used.
return object(json!({
"kind": state_kind, "entry": "", "exit": "", "do_activity": ""
}));
}
match kind {
Kind::Process | Kind::FunctionalChain => object(json!({"steps": []})),
Kind::ExchangeItem => object(json!({
"kind": "Information", "mechanism": "Unspecified", "supertype": null
})),
Kind::ExchangeCategory => object(json!({"family": "Work", "exchanges": []})),
Kind::DataType => object(json!({
"definition": {"String": {"minimum_length": 0, "maximum_length": null}},
"default": null, "unit": ""
})),
Kind::DataField => object(json!({
"type_ref": "String", "minimum": 1, "maximum": 1, "unit": "",
"direction": "Unspecified", "storage": "Value", "default": null
})),
Kind::Interface => object(json!({"supertype": null})),
// Port.contract is required, even when its explicit value is Legacy.
Kind::Port => object(json!({"legacy": null})),
Kind::Scenario => object(json!({
"viewpoint": "MixedLegacy", "context": null,
"precondition": "", "postcondition": "", "steps": []
})),
Kind::Lifeline => object(json!({"represented": null})),
Kind::Occurrence => object(json!({
"body": {"Exchange": {
"exchange": null, "sender": null, "receiver": null,
"message_kind": "Asynchronous", "reply_to": null,
"values": [], "operation": null
}},
"precondition": "", "postcondition": ""
})),
Kind::Event => object(json!({"body": {"Signal": {"items": []}}})),
Kind::Constraint => object(json!({"expression": "", "constrained": []})),
// Unit-body declarations have no additional omission defaults. A future
// Kind reaches this branch without gaining speculative payload rules.
_ => Map::new(),
}
}
fn relation_defaults(kind: RelationKind) -> Map<String, Value> {
match kind {
RelationKind::InterfaceItemAllocation => object(json!({
"order": 0, "transmission": "Unspecified", "acquisition": "Unspecified"
})),
RelationKind::DataAssociation => object(json!({
"source_role": "", "target_role": "",
"source_minimum": 0, "target_minimum": 0,
"source_maximum": null, "target_maximum": null,
"source_navigable": false, "target_navigable": false
})),
RelationKind::Interaction | RelationKind::FunctionalExchange => {
object(json!({"items": []}))
}
RelationKind::BoundaryExchange => object(json!({"items": [], "oriented": true})),
RelationKind::CommunicationMean => object(json!({"items": [], "oriented": false})),
RelationKind::CapabilityExtend => object(json!({"condition": ""})),
RelationKind::SequenceLink => object(json!({"guard": ""})),
RelationKind::StateTransition => object(json!({
"transition_kind": "External", "trigger": "", "guard": "", "effect": "",
"triggers": [], "effects": []
})),
RelationKind::StateAction => object(json!({"phase": "Do"})),
// source/target, FunctionalExchange ports and ContextualLink's owner,
// endpoints and purpose are required, despite factory placeholder values.
_ => Map::new(),
}
}
fn compact_nested(context: Context, fields: &mut Map<String, Value>) {
match context {
Context::Element(Kind::DataType) => compact_data_definition(fields),
Context::Element(Kind::Port) => {
if let Some(legacy) = fields.get_mut("legacy").and_then(Value::as_object_mut) {
omit_nulls(legacy, &["interface"]);
}
// Every selected PortContract payload field remains required.
}
Context::Element(Kind::Occurrence) => compact_occurrence(fields),
Context::Diagram => {
if let Some(options) = fields
.get_mut("view_options")
.and_then(Value::as_object_mut)
{
omit_defaults(
options,
&object(json!({
"hidden_edges": [], "relation_kinds": "All",
"work_categories": "All", "participant_categories": "All",
"coverage": "All", "work_contexts": "All", "exchange_labels": "Name"
})),
);
}
if let Some(routes) = fields.get_mut("routes").and_then(Value::as_array_mut) {
for route in routes {
if let Some(route) = route.as_object_mut() {
omit_defaults(
route,
&object(
json!({"waypoints": [], "label": null, "source_attachment": null, "target_attachment": null}),
),
);
}
}
}
// Route entries, EdgeLabel coordinates, projection machine IDs, and
// Only([])/Any([]) selectors remain even when their values are empty.
}
Context::Trace => {
if let Some(baseline) = fields.get_mut("baseline").and_then(Value::as_object_mut) {
omit_nulls(baseline, &["semantic"]);
}
// No recursion through snapshots or semantic historical facets.
}
// DataValue/TypeRef payloads, EventBody fields, Node geometry/anchors,
// contextual endpoints/purposes, and other required records stay intact.
_ => {}
}
}
fn compact_data_definition(fields: &mut Map<String, Value>) {
for tag in ["Integer", "Real"] {
if let Some(payload) = constructor_payload(fields, "definition", tag) {
omit_nulls(payload, &["minimum", "maximum"]);
}
}
if let Some(payload) = constructor_payload(fields, "definition", "String") {
omit_nulls(payload, &["maximum_length"]);
// minimum_length is required inside a selected String constructor.
}
if let Some(payload) = constructor_payload(fields, "definition", "Record") {
omit_nulls(payload, &["supertype"]);
}
// Boolean has no payload; Enumeration.literals remains required even [].
}
fn compact_occurrence(fields: &mut Map<String, Value>) {
if let Some(exchange) = constructor_payload(fields, "body", "Exchange") {
omit_defaults(
exchange,
&object(json!({
"exchange": null, "sender": null, "receiver": null,
"message_kind": "Asynchronous", "reply_to": null,
"values": [], "operation": null
})),
);
if let Some(operation) = exchange.get_mut("operation").and_then(Value::as_object_mut) {
omit_nulls(operation, &["allocation"]);
// Keep the containing operation field even if its record becomes {}:
// explicit unresolved operation intent is not an absent operation.
}
if let Some(values) = exchange.get_mut("values").and_then(Value::as_array_mut) {
for value in values {
if let Some(message_value) = value.as_object_mut() {
omit_defaults(message_value, &object(json!({"path": []})));
}
}
}
// MessageKind::Timer.expression, MessageValue.field/expression remain.
}
for (tag, optional) in [
("Action", &["action", "lifeline"][..]),
("State", &["state", "lifeline"][..]),
("SequenceReference", &["sequence"][..]),
("ScenarioUse", &["scenario"][..]),
] {
if let Some(payload) = constructor_payload(fields, "body", tag) {
omit_nulls(payload, optional);
}
}
// ScenarioUse.bindings, Control.kind, Fragment.operator/branches and
// Branch.guard/occurrences are required, including empty strings/lists.
}
fn constructor_payload<'a>(
fields: &'a mut Map<String, Value>,
key: &str,
tag: &str,
) -> Option<&'a mut Map<String, Value>> {
let wrapper = fields.get_mut(key)?.as_object_mut()?;
if wrapper.len() != 1 {
return None;
}
wrapper.get_mut(tag)?.as_object_mut()
}
fn omit_nulls(fields: &mut Map<String, Value>, names: &[&str]) {
for name in names {
if fields.get(*name).is_some_and(Value::is_null) {
fields.remove(*name);
}
}
}
fn omit_defaults(fields: &mut Map<String, Value>, defaults: &Map<String, Value>) {
for (name, default) in defaults {
if fields.get(name).is_some_and(|value| same(value, default)) {
fields.remove(name);
}
}
}
// Comparison recurses only to compare a known field with its exact declared
// default. It never traverses arbitrary data looking for values to remove.
fn same(value: &Value, default: &Value) -> bool {
match (value, default) {
(Value::Object(a), Value::Object(b)) => {
a.len() == b.len()
&& a.iter()
.all(|(key, value)| b.get(key).is_some_and(|d| same(value, d)))
}
(Value::Array(a), Value::Array(b)) => {
a.len() == b.len() && a.iter().zip(b).all(|(value, d)| same(value, d))
}
(Value::Number(a), Value::Number(b)) => {
// serde_json numeric equality alone can equate -0.0 with +0.0.
// Extracted f32 coordinates are represented exactly as f64 here.
a == b
&& match (a.as_f64(), b.as_f64()) {
(Some(x), Some(y)) if x == 0.0 && y == 0.0 => x.to_bits() == y.to_bits(),
_ => true,
}
}
_ => value == default,
}
}
fn object(value: Value) -> Map<String, Value> {
match value {
Value::Object(fields) => fields,
_ => unreachable!("literal source default is an object"),
}
}
#[cfg(test)]
#[path = "source_defaults_tests.rs"]
mod tests;
-718
View File
@@ -1,718 +0,0 @@
//! Independent literal oracles for the production source-default policy.
use super::{self as policy, Context, Mode};
use crate::{Kind, RelationKind};
use serde_json::{Value, json};
#[test]
fn complete_comparison_keeps_populated_serializer_skip_values() {
for (context, input) in [
(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"operation":{"allocation":"allocation-id"},"values":[]}}}),
),
(
Context::Node,
json!({"port_anchors":[{"port":"port-id","side":"Left","offset":0.0}]}),
),
(
Context::Trace,
json!({"baseline":{"semantic":{"historical":"payload"}}}),
),
] {
assert_eq!(rendered(context, &input, Mode::CompleteForEdit), input);
}
}
#[test]
fn compact_value_fragments_keep_selected_constructor_requirements() {
for (context, input, expected) in [
(
Context::Element(Kind::DataType),
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
json!({"definition":{"String":{"minimum_length":0}},"default":null,"unit":""}),
),
(
Context::Element(Kind::Event),
json!({"body":{"Signal":{"items":[]}}}),
json!({"body":{"Signal":{"items":[]}}}),
),
(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"operation":{"allocation":null},"values":[]}},"precondition":""}),
json!({"body":{"Exchange":{"operation":{}}},"precondition":""}),
),
(
Context::Element(Kind::Occurrence),
json!({"body":{"ScenarioUse":{"scenario":null,"bindings":[]}}}),
json!({"body":{"ScenarioUse":{"bindings":[]}}}),
),
] {
assert_eq!(rendered(context, &input, Mode::CompactValues), expected);
}
}
#[test]
fn source_scenario_default_is_independent_of_graphical_creation() {
use crate::{ElementBody, Layer, ScenarioViewpoint, SharedBody, language};
let project = language::parse(
"arcadia 7; project p \"P\" { oa_scenario o \"O\" {} sa_scenario s \"S\" {} }",
)
.unwrap();
for element in project.elements.values() {
assert!(matches!(
element.body.shared(),
Some(SharedBody::Scenario {
viewpoint: ScenarioViewpoint::MixedLegacy,
..
})
));
assert!(!matches!(
ElementBody::new(Kind::Scenario, element.layer())
.unwrap()
.shared(),
Some(SharedBody::Scenario {
viewpoint: ScenarioViewpoint::MixedLegacy,
..
})
));
}
let source = language::format(&project);
assert!(!source.contains("description"));
assert!(!source.contains("parent"));
assert!(!source.contains("viewpoint"));
assert_eq!(language::parse(&source).unwrap(), project);
let mut typed = project;
for element in typed.elements.values_mut() {
let viewpoint = if element.layer() == Layer::Oa {
ScenarioViewpoint::OperationalActivity
} else {
ScenarioViewpoint::Functional
};
*element.body.shared_mut().unwrap() = SharedBody::Scenario {
viewpoint,
context: None,
precondition: String::new(),
postcondition: String::new(),
steps: vec![],
};
}
let source = language::format(&typed);
assert!(source.contains("viewpoint OperationalActivity;"));
assert!(source.contains("viewpoint Functional;"));
assert_eq!(language::parse(&source).unwrap(), typed);
}
#[test]
fn shared_parser_defaults_preserve_required_fields_and_kind_checks() {
for (declaration, message) in [
(
"oa_port p0 \"Port\" {}",
"Missing required field 'contract'",
),
(
"allocation r \"R\" { source @owner; }",
"Missing required field 'target'",
),
(
"functional_exchange r \"R\" { source @owner; target @work; }",
"Missing required field 'source_port'",
),
(
"contextual_link r \"R\" {}",
"Missing required field 'owner'",
),
("node n { diagram @d; }", "Missing required field 'element'"),
("artifact a \"A\" {}", "Missing required field 'owner'"),
("trace t \"T\" {}", "Missing required field 'oa'"),
(
"oa_state s \"S\" { kind Initial; }",
"Declaration kind disagrees",
),
(
"oa_activity a \"A\" { unknown true; }",
"Unknown field 'unknown'",
),
(
"oa_data_type t \"T\" { definition { String {}; }; }",
"minimum_length",
),
(
"oa_occurrence o \"O\" { body { ScenarioUse {}; }; }",
"bindings",
),
] {
let source = format!(
"arcadia 7; project p \"P\" {{ oa_entity owner \"Owner\" {{}} oa_activity work \"Work\" {{}} diagram d \"D\" {{}} {declaration} }}"
);
let errors = crate::language::parse(&source).unwrap_err();
assert!(
errors.iter().any(|error| error.message.contains(message)),
"{declaration}: {errors:?}"
);
}
}
fn rendered(context: Context, input: &Value, mode: Mode) -> Value {
Value::Object(policy::fields(context, input.as_object().unwrap(), mode))
}
fn compact(context: Context, input: Value, expected: Value) {
let original = input.clone();
let result = rendered(context, &input, Mode::Compact);
assert_eq!(result, expected, "context {context:?}");
assert_eq!(input, original, "the input remains immutable");
assert_eq!(
rendered(context, &result, Mode::Compact),
result,
"compact policy is idempotent"
);
}
#[test]
fn complete_comparison_restores_only_the_three_known_skips() {
let exchange = json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}}});
let mut complete = exchange.clone();
complete["body"]["Exchange"]["operation"] = Value::Null;
assert_eq!(
rendered(
Context::Element(Kind::Occurrence),
&exchange,
Mode::CompleteForEdit
),
complete
);
assert_eq!(
rendered(
Context::Node,
&json!({"element":"e","diagram":"d"}),
Mode::CompleteForEdit
),
json!({"element":"e","diagram":"d","port_anchors":[]})
);
assert_eq!(
rendered(
Context::Trace,
&json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""}}}),
Mode::CompleteForEdit
),
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"","description":""},"semantic":null}})
);
for input in [
json!({"body":{"Action":{"action":null,"lifeline":null}}}),
json!({"body":{"State":{"state":null,"lifeline":null}}}),
] {
assert_eq!(
rendered(
Context::Element(Kind::Occurrence),
&input,
Mode::CompleteForEdit
),
input
);
}
for input in [
json!({"baseline":null}),
json!({"baseline":{"semantic":{"historical":[null,[],false,0,""]},"oa":{"name":"n","description":""},"sa":{"name":"s","description":""}}}),
] {
assert_eq!(
rendered(Context::Trace, &input, Mode::CompleteForEdit),
input
);
}
}
#[test]
fn every_element_omits_only_empty_common_defaults() {
for kind in Kind::ALL {
// Future/unknown payloads are preservation sentinels, not admitted source.
compact(
Context::Element(kind),
json!({"description":"","parent":null,"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
json!({"future":{"empty":[],"absent":null,"zero":0,"flag":false}}),
);
compact(
Context::Element(kind),
json!({"description":" \n","parent":"owner"}),
json!({"description":" \n","parent":"owner"}),
);
}
}
#[test]
fn ordinary_element_defaults_are_context_specific() {
for kind in [Kind::Process, Kind::FunctionalChain] {
compact(Context::Element(kind), json!({"steps":[]}), json!({}));
compact(
Context::Element(kind),
json!({"steps":["second","first"]}),
json!({"steps":["second","first"]}),
);
}
compact(
Context::Element(Kind::ExchangeItem),
json!({"kind":"Information","mechanism":"Unspecified","supertype":null}),
json!({}),
);
compact(
Context::Element(Kind::ExchangeItem),
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
json!({"kind":"Energy","mechanism":"Flow","supertype":"base"}),
);
compact(
Context::Element(Kind::ExchangeCategory),
json!({"family":"Work","exchanges":[]}),
json!({}),
);
compact(
Context::Element(Kind::ExchangeCategory),
json!({"family":"Participant","exchanges":["x"]}),
json!({"family":"Participant","exchanges":["x"]}),
);
compact(
Context::Element(Kind::Interface),
json!({"supertype":null}),
json!({}),
);
compact(
Context::Element(Kind::Lifeline),
json!({"represented":null}),
json!({}),
);
compact(
Context::Element(Kind::Constraint),
json!({"expression":"","constrained":[]}),
json!({}),
);
}
#[test]
fn scenario_source_default_never_uses_the_graphical_viewpoint_default() {
compact(
Context::Element(Kind::Scenario),
json!({"viewpoint":"MixedLegacy","context":null,"precondition":"","postcondition":"","steps":[]}),
json!({}),
);
for viewpoint in [
"OperationalActivity",
"Functional",
"ParticipantInteraction",
"BoundaryInterface",
] {
compact(
Context::Element(Kind::Scenario),
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","postcondition":"","steps":["two","one"]}),
json!({"viewpoint":viewpoint,"context":"scope","precondition":"ready","steps":["two","one"]}),
);
}
}
#[test]
fn each_state_header_supplies_its_own_kind() {
for (kind, name) in [
(Kind::State, "State"),
(Kind::Mode, "Mode"),
(Kind::Initial, "Initial"),
(Kind::Final, "Final"),
(Kind::Choice, "Choice"),
(Kind::Junction, "Junction"),
(Kind::Fork, "Fork"),
(Kind::Join, "Join"),
(Kind::ShallowHistory, "ShallowHistory"),
(Kind::DeepHistory, "DeepHistory"),
(Kind::EntryPoint, "EntryPoint"),
(Kind::ExitPoint, "ExitPoint"),
(Kind::Terminate, "Terminate"),
] {
compact(
Context::Element(kind),
json!({"kind":name,"entry":"","exit":"","do_activity":""}),
json!({}),
);
}
// Invalid mismatches must not be silently normalized by the writer policy.
compact(
Context::Element(Kind::Mode),
json!({"kind":"State","entry":"work"}),
json!({"kind":"State","entry":"work"}),
);
}
#[test]
fn data_field_unbounded_and_present_empty_values_are_not_defaults() {
compact(
Context::Element(Kind::DataField),
json!({"type_ref":"String","minimum":1,"maximum":1,"unit":"","direction":"Unspecified","storage":"Value","default":null}),
json!({}),
);
compact(
Context::Element(Kind::DataField),
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
json!({"type_ref":{"Named":"record"},"minimum":0,"maximum":null,"unit":"m","direction":"Out","storage":"Reference","default":[]}),
);
for value in [
json!({"Boolean":false}),
json!({"Integer":0}),
json!({"Real":0.0}),
json!({"String":""}),
] {
compact(
Context::Element(Kind::DataField),
json!({"default":[value.clone()]}),
json!({"default":[value]}),
);
}
}
#[test]
fn data_definition_whole_default_and_selected_required_fields_differ() {
compact(
Context::Element(Kind::DataType),
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null}},"default":null,"unit":""}),
json!({}),
);
for (input, expected) in [
(
json!({"Integer":{"minimum":null,"maximum":null}}),
json!({"Integer":{}}),
),
(
json!({"Real":{"minimum":null,"maximum":2.5}}),
json!({"Real":{"maximum":2.5}}),
),
(
json!({"String":{"minimum_length":4,"maximum_length":null}}),
json!({"String":{"minimum_length":4}}),
),
(json!({"Record":{"supertype":null}}), json!({"Record":{}})),
(
json!({"Enumeration":{"literals":[]}}),
json!({"Enumeration":{"literals":[]}}),
),
] {
compact(
Context::Element(Kind::DataType),
json!({"definition":input}),
json!({"definition":expected}),
);
}
compact(
Context::Element(Kind::DataType),
json!({"definition":"Boolean","default":{"Boolean":false}}),
json!({"definition":"Boolean","default":{"Boolean":false}}),
);
}
#[test]
fn port_contract_and_its_required_payload_stay_explicit() {
compact(
Context::Element(Kind::Port),
json!({"contract":"Legacy","legacy":{"direction":"Out","interface":null}}),
json!({"contract":"Legacy","legacy":{"direction":"Out"}}),
);
for contract in [
json!({"FunctionInput":{"incoming_items":[]}}),
json!({"FunctionOutput":{"outgoing_items":[]}}),
json!({"Component":{"kind":"Standard","orientation":"Unset"}}),
] {
compact(
Context::Element(Kind::Port),
json!({"contract":contract.clone(),"legacy":null}),
json!({"contract":contract}),
);
}
}
#[test]
fn occurrence_whole_default_is_distinct_from_selected_constructor_payload() {
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[]}},"precondition":"","postcondition":""}),
json!({}),
);
for (input, expected) in [
(
json!({"Action":{"action":null,"lifeline":null}}),
json!({"Action":{}}),
),
(
json!({"State":{"state":null,"lifeline":null}}),
json!({"State":{}}),
),
(
json!({"SequenceReference":{"sequence":null}}),
json!({"SequenceReference":{}}),
),
(
json!({"ScenarioUse":{"scenario":null,"bindings":[]}}),
json!({"ScenarioUse":{"bindings":[]}}),
),
(
json!({"Branch":{"guard":"","occurrences":[]}}),
json!({"Branch":{"guard":"","occurrences":[]}}),
),
(
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
json!({"Fragment":{"operator":"Parallel","branches":[]}}),
),
(
json!({"Control":{"kind":"Choice"}}),
json!({"Control":{"kind":"Choice"}}),
),
] {
compact(
Context::Element(Kind::Occurrence),
json!({"body":input}),
json!({"body":expected}),
);
}
}
#[test]
fn unresolved_operation_and_required_message_value_are_preserved() {
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[{"field":"f","path":[],"expression":""}],"operation":{"allocation":null}}}}),
json!({"body":{"Exchange":{"values":[{"field":"f","expression":""}],"operation":{}}}}),
);
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request","values":[],"operation":null}}}),
json!({"body":{"Exchange":{"exchange":"flow","sender":"a","receiver":"b","message_kind":{"Timer":{"expression":""}},"reply_to":"request"}}}),
);
}
#[test]
fn event_body_whole_default_does_not_prune_selected_required_fields() {
compact(
Context::Element(Kind::Event),
json!({"body":{"Signal":{"items":[]}}}),
json!({}),
);
for body in [
json!({"Signal":{"items":["i"]}}),
json!({"Time":{"kind":"After","expression":""}}),
json!({"Change":{"expression":""}}),
] {
compact(
Context::Element(Kind::Event),
json!({"body":body.clone()}),
json!({"body":body}),
);
}
}
#[test]
fn relation_required_endpoints_and_contextual_payloads_remain() {
for kind in RelationKind::ALL {
compact(
Context::Relation(kind),
json!({"description":"","source":"s","target":"t","future":[]}),
json!({"source":"s","target":"t","future":[]}),
);
}
compact(
Context::Relation(RelationKind::FunctionalExchange),
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp","items":[]}),
json!({"source":"s","target":"t","source_port":"sp","target_port":"tp"}),
);
let contextual = json!({"owner":"o","source":{"references":[],"occurrence":"s"},"target":{"references":[],"occurrence":"t"},"purpose":{"Precedence":{"guard":""}}});
compact(
Context::Relation(RelationKind::ContextualLink),
contextual.clone(),
contextual,
);
}
#[test]
fn relation_defaults_preserve_contextual_orientation_and_multiplicity() {
for (kind, orientation) in [
(RelationKind::BoundaryExchange, true),
(RelationKind::CommunicationMean, false),
] {
compact(
Context::Relation(kind),
json!({"oriented":orientation,"items":[]}),
json!({}),
);
compact(
Context::Relation(kind),
json!({"oriented":!orientation}),
json!({"oriented":!orientation}),
);
}
compact(
Context::Relation(RelationKind::DataAssociation),
json!({"source_role":"","target_role":"","source_minimum":0,"target_minimum":0,"source_maximum":null,"target_maximum":null,"source_navigable":false,"target_navigable":false}),
json!({}),
);
compact(
Context::Relation(RelationKind::DataAssociation),
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
json!({"source_maximum":0,"target_maximum":1,"source_navigable":true}),
);
compact(
Context::Relation(RelationKind::InterfaceItemAllocation),
json!({"order":0,"transmission":"Unspecified","acquisition":"Unspecified"}),
json!({}),
);
compact(
Context::Relation(RelationKind::CapabilityExtend),
json!({"condition":""}),
json!({}),
);
compact(
Context::Relation(RelationKind::SequenceLink),
json!({"guard":""}),
json!({}),
);
compact(
Context::Relation(RelationKind::StateAction),
json!({"phase":"Do"}),
json!({}),
);
compact(
Context::Relation(RelationKind::StateTransition),
json!({"transition_kind":"External","trigger":"","guard":"","effect":"","triggers":[],"effects":[]}),
json!({}),
);
compact(
Context::Relation(RelationKind::StateTransition),
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
json!({"transition_kind":"Internal","triggers":["t"],"effects":["e"]}),
);
}
#[test]
fn diagram_options_remove_only_unrestricted_defaults() {
let defaults = json!({"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name"});
compact(
Context::Diagram,
json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":defaults}),
json!({}),
);
compact(
Context::Diagram,
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"hidden_edges":[],"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"participant_categories":"All","coverage":"All","work_contexts":{"Any":[]},"exchange_labels":"Name"}}),
json!({"layer":"Sa","projection":{"StateMachine":{"machine":"m"}},"view_options":{"relation_kinds":{"Only":[]},"work_categories":{"Any":[]},"work_contexts":{"Any":[]}}}),
);
compact(
Context::Diagram,
json!({"routes":[{"edge":"e","waypoints":[],"label":null}]}),
json!({"routes":[{"edge":"e"}]}),
);
}
#[test]
fn node_whole_defaults_preserve_negative_zero_and_required_coordinates() {
compact(
Context::Node,
json!({"diagram":"d","element":"e","position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false}),
json!({"diagram":"d","element":"e"}),
);
for position in [
json!({"x":-0.0,"y":0.0}),
json!({"x":0.0,"y":-0.0}),
json!({"x":1.0,"y":0.0}),
] {
let result = rendered(
Context::Node,
&json!({"position":position.clone()}),
Mode::Compact,
);
assert_eq!(result, json!({"position":position.clone()}));
for coordinate in ["x", "y"] {
assert_eq!(
result["position"][coordinate].as_f64().unwrap().to_bits(),
position[coordinate].as_f64().unwrap().to_bits(),
"coordinate sign is part of fidelity"
);
}
}
compact(
Context::Node,
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
json!({"size":{"width":300.0,"height":94.0},"port_anchors":[{"port":"p","side":"Left","offset":0.0}]}),
);
}
#[test]
fn artifact_present_empty_strings_and_trace_history_stay_distinct() {
compact(
Context::Artifact,
json!({"owner":"o","description":"","relation":"References","uri":"","revision":null,"fragment":null}),
json!({"owner":"o"}),
);
compact(
Context::Artifact,
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
json!({"owner":"o","relation":"Implements","revision":"","fragment":""}),
);
compact(
Context::Trace,
json!({"oa":"o","sa":"s","description":"","kind":"Refinement","baseline":null}),
json!({"oa":"o","sa":"s"}),
);
compact(
Context::Trace,
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
json!({"baseline":{"oa":{"name":"","description":""},"sa":{"name":"s","description":""}}}),
);
let baseline = json!({"oa":{"name":"o","description":""},"sa":{"name":"s","description":""},"semantic":{"facets":[{"Value":null},{"Value":[]},{"Value":false},{"Value":0},{"Value":""}]}});
compact(
Context::Trace,
json!({"kind":"Manual","baseline":baseline.clone()}),
json!({"kind":"Manual","baseline":baseline}),
);
}
#[test]
fn nested_future_fields_prevent_whole_default_removal() {
// These sentinels exercise preservation, not current parser admission of
// unknown fields. Only known slots may lose their redundant defaults.
compact(
Context::Element(Kind::DataType),
json!({"definition":{"String":{"minimum_length":0,"maximum_length":null,"future":[]}}}),
json!({"definition":{"String":{"minimum_length":0,"future":[]}}}),
);
compact(
Context::Element(Kind::Occurrence),
json!({"body":{"Exchange":{"exchange":null,"sender":null,"receiver":null,"message_kind":"Asynchronous","reply_to":null,"values":[],"operation":null,"future":false}}}),
json!({"body":{"Exchange":{"future":false}}}),
);
compact(
Context::Diagram,
json!({"view_options":{"hidden_edges":[],"relation_kinds":"All","work_categories":"All","participant_categories":"All","coverage":"All","work_contexts":"All","exchange_labels":"Name","future":null}}),
json!({"view_options":{"future":null}}),
);
}
#[test]
fn unknown_and_multikey_constructor_wrappers_are_not_reinterpreted() {
for definition in [
json!({"Future":{"minimum":null,"maximum":null}}),
json!({"String":{"minimum_length":0,"maximum_length":null},"Future":{}}),
] {
compact(
Context::Element(Kind::DataType),
json!({"definition":definition.clone()}),
json!({"definition":definition}),
);
}
for body in [
json!({"Future":{"operation":null,"values":[]}}),
json!({"Exchange":{"exchange":null,"values":[]},"Future":{}}),
] {
let fields = json!({"body":body});
assert_eq!(
rendered(
Context::Element(Kind::Occurrence),
&fields,
Mode::CompleteForEdit
),
fields,
"do not invent a selected Exchange constructor in an unknown wrapper",
);
compact(Context::Element(Kind::Occurrence), fields.clone(), fields);
}
}
+4 -6
View File
@@ -40,7 +40,6 @@ 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 {
@@ -527,11 +526,10 @@ pub fn discover_missing(
result.expectations.keys().chain(result.bundle.files.keys()),
)?;
if result.diagnostics.is_empty() {
// 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);
match compile_bundle(&result.reachable_bundle(), limits) {
Ok(compiled) => result.diagnostics = compiled.diagnostics,
Err(diagnostics) => result.diagnostics = diagnostics,
}
}
let pending = journal_path(&result.root, &result.bundle.entry);
if fs::symlink_metadata(&pending).is_ok() {
+20 -131
View File
@@ -726,8 +726,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
pub fn format_bundle(project: &Project) -> SourceBundle {
let ownership = default_ownership(project);
let aliases: BTreeMap<_, _> = project.identities().into_iter().collect();
let mut declarations =
language::format_declarations(project, language::DeclarationFormat::Compact);
let mut declarations = language::format_declarations(project);
declarations.sort_by_key(|(id, _)| declaration_order(project, *id, &aliases));
let mut files: BTreeMap<LogicalPath, String> = BTreeMap::new();
for (id, declaration) in declarations {
@@ -736,13 +735,11 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
.or_default()
.push_str(&unindent(&declaration));
}
let mut entry = format!("arcadia {SCHEMA_VERSION};\n{}{{\n", project_header(project));
if !project.description.is_empty() {
entry.push_str(&format!(
" description {};\n",
quote(&project.description)
));
}
let mut entry = format!(
"arcadia {SCHEMA_VERSION};\n{}{{\n description {};\n",
project_header(project),
quote(&project.description)
);
for path in files.keys() {
entry.push_str(&format!(" include {};\n", quote(path)));
}
@@ -853,25 +850,8 @@ pub fn update_bundle_with_identity_map(
)]);
}
let origins: BTreeMap<_, _> = identities.iter().map(|(old, new)| (*new, *old)).collect();
// Comparison must include defaults even when persistence serialization or
// compact output omits them. Otherwise an unrelated payload edit erases
// unchanged explicit fields from the authored document.
let before: BTreeMap<_, _> =
language::format_declarations(old, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let after: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let compact: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::Compact)
.into_iter()
.collect();
let compact_values: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompactValues)
.into_iter()
.collect();
let before: BTreeMap<_, _> = language::format_declarations(old).into_iter().collect();
let after: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let old_aliases: BTreeMap<_, _> = old
.identities()
.into_iter()
@@ -927,27 +907,6 @@ pub fn update_bundle_with_identity_map(
};
let mut previous = parse(old_text)?;
let mut next = parse(new_text)?;
let next_id = identities[&id];
let value_text = &compact_values[&next_id];
let insertion = InsertionSyntax::new(
&next.fields,
&parse(value_text)?.fields,
value_text,
&parse(&compact[&next_id])?.fields,
);
let old_kind = previous.header["kind"];
let new_kind = next.header["kind"];
if old_text[old_kind.start..old_kind.end] != new_text[new_kind.start..new_kind.end]
&& let language::EditKind::Record(actual_fields) = &actual.fields.kind
&& let language::EditKind::Record(previous_fields) = &mut previous.fields.kind
{
// An implicit value is supplied by its declaration context.
// After a kind change, an unchanged value may need explicit
// syntax (e.g. BoundaryExchange true -> CommunicationMean true).
// Reconsider only absent fields; authored explicit values still
// compare against their complete old values and keep trivia.
previous_fields.retain(|key, _| actual_fields.contains_key(key));
}
if actual.lexical_owner.is_some()
&& let language::EditKind::Record(fields) = &actual.fields.kind
&& !fields.contains_key("parent")
@@ -964,7 +923,6 @@ pub fn update_bundle_with_identity_map(
source,
before: old_text,
after: new_text,
insertion: Some(&insertion),
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
@@ -995,7 +953,6 @@ pub fn update_bundle_with_identity_map(
source,
before: &before,
after: &after,
insertion: None,
old_aliases: &old_aliases,
new_aliases: &new_aliases,
legacy_value_paths: false,
@@ -1054,7 +1011,7 @@ pub fn update_bundle_with_identity_map(
project,
identities,
&original_order,
&compact,
&after,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
@@ -1162,73 +1119,10 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
output
}
// Value fragments retain required payload fields even when the enclosing
// declaration field is itself omittable. For example, a selected String type
// needs minimum_length; an Event Signal needs items. The complete tree remains
// the comparison authority; this parallel tree is only an insertion template.
struct InsertionSyntax<'a> {
source: &'a str,
values: BTreeMap<usize, Span>,
omitted_fields: BTreeSet<usize>,
}
impl<'a> InsertionSyntax<'a> {
fn new(
complete: &language::EditValue,
compact_values: &language::EditValue,
source: &'a str,
compact: &language::EditValue,
) -> Self {
let mut result = Self {
source,
values: BTreeMap::new(),
omitted_fields: BTreeSet::new(),
};
result.collect(complete, compact_values);
if let (language::EditKind::Record(all), language::EditKind::Record(kept)) =
(&complete.kind, &compact.kind)
{
for (key, field) in all {
if !kept.contains_key(key) {
result.omitted_fields.insert(field.value.span.start);
}
}
}
result
}
fn collect(&mut self, complete: &language::EditValue, compact: &language::EditValue) {
// Borrow the existing compact text once. Copying each containing record
// here would duplicate a large nested payload at every depth.
self.values.insert(complete.span.start, compact.span);
match (&complete.kind, &compact.kind) {
(language::EditKind::Record(all), language::EditKind::Record(kept)) => {
for (key, field) in all {
if let Some(next) = kept.get(key) {
self.collect(&field.value, &next.value);
} else {
self.omitted_fields.insert(field.value.span.start);
}
}
}
(language::EditKind::List(all), language::EditKind::List(kept)) => {
assert_eq!(
all.len(),
kept.len(),
"compaction preserves array membership"
);
for (field, next) in all.iter().zip(kept) {
self.collect(field, next);
}
}
_ => {}
}
}
}
struct SyntaxUpdate<'a> {
source: &'a str,
before: &'a str,
after: &'a str,
insertion: Option<&'a InsertionSyntax<'a>>,
old_aliases: &'a BTreeMap<String, String>,
new_aliases: &'a BTreeMap<String, String>,
// Source3 migration only adds path qualification; the existing field identity
@@ -1237,16 +1131,6 @@ struct SyntaxUpdate<'a> {
identity_origins: Option<&'a BTreeMap<Id, Id>>,
}
impl SyntaxUpdate<'_> {
fn inserted(&self, value: &language::EditValue) -> &str {
self.insertion
.and_then(|syntax| {
syntax
.values
.get(&value.span.start)
.map(|span| &syntax.source[span.start..span.end])
})
.unwrap_or(&self.after[value.span.start..value.span.end])
}
fn header(
&self,
actual: &BTreeMap<&str, Span>,
@@ -1359,7 +1243,7 @@ impl SyntaxUpdate<'_> {
_ => {
erase_syntax(self.source, actual.span, edits);
let text = inserted_value(
self.inserted(after),
&self.after[after.span.start..after.span.end],
indent_at(self.source, actual.span.start),
self.source,
);
@@ -1424,9 +1308,6 @@ impl SyntaxUpdate<'_> {
.iter()
.filter(|(key, field)| {
!actual.contains_key(*key)
&& self.insertion.is_none_or(|syntax| {
!syntax.omitted_fields.contains(&field.value.span.start)
})
&& before
.and_then(|fields| fields.get(*key))
.is_none_or(|old| !self.same(&old.value, &field.value))
@@ -1445,7 +1326,11 @@ impl SyntaxUpdate<'_> {
.unwrap_or_else(|| format!("{} ", indent_at(self.source, span.start)));
let mut text = String::new();
for (key, field) in added {
let value = inserted_value(self.inserted(&field.value), &indent, self.source);
let value = inserted_value(
&self.after[field.value.span.start..field.value.span.end],
&indent,
self.source,
);
if multiline {
text.push_str(&indent);
text.push_str(&format!("{key} {value};{}", newline(self.source)));
@@ -1565,7 +1450,11 @@ impl SyntaxUpdate<'_> {
} else {
text.push(' ');
}
text.push_str(&inserted_value(self.inserted(next), &indent, self.source));
text.push_str(&inserted_value(
&self.after[next.span.start..next.span.end],
&indent,
self.source,
));
suffix = "";
}
if i + 1 < after.len() {
@@ -11,10 +11,7 @@ pub(super) fn upgrade(
.as_ref()
.expect("an explicit source migration report");
let project = &current.decoded.project;
let canonical: BTreeMap<_, _> =
language::format_declarations(project, language::DeclarationFormat::CompleteForEdit)
.into_iter()
.collect();
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
let changed: BTreeSet<_> = report
.semantic_defaults
.iter()
@@ -62,7 +59,6 @@ pub(super) fn upgrade(
source,
before: source,
after,
insertion: None,
old_aliases: &aliases,
new_aliases: &aliases,
legacy_value_paths: true,
-14
View File
@@ -643,20 +643,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
let mut routed_edges = BTreeSet::new();
for (index, route) in d.routes.iter().enumerate() {
for attachment in [route.source_attachment, route.target_attachment]
.into_iter()
.flatten()
{
if !attachment.offset.is_finite() || !(0.0..=1.0).contains(&attachment.offset) {
error(
&mut out,
d.id,
"invalid_attachment_offset",
"routes",
"Connection attachment offset must be finite and between zero and one.",
);
}
}
if !routed_edges.insert(route.edge) {
error(
&mut out,
-385
View File
@@ -1,385 +0,0 @@
use oa_core::{projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn anchor(side: PortAnchorSide, offset: f32) -> EdgeAttachment {
EdgeAttachment { side, offset }
}
fn fixture() -> (Project, Id, Id, Id, Id) {
let mut p = Project::empty();
let a = p
.add_element(Kind::Activity, Layer::Oa, "Report", None)
.unwrap();
let b = p
.add_element(Kind::Activity, Layer::Oa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::Interaction, "Report status", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.place(d, a, pos(50., 50.));
p.place(d, b, pos(750., 200.));
(p, d, a, b, edge)
}
fn stored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
}
}
fn routed(p: &Project, d: Id) -> RoutedView {
let diagram = &p.diagrams[&d];
route_diagram(p, diagram, &project_diagram(p, diagram))
}
fn near(a: f32, b: f32) {
assert!((a - b).abs() < 0.002, "{a} != {b}");
}
fn orthogonal(points: &[Position]) {
assert!(points.len() >= 2);
for span in points.windows(2) {
assert!(span[0].x == span[1].x || span[0].y == span[1].y, "{span:?}");
}
}
fn length(points: &[Position]) -> f32 {
points
.windows(2)
.map(|p| (p[0].x - p[1].x).abs() + (p[0].y - p[1].y).abs())
.sum()
}
#[test]
fn authored_boundary_follows_projected_owner_and_preview_matches_committed_geometry() {
let (mut p, d, a, _, edge) = fixture();
let initial = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.25));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.75));
route.waypoints = vec![pos(460., 350.)];
p.diagrams.get_mut(&d).unwrap().routes.push(route.clone());
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &initial, edge);
let full = routed(&p, d);
assert_eq!(
preview, full,
"attachment-only changes must invalidate the label-only path cache"
);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
assert_eq!(
full.edges[&edge].points[0],
pos(
n.position.x + n.size.width * 0.25,
n.position.y + n.size.height
)
);
assert_eq!(full.edges[&edge].pins, route.waypoints);
orthogonal(&full.edges[&edge].points);
let node = p
.diagrams
.get_mut(&d)
.unwrap()
.nodes
.values_mut()
.find(|n| n.element == a)
.unwrap();
node.position.x += 75.;
node.size.width += 100.;
node.size.height += 50.;
let moved = routed(&p, d);
near(
moved.edges[&edge].points[0].x - full.edges[&edge].points[0].x,
100.,
);
near(
moved.edges[&edge].points[0].y - full.edges[&edge].points[0].y,
50.,
);
assert_eq!(p.diagrams[&d].routes[0], route);
}
#[test]
fn attachment_only_preview_cannot_reuse_old_endpoints_with_unchanged_pins() {
let (mut p, d, _, _, edge) = fixture();
let baseline = routed(&p, d);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.2));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let preview = route_edge_preview(&p, &p.diagrams[&d], &view, &baseline, edge);
assert_ne!(preview.edges[&edge].points, baseline.edges[&edge].points);
assert_eq!(preview, routed(&p, d));
}
#[test]
fn ports_remain_authoritative_even_when_projection_filters_the_port() {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let edge = p.relate(RelationKind::FunctionalExchange, "Data", a, b);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, a, pos(0., 0.));
p.place(d, b, pos(600., 0.));
let diagram = p.diagrams[&d].clone();
let view = project_diagram(&p, &diagram);
let expected = route_diagram(&p, &diagram, &view);
assert!(semantic_port_endpoint(&p, edge, EdgeEnd::Source).is_some());
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Bottom, 0.1));
route.target_attachment = Some(anchor(PortAnchorSide::Top, 0.9));
p.diagrams.get_mut(&d).unwrap().routes.push(route);
assert_eq!(routed(&p, d), expected);
let mut hidden = view.clone();
hidden.ports.clear();
for e in &mut hidden.edges {
e.source_port = None;
e.target_port = None;
}
let absent = route_diagram(&p, &diagram, &hidden);
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
absent,
"hidden ports must not activate a dormant node preference"
);
}
fn state(kind: Kind) -> (Project, Id, Id, Id) {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Entity, Layer::Oa, "Crew", None)
.unwrap();
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", Some(owner))
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let a = p.add_element(kind, Layer::Oa, "A", Some(region)).unwrap();
let b = p
.add_element(Kind::State, Layer::Oa, "B", Some(region))
.unwrap();
let edge = p.relate(RelationKind::StateTransition, "Next", a, b);
let d = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&d).unwrap().projection = ProjectionKind::StateMachine { machine };
p.place(d, a, pos(100., 100.));
p.place(d, b, pos(600., 100.));
(p, d, a, edge)
}
#[test]
fn nonrectangular_state_attachments_intersect_the_painted_shape() {
for kind in [
Kind::Initial,
Kind::Junction,
Kind::Final,
Kind::Choice,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
Kind::Fork,
Kind::Join,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let diagram = &p.diagrams[&d];
let view = project_diagram(&p, diagram);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for side in [
PortAnchorSide::Left,
PortAnchorSide::Right,
PortAnchorSide::Top,
PortAnchorSide::Bottom,
] {
for offset in [0., 0.2, 0.5, 0.8, 1.] {
let at = attachment_point(&p, diagram, n, &anchor(side, offset));
let x = (at.x - n.position.x) / n.size.width;
let y = (at.y - n.position.y) / n.size.height;
match kind {
Kind::Choice => near((2. * x - 1.).abs() + (2. * y - 1.).abs(), 1.),
Kind::Terminate => near((x - y).abs().min((x + y - 1.).abs()), 0.),
Kind::Fork | Kind::Join => near(x.min(1. - x).min(y.min(1. - y)), 0.),
Kind::State => {
assert!((0. ..=1.).contains(&x) && (0. ..=1.).contains(&y));
if offset == 0. || offset == 1. {
assert!(
x != 0. && x != 1. || y != 0. && y != 1.,
"rounded corner cannot use bounding-box corner"
);
}
}
_ => near((2. * x - 1.).powi(2) + (2. * y - 1.).powi(2), 1.),
}
}
}
}
}
#[test]
fn inset_contour_leads_keep_pin_fractions_and_labels_on_the_complete_path() {
let (mut p, d, _, edge) = state(Kind::Choice);
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Right, 0.));
route.waypoints = vec![pos(380., 40.)];
route.label = Some(EdgeLabel {
fraction: 0.3,
offset: pos(0., 0.),
});
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert!(r.warnings.is_empty(), "{:?}", r.warnings);
let pin = r.pins[0];
let pin_index = r.points.iter().position(|p| *p == pin).unwrap();
near(
r.pin_fractions[0],
length(&r.points[..=pin_index]) / length(&r.points),
);
assert_eq!(
r.label.anchor,
point_along_polyline(&r.points, 0.3).unwrap()
);
}
#[test]
fn small_symbol_drag_chooses_the_intended_exit_side_at_low_zoom() {
for kind in [
Kind::Initial,
Kind::Choice,
Kind::Terminate,
Kind::Fork,
Kind::State,
] {
let (p, d, a, _) = state(kind);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == a).unwrap();
for zoom in [0.5, 1., 2.] {
for overshoot in [0., 20.] {
let cursor = pos(n.position.x + n.size.width / 2., n.position.y - overshoot);
let chosen =
attachment_from_point(n, cursor, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
chosen,
anchor(PortAnchorSide::Top, 0.5),
"{kind:?}, zoom {zoom}, overshoot {overshoot}"
);
let painted = attachment_point(&p, &p.diagrams[&d], n, &chosen);
near(painted.x, n.position.x + n.size.width / 2.);
near(
painted.y,
n.position.y
+ if kind == Kind::Terminate {
n.size.height / 2.
} else {
0.
},
);
}
let near_corner = pos(n.position.x + n.size.width - 1., n.position.y + 1.);
let stable =
attachment_from_point(n, near_corner, Some(PortAnchorSide::Right), 6. / zoom);
assert_eq!(
stable.side,
PortAnchorSide::Right,
"corner hysteresis stays stable"
);
}
}
}
#[test]
fn coincident_self_attachments_retain_hard_pins_and_form_a_visible_loop() {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
for pins in [vec![], vec![pos(400., 30.)]] {
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
route.waypoints = pins.clone();
p.diagrams.get_mut(&d).unwrap().routes = vec![route.clone()];
let result = routed(&p, d);
let r = &result.edges[&edge];
orthogonal(&r.points);
assert_eq!(r.points.first(), r.points.last());
assert!(length(&r.points) > 2. * PORT_STUB + 70.);
assert_eq!(r.pins, pins);
assert_eq!(r.pin_fractions.len(), pins.len());
assert!(
r.warnings.contains(&RouteWarning::CoincidentAttachments),
"{:?}",
r.warnings
);
assert_eq!(routed(&p, d), result, "deterministic fallback");
assert_eq!(p.diagrams[&d].routes[0], route);
}
}
#[test]
fn coincident_loop_chooses_clear_side_instead_of_inventing_an_obstructed_pin() {
for blocker_side in [-1., 1.] {
let (mut p, d, a, edge) = state(Kind::State);
if let RelationBody::StateTransition { target, .. } =
&mut p.relations.get_mut(&edge).unwrap().body
{
*target = a;
}
let mut route = stored(edge);
route.source_attachment = Some(anchor(PortAnchorSide::Top, 0.5));
route.target_attachment = route.source_attachment;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
let view = project_diagram(&p, &p.diagrams[&d]);
let source = view.nodes.iter().find(|n| n.element == a).unwrap();
let tip = attachment_point(
&p,
&p.diagrams[&d],
source,
&anchor(PortAnchorSide::Top, 0.5),
);
let region = p.elements[&a].parent;
let blocker = p
.add_element(Kind::State, Layer::Oa, "Detour obstruction", region)
.unwrap();
let left = if blocker_side > 0. {
tip.x + 30.
} else {
tip.x - 30. - 224.
};
p.place(d, blocker, pos(left, tip.y - 144.));
let result = routed(&p, d);
let r = &result.edges[&edge];
assert_eq!(r.warnings, vec![RouteWarning::CoincidentAttachments]);
orthogonal(&r.points);
let view = project_diagram(&p, &p.diagrams[&d]);
let n = view.nodes.iter().find(|n| n.element == blocker).unwrap();
for segment in r.points.windows(2) {
for step in 0..=100 {
let t = step as f32 / 100.;
let q = pos(
segment[0].x + (segment[1].x - segment[0].x) * t,
segment[0].y + (segment[1].y - segment[0].y) * t,
);
assert!(
!(q.x > n.position.x - CLEARANCE
&& q.x < n.position.x + n.size.width + CLEARANCE
&& q.y > n.position.y - CLEARANCE
&& q.y < n.position.y + n.size.height + CLEARANCE),
"route crosses blocker: {q:?}"
);
}
}
assert!(
r.pins.is_empty(),
"generated detours must never become authored pins"
);
}
}
@@ -1,272 +0,0 @@
use oa_core::{language, projection::*, routing::*, *};
fn pos(x: f32, y: f32) -> Position {
Position { x, y }
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn authored(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![pos(520., 420.)],
label: Some(EdgeLabel {
fraction: 0.35,
offset: pos(17., -23.),
}),
}
}
fn routed(project: &Project, diagram: Id) -> RoutedView {
let d = &project.diagrams[&diagram];
route_diagram(project, d, &project_diagram(project, d))
}
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
let mut p = language::parse(
r#"arcadia 7;
project lifecycle "Attachment lifecycle" {
oa_activity group "Group" { oa_activity first "First" {} }
oa_activity second "Second" {}
oa_activity replacement "Replacement" {}
interaction signal "Signal" { source @first; target @second; }
diagram view "View" {}
diagram other "Other" {}
}"#,
)
.unwrap();
let (d, other, group, first, second, replacement, edge) = (
id(&p, "view"),
id(&p, "other"),
id(&p, "group"),
id(&p, "first"),
id(&p, "second"),
id(&p, "replacement"),
id(&p, "signal"),
);
for diagram in [d, other] {
p.place(diagram, group, pos(20., 20.));
p.place(diagram, first, pos(100., 130.));
p.place(diagram, second, pos(750., 150.));
p.place(diagram, replacement, pos(750., 570.));
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(authored(edge));
}
(p, d, other, group, first, replacement, edge)
}
#[test]
fn attachment_fold_promotion_suspends_the_complete_route_and_expand_restores_it() {
let (mut p, d, other, group, _, _, edge) = fixture();
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Source), None);
assert_eq!(semantic_port_endpoint(&p, edge, EdgeEnd::Target), None);
let before = p.clone();
let expanded = routed(&p, d);
let other_geometry = routed(&p, other);
let parent = p.diagrams[&d]
.nodes
.values()
.find(|n| n.element == group)
.unwrap()
.id;
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = true;
let folded = routed(&p, d);
assert!(folded.edges[&edge].custom_suspended);
assert_eq!(folded.edges[&edge].pins, []);
assert!(
folded.edges[&edge]
.warnings
.contains(&RouteWarning::PromotedEndpoints)
);
assert_eq!(p.diagrams[&d].routes, before.diagrams[&d].routes);
let mut automatic = p.clone();
automatic.diagrams.get_mut(&d).unwrap().routes.clear();
assert_eq!(
folded,
routed(&automatic, d),
"attachments, pins and label must all be dormant on promotion"
);
assert_eq!(p.diagrams[&other], before.diagrams[&other]);
assert_eq!(routed(&p, other), other_geometry);
p.diagrams
.get_mut(&d)
.unwrap()
.nodes
.get_mut(&parent)
.unwrap()
.collapsed = false;
assert_eq!(p, before);
assert_eq!(routed(&p, d), expanded);
}
#[test]
fn attachment_reconnect_retains_slot_preferences_and_other_view_records_with_exact_history() {
let (p, d, other, _, _, replacement, edge) = fixture();
let before = routed(&p, d);
let mut relation = p.relations[&edge].clone();
let RelationBody::Interaction { target, .. } = &mut relation.body else {
unreachable!()
};
*target = replacement;
let mut expected = p.clone();
expected.relations.insert(edge, relation.clone());
let mut session = Session::new(p.clone());
session
.execute(Command::EditRelation(edge, relation))
.unwrap();
assert_eq!(
session.project(),
&expected,
"reconnect changes only the semantic endpoint; all per-view records remain exact"
);
assert_eq!(session.project().diagrams[&other], p.diagrams[&other]);
for diagram in [d, other] {
let current = session.project();
let view = project_diagram(current, &current.diagrams[&diagram]);
let node = view
.nodes
.iter()
.find(|n| n.element == replacement)
.unwrap();
let preference = current.diagrams[&diagram].routes[0]
.target_attachment
.unwrap();
let expected_tip =
attachment_point(current, &current.diagrams[&diagram], node, &preference);
let result = routed(current, diagram);
assert_eq!(result.edges[&edge].points.last(), Some(&expected_tip));
assert_eq!(
result.edges[&edge].pins,
p.diagrams[&diagram].routes[0].waypoints
);
}
assert_ne!(
routed(session.project(), d).edges[&edge].points.last(),
before.edges[&edge].points.last()
);
session.undo();
assert_eq!(session.project(), &p);
assert_eq!(routed(session.project(), d), before);
session.redo();
assert_eq!(session.project(), &expected);
}
#[test]
fn attachment_direct_port_endpoints_remain_authoritative_when_their_glyphs_are_filtered() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Send", None)
.unwrap();
let mut ports = Vec::new();
for (owner, name, contract) in [
(
system,
"Output",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
),
(
actor,
"Input",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::In,
},
),
(
function,
"Function output",
PortContract::FunctionOutput {
outgoing_items: vec![],
},
),
] {
let port = Id::new_v4();
p.elements
.insert(port, make_port(&p, port, owner, name, contract).unwrap());
ports.push(port);
}
p.relate(
RelationKind::Allocation,
"Function allocation",
system,
function,
);
let boundary = p.relate(
RelationKind::BoundaryExchange,
"Boundary",
ports[0],
ports[1],
);
let allocation = p.relate(
RelationKind::PortAllocation,
"Port allocation",
ports[0],
ports[2],
);
let d = p.ensure_diagram(Layer::Sa);
p.place(d, system, pos(0., 0.));
p.place(d, actor, pos(700., 0.));
p.place(d, function, pos(0., 450.));
let original = p.clone();
let baseline = routed(&p, d);
for (edge, target) in [(boundary, ports[1]), (allocation, ports[2])] {
assert!(baseline.edges.contains_key(&edge));
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Source),
Some(ports[0])
);
assert_eq!(
semantic_port_endpoint(&p, edge, EdgeEnd::Target),
Some(target)
);
let mut route = authored(edge);
route.waypoints.clear();
route.label = None;
p.diagrams.get_mut(&d).unwrap().routes.push(route);
}
assert_eq!(routed(&p, d), baseline);
let mut hidden = project_diagram(&p, &p.diagrams[&d]);
hidden.ports.clear();
for edge in &mut hidden.edges {
edge.source_port = None;
edge.target_port = None;
}
assert_eq!(
route_diagram(&p, &p.diagrams[&d], &hidden),
route_diagram(&original, &original.diagrams[&d], &hidden),
"filtered direct Port endpoints must not activate their stored preferences"
);
}
@@ -1,136 +0,0 @@
use oa_core::{projection::*, routing::*, *};
#[test]
fn valid_item_trace_follows_manual_port_anchors_and_ignores_dormant_attachments() {
let mut project = Project::empty();
let oa = project
.add_element(Kind::Activity, Layer::Oa, "Send", None)
.unwrap();
let sa = project
.add_element(Kind::Function, Layer::Sa, "Receive", None)
.unwrap();
let ports = [(oa, Layer::Oa), (sa, Layer::Sa)].map(|(owner, layer)| {
let id = Id::new_v4();
project.elements.insert(
id,
Element {
id,
alias: format!("port_{}", id.simple()),
name: "Status port".into(),
description: "Existing legacy port retained by Item trace".into(),
parent: Some(owner),
body: ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::InOut,
interface: None,
}),
},
),
},
);
id
});
let trace = Trace {
id: Id::new_v4(),
alias: "status_correspondence".into(),
name: "Status correspondence".into(),
description: "Preserve this semantic evidence".into(),
oa: ports[0],
sa: ports[1],
kind: TraceKind::Item,
baseline: None,
};
project.traces.insert(trace.id, trace.clone());
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Traceability;
let owners = [
project.place(diagram, oa, Position { x: 100., y: 100. }),
project.place(diagram, sa, Position { x: 800., y: 300. }),
];
validate_candidate(&project).unwrap();
let semantic_elements = project.elements.clone();
let semantic_relations = project.relations.clone();
let semantic_traces = project.traces.clone();
let view = project_diagram(&project, &project.diagrams[&diagram]);
let edge = view.edges.iter().find(|edge| edge.id == trace.id).unwrap();
assert!(edge.trace);
assert_eq!((edge.source, edge.target), (owners[0], owners[1]));
assert_eq!((edge.source_port, edge.target_port), (None, None));
let initial = route_diagram(&project, &project.diagrams[&diagram], &view);
for (index, (side, offset)) in [(PortAnchorSide::Top, 0.25), (PortAnchorSide::Bottom, 0.75)]
.into_iter()
.enumerate()
{
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owners[index])
.unwrap()
.port_anchors
.push(PortAnchor {
port: ports[index],
side,
offset,
});
}
validate_candidate(&project).unwrap();
let moved_view = project_diagram(&project, &project.diagrams[&diagram]);
let moved = route_diagram(&project, &project.diagrams[&diagram], &moved_view);
for index in 0..2 {
let marker = moved_view
.ports
.iter()
.find(|port| port.element == ports[index] && port.owner == owners[index])
.unwrap();
let points = &moved.edges[&trace.id].points;
let tip = if index == 0 {
points[0]
} else {
*points.last().unwrap()
};
assert_eq!(tip, marker.position);
let old = &initial.edges[&trace.id].points;
assert_ne!(
tip,
if index == 0 {
old[0]
} else {
*old.last().unwrap()
}
);
}
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: trace.id,
waypoints: vec![],
label: None,
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.9,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.1,
}),
});
validate_candidate(&project).unwrap();
let dormant_view = project_diagram(&project, &project.diagrams[&diagram]);
assert_eq!(dormant_view, moved_view);
assert_eq!(
route_diagram(&project, &project.diagrams[&diagram], &dormant_view),
moved
);
assert_eq!(project.elements, semantic_elements);
assert_eq!(project.relations, semantic_relations);
assert_eq!(project.traces, semantic_traces);
assert_eq!(project.traces[&trace.id], trace);
}
-154
View File
@@ -1,154 +0,0 @@
//! Route the exact native acceptance fixture and intended saved checkpoints.
use oa_core::{projection::project_diagram, routing::route_diagram, *};
const FIXTURE: &str =
include_str!("../../../harness/experiments/edge-attachments-20260922/native-fixture.arcadia");
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn set(
project: &mut Project,
view: &str,
edge: &str,
end: EdgeEnd,
attachment: Option<(PortAnchorSide, f32)>,
) {
let view = id(project, view);
let edge = id(project, edge);
let route = project
.diagrams
.get_mut(&view)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == edge)
.unwrap();
route.set_attachment(
end,
attachment.map(|(side, offset)| EdgeAttachment { side, offset }),
);
}
fn clear(project: &Project, checkpoint: &str) {
validate_candidate(project).unwrap();
for (view, edges) in [
("dispatch_view", &["dispatch_request"][..]),
("readiness_view", &["activate", "initialize", "finish"][..]),
] {
let diagram = &project.diagrams[&id(project, view)];
let routed = route_diagram(project, diagram, &project_diagram(project, diagram));
for alias in edges {
let edge = &routed.edges[&id(project, alias)];
assert!(
edge.warnings.is_empty(),
"{checkpoint}/{view}/{alias}: {:?}",
edge.warnings
);
let stored = diagram
.routes
.iter()
.find(|r| r.edge == id(project, alias))
.unwrap();
assert_eq!(
edge.pins, stored.waypoints,
"{checkpoint}/{alias}: manual pins retained"
);
}
}
}
#[test]
fn native_workshop_baseline_and_planned_attachments_have_clear_routes() {
use EdgeEnd::{Source, Target};
use PortAnchorSide::{Bottom, Top};
let mut project = language::parse(FIXTURE).unwrap();
let baseline = project.clone();
clear(&project, "B");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
Some((Top, 0.25)),
);
clear(&project, "O1");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "O2");
set(
&mut project,
"dispatch_view",
"dispatch_request",
Source,
None,
);
clear(&project, "O3");
set(
&mut project,
"readiness_view",
"activate",
Source,
Some((Top, 0.25)),
);
clear(&project, "S1");
set(
&mut project,
"readiness_view",
"activate",
Target,
Some((Bottom, 0.75)),
);
clear(&project, "S2");
set(&mut project, "readiness_view", "activate", Target, None);
clear(&project, "S3");
set(
&mut project,
"readiness_view",
"initialize",
Source,
Some((Top, 0.5)),
);
clear(&project, "S4");
set(
&mut project,
"readiness_view",
"finish",
Source,
Some((Top, 0.5)),
);
clear(&project, "S5");
assert_eq!(project.elements, baseline.elements);
assert_eq!(project.relations, baseline.relations);
for alias in ["dispatch_reference", "readiness_reference", "ports_view"] {
let view = id(&project, alias);
assert_eq!(project.diagrams[&view], baseline.diagrams[&view]);
}
for alias in ["dispatch_view", "readiness_view"] {
let view = id(&project, alias);
assert_eq!(
project.diagrams[&view].nodes,
baseline.diagrams[&view].nodes
);
for route in &project.diagrams[&view].routes {
let original = baseline.diagrams[&view]
.routes
.iter()
.find(|r| r.edge == route.edge)
.unwrap();
assert_eq!(route.waypoints, original.waypoints);
assert_eq!(route.label, original.label);
}
}
}
@@ -1,344 +0,0 @@
//! Existing source-7 nested omission compatibility, independently of formatting.
//!
//! Selected constructors use their own field contracts. These handwritten
//! sources and literal models distinguish nullable optional slots from required
//! fields; they do not prescribe compact canonical output or broaden admission.
use oa_core::{source_project::*, *};
use std::collections::BTreeMap;
fn id(number: u8) -> Id {
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
}
fn bundle(declarations: &str) -> SourceBundle {
let source = format!(
"arcadia 7;\nproject p \"Admission\" id \"00000000-0000-4000-8000-000000000001\" {{\n{declarations}\n}}\n"
);
SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source)]),
}
}
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
Element {
id: id(number),
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body,
}
}
fn assert_admitted(input: &SourceBundle, elements: Vec<Element>) -> Project {
let expected = Project {
schema_version: 7,
id: id(1),
alias: "p".into(),
name: "Admission".into(),
description: String::new(),
elements: elements.into_iter().map(|e| (e.id, e)).collect(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
};
let compiled = compile_bundle(input, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, expected, "complete literal model");
assert_eq!(
compiled.bundle, *input,
"fixed IDs require no source stabilization"
);
compiled.decoded.project
}
fn prefix(layer: Layer) -> &'static str {
match layer {
Layer::Oa => "oa",
Layer::Sa => "sa",
}
}
fn data_bundle(layer: Layer, definition: &str) -> SourceBundle {
bundle(&format!(
" {}_data_type subject \"Subject\" id \"00000000-0000-4000-8000-000000000002\" {{ definition {{ {definition}; }}; }}",
prefix(layer)
))
}
fn admit_data(layer: Layer, source: &str, definition: DataTypeDefinition) -> Project {
assert_admitted(
&data_bundle(layer, source),
vec![element(
2,
"subject",
"Subject",
None,
ElementBody::shared_in(
layer,
SharedBody::DataType {
definition,
default: None,
unit: String::new(),
},
),
)],
)
}
fn assert_missing(input: &SourceBundle, field: &str) {
let errors = compile_bundle(input, ProjectLimits::default()).unwrap_err();
assert_eq!(
errors.len(),
1,
"expect the targeted admission error: {errors:?}"
);
let diagnostic = &errors[0].diagnostic;
assert_eq!(diagnostic.code, "invalid_source", "{errors:?}");
assert!(
diagnostic.message.to_lowercase().contains("missing") && diagnostic.message.contains(field),
"must reject the missing constructor field {field}, not an unrelated model rule: {errors:?}"
);
}
#[test]
fn integer_optional_bounds_can_be_omitted_individually_and_together() {
for layer in [Layer::Oa, Layer::Sa] {
for (omitted, explicit, minimum, maximum) in [
(
"Integer {}",
"Integer { minimum none; maximum none; }",
None,
None,
),
(
"Integer { maximum 9; }",
"Integer { minimum none; maximum 9; }",
None,
Some(9),
),
(
"Integer { minimum -3; }",
"Integer { minimum -3; maximum none; }",
Some(-3),
None,
),
] {
let expected = DataTypeDefinition::Integer { minimum, maximum };
assert_eq!(
admit_data(layer, omitted, expected.clone()),
admit_data(layer, explicit, expected),
"omitted and explicit optional Integer bounds agree"
);
}
}
}
#[test]
fn real_optional_bounds_can_be_omitted_individually_and_together() {
for layer in [Layer::Oa, Layer::Sa] {
for (omitted, explicit, minimum, maximum) in [
(
"Real {}",
"Real { minimum none; maximum none; }",
None,
None,
),
(
"Real { maximum 2.5; }",
"Real { minimum none; maximum 2.5; }",
None,
Some(2.5),
),
(
"Real { minimum -1.25; }",
"Real { minimum -1.25; maximum none; }",
Some(-1.25),
None,
),
] {
let expected = DataTypeDefinition::Real { minimum, maximum };
assert_eq!(
admit_data(layer, omitted, expected.clone()),
admit_data(layer, explicit, expected),
"omitted and explicit optional Real bounds agree"
);
}
}
}
#[test]
fn string_optional_maximum_can_be_omitted_without_resetting_required_minimum() {
for layer in [Layer::Oa, Layer::Sa] {
let expected = DataTypeDefinition::String {
minimum_length: 4,
maximum_length: None,
};
assert_eq!(
admit_data(layer, "String { minimum_length 4; }", expected.clone()),
admit_data(
layer,
"String { minimum_length 4; maximum_length none; }",
expected,
),
);
}
}
#[test]
fn explicitly_selected_string_requires_minimum_even_when_whole_definition_has_a_default() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(&data_bundle(layer, "String {}"), "minimum_length");
assert_missing(
&data_bundle(layer, "String { maximum_length none; }"),
"minimum_length",
);
admit_data(
layer,
"String { minimum_length 0; }",
DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
},
);
}
}
#[test]
fn absent_numeric_maximum_is_distinct_from_an_explicit_zero_bound() {
for layer in [Layer::Oa, Layer::Sa] {
let unbounded = admit_data(
layer,
"Integer { minimum -3; }",
DataTypeDefinition::Integer {
minimum: Some(-3),
maximum: None,
},
);
let bounded = admit_data(
layer,
"Integer { minimum -3; maximum 0; }",
DataTypeDefinition::Integer {
minimum: Some(-3),
maximum: Some(0),
},
);
assert_ne!(unbounded, bounded, "zero is a bound, not omitted syntax");
}
}
#[test]
fn record_has_no_required_payload_field_and_omitted_supertype_means_none() {
for layer in [Layer::Oa, Layer::Sa] {
let expected = DataTypeDefinition::Record { supertype: None };
assert_eq!(
admit_data(layer, "Record {}", expected.clone()),
admit_data(layer, "Record { supertype none; }", expected),
);
let errors = compile_bundle(
&data_bundle(layer, "Record { fields []; }"),
ProjectLimits::default(),
)
.unwrap_err();
assert_eq!(errors.len(), 1, "{errors:?}");
assert_eq!(errors[0].diagnostic.code, "invalid_source", "{errors:?}");
assert!(
errors[0]
.diagnostic
.message
.contains("unknown field `fields`"),
"Record remains a closed constructor: {errors:?}"
);
}
}
#[test]
fn enumeration_still_requires_its_list_even_when_empty_list_is_admitted() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(&data_bundle(layer, "Enumeration {}"), "literals");
admit_data(
layer,
"Enumeration { literals []; }",
DataTypeDefinition::Enumeration { literals: vec![] },
);
}
}
fn port_bundle(layer: Layer, fields: &str) -> SourceBundle {
let owner_kind = match layer {
Layer::Oa => "oa_entity",
Layer::Sa => "sa_system",
};
bundle(&format!(
" {owner_kind} owner \"Owner\" id \"00000000-0000-4000-8000-000000000002\" {{}}\n {}_port subject \"Subject\" id \"00000000-0000-4000-8000-000000000003\" {{ parent @owner; {fields} }}",
prefix(layer)
))
}
fn admit_port(layer: Layer, fields: &str) -> Project {
let owner_body = match layer {
Layer::Oa => ElementBody::Oa(OaBody::Entity),
Layer::Sa => ElementBody::Sa(SaBody::System),
};
assert_admitted(
&port_bundle(layer, fields),
vec![
element(2, "owner", "Owner", None, owner_body),
element(
3,
"subject",
"Subject",
Some(id(2)),
ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::Out,
interface: None,
}),
},
),
),
],
)
}
#[test]
fn legacy_port_interface_can_be_omitted_but_preserves_direction() {
for layer in [Layer::Oa, Layer::Sa] {
assert_eq!(
admit_port(layer, "contract Legacy; legacy { direction Out; };"),
admit_port(
layer,
"contract Legacy; legacy { direction Out; interface none; };"
),
);
}
}
#[test]
fn legacy_port_direction_remains_required_inside_selected_record() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(
&port_bundle(layer, "contract Legacy; legacy {};"),
"direction",
);
assert_missing(
&port_bundle(layer, "contract Legacy; legacy { interface none; };"),
"direction",
);
}
}
#[test]
fn port_declaration_still_requires_explicit_contract() {
for layer in [Layer::Oa, Layer::Sa] {
assert_missing(
&port_bundle(layer, "legacy { direction Out; };"),
"contract",
);
}
}
@@ -1,445 +0,0 @@
//! Public flat/modular formatter fidelity over frozen input and bounded mutations.
//! Legacy fixture migration uses the public parser. The original adapter tests
//! and exact inputs remain archived at source commit 2a64344; this suite exercises
//! production formatters, not that frozen experiment or lossless model edits.
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
const COASTAL: &str = include_str!("../../../examples/coastal-rescue.arcadia");
const KEYED: &str =
include_str!("../../../harness/fixtures/release1-native/semantic-keyed.arcadia");
const STATES: &str =
include_str!("../../../harness/fixtures/release1-native/state-actions.arcadia");
const DENSE: &str =
include_str!("../../../harness/fixtures/release1-native/dense-40/dense-40.arcadia");
const OPERATIONS: &str = include_str!(
"../../../harness/fixtures/interface-operation-probe/output/boundary-only.arcadia"
);
fn read(source: &str) -> Project {
language::parse(source).unwrap()
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn assert_common_omissions(project: &Project, compiled: &CompiledSourceProject) {
if project.description.is_empty() {
assert!(compiled.index.project_description.is_none());
}
let descriptions = project
.elements
.values()
.map(|e| (e.id, &e.description))
.chain(project.relations.values().map(|r| (r.id, &r.description)))
.chain(project.diagrams.values().map(|d| (d.id, &d.description)))
.chain(
project
.artifact_links
.values()
.map(|a| (a.id, &a.description)),
)
.chain(project.traces.values().map(|t| (t.id, &t.description)));
for (id, description) in descriptions {
if description.is_empty() {
assert!(
!compiled
.index
.fields
.contains_key(&(id, "description".into()))
);
}
}
for element in project.elements.values().filter(|e| e.parent.is_none()) {
assert!(
!compiled
.index
.fields
.contains_key(&(element.id, "parent".into()))
);
}
}
fn assert_fidelity(label: &str, project: &Project) -> (Project, String) {
let flat = language::format(project);
let compact_model = read(&flat);
assert_eq!(
compact_model, *project,
"{label}: every persistent field survives"
);
let input = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), flat.clone())]),
};
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.decoded.project, *project,
"{label}: source-project compile"
);
assert_eq!(
compiled.bundle, input,
"{label}: compact identities/source are stable"
);
assert_common_omissions(project, &compiled);
assert_eq!(
language::format(&compact_model),
flat,
"{label}: flat idempotence"
);
let modular = format_bundle(project);
assert!(
modular.files.len() > 1,
"{label}: exercise generated includes"
);
let modular_compiled = compile_bundle(&modular, ProjectLimits::default()).unwrap();
assert_eq!(
modular_compiled.decoded.project, *project,
"{label}: complete modular model"
);
assert_eq!(
modular_compiled.bundle, modular,
"{label}: modular source/IDs are stable"
);
assert_common_omissions(project, &modular_compiled);
assert_eq!(
format_bundle(&compact_model),
modular,
"{label}: flat-to-modular determinism"
);
assert_eq!(
format_bundle(&modular_compiled.decoded.project),
modular,
"{label}: modular idempotence"
);
// Pinned measured compact bytes from the published fidelity-1 stderr.log at
// 2a64344, not a comparison against today's (now compact) canonical writer.
// A small header-layout allowance avoids enforcing incidental blank lines.
let measured_compact_bytes = match label {
"coastal-source3" => 28_465,
"keyed-source5-history" => 12_066,
"state-actions-source5" => 6_673,
"dense40-source6" => 19_342,
"boundary-operations-source6" => 3_660,
"state-negative-zero" => 6_672,
"keyed-present-empty-default" => 12_108,
"coastal-present-empty-artifact-strings" => 28_483,
"boundary-selected-and-unresolved-operations" => 3_806,
_ => panic!("missing independent compact measurement for {label}"),
};
assert!(
flat.len() <= measured_compact_bytes + 16,
"{label}: {} bytes exceeds compact evidence budget {}",
flat.len(),
measured_compact_bytes + 16
);
(compact_model, flat)
}
#[test]
fn migrated_coastal_oa_sa_views_ports_artifacts_and_traces_roundtrip() {
assert!(COASTAL.starts_with("arcadia 3;"));
let project = read(COASTAL);
assert!(project.elements.values().any(|e| e.layer() == Layer::Oa));
assert!(project.elements.values().any(|e| e.layer() == Layer::Sa));
assert!(
project
.elements
.values()
.any(|e| matches!(e.shared(), Some(SharedBody::Port { .. })))
);
assert!(project.elements.values().any(
|e| matches!(e.shared(), Some(SharedBody::Scenario { steps, .. }) if !steps.is_empty())
));
assert!(project.diagrams.values().any(|d| d.layer == Layer::Oa
&& d.projection == ProjectionKind::FunctionalDataflow
&& !d.nodes.is_empty()));
assert!(project.diagrams.values().any(|d| d.layer == Layer::Sa
&& d.projection == ProjectionKind::Allocation
&& !d.nodes.is_empty()));
assert!(!project.artifact_links.is_empty());
assert!(project.traces.values().any(|t| t.baseline.is_some()));
assert_fidelity("coastal-source3", &project);
}
#[test]
fn keyed_scenarios_records_and_historical_binding_annotation_facets_roundtrip() {
let project = read(KEYED);
for layer in [Layer::Oa, Layer::Sa] {
assert!(project.elements.values().any(|e| e.layer() == layer
&& matches!(
e.shared(),
Some(SharedBody::DataType {
definition: DataTypeDefinition::Record { .. },
..
})
)));
assert!(project.elements.values().any(|e| e.layer() == layer && matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { bindings, .. }, .. }) if !bindings.is_empty())));
}
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { values, .. }, .. }) if values.iter().any(|v| !v.path.is_empty() && !v.expression.is_empty()))));
let facets: Vec<_> = project
.traces
.values()
.filter_map(|t| t.baseline.as_ref()?.semantic.as_ref())
.flat_map(|baseline| &baseline.facets)
.collect();
assert!(
facets
.iter()
.any(|facet| matches!(facet, semantic::FacetReview::Binding { .. }))
);
assert!(
facets
.iter()
.any(|facet| matches!(facet, semantic::FacetReview::Annotation { .. }))
);
assert!(
KEYED.contains("uuid \""),
"fixture contains historical identity atoms"
);
let (_, source) = assert_fidelity("keyed-source5-history", &project);
assert!(
source.contains("uuid \""),
"historical atoms remain historical"
);
}
#[test]
fn state_machine_projection_typed_actions_and_bounded_integer_roundtrip() {
let project = read(STATES);
let machine = identity(&project, "operating");
let view = &project.diagrams[&identity(&project, "state_view")];
assert_eq!(view.projection, ProjectionKind::StateMachine { machine });
assert!(!view.nodes.is_empty());
for phase in [
StateActionKind::Entry,
StateActionKind::Do,
StateActionKind::Exit,
] {
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateAction { phase: actual, .. } if *actual == phase)));
}
assert!(project.relations.values().any(|r| matches!(&r.body, RelationBody::StateTransition { transition_kind: TransitionKind::Internal, triggers, effects, .. } if !triggers.is_empty() && !effects.is_empty())));
assert!(
matches!(project.elements[&identity(&project, "retry_budget")].shared(), Some(SharedBody::DataType { definition: DataTypeDefinition::Integer { minimum: Some(0), maximum: Some(4) }, unit, .. }) if unit == "attempts")
);
assert_fidelity("state-actions-source5", &project);
}
#[test]
fn forty_exchange_diagram_with_categories_and_processes_roundtrip() {
let project = read(DENSE);
assert_eq!(
project
.relations
.values()
.filter(|r| r.kind() == RelationKind::Interaction)
.count(),
40
);
assert_eq!(
project
.elements
.values()
.filter(|e| e.kind() == Kind::ExchangeCategory)
.count(),
5
);
assert_eq!(
project
.elements
.values()
.filter(|e| e.kind() == Kind::Process)
.count(),
3
);
let view = &project.diagrams[&identity(&project, "forty_exchange_view")];
assert_eq!(view.projection, ProjectionKind::FunctionalDataflow);
assert_eq!(view.nodes.len(), 2);
assert!(view.routes.is_empty());
assert_eq!(
view.view_options.relation_kinds,
view_options::RelationKinds::All
);
assert_fidelity("dense40-source6", &project);
}
#[test]
fn component_ports_interface_operations_and_boundary_calls_roundtrip() {
let project = read(OPERATIONS);
assert_eq!(
project
.relations
.values()
.filter(|r| r.kind() == RelationKind::InterfaceItemAllocation)
.count(),
2
);
assert_eq!(
project
.elements
.values()
.filter(|e| matches!(
e.shared(),
Some(SharedBody::Port {
contract: PortContract::Component {
kind: ComponentPortKind::Standard,
..
},
..
})
))
.count(),
2
);
assert!(project.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::ExchangeItem {
mechanism: ExchangeMechanism::Operation,
..
})
)));
assert!(project.elements.values().any(|e| matches!(e.shared(), Some(SharedBody::Scenario { viewpoint: ScenarioViewpoint::BoundaryInterface, steps, .. }) if steps.len() == 2)));
assert_eq!(
project
.elements
.values()
.filter(|e| matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
operation: None,
..
},
..
})
))
.count(),
2
);
assert_fidelity("boundary-operations-source6", &project);
}
#[test]
fn negative_zero_node_coordinate_survives_canonical_compact_and_bundle_compile() {
let mut project = read(STATES);
let diagram = identity(&project, "state_view");
let node = identity(&project, "view_machine");
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position = Position { x: -0.0, y: 0.0 };
let (decoded, flat) = assert_fidelity("state-negative-zero", &project);
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), flat)]),
};
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let modular = compile_bundle(&format_bundle(&project), ProjectLimits::default()).unwrap();
// Project/Position PartialEq alone equates the signs of zero. Check bits in
// each independently decoded representation, including source-project compile.
for actual in [
&decoded,
&compiled.decoded.project,
&modular.decoded.project,
] {
let position = &actual.diagrams[&diagram].nodes[&node].position;
assert_eq!(position.x.to_bits(), (-0.0_f32).to_bits());
assert_eq!(position.y.to_bits(), 0.0_f32.to_bits());
}
}
#[test]
fn present_empty_field_default_and_unbounded_maximum_remain_distinct_from_omission() {
let mut project = read(KEYED);
let field_id = identity(&project, "oa_navigation_latitude");
let Some(SharedBody::DataField {
minimum,
maximum,
default,
storage,
..
}) = project.elements.get_mut(&field_id).unwrap().shared_mut()
else {
panic!("fixture must contain the claimed DataField");
};
assert_eq!(*minimum, 0);
assert_eq!(*maximum, Some(1));
assert_eq!(*default, None);
assert_eq!(*storage, FieldStorage::Value);
*maximum = None;
*default = Some(vec![]);
let (decoded, _) = assert_fidelity("keyed-present-empty-default", &project);
assert!(
matches!(decoded.elements[&field_id].shared(), Some(SharedBody::DataField { maximum: None, default: Some(values), .. }) if values.is_empty())
);
}
#[test]
fn present_empty_artifact_revision_and_fragment_survive() {
let mut project = read(COASTAL);
let artifact_id = identity(&project, "console_enclosure_cad");
let artifact = project.artifact_links.get_mut(&artifact_id).unwrap();
artifact.revision = Some(String::new());
artifact.fragment = Some(String::new());
let (decoded, _) = assert_fidelity("coastal-present-empty-artifact-strings", &project);
assert_eq!(
decoded.artifact_links[&artifact_id].revision.as_deref(),
Some("")
);
assert_eq!(
decoded.artifact_links[&artifact_id].fragment.as_deref(),
Some("")
);
}
#[test]
fn selected_and_unresolved_operation_intent_survive_as_distinct_present_records() {
let mut project = read(OPERATIONS);
let allocation = identity(&project, "operation_probe_30");
assert_eq!(
project.relations[&allocation].kind(),
RelationKind::InterfaceItemAllocation
);
let selected = identity(&project, "operation_probe_43");
let unresolved = identity(&project, "operation_probe_44");
for (subject, value) in [(selected, Some(allocation)), (unresolved, None)] {
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = project.elements.get_mut(&subject).unwrap().shared_mut()
else {
panic!("fixture must contain a Call Exchange");
};
assert_eq!(*operation, None);
*operation = Some(OperationUse { allocation: value });
}
let (decoded, _) = assert_fidelity("boundary-selected-and-unresolved-operations", &project);
assert!(
matches!(decoded.elements[&selected].shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(OperationUse { allocation: Some(actual) }), .. }, .. }) if *actual == allocation)
);
assert!(matches!(
decoded.elements[&unresolved].shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(OperationUse { allocation: None }),
..
},
..
})
));
}
@@ -1,256 +0,0 @@
//! Focused public-output contracts. Field locations come from the real compiler;
//! token comparisons ignore layout while retaining typed tags and required syntax.
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
fn project(declarations: &str) -> Project {
language::parse(&format!(
"arcadia 7; project p \"P\" id \"00000000-0000-4000-8000-000000000001\" {{ {declarations} }}"
)).unwrap()
}
fn exports(project: &Project) -> Vec<CompiledSourceProject> {
let flat = language::format(project);
assert_eq!(language::parse(&flat).unwrap(), *project);
let flat_bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), flat)]),
};
[flat_bundle, format_bundle(project)]
.into_iter()
.map(|bundle| {
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(compiled.decoded.project, *project);
assert_eq!(
compiled.bundle, bundle,
"all identities are explicit and stable"
);
compiled
})
.collect()
}
fn identity(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn field(compiled: &CompiledSourceProject, alias: &str, name: &str) -> Option<String> {
let location = compiled
.index
.fields
.get(&(identity(&compiled.decoded.project, alias), name.into()))?;
let text = &compiled.bundle.files[&location.path][location.span.start..location.span.end];
Some(
language::tokenize(text)
.into_iter()
.filter(|token| {
!matches!(
token.class,
language::TokenClass::Whitespace | language::TokenClass::Comment
)
})
.map(|token| &text[token.span.start..token.span.end])
.collect::<Vec<_>>()
.join(" "),
)
}
#[test]
fn empty_project_has_no_synthetic_description_or_module() {
let empty = project("");
for compiled in exports(&empty) {
assert!(compiled.index.project_description.is_none());
assert!(compiled.index.fields.is_empty());
assert!(compiled.index.includes.is_empty());
assert_eq!(compiled.bundle.files.len(), 1);
assert_eq!(compiled.index.declarations.len(), 1);
}
}
#[test]
fn only_empty_descriptions_disappear_whitespace_and_text_survive_exactly() {
let mut model =
project("oa_activity work \"Work\" id \"00000000-0000-4000-8000-000000000002\" {}");
let work = identity(&model, "work");
for description in ["", " \t\n", "A quoted \"requirement\" — 雨"] {
model.description = description.into();
model.elements.get_mut(&work).unwrap().description = description.into();
for compiled in exports(&model) {
assert_eq!(
compiled.index.project_description.is_some(),
!description.is_empty()
);
assert_eq!(
field(&compiled, "work", "description").is_some(),
!description.is_empty()
);
assert!(field(&compiled, "work", "parent").is_none());
// exports compares the complete model, including exact whitespace
// and escapes in both project and element descriptions.
}
}
}
#[test]
fn sa_view_keeps_its_layer_and_node_references_but_omits_presentation_defaults() {
let model = project(
r#"
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
diagram view "View" id "00000000-0000-4000-8000-000000000003" { layer Sa; }
node placement id "00000000-0000-4000-8000-000000000004" {
diagram @view; element @work;
position { x 0.0; y 0.0; }; size { width 224.0; height 94.0; };
collapsed false; port_anchors [];
}
"#,
);
for compiled in exports(&model) {
assert_eq!(field(&compiled, "view", "layer").as_deref(), Some("Sa"));
for name in ["description", "projection", "routes", "view_options"] {
assert!(field(&compiled, "view", name).is_none(), "view.{name}");
}
assert_eq!(
field(&compiled, "placement", "diagram").as_deref(),
Some("@ view")
);
assert_eq!(
field(&compiled, "placement", "element").as_deref(),
Some("@ work")
);
for name in ["position", "size", "collapsed", "port_anchors"] {
assert!(
field(&compiled, "placement", name).is_none(),
"placement.{name}"
);
}
}
}
#[test]
fn nested_type_defaults_disappear_without_losing_required_or_present_empty_values() {
let model = project(
r#"
sa_data_type implicit "Implicit" id "00000000-0000-4000-8000-000000000002" {}
sa_data_type integer "Integer" id "00000000-0000-4000-8000-000000000003" {
definition { Integer { minimum none; maximum none; }; };
}
sa_data_type text "Text" id "00000000-0000-4000-8000-000000000004" {
definition { String { minimum_length 4; maximum_length none; }; };
}
sa_data_type record "Record" id "00000000-0000-4000-8000-000000000005" {
definition { Record { supertype none; }; };
}
sa_data_type flag "Flag" id "00000000-0000-4000-8000-000000000006" {
definition Boolean; default { Boolean false; };
}
sa_data_field optional "Optional" id "00000000-0000-4000-8000-000000000007" {
parent @record; minimum 0; maximum none; default [];
}
"#,
);
for compiled in exports(&model) {
assert!(field(&compiled, "implicit", "definition").is_none());
assert_eq!(
field(&compiled, "integer", "definition").as_deref(),
Some("{ Integer { } ; }")
);
assert_eq!(
field(&compiled, "text", "definition").as_deref(),
Some("{ String { minimum_length 4 ; } ; }")
);
assert_eq!(
field(&compiled, "record", "definition").as_deref(),
Some("{ Record { } ; }")
);
assert_eq!(
field(&compiled, "flag", "definition").as_deref(),
Some("Boolean")
);
assert_eq!(
field(&compiled, "flag", "default").as_deref(),
Some("{ Boolean false ; }")
);
assert_eq!(
field(&compiled, "optional", "minimum").as_deref(),
Some("0")
);
assert_eq!(
field(&compiled, "optional", "maximum").as_deref(),
Some("none")
);
assert_eq!(
field(&compiled, "optional", "default").as_deref(),
Some("[ ]")
);
assert!(field(&compiled, "optional", "type_ref").is_none());
}
}
#[test]
fn required_port_contract_and_empty_item_list_survive_while_optional_interface_disappears() {
let model = project(
r#"
sa_function work "Work" id "00000000-0000-4000-8000-000000000002" {}
sa_port sink "Sink" id "00000000-0000-4000-8000-000000000003" {
parent @work; contract { FunctionInput { incoming_items []; }; };
}
sa_system host "Host" id "00000000-0000-4000-8000-000000000004" {}
sa_port preserved "Preserved" id "00000000-0000-4000-8000-000000000005" {
parent @host; contract Legacy; legacy { direction Out; interface none; };
}
"#,
);
for compiled in exports(&model) {
assert_eq!(
field(&compiled, "sink", "contract").as_deref(),
Some("{ FunctionInput { incoming_items [ ] ; } ; }")
);
assert!(field(&compiled, "sink", "legacy").is_none());
assert_eq!(
field(&compiled, "preserved", "contract").as_deref(),
Some("Legacy")
);
assert_eq!(
field(&compiled, "preserved", "legacy").as_deref(),
Some("{ direction Out ; }")
);
}
}
#[test]
fn source_scenario_default_and_selected_occurrence_constructor_remain_distinct() {
let model = project(
r#"
sa_scenario mixed "Mixed" id "00000000-0000-4000-8000-000000000002" { viewpoint MixedLegacy; }
sa_scenario strict "Strict" id "00000000-0000-4000-8000-000000000003" { viewpoint Functional; }
oa_process flow "Flow" id "00000000-0000-4000-8000-000000000004" { steps [@exchange, @action]; }
oa_occurrence exchange "Exchange" id "00000000-0000-4000-8000-000000000005" {
parent @flow; body { Exchange {}; };
}
oa_occurrence action "Action" id "00000000-0000-4000-8000-000000000006" {
parent @flow; body { Action { action none; lifeline none; }; };
}
"#,
);
for compiled in exports(&model) {
assert!(field(&compiled, "mixed", "viewpoint").is_none());
assert_eq!(
field(&compiled, "strict", "viewpoint").as_deref(),
Some("Functional")
);
assert!(field(&compiled, "exchange", "body").is_none());
assert_eq!(
field(&compiled, "action", "body").as_deref(),
Some("{ Action { } ; }")
);
assert_eq!(
field(&compiled, "action", "parent").as_deref(),
Some("@ flow")
);
}
}
@@ -1,612 +0,0 @@
//! Exact-source regressions for fields skipped by persistence serialization.
//!
//! The archived Windows baseline confirmed three exact-source failures after
//! full model equality passed. Complete comparison now preserves these fields;
//! the former ignored witnesses are normal regression tests.
//! No-op and same-record header controls remain normal tests: they must preserve
//! the same fixtures without entering the changed-payload record-erasure branch.
use oa_core::{source_project::*, *};
use std::collections::BTreeMap;
const EXCHANGE_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "00000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "00000000-0000-4000-8000-000000000003" {}
interaction flow "Flow" id "00000000-0000-4000-8000-000000000004" {
source @first; target @second;
}
oa_process owner "Owner" id "00000000-0000-4000-8000-000000000005" {
steps [@subject];
}
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000006" {
parent @owner;
body { Exchange {
exchange none;
sender none; receiver none;
message_kind Asynchronous; reply_to none; values [];
operation none ; // Keep this authored default and its spelling.
}; };
}
}
"#;
const NODE_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
diagram view "View" id "00000000-0000-4000-8000-000000000003" {}
node placement id "00000000-0000-4000-8000-000000000004" {
diagram @view; element @work;
position { x 12.5; y -4.0; };
port_anchors [] ; // Keep explicit automatic placement.
}
}
"#;
const TRACE_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_activity work "Work" id "00000000-0000-4000-8000-000000000002" {}
sa_function function "Function" id "00000000-0000-4000-8000-000000000003" {}
trace mapping "Mapping" id "00000000-0000-4000-8000-000000000004" {
oa @work; sa @function; kind Manual;
baseline {
oa { name "Work"; description "Old OA snapshot"; };
sa { name "Function"; description "Old SA snapshot"; };
semantic none ; // Keep the explicit prose-only baseline.
};
}
}
"#;
const CONSTRUCTOR_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_scenario owner "Owner" id "00000000-0000-4000-8000-000000000002" {
steps [@subject];
}
oa_occurrence subject "Subject" id "00000000-0000-4000-8000-000000000003" {
parent @owner;
body { Action {
action none;
lifeline none ; // Keep this shared field's distinctive spelling.
}; };
}
}
"#;
const RELATION_CONTEXT_SOURCE: &str = r#"arcadia 7;
project p "Preservation" id "00000000-0000-4000-8000-000000000001" {
oa_entity first "First" id "00000000-0000-4000-8000-000000000002" {}
oa_entity second "Second" id "00000000-0000-4000-8000-000000000003" {}
// These port declarations are untouched by the relation context change.
oa_port output "Output" id "00000000-0000-4000-8000-000000000005" {
parent @first; contract Legacy;
legacy { direction Out; interface none; };
}
oa_port input "Input" id "00000000-0000-4000-8000-000000000006" {
parent @second; contract Legacy;
legacy { direction In; interface none; };
}
boundary_exchange link "Link" id "00000000-0000-4000-8000-000000000004" {
source @output; target @input;
oriented true ; // Retain this rationale when changing the default value.
items [];
}
}
"#;
fn id(number: u8) -> Id {
Id::parse_str(&format!("00000000-0000-4000-8000-{number:012}")).unwrap()
}
fn source_bundle(source: &str) -> SourceBundle {
SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source.into())]),
}
}
fn project() -> Project {
Project {
schema_version: 7,
id: id(1),
alias: "p".into(),
name: "Preservation".into(),
description: String::new(),
elements: BTreeMap::new(),
relations: BTreeMap::new(),
diagrams: BTreeMap::new(),
artifact_links: BTreeMap::new(),
traces: BTreeMap::new(),
}
}
fn element(number: u8, alias: &str, name: &str, parent: Option<Id>, body: ElementBody) -> Element {
Element {
id: id(number),
alias: alias.into(),
name: name.into(),
description: String::new(),
parent,
body,
}
}
// Handwritten typed expectations are independent of canonical serialization and
// source hydration. No model factories or generated/random identities are used.
fn exchange_project() -> Project {
let mut expected = project();
expected.elements = [
element(2, "first", "First", None, ElementBody::Oa(OaBody::Activity)),
element(
3,
"second",
"Second",
None,
ElementBody::Oa(OaBody::Activity),
),
element(
5,
"owner",
"Owner",
None,
ElementBody::Oa(OaBody::Process { steps: vec![id(6)] }),
),
element(
6,
"subject",
"Subject",
Some(id(5)),
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
})),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
expected.relations.insert(
id(4),
Relation {
id: id(4),
alias: "flow".into(),
name: "Flow".into(),
description: String::new(),
body: RelationBody::Interaction {
source: id(2),
target: id(3),
items: vec![],
},
},
);
expected
}
fn node_project() -> Project {
let mut expected = project();
expected.elements.insert(
id(2),
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
);
expected.diagrams.insert(
id(3),
Diagram {
id: id(3),
alias: "view".into(),
name: "View".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::Overview,
view_options: view_options::ViewOptions {
hidden_edges: vec![],
relation_kinds: view_options::RelationKinds::All,
work_categories: view_options::CategorySelection::All,
participant_categories: view_options::CategorySelection::All,
coverage: view_options::Coverage::All,
work_contexts: view_options::ContextSelection::All,
exchange_labels: view_options::ExchangeLabels::Name,
},
nodes: BTreeMap::from([(
id(4),
NodePlacement {
id: id(4),
alias: "placement".into(),
element: id(2),
position: Position { x: 12.5, y: -4.0 },
size: Size {
width: 224.0,
height: 94.0,
},
collapsed: false,
port_anchors: vec![],
},
)]),
routes: vec![],
},
);
expected
}
fn trace_project() -> Project {
let mut expected = project();
expected.elements = [
element(2, "work", "Work", None, ElementBody::Oa(OaBody::Activity)),
element(
3,
"function",
"Function",
None,
ElementBody::Sa(SaBody::Function),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
expected.traces.insert(
id(4),
Trace {
id: id(4),
alias: "mapping".into(),
name: "Mapping".into(),
description: String::new(),
oa: id(2),
sa: id(3),
kind: TraceKind::Manual,
baseline: Some(TraceBaseline {
oa: TracedProperties {
name: "Work".into(),
description: "Old OA snapshot".into(),
},
sa: TracedProperties {
name: "Function".into(),
description: "Old SA snapshot".into(),
},
semantic: None,
}),
},
);
expected
}
fn checked_fixture(source: &str, expected: &Project) -> CompiledSourceProject {
let input = source_bundle(source);
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
assert_eq!(
compiled.decoded.project, *expected,
"fixture model admission"
);
assert_eq!(
compiled.bundle, input,
"fixed IDs must avoid stabilization edits"
);
compiled
}
fn replacement(source: &str, before: &str, after: &str) -> String {
assert_eq!(
source.matches(before).count(),
1,
"unique authored edit token"
);
source.replacen(before, after, 1)
}
fn assert_preserved_update(
original: &CompiledSourceProject,
expected: &Project,
expected_source: &str,
) {
// Independently admit the exact expected spelling before testing the updater.
let expected_bundle = source_bundle(expected_source);
let authored = compile_bundle(&expected_bundle, ProjectLimits::default()).unwrap();
assert_eq!(
authored.decoded.project, *expected,
"expected source/model agree"
);
assert_eq!(
authored.bundle, expected_bundle,
"expected source is already stable"
);
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
assert_eq!(
updated.decoded.project, *expected,
"entire intended model survives"
);
assert_eq!(
updated.bundle, expected_bundle,
"only the requested token may change; preserve explicit defaults and all trivia"
);
}
// Context changes may introduce/remove redundant default syntax. Admit each
// narrowly enumerated spelling against the same literal target model, and then
// compare complete source bundles. No normalization may mask edits to unrelated
// fields, declarations, whitespace, or comments.
fn assert_context_update(
original: &CompiledSourceProject,
expected: &Project,
allowed_sources: &[String],
) {
let allowed: Vec<_> = allowed_sources
.iter()
.map(|source| {
let bundle = source_bundle(source);
let authored = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
assert_eq!(
authored.decoded.project, *expected,
"allowed source/model agree"
);
assert_eq!(authored.bundle, bundle, "allowed source is already stable");
bundle
})
.collect();
let updated = update_bundle(original, expected, ProjectLimits::default()).unwrap();
assert_eq!(
updated.decoded.project, *expected,
"entire context-change model survives"
);
assert!(
allowed.contains(&updated.bundle),
"preserve unchanged source exactly; only listed changed/default syntax alternatives are allowed\nactual: {:?}\nallowed: {:?}",
updated.bundle,
allowed
);
}
#[test]
fn explicit_exchange_operation_none_survives_noop() {
let expected = exchange_project();
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
assert_preserved_update(&original, &expected, EXCHANGE_SOURCE);
}
#[test]
fn explicit_exchange_operation_none_survives_header_rename() {
let mut expected = exchange_project();
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
expected.elements.get_mut(&id(6)).unwrap().name = "Renamed subject".into();
let source = replacement(EXCHANGE_SOURCE, "\"Subject\"", "\"Renamed subject\"");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_exchange_operation_none_survives_exchange_binding_edit() {
let mut expected = exchange_project();
let original = checked_fixture(EXCHANGE_SOURCE, &expected);
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = expected.elements.get_mut(&id(6)).unwrap().body.shared_mut()
else {
panic!("literal fixture must be an Exchange occurrence");
};
*exchange = Some(id(4));
let source = replacement(EXCHANGE_SOURCE, "exchange none;", "exchange @flow;");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_node_empty_port_anchors_survive_noop() {
let expected = node_project();
let original = checked_fixture(NODE_SOURCE, &expected);
assert_preserved_update(&original, &expected, NODE_SOURCE);
}
#[test]
fn explicit_node_empty_port_anchors_survive_header_alias_rename() {
let mut expected = node_project();
let original = checked_fixture(NODE_SOURCE, &expected);
// Nodes have no display-name header. Rename the same node's header alias.
expected
.diagrams
.get_mut(&id(3))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.alias = "renamed_placement".into();
let source = replacement(
NODE_SOURCE,
"node placement id",
"node renamed_placement id",
);
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_node_empty_port_anchors_survive_position_edit() {
let mut expected = node_project();
let original = checked_fixture(NODE_SOURCE, &expected);
expected
.diagrams
.get_mut(&id(3))
.unwrap()
.nodes
.get_mut(&id(4))
.unwrap()
.position
.x = 25.0;
let source = replacement(NODE_SOURCE, "x 12.5;", "x 25.0;");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_trace_semantic_none_survives_noop() {
let expected = trace_project();
let original = checked_fixture(TRACE_SOURCE, &expected);
assert_preserved_update(&original, &expected, TRACE_SOURCE);
}
#[test]
fn explicit_trace_semantic_none_survives_header_rename() {
let mut expected = trace_project();
let original = checked_fixture(TRACE_SOURCE, &expected);
expected.traces.get_mut(&id(4)).unwrap().name = "Renamed mapping".into();
let source = replacement(TRACE_SOURCE, "\"Mapping\"", "\"Renamed mapping\"");
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn explicit_trace_semantic_none_survives_snapshot_prose_edit() {
let mut expected = trace_project();
let original = checked_fixture(TRACE_SOURCE, &expected);
expected
.traces
.get_mut(&id(4))
.unwrap()
.baseline
.as_mut()
.unwrap()
.oa
.description = "Revised OA snapshot".into();
let source = replacement(
TRACE_SOURCE,
"\"Old OA snapshot\"",
"\"Revised OA snapshot\"",
);
assert_preserved_update(&original, &expected, &source);
}
#[test]
fn constructor_change_removes_inapplicable_default_and_keeps_shared_default() {
let mut expected = project();
expected.elements = [
element(
2,
"owner",
"Owner",
None,
ElementBody::Oa(OaBody::Shared(SharedBody::Scenario {
viewpoint: ScenarioViewpoint::MixedLegacy,
context: None,
precondition: String::new(),
postcondition: String::new(),
steps: vec![id(3)],
})),
),
element(
3,
"subject",
"Subject",
Some(id(2)),
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: None,
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
})),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
let original = checked_fixture(CONSTRUCTOR_SOURCE, &expected);
expected.elements.get_mut(&id(3)).unwrap().body =
ElementBody::Oa(OaBody::Shared(SharedBody::Occurrence {
body: OccurrenceBody::State {
state: None,
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
}));
// Remove only the inapplicable field's syntax: its separating space and all
// surrounding trivia stay. The shared lifeline spelling/comment is exact.
let source = replacement(CONSTRUCTOR_SOURCE, "body { Action {", "body { State {");
let compact = replacement(&source, "action none;", " ");
let expanded = replacement(
&compact,
" }; };",
" state none;\n }; };",
);
// Omission of newly applicable state and explicit state none both mean the
// literal State target; neither may retain the old action field.
assert_context_update(&original, &expected, &[compact, expanded]);
}
#[test]
fn relation_kind_change_updates_explicit_value_when_context_default_changes() {
let mut expected = project();
expected.elements = [
element(2, "first", "First", None, ElementBody::Oa(OaBody::Entity)),
element(3, "second", "Second", None, ElementBody::Oa(OaBody::Entity)),
element(
5,
"output",
"Output",
Some(id(2)),
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::Out,
interface: None,
}),
})),
),
element(
6,
"input",
"Input",
Some(id(3)),
ElementBody::Oa(OaBody::Shared(SharedBody::Port {
contract: PortContract::Legacy,
legacy: Some(LegacyPortContract {
direction: PortDirection::In,
interface: None,
}),
})),
),
]
.into_iter()
.map(|element| (element.id, element))
.collect();
expected.relations.insert(
id(4),
Relation {
id: id(4),
alias: "link".into(),
name: "Link".into(),
description: String::new(),
body: RelationBody::BoundaryExchange {
source: id(5),
target: id(6),
oriented: true,
items: vec![],
},
},
);
let original = checked_fixture(RELATION_CONTEXT_SOURCE, &expected);
// BoundaryExchange connects participant ports; CommunicationMean connects
// their OA entity owners. Endpoint changes are explicit parts of this target,
// not inferred or normalized away. Port records remain exactly unchanged.
expected.relations.get_mut(&id(4)).unwrap().body = RelationBody::CommunicationMean {
source: id(2),
target: id(3),
oriented: false,
items: vec![],
};
let source = replacement(
RELATION_CONTEXT_SOURCE,
"boundary_exchange link",
"communication_mean link",
);
let source = replacement(&source, "source @output;", "source @first;");
let source = replacement(&source, "target @input;", "target @second;");
let explicit = replacement(&source, "oriented true ;", "oriented false ;");
// Three spaces between name/value plus one before the semicolon survive
// syntax removal. The following rationale comment and all other bytes stay.
let omitted = replacement(&source, "oriented true ;", " ");
assert_context_update(&original, &expected, &[explicit, omitted]);
}
@@ -1,222 +0,0 @@
//! Public update witnesses for compact insertion and complete comparison.
use oa_core::{language, source_project::*, *};
use std::collections::BTreeMap;
fn compile(body: &str) -> CompiledSourceProject {
compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
(
"project.arcadia".into(),
"arcadia 7; project p \"P\" { include \"model.arcadia\"; }".into(),
),
("model.arcadia".into(), body.into()),
]),
},
ProjectLimits::default(),
)
.unwrap()
}
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn update(before: &CompiledSourceProject, project: &Project) -> CompiledSourceProject {
let result = update_bundle(before, project, ProjectLimits::default()).unwrap();
assert_eq!(result.decoded.project, *project);
assert_eq!(
result.bundle.files["project.arcadia"],
before.bundle.files["project.arcadia"]
);
assert_eq!(
compile_bundle(&result.bundle, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle
);
assert_eq!(
update_bundle(&result, project, ProjectLimits::default())
.unwrap()
.bundle,
result.bundle
);
result
}
fn words(text: &str) -> Vec<&str> {
language::tokenize(text)
.into_iter()
.filter(|t| {
!matches!(
t.class,
language::TokenClass::Whitespace | language::TokenClass::Comment
)
})
.map(|t| &text[t.span.start..t.span.end])
.collect()
}
#[test]
fn omitted_exchange_body_changes_to_one_compact_action_constructor() {
let before = compile(
"oa_scenario s \"S\" { steps [@o]; }\noa_occurrence o \"O\" { parent @s; // keep this\n}\n",
);
let mut project = before.decoded.project.clone();
let occurrence = id(&project, "o");
let Some(SharedBody::Occurrence { body, .. }) =
project.elements.get_mut(&occurrence).unwrap().shared_mut()
else {
panic!("occurrence")
};
assert!(matches!(body, OccurrenceBody::Exchange { .. }));
*body = OccurrenceBody::Action {
action: None,
lifeline: None,
};
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert_eq!(tokens.iter().filter(|&&t| t == "body").count(), 1);
assert!(tokens.contains(&"Action"));
for absent in ["Exchange", "action", "lifeline", "operation", "description"] {
assert!(!tokens.contains(&absent), "{absent}: {text}");
}
assert!(text.contains("parent @s; // keep this"));
}
#[test]
fn default_signal_constructor_still_inserts_its_required_empty_items() {
let before =
compile("oa_event e \"E\" { body { Time { expression \"1 s\"; kind After; }; }; }\n");
let mut project = before.decoded.project.clone();
let event = id(&project, "e");
let Some(SharedBody::Event { body }) = project.elements.get_mut(&event).unwrap().shared_mut()
else {
panic!("event")
};
*body = EventBody::Signal { items: vec![] };
let result = update(&before, &project);
let tokens = words(&result.bundle.files["model.arcadia"]);
assert!(tokens.contains(&"Signal"));
assert!(tokens.windows(4).any(|w| w == ["items", "[", "]", ";"]));
assert!(!tokens.contains(&"expression"));
assert!(!tokens.contains(&"kind"));
}
#[test]
fn scalar_to_default_string_record_retains_required_minimum_only() {
let before = compile("oa_data_type t \"T\" { definition Boolean; // typed rationale\n}\n");
let mut project = before.decoded.project.clone();
let datatype = id(&project, "t");
let Some(SharedBody::DataType { definition, .. }) =
project.elements.get_mut(&datatype).unwrap().shared_mut()
else {
panic!("data type")
};
*definition = DataTypeDefinition::String {
minimum_length: 0,
maximum_length: None,
};
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert!(tokens.contains(&"String"));
assert!(tokens.windows(3).any(|w| w == ["minimum_length", "0", ";"]));
assert!(!tokens.contains(&"maximum_length"));
assert!(text.contains("// typed rationale"));
}
#[test]
fn inserted_route_list_item_omits_only_optional_fields() {
let before = compile(
"oa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction flow \"Flow\" { source @a; target @b; }\ndiagram d \"D\" { routes []; // routing rationale\n}\n",
);
let mut project = before.decoded.project.clone();
let diagram = id(&project, "d");
let edge = id(&project, "flow");
// Deserialize only the explicit witness value, independently of omission policy.
project.diagrams.get_mut(&diagram).unwrap().routes =
serde_json::from_value(serde_json::json!([{"edge":edge,"waypoints":[],"label":null}]))
.unwrap();
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert!(tokens.windows(4).any(|w| w == ["edge", "@", "flow", ";"]));
assert!(!tokens.contains(&"waypoints"));
assert!(!tokens.contains(&"label"));
assert!(text.contains("// routing rationale"));
}
#[test]
fn zero_sign_change_inserts_position_without_default_siblings() {
let before = compile(
"oa_activity a \"A\" {}\ndiagram d \"D\" {}\nnode n { diagram @d; element @a; // placement rationale\n}\n",
);
let mut project = before.decoded.project.clone();
let diagram = id(&project, "d");
let node = id(&project, "n");
assert_eq!(
project.diagrams[&diagram].nodes[&node].position.x.to_bits(),
0.0_f32.to_bits()
);
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.position
.x = -0.0;
let result = update(&before, &project);
assert_eq!(
result.decoded.project.diagrams[&diagram].nodes[&node]
.position
.x
.to_bits(),
(-0.0_f32).to_bits()
);
let text = &result.bundle.files["model.arcadia"];
let tokens = words(text);
assert!(tokens.windows(3).any(|w| w == ["x", "-0.0", ";"]));
for absent in ["size", "collapsed", "port_anchors"] {
assert!(!tokens.contains(&absent));
}
assert!(text.contains("// placement rationale"));
}
#[test]
fn kind_change_materializes_unchanged_value_when_its_old_omission_default_changes() {
let before = compile(
"oa_entity a \"A\" {}\noa_entity b \"B\" {}\noa_port out \"Out\" { parent @a; contract Legacy; legacy { direction Out; }; }\noa_port input \"In\" { parent @b; contract Legacy; legacy { direction In; }; }\nboundary_exchange flow \"Flow\" { source @out; target @input; // orientation inherited\n}\n",
);
let mut project = before.decoded.project.clone();
let flow = id(&project, "flow");
assert!(matches!(
project.relations[&flow].body,
RelationBody::BoundaryExchange { oriented: true, .. }
));
project.relations.get_mut(&flow).unwrap().body = RelationBody::CommunicationMean {
source: id(&before.decoded.project, "a"),
target: id(&before.decoded.project, "b"),
oriented: true,
items: vec![],
};
let result = update(&before, &project);
let text = &result.bundle.files["model.arcadia"];
assert!(
words(text)
.windows(3)
.any(|w| w == ["oriented", "true", ";"])
);
assert!(text.contains("// orientation inherited"));
let before_ports = &before.bundle.files["model.arcadia"];
assert_eq!(
text.split("communication_mean").next().unwrap(),
before_ports.split("boundary_exchange").next().unwrap()
);
}
-290
View File
@@ -1,290 +0,0 @@
use oa_core::{language, persistence, source_project::*, *};
use serde_json::{Value, json};
use std::collections::BTreeMap;
const SOURCE: &str = r#"arcadia 7;
project attachments "Connection ends" id "70000000-0000-4000-8000-000000000001" {
oa_activity first "First" id "70000000-0000-4000-8000-000000000002" {}
oa_activity second "Second" id "70000000-0000-4000-8000-000000000003" {}
interaction signal "Signal" id "70000000-0000-4000-8000-000000000004" { source @first; target @second; }
diagram view "View" id "70000000-0000-4000-8000-000000000005" {
routes [{ edge @signal; waypoints [{ x 120; y 80; }];
label { fraction 0.5; offset { x 3; y -7; }; };
source_attachment { side Left; offset 0.25; };
target_attachment { side Bottom; offset 1; };
}];
}
diagram other "Other" id "70000000-0000-4000-8000-000000000006" { routes [{ edge @signal; }]; }
}
"#;
fn id(project: &Project, alias: &str) -> Id {
project
.identities()
.into_iter()
.find(|(_, a)| *a == alias)
.unwrap()
.0
}
fn original() -> Project {
language::parse(SOURCE).unwrap()
}
fn set(diagram: Id, route: EdgeRoute) -> Command {
Command::SetEdgeRoute { diagram, route }
}
#[test]
fn edge_attachments_roundtrip_source_json_bundle_and_automatic_defaults() {
let project = original();
let view = id(&project, "view");
let route = &project.diagrams[&view].routes[0];
assert_eq!(
route.source_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Left,
offset: 0.25
})
);
assert_eq!(
route.target_attachment,
Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 1.0
})
);
for text in [SOURCE.to_owned(), language::format(&project)] {
assert_eq!(language::parse(&text).unwrap(), project);
}
assert_eq!(
persistence::decode(&serde_json::to_vec(&project).unwrap()).unwrap(),
project
);
assert_eq!(
compile_bundle(&format_bundle(&project), ProjectLimits::default())
.unwrap()
.decoded
.project,
project
);
let automatic = &project.diagrams[&id(&project, "other")].routes[0];
assert_eq!(automatic.source_attachment, None);
assert_eq!(automatic.target_attachment, None);
let encoded = serde_json::to_value(automatic).unwrap();
assert!(encoded.get("source_attachment").is_none());
assert!(encoded.get("target_attachment").is_none());
let mut with_null = encoded.clone();
with_null["source_attachment"] = Value::Null;
with_null["target_attachment"] = Value::Null;
assert_eq!(
serde_json::from_value::<EdgeRoute>(with_null).unwrap(),
*automatic
);
assert_eq!(
serde_json::from_value::<EdgeRoute>(encoded).unwrap(),
*automatic
);
// Old source without either new field still means automatic presentation.
let old = SOURCE
.replace(" source_attachment { side Left; offset 0.25; };\n", "")
.replace(" target_attachment { side Bottom; offset 1; };\n", "");
let old = language::parse(&old).unwrap();
assert!(
old.diagrams
.values()
.flat_map(|d| &d.routes)
.all(|r| r.source_attachment.is_none() && r.target_attachment.is_none())
);
assert!(!language::format(&old).contains("_attachment"));
assert_eq!(
persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap(),
old
);
}
#[test]
fn edge_attachments_reject_malformed_nested_source_and_json_without_defaults() {
let project = original();
let edge = id(&project, "signal");
for bad in [
json!({}),
json!({"side":"Top"}),
json!({"offset":0.5}),
json!({"side":"Diagonal","offset":0.5}),
json!({"side":"Top","offset":null}),
json!({"side":"Top","offset":0.5,"unknown":true}),
json!({"side":"Top","offset":"NaN"}),
] {
for field in ["source_attachment", "target_attachment"] {
let mut route = json!({"edge":edge});
route[field] = bad.clone();
assert!(
serde_json::from_value::<EdgeRoute>(route).is_err(),
"{field}: {bad}"
);
}
}
for bad in [
"{}",
"{ side Top; }",
"{ offset 0.5; }",
"{ side Diagonal; offset 0.5; }",
"{ side Top; offset null; }",
"{ side Top; offset 0.5; unknown true; }",
"{ side Top; offset -0.01; }",
"{ side Top; offset 1.01; }",
"{ side Top; offset NaN; }",
"{ side Top; offset 1e999; }",
] {
let source = SOURCE.replace("{ side Left; offset 0.25; }", bad);
assert!(language::parse(&source).is_err(), "{bad}");
let bundle = SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([("p.arcadia".into(), source)]),
};
assert!(
compile_bundle(&bundle, ProjectLimits::default()).is_err(),
"{bad}"
);
}
for version in [1, 2] {
let legacy =
format!("arcadia {version}; project p \"P\" {{ diagram v \"V\" {{ routes []; }} }}");
assert!(
language::parse(&legacy).is_err(),
"legacy route extension must not be silently admitted"
);
}
}
#[test]
fn edge_attachments_nonfinite_and_out_of_range_commands_reject_atomically() {
let project = original();
let view = id(&project, "view");
let first = id(&project, "first");
for offset in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -0.01, 1.01] {
for end in [EdgeEnd::Source, EdgeEnd::Target] {
let mut route = project.diagrams[&view].routes[0].clone();
route.set_attachment(
end,
Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset,
}),
);
let mut session = Session::new(project.clone());
assert!(
session
.execute(Command::Batch(vec![
Command::Rename(first, "Must roll back".into()),
set(view, route)
]))
.is_err()
);
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && !session.can_redo());
}
}
for offset in [-0.01, 1.01] {
let mut value = serde_json::to_value(&project).unwrap();
value["diagrams"][view.to_string()]["routes"][0]["source_attachment"]["offset"] =
json!(offset);
assert!(persistence::decode(&serde_json::to_vec(&value).unwrap()).is_err());
}
}
#[test]
fn edge_attachments_reset_one_end_and_all_routing_have_exact_history_and_view_scope() {
let project = original();
let view = id(&project, "view");
let edge = id(&project, "signal");
let mut session = Session::new(project.clone());
let mut route = project.diagrams[&view].routes[0].clone();
route.source_attachment = Some(EdgeAttachment {
side: PortAnchorSide::Right,
offset: 0.75,
});
let command = set(view, route.clone());
assert_eq!(
serde_json::from_value::<Command>(serde_json::to_value(&command).unwrap()).unwrap(),
command
);
session.execute(command).unwrap();
let mut expected = project.clone();
expected.diagrams.get_mut(&view).unwrap().routes[0] = route.clone();
assert_eq!(
session.project(),
&expected,
"only this view/end changes; semantic fields and other view stay exact"
);
session.undo();
assert_eq!(session.project(), &project);
assert!(!session.can_undo() && session.can_redo());
session.redo();
assert_eq!(session.project(), &expected);
route.source_attachment = None;
session.execute(set(view, route.clone())).unwrap();
let mut reset = expected.clone();
reset.diagrams.get_mut(&view).unwrap().routes[0] = route;
assert_eq!(
session.project(),
&reset,
"opposite attachment, pins and label survive endpoint reset"
);
session.undo();
assert_eq!(session.project(), &expected);
session
.execute(Command::RemoveEdgeRoute {
diagram: view,
edge,
})
.unwrap();
let mut removed = expected.clone();
removed.diagrams.get_mut(&view).unwrap().routes.clear();
assert_eq!(session.project(), &removed);
session.undo();
assert_eq!(session.project(), &expected);
}
#[test]
fn edge_attachments_nested_writeback_preserves_comments_adjacent_tokens_and_other_files() {
let current = compile_bundle(&SourceBundle {
entry: "p.arcadia".into(),
files: BTreeMap::from([
("p.arcadia".into(), "// project rationale\narcadia 7; project p \"P\" { include \"model.arcadia\"; include \"views.arcadia\"; }\n".into()),
("model.arcadia".into(), "// untouched semantic ownership\noa_activity a \"A\" {}\noa_activity b \"B\" {}\ninteraction signal \"Signal\" { source @a; target @b; }\n".into()),
("views.arcadia".into(), "// view rationale\r\ndiagram view \"View\" {\r\n routes [ { edge @signal; // endpoint explanation\r\n source_attachment { side Left; offset 0.25 ; // exact nested rationale\r\n }; target_attachment { side Bottom; offset 1; };\r\n waypoints [{ x 120; y 80; }]; label { fraction 0.5; offset { x 3; y -7; }; }; } ];\r\n}\r\ndiagram other \"Other\" { routes [{ edge @signal; }]; } // adjacent view\r\n".into()),
]),
}, ProjectLimits::default()).unwrap();
let mut project = current.decoded.project.clone();
let view = id(&project, "view");
project.diagrams.get_mut(&view).unwrap().routes[0]
.source_attachment
.as_mut()
.unwrap()
.offset = 0.375;
let changed = update_bundle(&current, &project, ProjectLimits::default()).unwrap();
assert_eq!(changed.decoded.project, project);
for path in ["p.arcadia", "model.arcadia"] {
assert_eq!(changed.bundle.files[path], current.bundle.files[path]);
}
assert_eq!(
changed.bundle.files["views.arcadia"],
current.bundle.files["views.arcadia"].replace("offset 0.25 ;", "offset 0.375 ;")
);
assert_eq!(
compile_bundle(&changed.bundle, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
assert_eq!(
update_bundle(&changed, &project, ProjectLimits::default())
.unwrap()
.bundle,
changed.bundle
);
}
+13 -34
View File
@@ -3,16 +3,6 @@ use oa_core::{language, persistence, validation, *};
fn read(source: &str) -> Project {
language::parse(source).unwrap_or_else(|e| panic!("{e:?}\n{source}"))
}
fn replace_once(source: &str, before: &str, after: &str) -> String {
assert_eq!(
source.matches(before).count(),
1,
"unique mutation anchor {before:?}"
);
let changed = source.replacen(before, after, 1);
assert_ne!(changed, source, "test mutation must change the source");
changed
}
fn rejected(source: &str, fragment: &str) {
let errors = language::parse(source).expect_err(source);
assert!(
@@ -763,8 +753,8 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
source.push('}');
let model = read(&source);
let canonical = language::format(&model);
assert!(!canonical.contains("transition_kind External;"));
for fragment in [
"transition_kind External;",
"transition_kind Local;",
"transition_kind Internal;",
"triggers [@deadline, @assess];",
@@ -786,7 +776,6 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
.find(|r| r.alias == "recover")
.unwrap();
let RelationBody::StateTransition {
transition_kind,
trigger,
guard,
effect,
@@ -795,42 +784,32 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
else {
panic!("expected transition")
};
assert_eq!(*transition_kind, TransitionKind::External);
assert_eq!(trigger, "Operator confirms\nНе запускать код");
assert_eq!(guard, "available && status == \"Ready\"");
assert_eq!(effect, "Record the assessment");
// External is omitted in compact output. Insert an explicit invalid
// discriminator into Recover using its unique, nondefault trigger list.
let invalid_kind = replace_once(
&canonical,
"triggers [@deadline, @assess];",
"transition_kind Unsupported;\n triggers [@deadline, @assess];",
);
rejected(&invalid_kind, "Unsupported");
for invalid in [
replace_once(
&canonical,
canonical.replace("transition_kind External;", "transition_kind Unsupported;"),
canonical.replace(
"triggers [@deadline, @assess];",
"triggers [\"deadline\", @assess];",
),
replace_once(
&canonical,
canonical.replace(
"triggers [@deadline, @assess];",
"triggers [@deadline, @deadline];",
),
replace_once(&canonical, "effects [@assess];", "effects [@payload];"),
replace_once(&canonical, "kind After;", "kind Relative;"),
replace_once(&canonical, "items [@payload];", "items [@assess];"),
canonical.replace("effects [@assess];", "effects [@payload];"),
canonical.replace("kind After;", "kind Relative;"),
canonical.replace("items [@payload];", "items [@assess];"),
] {
assert!(
language::parse(&invalid).is_err(),
"invalid typed state/event source accepted"
);
}
// Canonical source already omits External. Removing the supplied lists
// independently exercises their empty omission defaults and retained prose.
let legacy = replace_once(&canonical, "triggers [@deadline, @assess];", "");
let legacy = replace_once(&legacy, "effects [@assess];", "");
let legacy = canonical
.replace("transition_kind External;", "")
.replace("triggers [@deadline, @assess];", "")
.replace("effects [@assess];", "");
let legacy = read(&legacy);
let RelationBody::StateTransition {
transition_kind,
@@ -850,7 +829,7 @@ fn typed_events_vertices_and_transition_kinds_round_trip_in_both_layers() {
(retained_trigger, retained_guard, retained_effect),
(trigger, guard, effect)
);
let draft = replace_once(&canonical, "expression \"30 s\";", "expression \"\";");
let draft = canonical.replace("expression \"30 s\";", "expression \"\";");
assert!(language::parse(&draft).is_ok());
let deadline = model
.elements
@@ -1059,7 +1038,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
"fixture must cover actual old scenario content"
);
let canonical = language::format(&from_source);
assert!(!canonical.contains("viewpoint MixedLegacy;"));
assert!(canonical.contains("viewpoint MixedLegacy;"));
for relation in from_source.relations.values() {
if let RelationBody::StateTransition {
transition_kind,
+1 -11
View File
@@ -441,8 +441,6 @@ fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometr
diagram: d,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![pos(500., 420.)],
label: None,
},
@@ -769,15 +767,7 @@ fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_rec
assert_eq!(cleared.decoded.project, prepared.project);
let text = &cleared.bundle.files["project.arcadia"];
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
// Lossless editing may retain the authored empty field and its annotations.
// Canonical export, rather than a model update, normalizes default syntax.
assert!(!language::format(&cleared.decoded.project).contains("port_anchors"));
assert_eq!(
update_bundle(&cleared, &cleared.decoded.project, ProjectLimits::default())
.unwrap()
.bundle,
cleared.bundle
);
assert!(!text.contains("port_anchors"));
let prepared = prepare_command(
&cleared.decoded.project,
&Command::SetPortAnchor {
-4
View File
@@ -70,8 +70,6 @@ fn fixture() -> Fixture {
fn route(edge: Id) -> EdgeRoute {
EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![Position { x: 550., y: 240. }, Position { x: 550., y: 360. }],
label: Some(EdgeLabel {
fraction: 0.4,
@@ -691,8 +689,6 @@ fn old_models_get_empty_routes_and_route_json_is_strict() {
serde_json::from_value::<EdgeRoute>(minimal).unwrap(),
EdgeRoute {
edge: f.edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None
}
-2
View File
@@ -27,8 +27,6 @@ fn route(p: &Project, d: Id) -> RoutedView {
fn set_route(p: &mut Project, d: Id, edge: Id, pins: Vec<Position>, label: Option<EdgeLabel>) {
p.diagrams.get_mut(&d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: pins,
label,
});
-2
View File
@@ -117,8 +117,6 @@ fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
.routes
.push(EdgeRoute {
edge: fe,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(authored.clone()),
});
-84
View File
@@ -383,87 +383,3 @@ 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);
}
-16
View File
@@ -301,8 +301,6 @@ fn folding_retains_authored_routes_without_false_self_transitions() {
let pins = vec![point(690., 480.), point(690., 230.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: out,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -364,15 +362,11 @@ fn grouped_moves_translate_hidden_descendants_and_pins_only_in_one_view() {
let pins = vec![point(570., 460.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(label.clone()),
});
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.external,
source_attachment: None,
target_attachment: None,
waypoints: vec![point(690., 230.)],
label: None,
});
@@ -428,8 +422,6 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
let placement = f.p.place(f.d, orphan, point(30., 750.));
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
@@ -464,8 +456,6 @@ fn cross_machine_reparent_requires_atomic_placement_and_route_repair() {
diagram: other,
route: EdgeRoute {
edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
},
@@ -593,8 +583,6 @@ fn layout_reserves_internal_rows_and_state_routes_retain_hard_pins() {
let pins = vec![point(730., 130.), point(1060., 130.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: Some(EdgeLabel {
fraction: 0.25,
@@ -664,8 +652,6 @@ fn moving_a_region_does_not_translate_pins_attached_to_a_stationary_state_bounda
let pins = vec![point(280., 390.), point(280., 470.)];
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.local,
source_attachment: None,
target_attachment: None,
waypoints: pins.clone(),
label: None,
});
@@ -744,8 +730,6 @@ fn initial_transition_attaches_to_the_compact_symbol_and_preview_keeps_settled_g
);
f.p.diagrams.get_mut(&f.d).unwrap().routes.push(EdgeRoute {
edge: f.self_edge,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: Some(EdgeLabel {
fraction: 0.3,
-1
View File
@@ -18,7 +18,6 @@ serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
tempfile = "3"
unicode-segmentation = "1.13.3"
[dev-dependencies]
iced_runtime = "0.14"
-836
View File
@@ -1,836 +0,0 @@
//! 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
-268
View File
@@ -1,268 +0,0 @@
//! A narrow dispatch boundary for Allocation work gestures. Iced rows visit
//! siblings after capture; refusing inside Canvas alone still blurs an inspector
//! field. Refuse before dispatching that press to any ordinary child instead.
use crate::{
app::{
App, Message,
allocation_drag::{self, Event as WorkEvent},
},
diagram::DiagramCanvas,
};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Id, Operation, Tree, tree},
};
use iced::{
Element, Event, Length, Point, Rectangle, Renderer, Size, Theme, Vector, keyboard, mouse,
};
pub const CANVAS_ID: &str = "allocation-work-canvas";
pub const ARRANGE_SHORTCUT: &str = "Alt+A";
struct Root<'a> {
app: &'a App,
content: Element<'a, Message>,
}
#[derive(Default)]
struct State {
pointer: Option<Point>,
refused_left: bool,
}
#[derive(Default)]
struct CanvasBounds(Option<Rectangle>);
impl Operation for CanvasBounds {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&Id>, bounds: Rectangle) {
if id == Some(&Id::new(CANVAS_ID)) {
self.0 = Some(bounds);
}
}
}
pub fn root<'a>(app: &'a App, content: impl Into<Element<'a, Message>>) -> Element<'a, Message> {
Element::new(Root {
app,
content: content.into(),
})
}
fn arrange_key(event: &Event) -> bool {
matches!(event, Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(key), modifiers, repeat: false, ..
}) if key.eq_ignore_ascii_case("a") && *modifiers == keyboard::Modifiers::ALT)
}
impl Root<'_> {
fn dirty_object(&self, bounds: Rectangle, screen: Point) -> bool {
let Some(diagram) = self.app.active_diagram else {
return false;
};
if !allocation_drag::contains(bounds, screen) {
return false;
}
let canvas = DiagramCanvas {
allocation: Some(self.app.allocation_capability()),
project: self.app.project(),
view_index: Some(self.app.document.view_index()),
diagram,
selected: self.app.selected,
navigation_focus: None,
zoom: self.app.zoom,
pan: self.app.pan,
context: self.app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: false,
placing: false,
connection: None,
};
canvas.work_dirty_point(bounds, screen)
}
}
impl Widget<Message, Theme, Renderer> for Root<'_> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<State>()
}
fn state(&self) -> tree::State {
tree::State::new(State::default())
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(
&mut self,
tree: &mut Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn Operation,
) {
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
let state = tree.state.downcast_mut::<State>();
match event {
Event::Mouse(mouse::Event::CursorMoved { position }) => state.pointer = Some(*position),
Event::Mouse(mouse::Event::CursorLeft) => state.pointer = None,
Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position()
}
Event::Window(iced::window::Event::Unfocused) => {
state.pointer = None;
state.refused_left = false;
}
_ => {}
}
if state.refused_left {
if matches!(
event,
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left))
) {
state.refused_left = false;
shell.capture_event();
return;
}
if matches!(event, Event::Mouse(mouse::Event::CursorMoved { .. })) {
shell.capture_event();
return;
}
}
if self.app.allocation_input_owner() && !shell.is_event_captured() {
if arrange_key(event) {
// App rechecks clean ownership before arming. A dirty field
// retains its actual widget focus and selection on refusal.
shell.publish(Message::WorkDrag(WorkEvent::ArrangeNext));
shell.capture_event();
return;
}
if self.app.allocation_drag.arrange_next
&& matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::Escape),
..
})
)
{
shell.publish(Message::Escape);
shell.capture_event();
return;
}
if !self.app.allocation_drag_clean()
&& matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
)
&& let Some(screen) = state.pointer.or_else(|| cursor.position())
{
let mut canvas = CanvasBounds::default();
self.content.as_widget_mut().operate(
&mut tree.children[0],
layout,
renderer,
&mut canvas,
);
if canvas
.0
.is_some_and(|bounds| self.dirty_object(bounds, screen))
{
state.refused_left = true;
shell.publish(Message::WorkDrag(WorkEvent::Refused(
self.app.allocation_drag.epoch,
allocation_drag::FINISH_EDIT.into(),
)));
shell.capture_event();
return;
}
}
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
File diff suppressed because it is too large Load Diff
@@ -1,672 +0,0 @@
//! 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;
@@ -1,728 +0,0 @@
//! 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
@@ -1,203 +0,0 @@
//! 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"
);
}
@@ -70,16 +70,13 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
allocation: None,
};
let mut state = crate::diagram::State::default();
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
@@ -91,8 +88,17 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
text: None,
repeat: false,
});
// Program receives keys already admitted by diagram::focus::Owner. Real
// unfocused/Inspector refusal is covered by app::diagram_focus_tests.
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.)),
);
let action = canvas
.update(&mut state, &key, bounds, mouse::Cursor::Unavailable)
.unwrap();
@@ -225,16 +225,13 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: true,
connection: None,
allocation: None,
};
let mut state = crate::diagram::State::default();
let action = canvas
@@ -302,16 +299,13 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
allocation: None,
};
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
@@ -325,11 +319,16 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
text: None,
repeat: false,
});
// Program receives keys already admitted by diagram::focus::Owner. Real
// unfocused/Inspector refusal is covered by app::diagram_focus_tests.
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,
);
let message = canvas
.update(&mut state, &key, bounds, cursor)
.expect("admitted F2 opens the selected semantic name")
.expect("focused 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(0),
Message::ConfirmDelete,
Message::Undo,
Message::Redo,
Message::ShortcutUndo(false),
@@ -1,594 +0,0 @@
//! 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);
}
}
@@ -1,156 +0,0 @@
//! 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());
}
}
}
@@ -1,240 +0,0 @@
//! 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
@@ -1,146 +0,0 @@
//! 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;
}
-146
View File
@@ -1,146 +0,0 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn is_diagram_viewport(message: &Message) -> bool {
matches!(
message,
Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
)
}
pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> {
self.pending_diagram_reveal
.as_ref()
.filter(|(_, u, _)| u.surface == Surface::Saved(diagram))
.map(|(context, _, epoch)| (*context, *epoch))
}
pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option<Surface> {
let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?;
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| *epoch != self.canvas_epoch
{
return None;
}
let (surface, size, valid) = match message {
Message::DiagramRevealViewport(c, e, size) => (
usage.surface,
*size,
c == context
&& e == epoch
&& self.page == Page::Diagram
&& Some(usage.surface.id()) == self.active_diagram,
),
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => (
Surface::Process(c.owner),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
Surface::Scenario(c.scenario),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
_ => return None,
};
(valid
&& surface == usage.surface
&& size.width.is_finite()
&& size.height.is_finite()
&& size.width > 0.
&& size.height > 0.)
.then_some(surface)
}
pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) {
if !matches!(
message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::DiagramUsage(_)
| 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;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
}
}
fn is_navigation_camera(message: &Message) -> bool {
if let Message::BehaviorControl(intent) = message {
return Self::is_navigation_camera(&intent.message);
}
matches!(
message,
Message::Pan(_)
| Message::Zoom(_)
| Message::Fit
| Message::TopologyCanvas(
behavior::topology::CanvasEvent::Camera(..)
| behavior::topology::CanvasEvent::Zoom(..)
| behavior::topology::CanvasEvent::Scrolled(..)
)
| Message::ScenarioCanvas(
behavior::scenario::CanvasEvent::Camera(..)
| behavior::scenario::CanvasEvent::Zoom(..)
| behavior::scenario::CanvasEvent::Scrolled(..)
)
| Message::StateDiagram(
behavior::state_edit::Event::Pan(..)
| behavior::state_edit::Event::Zoom(..)
| behavior::state_edit::Event::Fit(..)
)
)
}
pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) {
let Some((context, usage, _)) = self.pending_diagram_reveal.take() else {
return;
};
if context.document == self.canvas_document
&& context.revision == self.document.revision()
&& usage.surface == surface
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.behavior.has_pending()
{
// The normal viewport handler has now stored the measured size.
// Target identity was validated for this revision before navigation.
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
}
}
@@ -1,403 +0,0 @@
//! Independent first-layout navigation oracles, through the real App boundary.
use super::*;
use crate::diagram_usage::{self as usage, Target, Usage};
use iced::{Size, Vector};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Canvas {
Architecture,
Process,
Scenario,
State,
}
const CANVASES: [Canvas; 4] = [
Canvas::Architecture,
Canvas::Process,
Canvas::Scenario,
Canvas::State,
];
const WIDE: Size = Size::new(1200., 700.);
const ACTUAL: Size = Size::new(500., 300.);
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn fixture(canvas: Canvas) -> (App, Usage) {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let (surface, subject) = match canvas {
Canvas::Architecture => {
let diagram = p.ensure_diagram(Layer::Oa);
p.place(
diagram,
work,
Position {
x: 12_000.,
y: 9_000.,
},
);
(Surface::Saved(diagram), work)
}
Canvas::Process | Canvas::Scenario => {
let kind = if matches!(canvas, Canvas::Process) {
Kind::Process
} else {
Kind::Scenario
};
let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner))
.unwrap();
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap();
if !steps.contains(&occurrence) {
steps.push(occurrence);
}
(
if matches!(canvas, Canvas::Process) {
Surface::Process(owner)
} else {
Surface::Scenario(owner)
},
occurrence,
)
}
Canvas::State => {
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "ready_view".into(),
name: "Ready view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
routes: vec![],
view_options: Default::default(),
},
);
p.place(
diagram,
state,
Position {
x: 8_000.,
y: 11_000.,
},
);
(Surface::Saved(diagram), state)
}
};
let mut app = App::with_project(p);
let found = usage::collect(
app.project(),
subject,
&app.behavior.topology,
&app.behavior.scenario,
)
.into_iter()
.find(|u| u.surface == surface && u.target != Target::Whole)
.unwrap();
app.canvas_size = Some(WIDE);
app.behavior.diagram.viewport = Some(WIDE);
if let Surface::Process(owner) = surface {
app.behavior.topology.enter(app.canvas_document, owner);
app.behavior.topology.viewport(WIDE);
}
if let Surface::Scenario(owner) = surface {
app.behavior.scenario.enter(app.canvas_document, owner);
app.behavior.scenario.viewport(WIDE);
}
app.page = Page::Text;
(app, found)
}
fn measurement(
canvas: Canvas,
context: Context,
epoch: u64,
surface: Surface,
size: Size,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
behavior::topology::Context {
document: context.document,
owner: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport(
behavior::state_edit::Context {
document: context.document,
diagram: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
}
}
fn stamp(app: &App) -> (Context, u64) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(*context, *epoch)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
// All independently authored fixture targets fit ACTUAL at the readable cap.
// This oracle does not call the production camera/reveal implementation.
let zoom = 1.25;
let pan = Vector::new(
250. - target.bounds.center_x() * zoom,
150. - target.bounds.center_y() * zoom,
);
match canvas {
Canvas::Architecture => {
assert_eq!(app.canvas_size, Some(ACTUAL));
assert_eq!(app.zoom, zoom);
assert_eq!(app.pan, pan);
assert!(!app.fit_to_view);
}
Canvas::State => {
assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL));
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.pan, pan);
assert!(!app.behavior.diagram.fit);
}
Canvas::Process => {
let mut expected = behavior::topology::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary());
}
Canvas::Scenario => {
let mut expected = behavior::scenario::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
}
#[test]
fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let history = (
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
// The exact delivered callback is a replay after the one-shot is consumed.
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
assert_centered(&app, canvas, &target);
}
}
#[test]
fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for canvas in CANVASES {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
let mut size = Size::new(111., 122.);
match invalid {
0 => stale.document = Id::new_v4(),
1 => stale.revision = stale.revision.wrapping_add(1),
2 => wrong_epoch = wrong_epoch.wrapping_add(1),
3 => size.width = f32::NAN,
4 => size.height = f32::INFINITY,
_ => size.width = 0.,
}
send(
&mut app,
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));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
"{canvas:?} invalid case {invalid}"
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
}
}
}
#[test]
fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch) = stamp(&app);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
assert_ne!(epoch, old_epoch);
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
);
assert_eq!(stamp(&app), (current, epoch));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() {
for canvas in CANVASES {
for action in 0..3 {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan(
app.state_context().unwrap(),
Vector::new(31., -17.),
)),
Canvas::Process => {
Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera(
app.topology_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
Canvas::Scenario => {
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera(
app.scenario_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
},
1 => Message::Field("/name".into(), "Pending title edit".into()),
_ => Message::Page(Page::Catalog),
};
send(&mut app, event);
assert!(
app.pending_diagram_reveal.is_none(),
"{canvas:?} action {action}"
);
let camera = app.camera_boundary();
let page = app.page;
// A stale layout callback at the already retained size cannot
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
app.camera_boundary() == camera,
"{canvas:?} action {action}"
);
assert_eq!(app.page, page);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
if action == 1 {
assert!(app.draft_dirty());
}
}
}
}
-451
View File
@@ -1,451 +0,0 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)]
mod tests;
impl App {
pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message {
Message::DiagramUsage(Event::Open(Context {
document: self.canvas_document,
revision: self.document.revision(),
subject,
}))
}
fn usage_navigation_available(&self) -> bool {
!self.busy
&& !self.source_rename.open()
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.architecture.is_none()
&& !self.reference_input_active()
&& !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
}
/// Admit at most one expensive lookup, including one accepted Project clone.
/// A replacement panel waits for the previous worker's exact completion.
fn start_diagram_lookup(&mut self) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if !panel.loading || self.diagram_lookup_in_flight.is_some() {
return Task::none();
}
let (token, context) = (panel.token, panel.context);
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.usage_navigation_available()
{
let panel = self.diagram_usages.as_mut().unwrap();
panel.loading = false;
panel.notice = Some(
"The model or pending edits changed. Reopen diagram navigation when ready.".into(),
);
return Task::none();
}
let project = self.project().clone();
let presentation = self.behavior.topology.usage_snapshot();
self.diagram_lookup_in_flight = Some(token);
Task::perform(
async move {
let result = tokio::task::spawn_blocking(move || {
let process = presentation.into_query_state();
let scenario = behavior::scenario::State::default();
usage::collect(&project, context.subject, &process, &scenario)
})
.await
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
usage::Completion {
token,
context,
result,
}
},
Message::DiagramUsagesReady,
)
}
fn refresh_diagram_lookup(&mut self, notice: &str) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_mut() else {
return Task::none();
};
panel.token = Id::new_v4();
panel.context.revision = self.document.revision();
panel.usages.clear();
panel.loading = true;
panel.page = 0;
panel.cursor = 0;
panel.destination = None;
panel.notice = Some(notice.into());
self.start_diagram_lookup()
}
/// Must run before modal/input dispatch. A closed or replaced panel still
/// owns its worker slot until this exact completion arrives.
pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task<Message> {
if self.diagram_lookup_in_flight != Some(completion.token) {
return Task::none();
}
self.diagram_lookup_in_flight = None;
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.token != completion.token {
// A newer Open/refresh waited without cloning or spawning a second job.
return self.start_diagram_lookup();
}
let panel = self.diagram_usages.as_mut().unwrap();
if panel.context != completion.context || !panel.loading {
panel.loading = false;
panel.notice = Some(
"This lookup no longer matches the requested model. Close and try again.".into(),
);
return Task::none();
}
panel.loading = false;
match completion.result {
Ok(usages) => panel.usages = usages,
Err(error) => panel.notice = Some(error),
}
Task::none()
}
/// Selection validates one destination only; opening the chooser does the
/// whole-project scan on its worker, never on the GUI thread.
fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec<Usage> {
match surface {
Surface::Saved(diagram) => {
usage::saved_usages_for(self.project(), subject, Some(diagram))
}
Surface::Process(owner) => {
self.behavior
.topology
.diagram_usages(self.project(), owner, subject)
}
Surface::Scenario(owner) => {
self.behavior
.scenario
.diagram_usages(self.project(), owner, subject)
}
}
}
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();
}
let mut panel = Panel::new(context, Vec::new());
panel.loading = true;
let filter_id = format!("diagram-usage-filter-{}", panel.token);
self.diagram_usages = Some(panel);
self.model_menu = None;
// Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None;
self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([
self.start_diagram_lookup(),
iced::widget::operation::focus(filter_id),
]);
}
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 {
self.restore_source_editor_focus();
}
return Task::none();
}
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.loading {
// Search and Close remain useful while loading; queued activation
// must not perform a synchronous fallback or choose an old row.
if let Event::Search(_, query) = event {
self.diagram_usages.as_mut().unwrap().search = query;
}
return Task::none();
}
match event {
Event::Move(_, direction) => {
let panel = self.diagram_usages.as_ref().unwrap();
let count = panel.choices(self.project()).len();
let panel = self.diagram_usages.as_mut().unwrap();
if count > 0 {
panel.cursor = if direction < 0 {
(panel.cursor + count - 1) % count
} else {
(panel.cursor + 1) % count
};
panel.page = panel.cursor / 6;
}
}
Event::Activate(_) => {
let panel = self.diagram_usages.as_ref().unwrap();
if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() {
return self.diagram_usage_event(event);
}
}
Event::Search(_, query) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.search = query;
panel.page = 0;
panel.cursor = 0;
}
Event::Page(_, page) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.page = page;
panel.cursor = page.saturating_mul(6);
}
Event::Destination(_, surface) => {
let panel = self.diagram_usages.as_mut().unwrap();
if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) {
panel.destination = surface;
panel.page = 0;
panel.cursor = 0;
}
}
Event::Choose(_, requested) => {
if !self.usage_navigation_available() {
return Task::none();
}
let subject = self.diagram_usages.as_ref().unwrap().context.subject;
let fresh = self
.current_surface_usages(subject, requested.surface)
.into_iter()
.find(|u| u.subject == requested.subject && u.target == requested.target);
let Some(fresh) = fresh else {
return self.refresh_diagram_lookup(
"This representation changed. Review the refreshed destinations.",
);
};
if self.reveal_diagram_usage(&fresh) {
self.diagram_usages = None;
self.status = format!(
"{} · {} in {}",
self.project().named(subject),
fresh.role,
self.project().named(fresh.surface.id())
);
return Task::batch(["model-explorer", "model-browser", "inspector"].map(
|id| {
iced::widget::operation::snap_to(
id,
iced::widget::scrollable::RelativeOffset::START,
)
},
));
}
}
Event::Open(_) | Event::Close(_) => unreachable!(),
}
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 p = self.document.project();
let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None),
Target::Placement(id) => {
let Some(node) = p
.diagrams
.get(&usage.surface.id())
.and_then(|d| d.nodes.get(id))
else {
return false;
};
(node.element, Some(*id))
}
Target::Port { port, placement } => (*port, Some(*placement)),
Target::StateRow {
placement,
relation,
} => (*relation, Some(*placement)),
Target::Edge(id)
| Target::ProcessLink(id)
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
};
match usage.surface {
Surface::Saved(id) => {
let Some(diagram) = p.diagrams.get(&id) else {
return false;
};
self.layer = diagram.layer;
self.active_diagram = Some(id);
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.),
));
let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport);
self.behavior.diagram = behavior::state_view::State {
diagram: Some(id),
placement,
viewport: Some(viewport),
zoom,
pan,
fit: false,
..Default::default()
};
self.behavior.active_owner = Some(machine);
self.page = Page::Behavior;
} else {
let viewport = self.canvas_size.unwrap_or(iced::Size::new(
(self.window_size.width - 550.).max(280.),
(self.window_size.height - 284.).max(240.),
));
(self.zoom, self.pan) =
usage::camera_for(&usage.target, usage.bounds, viewport);
self.fit_to_view = false;
self.page = Page::Diagram;
}
}
Surface::Process(owner) => {
if !self.behavior.topology.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;
}
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;
}
}
self.selected = selected;
self.selected_node = placement;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
self.connection = None;
self.last_workspace_page = self.page;
if previous_layer != self.layer {
self.data_state = Default::default();
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
self.explorer_group = None;
self.explorer_page = 0;
}
self.normalize_creation();
self.reveal_explorer_selection();
self.refresh_draft();
self.diagram_navigation_focus = Some(usage::Focus {
context: Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage: usage.clone(),
selected,
});
self.pending_diagram_reveal = Some((
Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage.clone(),
self.canvas_epoch,
));
true
}
/// Validated presentation identity. Edit selection and Delete capture remain
/// semantic; a port owner or allocation work box is only a visual marker.
pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> {
let focus = self.diagram_navigation_focus.as_ref()?;
let current_surface = match surface {
Surface::Saved(id) => {
self.active_diagram == Some(id)
&& matches!(self.page, Page::Diagram | Page::Behavior)
}
Surface::Process(owner) | Surface::Scenario(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
};
(current_surface
&& focus.usage.surface == surface
&& focus.context.document == self.canvas_document
&& focus.context.revision == self.document.revision()
&& focus.selected == self.selected)
.then_some(&focus.usage)
}
}
File diff suppressed because it is too large Load Diff
+44 -105
View File
@@ -15,15 +15,18 @@ impl App {
epoch: self.canvas_epoch,
})
}
pub(super) fn reveal_process_selection(&mut self) -> Task<Message> {
if let Some(context) = self.topology_context() {
let mut state = std::mem::take(&mut self.behavior.topology);
if state.reveal_selection(self.project(), context.owner, self.selected) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
self.behavior.topology = state;
}
Task::none()
pub(super) fn reveal_process_selection(&self) -> Task<Message> {
let Some(context) = self.topology_context() else {
return Task::none();
};
let Some(offset) =
self.behavior
.topology
.selection_offset(self.project(), context.owner, self.selected)
else {
return Task::none();
};
iced::widget::operation::scroll_to("process-canvas", offset)
}
pub(super) fn edit_process_canvas(&mut self, event: CanvasEvent) -> Task<Message> {
let context = event.context();
@@ -49,43 +52,6 @@ impl App {
}
return Task::none();
}
if let CanvasEvent::Camera(_, lifetime, input) = event {
use crate::behavior::canvas_workspace::{self, Event};
if lifetime != self.camera_epoch
|| !self.topology_context().is_some_and(|c| {
c.document == context.document
&& c.owner == context.owner
&& c.revision == context.revision
})
{
return Task::none();
}
let scope = canvas_workspace::Scope {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: lifetime,
};
if self.behavior.has_pending()
|| self.draft_dirty()
|| self.source_dirty()
|| !canvas_workspace::keyboard_enabled(self)
{
return Task::none();
}
match input {
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),
input => {
if self.behavior.topology.camera(input) {
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
}
}
return Task::none();
}
if self.topology_context() != Some(context) {
return Task::none();
}
@@ -93,26 +59,6 @@ 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);
@@ -135,7 +81,6 @@ impl App {
.first()
.copied()
.unwrap_or(endpoint.occurrence),
resolved.defining_sequence,
);
self.behavior.active_owner = Some(resolved.defining_sequence);
self.select(endpoint.occurrence);
@@ -159,6 +104,10 @@ 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)
@@ -169,18 +118,14 @@ impl App {
}
}
CanvasEvent::Choose(_, tool) => {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before choosing a process tool.".into();
if self.behavior.has_pending() || !self.apply() {
return Task::none();
}
self.behavior.topology.choose(tool);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Zoom(_, zoom) => {
let mut state = std::mem::take(&mut self.behavior.topology);
state.camera_zoom(self.project(), context.owner, zoom);
self.behavior.topology = state;
self.behavior.topology.zoom(zoom);
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
}
CanvasEvent::Expand(_, id) => {
@@ -204,9 +149,7 @@ impl App {
.get(&target)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
{
self.behavior
.topology
.remember(context.owner, occurrence, target);
self.behavior.topology.remember(context.owner, occurrence);
self.behavior.active_owner = Some(target);
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
@@ -341,7 +284,7 @@ mod tests {
}
}
#[test]
fn process_zoom_retains_rejected_draft_and_guards_selection() {
fn process_focus_retains_rejected_draft_and_guards_selection_until_panels_restore() {
let (mut app, f) = setup();
let original = app.document.snapshot();
let initial = app.selected;
@@ -351,14 +294,9 @@ 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::Zoom(
context,
Some(0.75),
)));
assert_ne!(app.canvas_epoch, context.epoch);
assert!(app.behavior.topology.camera_boundary() != camera);
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(app.behavior.topology.focused);
let raw = app.behavior.recovery_snapshot().unwrap();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Select(
@@ -370,8 +308,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::Zoom(context, None)));
assert_ne!(app.canvas_epoch, context.epoch);
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Focus(context)));
assert!(!app.behavior.topology.focused);
assert_eq!(app.behavior.recovery_snapshot().unwrap(), raw);
}
#[test]
@@ -586,30 +524,32 @@ mod tests {
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() {
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(25., 100.)),
)));
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
)));
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
let camera = app.behavior.topology.camera_boundary();
assert_eq!(
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
@@ -617,12 +557,12 @@ mod tests {
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Page,
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
@@ -642,7 +582,6 @@ mod tests {
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
assert!(app.behavior.topology.camera_boundary() == camera);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
@@ -1,304 +0,0 @@
//! One transient chooser for the currently mounted Inspector or review field.
//! Browsing never calls a draft setter or semantic planner.
use super::{App, Message, Page, Pending};
use crate::{
forms::{
Draft,
reference_chooser::{self as chooser, Action, Close, Outcome, adapter, trigger},
},
navigation::InspectorTab,
semantic_review::{self, RowAddress, RowEvent},
workflow,
};
use iced::{Element, Rectangle, Task};
use oa_core::{FacetSelector, Id, Layer};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Host {
Inspector,
Review {
address: Option<RowAddress>,
facet: FacetSelector,
},
}
impl Host {
fn message(&self, event: chooser::Event) -> Message {
match self {
Self::Inspector => Message::FieldReferenceEvent(event),
Self::Review { address, facet } => {
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
address: *address,
facet: Some(facet.clone()),
event: RowEvent::ReferenceEvent(event),
}))
}
}
}
}
struct Opened {
host: Host,
request: adapter::Open,
document_session: Id,
page: Page,
layer: Layer,
chooser: chooser::State,
}
#[derive(Default)]
pub(super) struct State {
opened: Option<Opened>,
}
impl App {
pub(crate) fn reference_input_active(&self) -> bool {
self.reference_authoring.opened.is_some()
}
fn reference_draft(&self, host: &Host, path: &str) -> Option<&Draft> {
match host {
Host::Inspector
if self.workflow.is_none() && self.inspector_tab == InspectorTab::Properties =>
{
self.draft.as_ref()
}
Host::Review { address, facet } => match self.workflow.as_ref()? {
workflow::Workflow::Semantic(state) => {
state.reference_draft(self.project(), *address, facet, path)
}
_ => None,
},
_ => None,
}
}
fn reference_available(&self) -> bool {
!self.busy
&& !self.hosted.visible
&& self.architecture.is_none()
&& self.model_menu.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.pending.is_none()
&& !self.source_dirty()
&& !self.behavior.has_pending()
&& !matches!(self.page, Page::Home | Page::Guide | Page::Text)
}
fn reference_valid(&self, opened: &Opened) -> bool {
self.reference_available()
&& self.canvas_document == opened.document_session
&& self.page == opened.page
&& self.layer == opened.layer
&& self.document.revision() == opened.chooser.stamp().context.revision
&& self.project().id == opened.request.document
&& self
.reference_draft(&opened.host, &opened.request.path)
.is_some_and(|draft| draft.reference_matches(&opened.request))
}
pub(super) fn reconcile_reference_chooser(&mut self) {
if self
.reference_authoring
.opened
.as_ref()
.is_some_and(|opened| !self.reference_valid(opened))
{
self.reference_authoring
.opened
.as_mut()
.unwrap()
.chooser
.invalidate();
self.reference_authoring.opened = None;
}
}
fn open_reference(&mut self, host: Host, request: adapter::Open) -> Task<Message> {
if !self.reference_available() {
return Task::none();
}
let Some(draft) = self.reference_draft(&host, &request.path) else {
return Task::none();
};
let Ok(chooser) =
draft.reference_catalog(self.project(), &request, self.document.revision())
else {
return Task::none();
};
self.reference_authoring.opened = Some(Opened {
host,
request,
document_session: self.canvas_document,
page: self.page,
layer: self.layer,
chooser,
});
Task::none()
}
fn reference_event(&mut self, host: Host, event: chooser::Event) -> Task<Message> {
let Some(mut opened) = self.reference_authoring.opened.take() else {
return Task::none();
};
if !self.reference_valid(&opened) {
return Task::none();
}
if opened.host != host {
self.reference_authoring.opened = Some(opened);
return Task::none();
}
match opened.chooser.update(event) {
Outcome::Ignored | Outcome::Browsed => {
self.reference_authoring.opened = Some(opened);
Task::none()
}
Outcome::Closed(reason) => self.reference_focus(
&opened,
match reason {
Close::Escape => Some(0),
Close::Tab { backwards } => Some(if backwards { -1 } else { 1 }),
Close::Outside | Close::WindowChanged => None,
},
),
Outcome::Write { context, value } => {
let intent = adapter::Set {
context,
generation: opened.request.generation,
value,
};
let result = match &opened.host {
Host::Inspector => self
.draft
.as_mut()
.ok_or_else(|| "The property editor closed.".to_owned())
.and_then(|draft| draft.reference_set(self.document.project(), &intent)),
Host::Review { address, facet } => match self.workflow.as_mut() {
Some(workflow::Workflow::Semantic(state)) => {
state.set_reference(self.document.project(), *address, facet, intent)
}
_ => Err("The review editor closed.".into()),
},
};
if let Err(error) = result {
self.status = error;
}
// A scoped write rotates field generation. The task targets the
// newly rendered field, while a later structural edit makes it inert.
self.reference_focus(&opened, Some(0))
}
}
}
fn reference_focus(&self, opened: &Opened, step: Option<i32>) -> Task<Message> {
let Some(step) = step else {
return Task::none();
};
let Some(draft) = self.reference_draft(&opened.host, &opened.request.path) else {
return Task::none();
};
if draft.reference_session() != opened.request.session {
return Task::none();
}
trigger::focus(
draft.reference_session(),
draft.reference_generation(),
&opened.request.path,
step,
)
}
/// Called before generic modal dispatch: the review owns a modal, and this
/// chooser owns only its active field. All browser events stop here.
pub(super) fn reference_message(&mut self, message: &Message) -> Option<Task<Message>> {
self.reconcile_reference_chooser();
match message {
Message::FieldReferenceOpen(request) => {
Some(self.open_reference(Host::Inspector, request.clone()))
}
Message::FieldReferenceEvent(event) => {
Some(self.reference_event(Host::Inspector, event.clone()))
}
Message::Workflow(workflow::Event::Semantic(review_event)) => {
let semantic_review::Event::Row {
address,
facet: Some(facet),
event,
} = review_event.as_ref()
else {
return None;
};
let host = Host::Review {
address: *address,
facet: facet.clone(),
};
match event {
RowEvent::ReferenceOpen(request) => {
Some(self.open_reference(host, request.clone()))
}
RowEvent::ReferenceEvent(event) => {
Some(self.reference_event(host, event.clone()))
}
_ => None,
}
}
Message::Escape | Message::Request(Pending::Close(_))
if self.reference_authoring.opened.is_some() =>
{
let opened = self.reference_authoring.opened.as_ref().unwrap();
let host = opened.host.clone();
let event = opened.chooser.event(Action::Close(Close::Escape));
Some(self.reference_event(host, event))
}
// Global shortcuts are also emitted for keys captured by the search
// input. They must not apply drafts, navigate, or undo the model.
Message::Save(_)
| Message::ShortcutUndo(_)
| Message::Request(Pending::Open | Pending::New)
| Message::SourceNavigation(_)
| Message::Page(Page::Guide)
if self.reference_authoring.opened.is_some() =>
{
Some(Task::none())
}
_ => None,
}
}
pub(super) fn reference_overlay<
'a,
R: iced::advanced::text::Renderer<Font = iced::Font> + 'a,
>(
&'a self,
content: Element<'a, Message, iced::Theme, R>,
) -> Element<'a, Message, iced::Theme, R> {
let opened = self
.reference_authoring
.opened
.as_ref()
.filter(|opened| self.reference_valid(opened));
let host = opened
.map(|opened| opened.host.clone())
.unwrap_or(Host::Inspector);
let focus_scope = if self.reference_available() {
match self.workflow.as_ref() {
None if self.inspector_tab == InspectorTab::Properties => {
self.draft.as_ref().map(|draft| trigger::FormScope {
session: draft.reference_session(),
modal: false,
})
}
Some(workflow::Workflow::Semantic(state)) => state
.reference_form(self.project())
.map(|draft| trigger::FormScope {
session: draft.reference_session(),
modal: true,
}),
_ => None,
}
} else {
None
};
chooser::widget::Popup::new(
content,
opened.map(|opened| &opened.chooser),
opened.map_or(Rectangle::default(), |opened| opened.request.anchor),
move |event| host.message(event),
)
.focus_scope(focus_scope)
.into()
}
}
#[cfg(test)]
mod tests;
@@ -1,586 +0,0 @@
use super::*;
use crate::forms::Draft;
use oa_core::*;
use serde_json::{Value, json};
const OWNER: Id = Id::from_u128(100);
const BASE: u128 = 10_000;
const PATH: &str = "/body/Oa/Shared/DataType/definition/Record/supertype";
fn element(p: &mut Project, id: Id, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) {
let generated = p.add_element(kind, layer, name, parent).unwrap();
let mut element = p.elements.remove(&generated).unwrap();
element.id = id;
element.alias = format!("object_{}", id.as_u128());
if let Some(SharedBody::DataType { definition, .. }) = element.shared_mut() {
*definition = DataTypeDefinition::Record { supertype: None };
}
p.elements.insert(id, element);
}
fn catalog() -> Project {
let mut p = Project::empty();
p.id = Id::from_u128(1);
p.name = "Reference catalog".into();
element(
&mut p,
OWNER,
Kind::DataType,
Layer::Oa,
"Edited record",
None,
);
for n in (0..1_000).rev() {
let id = Id::from_u128(BASE + n);
element(&mut p, id, Kind::DataType, Layer::Oa, "Controller", None);
p.elements.get_mut(&id).unwrap().alias = format!("controller_{n:04}");
}
element(
&mut p,
Id::from_u128(200),
Kind::DataType,
Layer::Sa,
"Controller",
None,
);
element(
&mut p,
Id::from_u128(201),
Kind::Activity,
Layer::Oa,
"Controller",
None,
);
p
}
fn inspector(p: Project, selected: Id) -> App {
let mut app = App::with_project(p);
let _ = app.update(Message::Select(selected));
app
}
fn request(app: &App, host: &Host, path: &str) -> adapter::Open {
app.reference_draft(host, path).unwrap().reference_open(
app.project(),
path,
Rectangle {
x: 790.,
y: 640.,
width: 278.,
height: 32.,
},
)
}
fn open(app: &mut App, host: &Host, path: &str) {
let request = request(app, host, path);
let message = match host {
Host::Inspector => Message::FieldReferenceOpen(request),
Host::Review { address, facet } => {
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
address: *address,
facet: Some(facet.clone()),
event: RowEvent::ReferenceOpen(request),
}))
}
};
let _ = app.update(message);
assert!(
app.reference_authoring.opened.is_some(),
"field must open: {}",
app.status
);
}
fn event(app: &App, action: Action) -> Message {
let opened = app.reference_authoring.opened.as_ref().unwrap();
opened.host.message(opened.chooser.event(action))
}
fn search(app: &mut App, query: &str) {
let serial = app
.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.query_serial()
+ 1;
let event = event(
app,
Action::Query {
text: query.into(),
serial,
},
);
let _ = app.update(event);
}
fn choose(app: &mut App, id: Id) {
let event = event(app, Action::Choose(id));
let _ = app.update(event);
}
fn raw(app: &App) -> Value {
serde_json::to_value(app.draft.as_ref().unwrap().recovery_snapshot()).unwrap()
}
fn review(app: &App) -> &semantic_review::State {
let Some(workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
panic!("Review is open")
};
state
}
fn send_review(app: &mut App, event: RowEvent) {
let addressed = review(app).address_event(event);
let _ = app.update(Message::Workflow(workflow::Event::semantic(addressed)));
}
fn review_host(app: &App) -> Host {
let semantic_review::Event::Row {
address,
facet: Some(facet),
..
} = review(app).address_event(RowEvent::Preview)
else {
panic!("Active facet")
};
Host::Review { address, facet }
}
fn trace(p: &mut Project, id: Id, oa: Id, sa: Id) {
p.traces.insert(
id,
Trace {
id,
alias: format!("trace_{}", id.as_u128()),
name: "Rationale".into(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
},
);
}
#[test]
fn inspector_reference_browse_is_read_only_then_one_scoped_value_applies_with_exact_undo_redo() {
let p = catalog();
let mut app = inspector(p.clone(), OWNER);
let before = app.document.bundle().clone();
let revision = app.document.revision();
// Malformed escaped text is unrelated to the selected reference. Browsing
// and the scoped write must keep its exact spelling and inactive payload.
app.draft.as_mut().unwrap().set_exact(true).unwrap();
let _ = app.update(Message::Field("/name".into(), "\"unfinished".into()));
let raw_before = raw(&app);
open(&mut app, &Host::Inspector, PATH);
assert_eq!(
app.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.count(),
1_000
);
search(&mut app, "controller @controller_0720 OA");
let picker = &app.reference_authoring.opened.as_ref().unwrap().chooser;
assert_eq!(picker.count(), 1);
assert_eq!(picker.highlighted().unwrap().id, Id::from_u128(BASE + 720));
assert_eq!(raw(&app), raw_before);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.document.revision(), revision);
assert!(!app.document.can_undo());
// Captured search shortcuts cannot accidentally save/apply or undo a model.
let _ = app.update(Message::Save(false));
let _ = app.update(Message::ShortcutUndo(false));
assert_eq!(raw(&app), raw_before);
assert_eq!(app.document.bundle(), &before);
choose(&mut app, Id::from_u128(BASE + 720));
let mut expected_raw = raw_before;
*expected_raw["value"].pointer_mut(PATH).unwrap() = json!(Id::from_u128(BASE + 720));
expected_raw["inputs"][PATH] = json!(Id::from_u128(BASE + 720).to_string());
assert_eq!(raw(&app), expected_raw);
assert_eq!(app.document.bundle(), &before);
assert!(!app.document.can_undo());
let _ = app.update(Message::Apply);
assert_eq!(
app.document.bundle(),
&before,
"Unrelated invalid text remains an Apply error"
);
let _ = app.update(Message::Field("/name".into(), "\"Edited record\"".into()));
let _ = app.update(Message::Apply);
let mut expected = p;
if let Some(SharedBody::DataType { definition, .. }) =
expected.elements.get_mut(&OWNER).unwrap().shared_mut()
{
*definition = DataTypeDefinition::Record {
supertype: Some(Id::from_u128(BASE + 720)),
};
}
assert_eq!(app.project(), &expected);
let accepted = app.document.bundle().clone();
assert_ne!(accepted, before);
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before);
let _ = app.update(Message::Redo);
assert_eq!(app.document.bundle(), &accepted);
assert_eq!(app.project(), &expected);
}
#[test]
fn inspector_reference_retains_invalid_missing_and_incompatible_current_until_explicit_clear() {
for (raw_id, expected) in [
("-".into(), "invalid"),
(Id::from_u128(999).to_string(), "missing"),
(Id::from_u128(200).to_string(), "incompatible"),
] {
let mut app = inspector(catalog(), OWNER);
app.draft
.as_mut()
.unwrap()
.shape_scoped(PATH, json!(raw_id))
.unwrap();
let original = raw(&app);
let accepted = app.document.bundle().clone();
open(&mut app, &Host::Inspector, PATH);
let current = &app
.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.current;
assert!(matches!(
(expected, current),
("invalid", chooser::Current::InvalidRaw(_))
| ("missing", chooser::Current::Missing(_))
| ("incompatible", chooser::Current::Incompatible(_))
));
let _ = app.update(Message::Escape);
assert_eq!(raw(&app), original);
open(&mut app, &Host::Inspector, PATH);
let clear = event(&app, Action::Clear);
let _ = app.update(clear);
assert!(
app.draft
.as_ref()
.unwrap()
.value
.pointer(PATH)
.unwrap()
.is_null()
);
assert_eq!(app.document.bundle(), &accepted);
assert!(!app.document.can_undo());
}
}
#[test]
fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_history_text_out() {
for change in 0..7 {
let mut app = inspector(catalog(), OWNER);
open(&mut app, &Host::Inspector, PATH);
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 1)));
match change {
0 => {
app.draft
.as_mut()
.unwrap()
.shape_scoped(PATH, json!(Id::from_u128(BASE + 2)))
.unwrap();
}
1 => {
let saved = app.draft.as_ref().unwrap().recovery_snapshot();
app.draft = Some(Draft::from_recovery(app.project(), saved).unwrap());
}
2 => {
app.canvas_document = Id::new_v4();
}
3 => {
app.hosted.visible = true;
}
4 => {
app.draft
.as_mut()
.unwrap()
.set_context(Some(json!({"different":"scope"})));
}
5 => {
let _ = app.update(Message::Command(Command::Rename(
Id::from_u128(BASE + 3),
"Changed model".into(),
)));
}
_ => {
app.source_workspace.edit(
"unfinished source".into(),
iced::widget::text_editor::Cursor {
position: iced::widget::text_editor::Position { line: 0, column: 0 },
selection: None,
},
);
}
}
let expected = raw(&app);
let source = app.document.bundle().clone();
let _ = app.update(delayed);
assert!(app.reference_authoring.opened.is_none());
assert_eq!(raw(&app), expected);
assert_eq!(app.document.bundle(), &source);
}
let mut p = catalog();
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
let app = inspector(p, Id::from_u128(300));
let draft = app.draft.as_ref().unwrap();
let request = draft.reference_open(
app.project(),
"/baseline/semantic/facets/0/references/0/target",
Rectangle::default(),
);
assert!(draft.reference_catalog(app.project(), &request, 0).is_err());
}
#[test]
fn reference_array_row_reorder_and_constructor_switch_invalidate_exact_old_field() {
let mut p = Project::empty();
for n in 0..3 {
element(
&mut p,
Id::from_u128(BASE + n),
Kind::ExchangeItem,
Layer::Oa,
"Signal item",
None,
);
}
element(&mut p, OWNER, Kind::Event, Layer::Oa, "Signal", None);
*p.elements.get_mut(&OWNER).unwrap().shared_mut().unwrap() = SharedBody::Event {
body: EventBody::Signal {
items: vec![Id::from_u128(BASE), Id::from_u128(BASE + 1)],
},
};
let mut app = inspector(p, OWNER);
let list = "/body/Oa/Shared/Event/body/Signal/items";
let path = format!("{list}/0");
open(&mut app, &Host::Inspector, &path);
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
let _ = app.update(Message::FieldArray(list.into(), Some(0), 1));
let expected = raw(&app);
assert_eq!(
app.draft.as_ref().unwrap().value.pointer(list),
Some(&json!([Id::from_u128(BASE + 1), Id::from_u128(BASE)]))
);
let _ = app.update(delayed);
assert_eq!(raw(&app), expected);
open(&mut app, &Host::Inspector, &path);
let delayed = event(&app, Action::Choose(Id::from_u128(BASE + 2)));
let _ = app.update(Message::FieldVariant(
"/body/Oa/Shared/Event/body".into(),
json!({"Change":{"expression":"unfinished"}}),
));
let expected = raw(&app);
let _ = app.update(delayed);
assert_eq!(raw(&app), expected);
assert!(!app.document.can_undo());
}
#[test]
fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning_and_reject_old_facet()
{
for mode in 0..3 {
let mut p = Project::empty();
p.id = Id::from_u128(1);
for (id, layer, parent) in [
(100, Layer::Oa, None),
(101, Layer::Sa, None),
(102, Layer::Sa, None),
(103, Layer::Oa, Some(Id::from_u128(100))),
(104, Layer::Sa, Some(Id::from_u128(101))),
] {
element(
&mut p,
Id::from_u128(id),
if parent.is_some() {
Kind::DataField
} else {
Kind::DataType
},
layer,
"Record",
parent,
);
}
trace(
&mut p,
Id::from_u128(300),
Id::from_u128(100),
Id::from_u128(101),
);
let facet = match mode {
0 => FacetSelector::Data(DataFacet::RecordSupertype),
1 => FacetSelector::Data(DataFacet::Constructor),
_ => FacetSelector::Child(KeyPair {
oa: Some(Id::from_u128(103)),
sa: Some(Id::from_u128(104)),
}),
};
let path = if mode == 2 {
"/parent"
} else {
"/body/Sa/Shared/DataType/definition/Record/supertype"
};
let mut app = App::with_project(p.clone());
let _ = app.update(Message::StartSemanticReview(Id::from_u128(100)));
if mode == 2 {
// A paired attachment is a real catalog facet only after explicit
// correspondence; selecting an unstaged synthetic pair is rejected.
for (id, oa) in [(103, true), (104, false)] {
let choice = crate::app::Choice {
id: Id::from_u128(id),
label: format!("Record · @object_{id}"),
};
send_review(
&mut app,
if oa {
RowEvent::ReferenceOa(choice)
} else {
RowEvent::ReferenceSa(choice)
},
);
}
send_review(&mut app, RowEvent::AddReference);
}
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet.clone(), Some(SemanticDecision::Candidate)),
);
let host = review_host(&app);
let record = if mode == 2 {
Id::from_u128(104)
} else {
Id::from_u128(101)
};
assert_eq!(
app.reference_draft(&host, path)
.unwrap_or_else(|| {
panic!(
"Reference fixture mode={mode}, facet={facet:?}, selected={:?}, decisions={:?}, error={:?}",
review(&app).selected,
review(&app).decisions,
review(&app).error
)
})
.original,
record
);
let before = review(&app).recovery_snapshot().unwrap();
let plan = serde_json::to_value(&review(&app).plan).unwrap();
let proposed = review(&app).proposed.clone();
let source = app.document.bundle().clone();
open(&mut app, &host, path);
search(&mut app, "@object_102 SA");
assert_eq!(
app.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.count(),
1
);
assert_eq!(review(&app).recovery_snapshot().unwrap(), before);
assert_eq!(serde_json::to_value(&review(&app).plan).unwrap(), plan);
assert_eq!(review(&app).proposed, proposed);
assert_eq!(app.document.bundle(), &source);
let old = event(&app, Action::Choose(Id::from_u128(102)));
send_review(
&mut app,
RowEvent::Select(FacetSelector::Header(HeaderFacet::Name)),
);
let switched = review(&app).recovery_snapshot().unwrap();
let _ = app.update(old);
assert_eq!(review(&app).recovery_snapshot().unwrap(), switched);
send_review(&mut app, RowEvent::Select(facet));
open(&mut app, &host, path);
choose(&mut app, Id::from_u128(102));
let draft = app.reference_draft(&host, path).unwrap();
let mut expected_record = serde_json::to_value(&p.elements[&record]).unwrap();
*expected_record.pointer_mut(path).unwrap() = json!(Id::from_u128(102));
assert_eq!(draft.materialize().unwrap(), expected_record);
assert_eq!(app.document.bundle(), &source);
assert!(
!app.document.can_undo(),
"A choice only stages the active review candidate"
);
}
}
mod mounted;
#[test]
fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_focus() {
let mut p = Project::empty();
for (id, layer) in [
(100, Layer::Oa),
(101, Layer::Sa),
(102, Layer::Sa),
(110, Layer::Oa),
(111, Layer::Sa),
] {
element(
&mut p,
Id::from_u128(id),
Kind::DataType,
layer,
"Record",
None,
);
}
trace(
&mut p,
Id::from_u128(300),
Id::from_u128(100),
Id::from_u128(101),
);
trace(
&mut p,
Id::from_u128(310),
Id::from_u128(110),
Id::from_u128(111),
);
let mut app = App::with_project(p);
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
let facet = FacetSelector::Data(DataFacet::RecordSupertype);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
let host = review_host(&app);
let path = "/body/Sa/Shared/DataType/definition/Record/supertype";
let old_open = request(&app, &host, path);
open(&mut app, &host, path);
let old_choice = event(&app, Action::Choose(Id::from_u128(102)));
for event in [
semantic_review::Event::ChooseTrace(crate::app::Choice {
id: Id::from_u128(310),
label: "Second exact trace".into(),
}),
semantic_review::Event::OpenTrace,
] {
let _ = app.update(Message::Workflow(workflow::Event::semantic(event)));
}
assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111)));
let expected = review(&app).recovery_snapshot().unwrap();
let source = app.document.bundle().clone();
let _ = app.update(old_choice);
let Host::Review { address, facet } = host else {
unreachable!()
};
let _ = app.update(Message::Workflow(workflow::Event::semantic(
semantic_review::Event::Row {
address,
facet: Some(facet),
event: RowEvent::ReferenceOpen(old_open),
},
)));
assert!(app.reference_authoring.opened.is_none());
assert_eq!(review(&app).recovery_snapshot().unwrap(), expected);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
@@ -1,910 +0,0 @@
//! Real field + app-root overlay interaction. The null text renderer lets the
//! same retained widget tree exercise focus and geometry without a GPU/window.
use super::*;
use iced::futures::StreamExt;
use iced::{
Element, Event as Input, Point, Size, Theme, Vector,
advanced::{
Layout, Shell, layout,
widget::{Id as WidgetId, Operation, Tree, operation},
},
keyboard, mouse,
widget::{Space, column, container, opaque, scrollable, stack, text_input},
};
use oa_core::transitions::TransitionDirection;
#[derive(Default)]
struct Clipboard;
impl iced::advanced::Clipboard for Clipboard {
fn read(&self, _: iced::advanced::clipboard::Kind) -> Option<String> {
None
}
fn write(&mut self, _: iced::advanced::clipboard::Kind, _: String) {}
}
#[derive(Default)]
struct Fields {
values: Vec<(WidgetId, Rectangle, bool)>,
scrolls: Vec<(Rectangle, Vector)>,
}
impl Operation for Fields {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&WidgetId>,
bounds: Rectangle,
state: &mut dyn operation::Focusable,
) {
if let Some(id) = id {
self.values.push((id.clone(), bounds, state.is_focused()));
}
}
fn scrollable(
&mut self,
_: Option<&WidgetId>,
bounds: Rectangle,
_: Rectangle,
translation: Vector,
_: &mut dyn operation::Scrollable,
) {
self.scrolls.push((bounds, translation));
}
}
impl Fields {
fn field(&self, id: &WidgetId) -> (Rectangle, bool) {
self.values
.iter()
.find(|(actual, _, _)| actual == id)
.map(|(_, bounds, focused)| (*bounds, *focused))
.expect("Mounted field identity")
}
}
fn view(
app: &App,
scrolled: bool,
nested: bool,
show_review: bool,
) -> Element<'_, Message, Theme, ()> {
let draft = app.draft.as_ref().unwrap();
let form = column![
text_input("Name", &draft.inputs["/name"].raw)
.id("before-reference")
.on_input(|raw| Message::Field("/name".into(), raw)),
adapter::field(draft, app.project(), PATH),
text_input("Description", &draft.inputs["/description"].raw)
.id("after-reference")
.on_input(|raw| Message::Field("/description".into(), raw)),
]
.spacing(9);
let form = trigger::scope(form, draft.reference_session());
let base: Element<'_, Message, Theme, ()> = if scrolled {
container(
scrollable(column![Space::new().height(500), form])
.id("reference-scroll")
.height(180),
)
.width(300)
.into()
} else {
container(form).width(300).height(iced::Fill).into()
};
let base: Element<'_, Message, Theme, ()> = if nested {
scrollable(column![Space::new().height(400), base])
.id("outer-reference-scroll")
.height(180)
.width(300)
.into()
} else {
base
};
let base = if show_review {
let review: Element<'_, Message, Theme, ()> = match app.workflow.as_ref() {
Some(workflow::Workflow::Semantic(state)) => {
match state.reference_form(app.project()) {
Some(draft) => {
let mut content = column![];
if let Some(raw) = &state.operation_omission {
content = content.push(
text_input("Required engineering rationale", raw)
.id("review-rationale")
.on_input(|raw| {
Message::Workflow(workflow::Event::semantic(
state.address_event(RowEvent::OperationRationale(raw)),
))
}),
);
}
content = content.push(trigger::scope(
adapter::field(draft, app.project(), "/parent").map(|message| {
match message {
Message::FieldReferenceOpen(request) => {
Message::Workflow(workflow::Event::semantic(
state.address_event(RowEvent::ReferenceOpen(request)),
))
}
other => other,
}
}),
draft.reference_session(),
));
opaque(container(trigger::modal(content)).width(300).height(180))
}
None => Space::new().into(),
}
}
_ => Space::new().into(),
};
stack![base, review].into()
} else {
base
};
app.reference_overlay(base)
}
struct Mount {
tree: Option<Tree>,
size: Size,
scrolled: bool,
nested: bool,
show_review: bool,
}
impl Default for Mount {
fn default() -> Self {
Self {
tree: None,
size: Size::new(360., 320.),
scrolled: false,
nested: false,
show_review: false,
}
}
}
impl Mount {
fn frame(
&mut self,
app: &App,
events: &[(Input, Option<Point>)],
) -> (Vec<Message>, Fields, Option<Rectangle>) {
let mut view = view(app, self.scrolled, self.nested, self.show_review);
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
view.as_widget().diff(tree);
let node =
view.as_widget_mut()
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
let viewport = Rectangle::with_size(self.size);
let overlay_node = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.map(|mut overlay| overlay.as_overlay_mut().layout(&(), self.size));
let mut messages = Vec::new();
for (input, point) in events {
let mut shell = Shell::new(&mut messages);
let cursor = point.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available);
if let Some(overlay_node) = &overlay_node {
let mut overlay = view
.as_widget_mut()
.overlay(tree, Layout::new(&node), &(), &viewport, Vector::ZERO)
.unwrap();
overlay.as_overlay_mut().update(
input,
Layout::new(overlay_node),
cursor,
&(),
&mut Clipboard,
&mut shell,
);
assert!(shell.is_event_captured());
} else {
view.as_widget_mut().update(
tree,
input,
Layout::new(&node),
cursor,
&(),
&mut Clipboard,
&mut shell,
&viewport,
);
}
}
let mut fields = Fields::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &(), &mut fields);
(
messages,
fields,
overlay_node.map(|node| node.children()[0].bounds()),
)
}
fn click(&mut self, app: &App, point: Point) -> Vec<Message> {
self.frame(
app,
&[
(
Input::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Some(point),
),
(
Input::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
Some(point),
),
],
)
.0
}
async fn perform(&mut self, app: &App, task: Task<Message>) {
let Some(mut stream) = iced_runtime::task::into_stream(task) else {
return;
};
while let Some(action) = stream.next().await {
let iced_runtime::Action::Widget(mut operation) = action else {
panic!("Chooser only requests scoped widget focus")
};
let mut view = view(app, self.scrolled, self.nested, self.show_review);
let tree = self.tree.get_or_insert_with(|| Tree::new(view.as_widget()));
view.as_widget().diff(tree);
let node =
view.as_widget_mut()
.layout(tree, &(), &layout::Limits::new(Size::ZERO, self.size));
loop {
view.as_widget_mut()
.operate(tree, Layout::new(&node), &(), operation.as_mut());
match operation.finish() {
operation::Outcome::Chain(next) => operation = next,
_ => break,
}
}
}
}
async fn dispatch(&mut self, app: &mut App, messages: Vec<Message>) {
for message in messages {
let task = app.update(message);
self.perform(app, task).await;
}
}
}
fn key(key: keyboard::key::Named, shift: bool) -> Input {
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(key),
modified_key: keyboard::Key::Named(key),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Space),
location: keyboard::Location::Standard,
modifiers: if shift {
keyboard::Modifiers::SHIFT
} else {
keyboard::Modifiers::empty()
},
text: None,
repeat: false,
})
}
fn typing(raw: &str) -> Vec<(Input, Option<Point>)> {
raw.chars()
.map(|character| {
let raw = character.to_string();
(
Input::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Character(raw.clone().into()),
modified_key: keyboard::Key::Character(raw.clone().into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyA),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: Some(raw.into()),
repeat: false,
}),
None,
)
})
.collect()
}
#[tokio::test]
async fn mounted_reference_trigger_tabs_to_root_popup_search_escape_and_exact_adjacent_fields() {
let mut app = inspector(catalog(), OWNER);
let accepted = app.document.bundle().clone();
let original = raw(&app);
let mut mount = Mount::default();
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("before-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let (_, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
assert!(
fields.field(&id).1,
"Tab reaches the real reference trigger"
);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
assert_eq!(messages.len(), 1, "One keyboard activation opens one field");
mount.dispatch(&mut app, messages).await;
let popup = mount
.frame(&app, &[])
.2
.expect("App root overlay is mounted");
assert!(
popup.x >= 0.
&& popup.y >= 0.
&& popup.x + popup.width <= 360.
&& popup.y + popup.height <= 320.
);
let mut inputs = typing("@controller_0720");
inputs.push((key(keyboard::key::Named::Escape, false), None));
let messages = mount.frame(&app, &inputs).0;
mount.dispatch(&mut app, messages).await;
let (_, fields, popup) = mount.frame(&app, &[]);
assert!(popup.is_none());
assert!(
fields.field(&id).1,
"Escape returns to its exact reference field"
);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &accepted);
for (backwards, expected) in [(false, "after-reference"), (true, "before-reference")] {
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)])
.0;
mount.dispatch(&mut app, messages).await;
let (_, fields, popup) = mount.frame(&app, &[]);
assert!(popup.is_none());
assert!(
fields.field(&WidgetId::new(expected)).1,
"Chooser Tab stays within the originating form"
);
}
assert_eq!(app.document.bundle(), &accepted);
}
#[tokio::test]
async fn mounted_reference_choice_stages_literal_identity_and_delayed_focus_cannot_hit_replacement_field()
{
let mut app = inspector(catalog(), OWNER);
let accepted = app.document.bundle().clone();
let mut mount = Mount::default();
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
let messages = mount.frame(&app, &typing("@controller_0720")).0;
mount.dispatch(&mut app, messages).await;
assert_eq!(
app.reference_authoring
.opened
.as_ref()
.unwrap()
.chooser
.count(),
1
);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
assert_eq!(messages.len(), 1);
let delayed_focus = app.update(messages.into_iter().next().unwrap());
assert_eq!(
app.draft.as_ref().unwrap().value.pointer(PATH),
Some(&json!(Id::from_u128(BASE + 720)))
);
assert_eq!(app.document.bundle(), &accepted);
// A structural edit occurs after Choose but before its asynchronous focus.
app.draft
.as_mut()
.unwrap()
.shape_scoped(PATH, Value::Null)
.unwrap();
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("after-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
mount.perform(&app, delayed_focus).await;
let fields = mount.frame(&app, &[]).1;
assert!(
fields.field(&WidgetId::new("after-reference")).1,
"A missing old field target makes no focus changes"
);
assert!(
!fields
.field(&trigger::field_id(
app.draft.as_ref().unwrap().reference_generation(),
PATH
))
.1
);
assert_eq!(app.document.bundle(), &accepted);
}
#[tokio::test]
async fn mounted_scrolled_reference_anchor_uses_window_geometry_for_pointer_and_keyboard() {
let mut app = inspector(catalog(), OWNER);
let mut mount = Mount {
scrolled: true,
..Default::default()
};
mount.frame(&app, &[]);
mount
.perform(
&app,
iced::widget::operation::snap_to("reference-scroll", scrollable::RelativeOffset::END),
)
.await;
let fields = mount.frame(&app, &[]).1;
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let content = fields.field(&id).0;
let (viewport, offset) = fields.scrolls[0];
assert!(offset.y > 400., "Fixture is genuinely scrolled");
let expected = Rectangle {
x: content.x - offset.x,
y: content.y - offset.y,
..content
}
.intersection(&viewport)
.unwrap();
let messages = mount.click(&app, expected.center());
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
panic!("Scrolled field should emit one actual Open")
};
assert_eq!(request.anchor, expected);
mount.dispatch(&mut app, messages).await;
let close = app.update(Message::Escape);
mount.perform(&app, close).await;
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
let [Message::FieldReferenceOpen(request)] = messages.as_slice() else {
panic!("Focused scrolled field opens without a pointer")
};
assert_eq!(request.anchor, expected);
}
#[tokio::test]
async fn mounted_fresh_tab_reveals_offscreen_reference_and_return_reveals_it_again() {
for nested in [false, true] {
let mut app = inspector(catalog(), OWNER);
let original = raw(&app);
let source = app.document.bundle().clone();
let mut mount = Mount {
scrolled: true,
nested,
..Default::default()
};
let fields = mount.frame(&app, &[]).1;
assert!(
fields
.scrolls
.iter()
.all(|(_, offset)| *offset == Vector::ZERO)
);
assert!(fields.field(&WidgetId::new("before-reference")).0.y > 180.);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
.0;
assert!(
messages.is_empty(),
"Keyboard reveal must not author any input"
);
let fields = mount.frame(&app, &[]).1;
assert!(fields.field(&WidgetId::new("before-reference")).1);
assert!(
fields.scrolls.iter().all(|(_, offset)| offset.y > 0.),
"Every enclosing viewport reveals fresh focus"
);
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
assert!(
fields.field(&id).0.y - offset >= 180.,
"The next reference starts below the fold"
);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)])
.0;
assert!(messages.is_empty());
let fields = mount.frame(&app, &[]).1;
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
let (bounds, focused) = fields.field(&id);
assert!(focused);
assert!(bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01);
let messages = mount
.frame(&app, &[(key(keyboard::key::Named::Enter, false), None)])
.0;
assert_eq!(
messages.len(),
1,
"A newly revealed field opens through Enter"
);
mount.dispatch(&mut app, messages).await;
let return_focus = app.update(Message::Escape);
mount
.perform(
&app,
iced::widget::operation::snap_to(
"reference-scroll",
scrollable::RelativeOffset::START,
),
)
.await;
if nested {
mount
.perform(
&app,
iced::widget::operation::snap_to(
"outer-reference-scroll",
scrollable::RelativeOffset::START,
),
)
.await;
}
assert!(
mount
.frame(&app, &[])
.1
.scrolls
.iter()
.all(|(_, offset)| *offset == Vector::ZERO)
);
mount.perform(&app, return_focus).await;
let fields = mount.frame(&app, &[]).1;
let offset: f32 = fields.scrolls.iter().map(|(_, offset)| offset.y).sum();
let (bounds, focused) = fields.field(&id);
assert!(
focused && bounds.y - offset >= -0.01 && bounds.y + bounds.height - offset <= 180.01
);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &source);
}
}
#[tokio::test]
async fn mounted_form_tab_enters_in_both_directions_and_cycles_boundaries_without_losing_focus() {
for backwards in [false, true] {
let app = inspector(catalog(), OWNER);
let original = raw(&app);
let mut mount = Mount::default();
let reference = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let forward = [
WidgetId::new("before-reference"),
reference.clone(),
WidgetId::new("after-reference"),
];
let reverse = [
WidgetId::new("after-reference"),
reference,
WidgetId::new("before-reference"),
];
let expected = if backwards { &reverse } else { &forward };
for n in 0..7 {
let (messages, fields, popup) =
mount.frame(&app, &[(key(keyboard::key::Named::Tab, backwards), None)]);
assert!(messages.is_empty() && popup.is_none());
assert!(fields.field(&expected[n % 3]).1);
assert_eq!(
fields
.values
.iter()
.filter(|(_, _, focused)| *focused)
.count(),
1
);
}
assert_eq!(raw(&app), original);
assert!(!app.document.can_undo());
}
}
#[tokio::test]
async fn mounted_delayed_return_cannot_focus_under_review_and_fresh_modal_tab_owns_its_form() {
let mut p = catalog();
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
let mut app = inspector(p, OWNER);
let source = app.document.bundle().clone();
let original = raw(&app);
let mut mount = Mount {
show_review: true,
..Default::default()
};
let id = trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH);
let session = app.draft.as_ref().unwrap().reference_session();
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
let return_focus = app.update(Message::Escape);
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("after-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
assert_eq!(
app.draft.as_ref().unwrap().reference_session(),
session,
"Old Inspector really remains mounted"
);
assert_eq!(
trigger::field_id(app.draft.as_ref().unwrap().reference_generation(), PATH),
id
);
let review_before = review(&app).recovery_snapshot().unwrap();
let review_field = trigger::field_id(
review(&app)
.reference_form(app.project())
.unwrap()
.reference_generation(),
"/parent",
);
mount.perform(&app, return_focus).await;
let fields = mount.frame(&app, &[]).1;
assert!(
!fields.field(&id).1,
"A live old field underneath a modal cannot receive the stale focus task"
);
assert!(fields.field(&WidgetId::new("after-reference")).1);
assert!(!fields.field(&review_field).1);
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, false), None)]);
assert!(messages.is_empty());
assert!(
fields.field(&review_field).1,
"Review entry ignores retained focus under its modal"
);
assert!(
!fields.field(&WidgetId::new("after-reference")).1,
"Modal focus acquisition deactivates background inputs"
);
let messages = mount.frame(&app, &typing("x")).0;
assert!(
messages.is_empty(),
"A character at the focused Review trigger cannot type into the Inspector below it"
);
let (messages, fields, _) = mount.frame(&app, &[(key(keyboard::key::Named::Tab, true), None)]);
assert!(messages.is_empty());
assert!(fields.field(&review_field).1);
assert!(!fields.field(&id).1);
assert_eq!(review(&app).recovery_snapshot().unwrap(), review_before);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &source);
}
#[tokio::test]
async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input_inactive() {
let mut p = catalog();
trace(&mut p, Id::from_u128(300), OWNER, Id::from_u128(200));
let mut app = inspector(p, OWNER);
let original = raw(&app);
let source = app.document.bundle().clone();
let mut mount = Mount {
show_review: true,
..Default::default()
};
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("after-reference")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let _ = app.update(Message::StartSemanticReview(Id::from_u128(300)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
let saved = review(&app).recovery_snapshot().unwrap();
let id = trigger::field_id(
review(&app)
.reference_form(app.project())
.unwrap()
.reference_generation(),
"/parent",
);
let fields = mount.frame(&app, &[]).1;
assert!(
fields.field(&WidgetId::new("after-reference")).1,
"Fixture retains Inspector focus before the modal receives input"
);
let messages = mount.click(&app, fields.field(&id).0.center());
mount.dispatch(&mut app, messages).await;
assert!(matches!(
&app.reference_authoring.opened.as_ref().unwrap().host,
Host::Review { .. }
));
let fields = mount.frame(&app, &[]).1;
assert!(!fields.field(&WidgetId::new("after-reference")).1);
let messages = mount.click(&app, Point::new(359., 319.));
assert!(matches!(
messages.as_slice(),
[Message::Workflow(workflow::Event::Semantic(event))] if matches!(event.as_ref(),
semantic_review::Event::Row {
event: RowEvent::ReferenceEvent(chooser::Event {
action: Action::Close(Close::Outside),
..
}),
..
}
)
));
for message in messages {
let task = app.update(message);
assert!(
iced_runtime::task::into_stream(task).is_none(),
"Outside dismissal must not request focus or apply"
);
}
let (messages, fields, popup) = mount.frame(&app, &typing("x"));
assert!(messages.is_empty() && popup.is_none());
assert!(!fields.field(&WidgetId::new("after-reference")).1);
assert_eq!(review(&app).recovery_snapshot().unwrap(), saved);
assert_eq!(raw(&app), original);
assert_eq!(app.document.bundle(), &source);
}
#[tokio::test]
async fn mounted_reverse_review_required_rationale_remains_editable_beside_candidate_reference_form()
{
let mut p = persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap();
let sa = p
.elements
.values()
.find(|element| element.alias == "operation_probe_43")
.unwrap()
.id;
let allocation = p
.relations
.values()
.find(|relation| relation.alias == "operation_probe_30")
.unwrap()
.id;
// The boundary fixture deliberately has no selected operation. Author one
// through the validated command path before testing reverse omission.
let mut message = p.elements[&sa].clone();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = message.shared_mut()
else {
panic!("The operation fixture must contain an Exchange call")
};
*operation = Some(OperationUse {
allocation: Some(allocation),
});
p = execute_batch(&p, &[Command::EditElement(sa, message)]).unwrap();
assert!(matches!(
oa_core::operations::resolve(&p, sa).unwrap(),
oa_core::operations::OperationResolution::Selected(value)
if value.allocation == allocation
));
let inspector_id = Id::new_v4();
element(
&mut p,
inspector_id,
Kind::DataType,
Layer::Oa,
"Inspector record",
None,
);
let scenario = p
.add_element(Kind::Scenario, Layer::Oa, "Operational counterpart", None)
.unwrap();
let oa = p
.add_element(
Kind::Occurrence,
Layer::Oa,
"Operational message",
Some(scenario),
)
.unwrap();
*p.elements.get_mut(&oa).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: None,
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.push(oa);
let trace_id = Id::new_v4();
trace(&mut p, trace_id, oa, sa);
let mut app = inspector(p, inspector_id);
let source = app.document.bundle().clone();
let inspector_raw = raw(&app);
let _ = app.update(Message::StartSemanticReview(trace_id));
let _ = app.update(Message::Workflow(workflow::Event::semantic(
semantic_review::Event::Direction(TransitionDirection::SaToOa),
)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
RowEvent::Decide(facet, Some(SemanticDecision::Candidate)),
);
send_review(&mut app, RowEvent::OperationOmission(true));
assert_eq!(review(&app).direction, TransitionDirection::SaToOa);
assert_eq!(review(&app).operation_omission.as_deref(), Some(""));
let candidate = serde_json::to_value(
review(&app)
.reference_form(app.project())
.unwrap_or_else(|| {
panic!(
"Reverse review candidate: selected={:?}, decisions={:?}, error={:?}",
review(&app).selected,
review(&app).decisions,
review(&app).error
)
})
.recovery_snapshot(),
)
.unwrap();
let mut mount = Mount {
show_review: true,
..Default::default()
};
let fields = mount.frame(&app, &[]).1;
let messages = mount.click(
&app,
fields.field(&WidgetId::new("review-rationale")).0.center(),
);
mount.dispatch(&mut app, messages).await;
let messages = mount.frame(&app, &typing("Engineering reason")).0;
assert_eq!(
messages.len(),
"Engineering reason".chars().count(),
"Every key reaches the legitimate sibling inside Review"
);
assert!(messages.iter().all(|message| matches!(
message,
Message::Workflow(workflow::Event::Semantic(event)) if matches!(event.as_ref(), semantic_review::Event::Row {
event: RowEvent::OperationRationale(_),
..
})
)));
mount.dispatch(&mut app, messages).await;
assert_eq!(
review(&app).operation_omission.as_deref(),
Some("Engineering reason")
);
assert!(
mount
.frame(&app, &[])
.1
.field(&WidgetId::new("review-rationale"))
.1
);
assert_eq!(
serde_json::to_value(
review(&app)
.reference_form(app.project())
.unwrap()
.recovery_snapshot()
)
.unwrap(),
candidate
);
assert_eq!(raw(&app), inspector_raw);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
@@ -16,7 +16,7 @@ fn state(app: &App) -> &State {
}
fn row_event(app: &mut App, event: RowEvent) {
let event = state(app).address_event(event);
send(app, Message::Workflow(Event::semantic(event)));
send(app, Message::Workflow(Event::Semantic(event)));
}
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
let mut p = Project::empty();
+7 -53
View File
@@ -49,7 +49,6 @@ 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),
@@ -113,7 +112,6 @@ struct Form {
line: usize,
segment: usize,
preview_destination: Id,
preview_offset: f32,
details: bool,
detail_segment: usize,
detail_editor: text_editor::Content,
@@ -157,32 +155,19 @@ impl App {
// old Close/Confirm cannot disturb a newer form with the same source path.
pub(super) fn source_rename_envelope(&self, event: &Event) -> bool {
if let Event::Begin(begin) = event {
return self.page == Page::Text
&& !self.source_rename.open()
&& *begin == self.source_rename_begin();
return !self.source_rename.open() && *begin == self.source_rename_begin();
}
if let Event::Undo(id) = event {
return self.source_rename.undo.as_ref().is_some_and(|undo| {
undo.id == *id
&& !self.source_rename.open()
&& self.rename_undo_matches(&undo.context)
});
return self
.source_rename
.undo
.as_ref()
.is_some_and(|undo| undo.id == *id);
}
let Some(form) = &self.source_rename.form else {
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
}
@@ -225,8 +210,6 @@ impl App {
|| self.hosted.visible
|| self.model_menu.is_some()
|| self.architecture.is_some()
|| self.diagram_usages.is_some()
|| self.reference_input_active()
|| self.behavior.has_pending()
|| self.draft_dirty()
{
@@ -443,28 +426,9 @@ impl App {
}
Event::Details(_, value) => {
let form = self.source_rename.form.as_mut().unwrap();
if form.details == value {
return Task::none();
}
form.details = value;
// Leaving or returning to the preview ends the old visit. Keep
// 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();
@@ -541,7 +505,6 @@ 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(),
@@ -979,7 +942,6 @@ 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
{
@@ -1145,15 +1107,7 @@ 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))
.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),
scrollable(displayed).id(preview_scroll_id(form, preview_id)).height(Fill).width(Fill).spacing(8),
].spacing(6).height(Fill).into()
} else {
scrollable(column![
@@ -1,451 +0,0 @@
//! Combined App-boundary oracles: the workers are completed independently and
//! no returned Task is polled. Rendering measurements use the real reveal path.
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()
}
fn navigation_fixture() -> App {
let mut source = bundle();
let entry = source.files.get_mut("project.arcadia").unwrap();
*entry = entry.replace(
"include \"uses.arcadia\";",
"include \"uses.arcadia\"; include \"views.arcadia\";",
);
source.files.insert(
"views.arcadia".into(),
concat!(
"// Keep @pump and the exact distant placement.\r\n",
"diagram overview \"Pump overview\" id \"00000000-0000-0000-0000-000000000008\" { layer Oa; }\r\n",
"node placed_pump id \"00000000-0000-0000-0000-000000000009\" { diagram @overview; element @pump; position { x 12000; y 9000; }; size { width 260; height 94; }; }\r\n",
).into(),
);
let mut app = fixture();
app.document = Document::from_bundle(source.clone(), LIMITS).unwrap();
assert_eq!(app.document.bundle(), &source);
assert!(
!validate(app.project())
.iter()
.any(|issue| issue.severity == validation::Severity::Error)
);
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.selected = app.project().id;
app.refresh_draft();
app.source_workspace.active = "uses.arcadia".into();
app.show_source_file(false);
seek_pump(&mut app);
app.canvas_size = Some(Size::new(1200., 700.));
app.zoom = 0.8;
app.pan = Vector::new(31., 47.);
app
}
fn seek_pump(app: &mut App) {
let source = app.source_workspace.text();
let offset = source.rfind("pump;").unwrap();
app.source_editor
.move_to(cursor(source, offset, offset + 4));
}
fn source_completion(app: &App, request: nav::Request) -> nav::Completion {
let snapshot = Arc::new(
app.with_source_input(|input| query::capture(input, query::Limits::default()).unwrap()),
);
let results = snapshot
.query(query::Query {
stamp: request.stamp,
path: &request.path,
byte_offset: request.offset,
action: request.action,
})
.unwrap();
nav::Completion {
request,
result: Ok((snapshot, Arc::new(results))),
}
}
fn diagram_completion(app: &App) -> usage::Completion {
let panel = app.diagram_usages.as_ref().unwrap();
let process = app.behavior.topology.usage_snapshot().into_query_state();
usage::Completion {
token: panel.token,
context: panel.context,
result: Ok(usage::collect(
app.project(),
panel.context.subject,
&process,
&behavior::scenario::State::default(),
)),
}
}
fn finish_lookup(app: &mut App) -> (Id, Usage) {
let completion = diagram_completion(app);
send(app, Message::DiagramUsagesReady(completion));
let panel = app.diagram_usages.as_ref().unwrap();
assert!(!panel.loading);
assert!(app.diagram_lookup_in_flight.is_none());
let found = panel
.usages
.iter()
.find(|u| {
u.surface == Surface::Saved(model_id(8))
&& u.target == UsageTarget::Placement(model_id(9))
})
.expect("the exact placed pump is represented")
.clone();
assert_eq!(found.subject, model_id(2));
assert_eq!((found.bounds.x, found.bounds.y), (12000., 9000.));
(panel.token, found)
}
fn navigate(app: &mut App) -> Usage {
let message = app.diagram_usage_message(model_id(2));
send(app, message);
let (token, found) = finish_lookup(app);
send(
app,
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
);
assert_eq!(app.page, Page::Diagram);
assert_eq!(app.active_diagram, Some(model_id(8)));
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
assert!(app.diagram_usages.is_none());
assert!(app.pending_diagram_reveal.is_some());
assert!(app.diagram_focus(found.surface).is_some());
found
}
#[derive(Debug, PartialEq)]
struct Navigation {
page: Page,
selected: Id,
placement: Option<Id>,
diagram: Option<Id>,
zoom: f32,
pan: Vector,
pending: Option<(usage::Context, Usage, u64)>,
focus: Option<(usage::Context, Usage, Id)>,
canvas_epoch: u64,
camera_epoch: u64,
source_interaction: Id,
source_reveal: Option<Id>,
}
fn navigation(app: &App) -> Navigation {
Navigation {
page: app.page,
selected: app.selected,
placement: app.selected_node,
diagram: app.active_diagram,
zoom: app.zoom,
pan: app.pan,
pending: app.pending_diagram_reveal.clone(),
focus: app
.diagram_navigation_focus
.as_ref()
.map(|f| (f.context, f.usage.clone(), f.selected)),
canvas_epoch: app.canvas_epoch,
camera_epoch: app.camera_epoch,
source_interaction: app.source_symbols.interaction,
source_reveal: app.source_reveal_ticket(),
}
}
fn first_measurement(app: &mut App, found: &Usage) {
let (context, _, epoch) = app.pending_diagram_reveal.as_ref().unwrap();
let (context, epoch) = (*context, *epoch);
let actual = Size::new(500., 300.);
send(app, Message::DiagramRevealViewport(context, epoch, actual));
assert!(app.pending_diagram_reveal.is_none());
assert_eq!(app.canvas_size, Some(actual));
assert!((found.bounds.center_x() * app.zoom + app.pan.x - 250.).abs() < 0.05);
assert!((found.bounds.center_y() * app.zoom + app.pan.y - 150.).abs() < 0.05);
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
assert!(app.diagram_focus(found.surface).is_some());
}
#[test]
fn rename_modal_rejects_queued_diagram_and_source_navigation_without_losing_approval() {
let mut app = navigation_fixture();
let original = evidence(&app);
let snapshot = app.document.snapshot();
let queued = app.diagram_usage_message(model_id(2));
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
let form = app.source_rename.form.as_ref().unwrap();
let approved = (
form.invocation,
form.generation,
form.approval.as_ref().unwrap().id,
);
send(&mut app, queued);
let source = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
send(&mut app, source);
let definition = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(&mut app, definition);
assert!(app.diagram_usages.is_none());
assert!(app.diagram_lookup_in_flight.is_none());
assert!(app.source_symbols.in_flight.is_none());
assert!(!app.source_navigation_available());
let form = app.source_rename.form.as_ref().unwrap();
assert_eq!(
(
form.invocation,
form.generation,
form.approval.as_ref().unwrap().id
),
approved
);
assert_eq!(form.replacement, "send_water");
assert_eq!(evidence(&app), original);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
send(&mut app, Message::Escape);
send(&mut app, confirm);
assert!(!app.source_rename.open());
assert_eq!(evidence(&app), original);
}
#[test]
fn loading_and_ready_diagram_choosers_reject_a_previously_captured_rename_begin() {
for finish in [false, true] {
let mut app = navigation_fixture();
let before = evidence(&app);
let queued_rename = app.source_rename_message();
let open = app.diagram_usage_message(model_id(2));
send(&mut app, open);
if finish {
finish_lookup(&mut app);
}
let panel = app.diagram_usages.as_ref().unwrap();
let token = panel.token;
let loading = panel.loading;
assert!(app.rename_admission().is_err());
assert!(!app.source_navigation_available());
send(&mut app, queued_rename);
assert!(!app.source_rename.open());
assert!(app.source_rename.in_flight.is_none());
let panel = app.diagram_usages.as_ref().unwrap();
assert_eq!((panel.token, panel.loading), (token, loading));
assert_eq!(evidence(&app), before);
send(&mut app, Message::DiagramUsage(usage::Event::Close(token)));
let rename = app.source_rename_message();
send(&mut app, rename);
assert!(
app.source_rename.open(),
"the dismissed chooser no longer owns input"
);
}
}
#[test]
fn late_target_and_preview_results_release_only_their_slot_and_preserve_first_camera_measurement() {
for preview_pending in [false, true] {
let mut app = navigation_fixture();
let before = evidence(&app);
let completion = if preview_pending {
begin(&mut app);
input(&mut app, "send_water");
let job = start_preview(&mut app);
preview_completion(&app, job)
} else {
let rename = app.source_rename_message();
send(&mut app, rename);
target_completion(&app, current_job(&app))
};
let occupied = completion.job.id;
event(&mut app, Event::Close(completion.job.invocation));
assert_eq!(app.source_rename.in_flight, Some(occupied));
let found = navigate(&mut app);
let location = navigation(&app);
assert_eq!(evidence(&app), before);
let mut forged = completion.clone();
forged.job.id = Id::new_v4();
send(&mut app, Message::SourceRenameReady(forged));
assert_eq!(app.source_rename.in_flight, Some(occupied));
assert_eq!(navigation(&app), location);
send(&mut app, Message::SourceRenameReady(completion.clone()));
assert!(app.source_rename.in_flight.is_none());
assert!(!app.source_rename.open());
assert_eq!(navigation(&app), location);
assert_eq!(evidence(&app), before);
send(&mut app, Message::SourceRenameReady(completion));
assert_eq!(
navigation(&app),
location,
"duplicate completion is passive too"
);
first_measurement(&mut app, &found);
assert_eq!(evidence(&app), before);
}
}
#[test]
fn stale_begin_close_and_actual_confirm_leave_new_diagram_focus_and_pending_measurement_unchanged()
{
let mut app = navigation_fixture();
let before = evidence(&app);
let snapshot = app.document.snapshot();
let queued_begin = app.source_rename_message();
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
let invocation = app.source_rename.form.as_ref().unwrap().invocation;
event(&mut app, Event::Close(invocation));
let found = navigate(&mut app);
let location = navigation(&app);
for stale in [
queued_begin,
Message::SourceRename(Event::Close(invocation)),
confirm,
] {
send(&mut app, stale);
assert_eq!(navigation(&app), location);
assert_eq!(evidence(&app), before);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
}
first_measurement(&mut app, &found);
}
#[test]
fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arrive() {
let mut app = navigation_fixture();
let source = app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition));
send(&mut app, source);
let request = app.source_symbols.request.clone().unwrap();
let source_ready = source_completion(&app, request.clone());
let rename = app.source_rename_message();
send(&mut app, rename);
let rename_ready = target_completion(&app, current_job(&app));
let rename_job = rename_ready.job.id;
event(&mut app, Event::Close(rename_ready.job.invocation));
let open = app.diagram_usage_message(model_id(2));
send(&mut app, open);
let old_diagram = diagram_completion(&app);
let diagram_job = old_diagram.token;
let old_document = app.canvas_document;
let project = app.project().clone();
app.reset(project, None);
assert_ne!(app.canvas_document, old_document);
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.source_rename.in_flight, Some(rename_job));
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
assert!(!app.source_rename.open());
assert!(app.diagram_usages.is_none());
let before = evidence(&app);
let snapshot = app.document.snapshot();
let new_open = app.diagram_usage_message(model_id(2));
send(&mut app, new_open);
let new_token = app.diagram_usages.as_ref().unwrap().token;
assert_ne!(new_token, diagram_job);
assert_eq!(
app.diagram_lookup_in_flight,
Some(diagram_job),
"replacement waits for old worker"
);
send(&mut app, Message::SourceRenameReady(rename_ready));
assert!(app.source_rename.in_flight.is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
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.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);
finish_lookup(&mut app);
assert_eq!(evidence(&app), before);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert!(!app.source_rename.open());
}
#[test]
fn rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo_history() {
let mut app = navigation_fixture();
let baseline = app.document.snapshot();
let project = app.project().id;
app.document
.execute(Command::Rename(project, "Previously accepted name".into()))
.unwrap();
app.sync_source();
app.refresh_draft();
seek_pump(&mut app);
let before = app.document.snapshot();
let mut expected_project = app.project().clone();
expected_project
.elements
.get_mut(&model_id(2))
.unwrap()
.alias = "send_water".into();
let mut expected_source = app.document.bundle().clone();
let defs = expected_source.files.get_mut("defs.arcadia").unwrap();
*defs = defs.replace("oa_activity pump ", "oa_activity send_water ");
let uses = expected_source.files.get_mut("uses.arcadia").unwrap();
*uses = uses
.replace("source @pump;", "source @send_water;")
.replace("// keep @pump\r\n pump;", "// keep @pump\r\n send_water;");
let views = expected_source.files.get_mut("views.arcadia").unwrap();
*views = views.replace("element @pump;", "element @send_water;");
begin(&mut app);
input(&mut app, "send_water");
let confirm = preview(&mut app);
send(&mut app, confirm);
assert_eq!(app.project(), &expected_project);
assert_eq!(app.document.bundle(), &expected_source);
let renamed = app.document.snapshot();
let accepted = evidence(&app);
let queued_undo_rename = app
.source_rename_undo_message()
.expect("fresh rename offers its guarded undo");
let message = app.source_symbol_message(nav::SymbolEvent::DiagramUsages);
send(&mut app, message);
let request = app
.source_symbols
.request
.clone()
.expect("renamed exact caret remains queryable");
let ready = source_completion(&app, request);
send(&mut app, Message::SourceSymbolsReady(ready));
let (token, found) = finish_lookup(&mut app);
send(
&mut app,
Message::DiagramUsage(usage::Event::Choose(token, found.clone())),
);
assert_eq!(app.selected, model_id(2));
assert_eq!(app.selected_node, Some(model_id(9)));
let destination = navigation(&app);
send(&mut app, queued_undo_rename);
assert_eq!(navigation(&app), destination);
assert_eq!(evidence(&app), accepted);
first_measurement(&mut app, &found);
assert_eq!(evidence(&app), accepted);
assert!(Arc::ptr_eq(&renamed, &app.document.snapshot()));
for (message, expected) in [
(Message::Undo, before.clone()),
(Message::Redo, renamed.clone()),
(Message::Undo, before.clone()),
(Message::Undo, baseline.clone()),
(Message::Redo, before),
(Message::Redo, renamed),
] {
send(&mut app, message);
assert!(
Arc::ptr_eq(&expected, &app.document.snapshot()),
"exact history entry restored"
);
assert_eq!(app.project(), &expected.decoded.project);
assert_eq!(app.document.bundle(), &expected.bundle);
assert_eq!(app.source_workspace.bundle(), expected.bundle);
assert!(!app.source_dirty());
}
}
@@ -1,288 +0,0 @@
//! 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,7 +50,6 @@ 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)) {
@@ -59,14 +58,13 @@ impl widget::Operation for Geometry {
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&self.id) {
self.offset = Some(offset);
self.bounds = Some(bounds);
}
}
}
@@ -97,7 +95,6 @@ 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
@@ -117,41 +114,6 @@ 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) {
@@ -346,211 +308,3 @@ async fn repeated_page_visits_filters_and_fast_preview_completion_have_distinct_
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(evidence(&app), before);
}
#[tokio::test]
async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated_visibility_scroll()
{
// Exercise both an actually mounted Details view and a fast round trip
// between frames, where Iced retains the same preview Scrollable state.
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 mut mounted = Mounted::new(&app);
mounted.scroll_down(&app);
let held_reset = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
let old_preview = scroll_id(&app);
let form = app.source_rename.form.as_ref().unwrap();
let page = (form.file, form.hunk, form.line, form.segment);
let show_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
assert_eq!(mounted.execute(&app, show_details).await, 0);
let details_visit = scroll_id(&app);
assert_ne!(details_visit, old_preview);
if mount_details {
let mut hidden = Geometry {
id: old_preview.clone(),
offset: None,
bounds: None,
};
mounted.operate(&app, &mut hidden);
assert!(
hidden.offset.is_none(),
"Details actually replaced the preview"
);
}
let repeat_details = app.update(Message::SourceRename(Event::Details(invocation, true)));
assert_eq!(mounted.execute(&app, repeat_details).await, 0);
assert_eq!(
scroll_id(&app),
details_visit,
"unchanged visibility keeps its visit"
);
let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(
mounted.execute(&app, show_preview).await,
1,
"returning restores the reported offset in the new preview subtree"
);
let returned_visit = scroll_id(&app);
assert_ne!(returned_visit, old_preview);
assert_ne!(returned_visit, details_visit);
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);
let offset = mounted.scroll_down(&app);
let repeat_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(mounted.execute(&app, repeat_preview).await, 0);
assert_eq!(scroll_id(&app), returned_visit);
assert_eq!(
mounted.offset(&app),
offset,
"repeated Preview preserves deliberate scroll"
);
assert_eq!(mounted.execute(&app, held_reset).await, 1);
assert_eq!(
mounted.offset(&app),
offset,
"the previous visit cannot reset the returned preview"
);
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);
}
@@ -2,9 +2,6 @@ use super::*;
use oa_core::source_project::SourceBundle;
use std::collections::BTreeMap;
#[path = "navigation_union_tests.rs"]
mod navigation_union_tests;
#[cfg(debug_assertions)]
#[path = "preview_glyph_tests.rs"]
mod preview_glyph_tests;
@@ -804,25 +801,13 @@ 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) {
if matches!(
&message,
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
) {
keyboard_boundaries += 1;
} else {
messages.push(message);
}
messages.push(message);
}
}
assert_eq!(
keyboard_boundaries, 1,
"each key has exactly one passive allocation notification"
);
messages
}
@@ -856,13 +841,11 @@ 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());
+5 -103
View File
@@ -31,44 +31,6 @@ 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(),
@@ -126,20 +88,6 @@ 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()
@@ -150,8 +98,6 @@ impl App {
&& self.workflow.is_none()
&& !self.hosted.visible
&& 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()
@@ -168,10 +114,10 @@ impl App {
))
}
/// Capture the same source-owned query used by the keyboard/menu adapter.
/// The reviewed diagram-query union is not integrated into this source yet.
/// Its eventual adapter must return the peer's captured chooser message.
pub(super) fn text_diagram_usages_message(&self) -> Option<Message> {
self.source_navigation_available()
.then(|| self.source_symbol_message(nav::SymbolEvent::DiagramUsages))
None
}
pub fn source_reveal_ticket(&self) -> Option<Id> {
@@ -254,20 +200,8 @@ impl App {
return Task::none();
}
match event {
nav::SymbolEvent::DiagramUsages => {
if self.source_dirty() || !self.source_workspace.applied {
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();
}
let task = self.start_source_query(query::Action::Definition);
self.source_diagram_job = self
.source_symbols
.request
.as_ref()
.map(|request| request.job);
return task;
nav::SymbolEvent::Close => {
return self.show_text_drawer(None);
}
nav::SymbolEvent::Back => {
self.source_symbol_back();
@@ -442,10 +376,6 @@ impl App {
return Task::none();
}
self.source_symbols.in_flight = None;
let diagram_query = self.source_diagram_job == Some(request.job);
if diagram_query {
self.source_diagram_job = None;
}
let wanted_navigation = self
.source_symbols
.request
@@ -485,31 +415,6 @@ impl App {
.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()
&& let query::Identity::Unique(identity) = row.declaration.identity
&& row.membership == query::Membership::Included
&& row.evidence == query::Evidence::Validated
&& self.project().contains(identity.id())
&& crate::diagram_usage::alias(self.project(), identity.id())
== row.declaration.alias
{
self.text_workspace.show_drawer(None);
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
crate::diagram_usage::Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: identity.id(),
},
));
}
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 wanted_navigation
&& results.disposition == query::Disposition::Direct
&& let Some(row) = results.definitions.first()
@@ -614,6 +519,3 @@ impl App {
#[cfg(test)]
mod tests;
#[cfg(all(test, debug_assertions))]
mod focus_selection_tests;
+202 -211
View File
@@ -5,8 +5,6 @@ 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";
@@ -74,6 +72,190 @@ fn select_effective_pump(app: &mut App, line: bool) -> String {
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())
);
}
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.
@@ -85,11 +267,6 @@ fn symbol(app: &mut App, event: nav::SymbolEvent) {
send(app, message);
}
fn close_drawer(app: &mut App) {
let message = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
send(app, message);
}
fn choice(app: &App, stamp: query::QueryStamp, reference: bool, index: usize) -> Message {
app.source_symbol_message(nav::SymbolEvent::Choose {
stamp,
@@ -419,7 +596,7 @@ fn close_during_busy_retains_hidden_completion_without_reopening_or_navigation()
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
assert!(references_visible(&app));
close_drawer(&mut app);
symbol(&mut app, nav::SymbolEvent::Close);
assert!(app.text_workspace.drawer().is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.request.is_none());
@@ -490,18 +667,11 @@ fn escape_hides_results_and_cancels_navigation_but_retains_latest_display() {
if !pending {
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
}
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!(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);
@@ -566,7 +736,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
let origin = app.source_editor.cursor();
finish(&mut app, query::Action::Definition);
reveal(&mut app);
close_drawer(&mut app);
symbol(&mut app, nav::SymbolEvent::Close);
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.
@@ -857,8 +1027,7 @@ 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. Require the canvas keyboard
/// notification separately, then return every command routed to an Escape owner.
/// file dialog or App task is executed by this helper.
fn captured_input_subscription_messages(app: &App) -> Vec<Message> {
input_subscription_messages(app, true)
}
@@ -929,7 +1098,6 @@ fn input_subscription_messages(app: &App, focused: bool) -> 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());
@@ -938,20 +1106,9 @@ fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
!matches!(message, Message::CheckpointTick),
"timer must remain pending"
);
if matches!(
&message,
Message::WorkDrag(crate::app::allocation_drag::Event::KeyboardBoundary)
) {
keyboard_boundaries += 1;
} else {
messages.push(message);
}
messages.push(message);
}
}
assert_eq!(
keyboard_boundaries, 1,
"each key has exactly one passive allocation notification"
);
messages
}
@@ -1009,12 +1166,6 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
}
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);
@@ -1438,7 +1589,7 @@ fn stale_envelope_trial(reset: bool) {
let old_messages = [
app.source_symbol_message(nav::SymbolEvent::Query(query::Action::Definition)),
app.source_symbol_message(nav::SymbolEvent::Back),
Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context())),
app.source_symbol_message(nav::SymbolEvent::Close),
choice(&app, old_results.stamp, false, 0),
];
if reset {
@@ -1499,7 +1650,7 @@ fn stale_envelope_trial(reset: bool) {
// Replay once more with no busy-slot fallback and visible current results.
assert_stale_envelopes_preserve_current(&mut app, &old_messages);
let interaction = app.source_symbols.interaction;
close_drawer(&mut app);
symbol(&mut app, nav::SymbolEvent::Close);
assert_ne!(app.source_symbols.interaction, interaction);
assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some());
let next = begin(&mut app, query::Action::References);
@@ -1665,9 +1816,7 @@ fn delayed_definition_after_hide_or_tab_change_retains_data_without_reopening_or
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)
});
let event = destination.map_or(TextEvent::HideDrawer, TextEvent::ShowDrawer);
send(&mut app, Message::TextWorkspace(event));
assert_eq!(app.text_workspace.drawer(), destination);
assert!(app.source_symbols.request.is_none());
@@ -1690,13 +1839,10 @@ fn delayed_definition_after_hide_or_tab_change_retains_data_without_reopening_or
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);
};
send(
&mut app,
Message::TextWorkspace(TextEvent::ShowDrawer(DrawerTab::References)),
);
assert!(references_visible(&app));
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
@@ -1777,10 +1923,7 @@ fn hidden_completion_checks_exact_bytes_even_when_snapshot_key_was_not_advanced(
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);
};
send(&mut app, Message::TextWorkspace(TextEvent::HideDrawer));
// 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
@@ -1920,9 +2063,9 @@ fn retained_choices_cannot_replay_after_hide_or_references_problems_references()
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let event = if hide {
TextEvent::HideDrawer(app.text_drawer_context())
TextEvent::HideDrawer
} else {
TextEvent::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems)
TextEvent::ShowDrawer(DrawerTab::Problems)
};
send(&mut app, Message::TextWorkspace(event));
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
@@ -1931,13 +2074,10 @@ fn retained_choices_cannot_replay_after_hide_or_references_problems_references()
&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);
};
send(
&mut app,
Message::TextWorkspace(TextEvent::ShowDrawer(DrawerTab::References)),
);
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
assert_eq!(
app.source_key(),
@@ -1972,152 +2112,3 @@ fn retained_choices_cannot_replay_after_hide_or_references_problems_references()
}
}
}
#[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();
match modal {
0 => app.busy = true,
1 => app.hosted.visible = true,
2 => app.pending = Some(Pending::Walkthrough),
3 => {
app.file_prompt = Some(FilePrompt {
operation: FileOperation::Save,
path: "unchanged.arcadia".into(),
overwrite: false,
});
}
4 => {
let project = app.project().clone();
assert!(
app.behavior
.creation
.update(&project, crate::behavior::creation::Event::Open(Layer::Oa))
.unwrap()
.is_none()
);
assert!(app.behavior.has_pending());
}
_ => unreachable!(),
}
assert!(!app.source_navigation_available());
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none(), "modal {modal}");
assert!(app.source_symbols.request.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);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconciliation() {
let mut project = Project::empty();
let record = project
.add_element(Kind::DataType, Layer::Oa, "Record", None)
.unwrap();
let Some(SharedBody::DataType { definition, .. }) =
project.elements.get_mut(&record).unwrap().shared_mut()
else {
panic!("record datatype fixture");
};
*definition = DataTypeDefinition::Record { supertype: None };
let mut app = App::with_project(project);
send(&mut app, Message::Select(record));
let path = "/body/Oa/Shared/DataType/definition/Record/supertype";
let open = app.draft.as_ref().unwrap().reference_open(
app.project(),
path,
iced::Rectangle {
x: 100.,
y: 100.,
width: 240.,
height: 32.,
},
);
let alias = app.project().elements[&record].alias.clone();
let source_path = app
.source_workspace
.files
.iter()
.find(|(_, buffer)| buffer.text.contains(&format!(" {alias} ")))
.map(|(path, _)| path.clone())
.unwrap();
app.page = Page::Text;
show(&mut app, &source_path);
let text = app.source_workspace.text();
let offset = text.find(&format!(" {alias} ")).unwrap() + 1;
app.source_editor
.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);
// Reproduce a queued completion observing a retained chooser before normal
// reconciliation catches the changed page. Neither may authorize navigation.
app.page = Page::Text;
assert!(!app.source_navigation_available());
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.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(),
"internal completion must precede chooser reconciliation and modal dispatch"
);
assert_eq!(app.source_editor.cursor(), cursor);
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]);
}
}
@@ -1,286 +0,0 @@
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())
);
}
@@ -1,158 +0,0 @@
//! Production App tree and its actual keyboard subscription, without a canvas click.
use super::*;
use crate::behavior::canvas_workspace::tests::{Mounted, key};
use iced::{
advanced::subscription,
futures::{StreamExt, stream, task::noop_waker_ref},
keyboard::key::Named,
mouse,
};
use std::task::{Context as PollContext, Poll};
async fn press(mounted: &mut Mounted, app: &mut App, named: Named) -> Vec<Message> {
let input = key(named);
let (mut messages, statuses) = mounted.input_with_status(
app,
std::slice::from_ref(&input),
mouse::Cursor::Unavailable,
);
let interaction = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event: input,
status: statuses[0],
};
for recipe in subscription::into_recipes(app.subscription()) {
let mut output = recipe.stream(Box::pin(stream::iter([interaction.clone()])));
let mut cx = PollContext::from_waker(noop_waker_ref());
while let Poll::Ready(Some(message)) = output.poll_next_unpin(&mut cx) {
messages.push(message);
}
}
for message in messages.iter().cloned() {
let task = app.update(message);
mounted.perform(app, task).await;
}
mounted.settle(app).await;
messages
}
async fn armed() -> (App, Mounted, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Switch", f.a, f.b);
let mut app = App::with_project(project);
app.window_size = iced::Size::new(1800., 1100.);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let mut mounted = Mounted::new(&mut app).await;
app.behavior.diagram.fit = false;
let context = app.state_context().unwrap();
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
anchor: iced::Point::new(450., 400.),
},
);
mounted.settle(&mut app).await;
let token = app.model_menu.as_ref().unwrap().token;
send(
&mut app,
Message::Menu(crate::model_menu::Event::Invoke(
token,
Box::new(Message::StateDiagram(Event::Choose(
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
))),
)),
);
mounted.settle(&mut app).await;
assert!(app.model_menu.is_none());
assert_eq!(
app.behavior.diagram.tool,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source
}
);
(app, mounted, relation)
}
fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) {
let id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, bounds, focused) = mounted
.geometry(app)
.focused
.into_iter()
.find(|(candidate, _, _)| candidate.as_ref() == Some(&id))
.unwrap();
(bounds, focused)
}
#[tokio::test]
async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
let history = (app.document.can_undo(), app.document.can_redo());
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
assert!(!canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(messages.iter().any(|m| matches!(m, Message::Escape)));
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(!canvas_focus(&mut mounted, &app).1);
press(&mut mounted, &mut app, Named::Delete).await;
assert!(
!app.delete_pending,
"Escape must not grant canvas Delete ownership"
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
}
#[tokio::test]
async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
// Existing modal Escape consumes the first key without canceling the tool underneath.
send(&mut app, Message::Delete);
assert!(app.delete_pending && app.delete_target.is_some());
mounted.settle(&mut app).await;
press(&mut mounted, &mut app, Named::Escape).await;
assert!(!app.delete_pending);
assert!(matches!(
app.behavior.diagram.tool,
Tool::AdjustAttachment { .. }
));
// A real canvas click grants ownership; inner-canvas Escape remains live.
let bounds = canvas_focus(&mut mounted, &app).0;
mounted
.click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.))
.await;
assert!(canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select))))
);
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
@@ -114,7 +114,6 @@ impl App {
}
}
match event {
Event::Remove(intent) => self.remove_from_diagram(intent),
Event::Viewport(_, size) => {
if size.width.is_finite()
&& size.height.is_finite()
@@ -172,24 +171,6 @@ impl App {
)));
}
Event::Choose(tool) => {
if let Tool::AdjustAttachment { relation, end } = tool {
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
self.status =
"Apply or cancel pending edits before adjusting an attachment.".into();
return Task::none();
}
let Some(context) = self.state_context() else {
return Task::none();
};
if state_edit::attachment_node(self.project(), context.diagram, relation, end)
.is_none()
{
return Task::none();
}
self.selected = relation;
self.selected_node = None;
self.refresh_draft();
}
if !self.apply() {
return Task::none();
}
@@ -228,22 +209,6 @@ impl App {
let Some(context) = self.state_context() else {
return Task::none();
};
if let Event::SetAttachment {
relation,
end,
attachment,
..
} = &mutation
{
let existing = self.project().diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == *relation)
.and_then(|route| route.attachment(*end));
if existing == *attachment {
return Task::none();
}
}
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if self.execute(plan.command) {
@@ -1,7 +1,5 @@
//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
#[path = "state_attachment_escape_tests.rs"]
mod attachment_escape;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
+13 -71
View File
@@ -118,19 +118,11 @@ pub struct DiagnosticTarget {
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),
ShowDrawer(DrawerTab),
HideDrawer,
ResizeDrawer(f32),
ToggleFile {
stamp: QueryStamp,
visit: Id,
@@ -331,13 +323,6 @@ impl State {
}
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,
@@ -399,24 +384,6 @@ impl App {
))
})
}
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.
@@ -486,7 +453,6 @@ impl App {
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,
@@ -532,7 +498,6 @@ impl App {
.as_ref()
.is_some_and(|menu| menu.token == token)
{
self.text_presentation_boundary(Event::DismissMenu(token));
self.text_workspace.menu = None;
self.source_interaction();
}
@@ -567,44 +532,32 @@ impl App {
return self.dispatch_text_action(action);
}
}
Event::ShowDrawer(context, tab) => {
if context != self.text_drawer_context() || !self.source_navigation_available() {
Event::ShowDrawer(tab) => {
if !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() {
Event::HideDrawer => {
if !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()
{
Event::ResizeDrawer(height) => {
if 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();
}
}
@@ -627,7 +580,6 @@ impl App {
{
return Task::none();
}
self.text_presentation_boundary(Event::Diagnostic(target.clone()));
self.source_interaction();
self.jump_to_source(target.location);
}
@@ -637,22 +589,9 @@ impl App {
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))
}
Action::Drawer(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);
@@ -684,3 +623,6 @@ mod tests;
#[cfg(test)]
mod lifecycle_tests;
#[cfg(all(test, debug_assertions))]
mod focus_selection_tests;
@@ -5,10 +5,9 @@
//! 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 super::*;
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};
@@ -16,7 +15,6 @@ 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};
@@ -39,6 +37,7 @@ fn fixture() -> App {
app.refresh_draft();
app.page = Page::Text;
app.show_source_file(false);
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
app
}
@@ -174,13 +174,10 @@ fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and
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);
};
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
let old = warning_messages(&app);
let warnings = app.source_diagnostics.clone();
let publication = app.text_workspace.diagnostic_publication().unwrap();
@@ -210,13 +207,10 @@ fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and
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);
};
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
for message in warning_messages(&app) {
send(&mut app, message);
assert!(
@@ -255,13 +249,10 @@ fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_
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);
};
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
let old = warning_messages(&app);
let old_publication = app.text_workspace.diagnostic_publication().unwrap();
let old_source = app.source_key();
@@ -289,13 +280,10 @@ fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_
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);
};
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
let issue = app
.source_diagnostics
.iter()
@@ -407,13 +395,10 @@ fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
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);
};
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
let old = warning_messages(&app);
let publication = app.text_workspace.diagnostic_publication().unwrap();
let draft = format!("{VALID}// unsaved current draft\r\n");
@@ -427,13 +412,10 @@ fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
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);
};
send(
&mut app,
Message::TextWorkspace(Event::ShowDrawer(DrawerTab::Problems)),
);
assert!(
app.text_diagnostic_message(SourceLocation {
path: app.source_diagnostics[0].path.clone(),
@@ -454,158 +436,3 @@ fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
"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);
}
}
+19 -279
View File
@@ -212,10 +212,7 @@ fn queued_toolbar_request_cannot_retarget_after_source_change() {
#[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);
};
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
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();
@@ -234,10 +231,7 @@ fn equal_diagnostic_republication_invalidates_primary_and_related_targets() {
#[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);
};
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_some());
@@ -258,10 +252,7 @@ fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it()
#[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);
};
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
app.source_workspace
.edit("a雨b".into(), app.source_editor.cursor());
app.show_source_file(false);
@@ -311,10 +302,7 @@ fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries()
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::ShowDrawer(DrawerTab::Status));
send(
&mut app,
Event::ReferenceScrolled {
@@ -323,10 +311,7 @@ fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries()
y: 0.,
},
);
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
send(&mut app, captured);
};
send(&mut app, Event::ShowDrawer(DrawerTab::References));
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
send(
@@ -362,15 +347,9 @@ fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() {
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);
};
send(&mut app, Event::ResizeDrawer(f32::NAN));
assert_eq!(app.text_workspace.drawer_height(), 180.);
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::MAX);
send(&mut app, captured);
};
send(&mut app, Event::ResizeDrawer(f32::MAX));
assert_eq!(app.text_workspace.drawer_height(), 1200.);
}
@@ -403,14 +382,8 @@ fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() {
};
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);
};
send(&mut app, Event::ResizeDrawer(f32::NAN));
send(&mut app, Event::ResizeDrawer(180.));
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.source_symbols.request.is_some());
let event = toggle(&app, current, "uses.arcadia".into());
@@ -423,16 +396,10 @@ fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() {
"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);
};
send(&mut app, Event::ResizeDrawer(240.));
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);
};
send(&mut app, Event::HideDrawer);
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));
@@ -486,10 +453,7 @@ fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority()
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);
};
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
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)];
@@ -507,18 +471,14 @@ fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_ref
app.document.dirty(),
);
let event = if hide {
Event::HideDrawer(app.text_drawer_context())
Event::HideDrawer
} else {
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References)
Event::ShowDrawer(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);
};
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
} else {
assert!(app.text_diagnostic_message(targets[0].clone()).is_none());
}
@@ -582,27 +542,12 @@ fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
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);
};
send(&mut app, Event::HideDrawer);
send(&mut app, Event::ShowDrawer(DrawerTab::References));
}
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);
};
send(&mut app, Event::ShowDrawer(DrawerTab::Problems));
send(&mut app, Event::ShowDrawer(DrawerTab::References));
}
_ => {
let event = toggle(&app, current, "b.arcadia".into());
@@ -670,208 +615,3 @@ fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
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(&current.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());
}
}
File diff suppressed because it is too large Load Diff
@@ -1,250 +0,0 @@
//! Shared presentation and input contract; contains no model commands.
use crate::app::{App, Message};
use iced::widget::{button, column, container, opaque, row, scrollable, stack, text, tooltip};
use iced::{Element, Length::Fill, Point, Theme, Vector};
use oa_core::Id;
mod control;
mod focus;
mod viewport;
pub use control::Control;
pub use viewport::Viewport;
pub fn keyboard_enabled(app: &crate::app::App) -> bool {
!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
&& app.pending.is_none()
&& app.model_menu.is_none()
&& app.diagram_usages.is_none()
&& !app.reference_input_active()
}
pub fn controls<'a>(
scope: Scope,
keyboard: bool,
scale: f32,
details: Message,
zoom: impl Fn(Option<f32>) -> Message,
publish: impl Fn(Event) -> Message + Copy + 'a,
) -> Element<'a, Message> {
let control = |name: &'static str, label: &'static str, message: Option<Message>| {
let message = message.map(|message| intent(scope, name, message));
Control::new(
button(text(label).size(12))
.padding(6)
.on_press_maybe(message.clone()),
scope,
name,
keyboard,
message,
publish,
)
};
let controls = row![
control("Details", "Details", Some(details)),
control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
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);
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 document: Id,
pub owner: Id,
pub revision: u64,
pub epoch: u64,
}
impl Scope {
pub(crate) fn current(app: &App) -> Option<Self> {
if let Some(context) = app.scenario_context() {
Some(Self {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: app.camera_epoch,
})
} else {
let context = app.topology_context()?;
Some(Self {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: app.camera_epoch,
})
}
}
pub(crate) fn input_owner(app: &App) -> Option<Self> {
if keyboard_enabled(app)
&& !app.behavior.has_pending()
&& !app.draft_dirty()
&& !app.source_dirty()
{
Self::current(app)
} else {
None
}
}
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
))
}
}
/// Only a live presentation intent may carry a toolbar action across its own pan.
#[derive(Debug, Clone)]
pub struct Intent {
pub scope: Scope,
pub name: &'static str,
pub message: Box<Message>,
}
pub fn intent(scope: Scope, name: &'static str, message: Message) -> Message {
Message::BehaviorControl(Intent {
scope,
name,
message: Box::new(message),
})
}
#[derive(Debug, Clone, Copy)]
pub enum Event {
CancelGesture,
Pan(Vector),
Zoom { factor: f32, at: Point, base: f32 },
Traverse(bool),
ControlFocus(&'static str),
ToggleFocus,
Leave,
}
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 {
match event {
Event::Pan(delta) if delta.x.is_finite() && delta.y.is_finite() => {
let next = *pan + delta;
if next.x.is_finite() && next.y.is_finite() && next != *pan {
*pan = next;
true
} else {
false
}
}
Event::Zoom { factor, at, base }
if factor.is_finite()
&& factor > 0.
&& base.is_finite()
&& base > 0.
&& at.x.is_finite()
&& at.y.is_finite() =>
{
let old = if *fit { base } else { zoom.unwrap_or(base) };
let next = (old * factor).clamp(0.05, 3.);
if old == next {
return false;
}
let anchor = Vector::new(at.x, at.y);
let next_pan = anchor - (anchor - *pan) * (next / old);
if !next_pan.x.is_finite() || !next_pan.y.is_finite() {
return false;
}
*pan = next_pan;
*zoom = Some(next);
*fit = false;
true
}
_ => false,
}
}
/// Constant-width labelled palette. Arming a tool never reallocates the canvas.
pub fn palette<'a>(
scope: Scope,
keyboard: bool,
publish: impl Fn(Event) -> Message + Copy + 'a,
entries: impl IntoIterator<Item = (&'static str, &'static str, bool, Option<Message>)>,
) -> Element<'a, Message> {
let mut tools = column![].spacing(3);
for (glyph, label, selected, message) in entries {
let message = message.map(|message| intent(scope, glyph, message));
tools = tools.push(tooltip(
container(Control::new(
button(text(glyph).size(12))
.width(34)
.height(30)
.padding(3)
.style(if selected {
button::primary
} else {
crate::style::nav
})
.on_press_maybe(message.clone()),
scope,
glyph,
keyboard,
message,
publish,
))
.id(format!("behavior-tool:{glyph}")),
container(text(label).size(12))
.padding(7)
.style(crate::style::card),
tooltip::Position::Left,
));
}
container(scrollable(tools).id(scope.control_id("palette-scroll")))
.width(40)
.height(Fill)
.padding(3)
.style(crate::style::panel)
.into()
}
/// Header and palette are permanent. Details/editors overlay the same viewport.
pub fn host<'a, Message: 'a, R: iced::advanced::text::Renderer + 'a>(
header: Element<'a, Message, Theme, R>,
palette: Element<'a, Message, Theme, R>,
canvas: Element<'a, Message, Theme, R>,
editor: Option<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
let mut body = stack![canvas];
if let Some(editor) = editor {
body = body.push(
container(opaque(
container(editor)
.width(300)
.height(Fill)
.style(crate::style::card),
))
.align_right(Fill)
.height(Fill)
.padding(8),
);
}
column![header, row![body, palette].height(Fill)]
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
#[cfg(test)]
pub(crate) mod tests;
@@ -1,256 +0,0 @@
//! A real focusable keyboard target around the existing styled pointer button.
use super::{Event as CameraEvent, Scope, focus::Member};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Rectangle, Size, Theme, Vector, keyboard, mouse};
#[derive(Default)]
struct Input {
identity: Option<iced::advanced::widget::Id>,
focused: bool,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
fn focus(&mut self) {
self.focused = true;
}
fn unfocus(&mut self) {
self.focused = false;
}
}
pub struct Control<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Control<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
name: &'static str,
enabled: bool,
activate: Option<Message>,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
name,
enabled: enabled && activate.is_some(),
activate,
publish: Box::new(publish),
}
}
}
impl<Message: Clone, R: renderer::Renderer> Widget<Message, Theme, R> for Control<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
identity: Some(self.scope.control_id(self.name)),
focused: false,
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
let identity = self.scope.control_id(self.name);
if state.identity.as_ref() != Some(&identity) || !self.enabled {
*state = Input {
identity: Some(identity),
focused: false,
};
}
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: &R, 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: &R,
operation: &mut dyn Operation,
) {
let id = self.scope.control_id(self.name);
operation.container(Some(&id), layout.bounds());
if self.enabled {
operation.custom(Some(&id), layout.bounds(), &mut Member(self.scope));
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
operation.traverse(&mut |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: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
return;
}
let state = tree.state.downcast_mut::<Input>();
if self.enabled {
if let Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers,
repeat,
..
}) = event
&& state.focused
{
use keyboard::{Key, key::Named};
let action = match key.as_ref() {
Key::Named(Named::Tab) => {
Some((self.publish)(CameraEvent::Traverse(modifiers.shift())))
}
Key::Named(Named::Escape) => Some((self.publish)(CameraEvent::Leave)),
Key::Named(Named::F6) if modifiers.is_empty() && !repeat => {
Some((self.publish)(CameraEvent::ToggleFocus))
}
Key::Named(Named::Enter | Named::Space) if modifiers.is_empty() && !repeat => {
self.activate.clone()
}
_ => None,
};
if let Some(action) = action {
shell.publish(action);
shell.capture_event();
shell.request_redraw();
return;
}
}
if matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) && !layout
.bounds()
.intersection(viewport)
.is_some_and(|bounds| cursor.is_over(bounds))
{
state.focused = false;
}
}
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
state.focused = false;
}
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
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,
);
if self.enabled && tree.state.downcast_ref::<Input>().focused {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::INK,
width: 2.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: Clone + 'a, R: renderer::Renderer + 'a> From<Control<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(control: Control<'a, Message, R>) -> Self {
Element::new(control)
}
}
@@ -1,292 +0,0 @@
//! Explicit canvas entry; ordinary initial Tab continues to belong to Inspector.
use super::{Event, Scope};
use iced::advanced::widget::{Id, Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event as InputEvent, Length, Rectangle, Size, Theme, Vector, mouse};
pub(super) struct Member(pub Scope);
pub fn focus_task<Message: Send + 'static>(scope: Scope, event: Event) -> iced::Task<Message> {
iced::advanced::widget::operate(Collect {
scope,
valid: false,
event,
members: vec![],
focused: None,
palette: None,
})
}
struct Collect {
scope: Scope,
valid: bool,
event: Event,
members: Vec<(Id, Rectangle)>,
focused: Option<usize>,
palette: Option<(Rectangle, Rectangle)>,
}
impl<T: Send + 'static> Operation<T> for Collect {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, id: Option<&Id>, bounds: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
if self.valid
&& value
.downcast_ref::<Member>()
.is_some_and(|m| m.0 == self.scope)
&& let Some(id) = id
{
self.members.push((id.clone(), bounds));
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, value: &mut dyn operation::Focusable) {
if value.is_focused() {
self.focused = self
.members
.iter()
.position(|(member, _)| Some(member) == id)
.or(self.focused);
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
_: Vector,
_: &mut dyn operation::Scrollable,
) {
if id == Some(&self.scope.control_id("palette-scroll")) {
self.palette = Some((bounds, content));
}
}
fn finish(&self) -> operation::Outcome<T> {
let canvas = self.scope.control_id("viewport");
// An enabled viewport witnesses the live lifetime. Hidden/dirty/modal
// owners expose no members; a delayed request cannot clear their focus.
if !self.valid || !self.members.iter().any(|(id, _)| *id == canvas) {
return operation::Outcome::None;
}
let target = match self.event {
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,
Event::Traverse(back) => {
let Some(current) = self.focused else {
return operation::Outcome::None;
};
let next = if back {
(current + self.members.len() - 1) % self.members.len()
} else {
(current + 1) % self.members.len()
};
Some(self.members[next].0.clone())
}
_ => return operation::Outcome::None,
};
if target
.as_ref()
.is_some_and(|target| !self.members.iter().any(|(id, _)| id == target))
{
return operation::Outcome::None;
}
let reveal = target.as_ref().and_then(|target| {
let (_, field) = self.members.iter().find(|(id, _)| id == target)?;
let (bounds, content) = self.palette?;
// Only the palette has member controls inside this scrollable.
(field.x >= bounds.x && field.x < bounds.x + bounds.width).then(|| {
(field.center_y() - bounds.y - bounds.height / 2.)
.clamp(0., (content.height - bounds.height).max(0.))
})
});
operation::Outcome::Chain(Box::new(Apply {
scope: self.scope,
valid: false,
target,
members: self.members.iter().map(|(id, _)| id.clone()).collect(),
palette: self.scope.control_id("palette-scroll"),
reveal,
}))
}
}
struct Apply {
scope: Scope,
valid: bool,
target: Option<Id>,
members: Vec<Id>,
palette: Id,
reveal: Option<f32>,
}
impl<T> Operation<T> for Apply {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation<T>)) {
visit(self);
}
fn custom(&mut self, _: Option<&Id>, _: Rectangle, value: &mut dyn std::any::Any) {
if let Some(owner) = value.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.scope);
}
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
if !self.valid {
return;
}
if self
.target
.as_ref()
.is_some_and(|target| Some(target) == id)
{
state.focus();
} else if self.target.is_some() || id.is_some_and(|id| self.members.contains(id)) {
state.unfocus();
}
}
fn scrollable(
&mut self,
id: Option<&Id>,
_: Rectangle,
_: Rectangle,
_: Vector,
state: &mut dyn operation::Scrollable,
) {
if self.valid
&& id == Some(&self.palette)
&& let Some(y) = self.reveal
{
state.scroll_to(operation::scrollable::AbsoluteOffset {
x: None,
y: Some(y),
});
}
}
}
// Every Collect/Apply pass observes current ownership before touching any field.
struct Owner(Option<Scope>);
struct Root<'a, Message, R: renderer::Renderer> {
owner: Option<Scope>,
content: Element<'a, Message, Theme, R>,
}
pub fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
owner: Option<Scope>,
) -> Element<'a, Message, Theme, R> {
Element::new(Root {
owner,
content: content.into(),
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
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: &R, 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: &R,
operation: &mut dyn Operation,
) {
operation.custom(None, layout.bounds(), &mut Owner(self.owner));
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut Tree,
event: &InputEvent,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
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: &R,
) -> 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: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
@@ -1,768 +0,0 @@
use super::*;
use crate::app::{App, Message};
use iced::advanced::{Layout, Shell, layout, widget};
use iced::futures::{
StreamExt,
stream::{BoxStream, SelectAll},
};
use iced::{Rectangle, Size, mouse};
use iced_runtime::{
Action,
user_interface::{Cache, UserInterface},
};
use std::collections::HashMap;
type Pending = SelectAll<BoxStream<'static, Action<Message>>>;
// iced_winit drains messages while collecting immediately ready actions. A
// pending Task stream survives until its widget operation sends its result.
fn enqueue(task: iced::Task<Message>, ready: &mut Vec<Action<Message>>, pending: &mut Pending) {
if let Some(mut stream) = iced_runtime::task::into_stream(task) {
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
loop {
match stream.poll_next_unpin(&mut context) {
std::task::Poll::Ready(Some(action)) => ready.push(action),
std::task::Poll::Ready(None) => break,
std::task::Poll::Pending => {
pending.push(stream);
break;
}
}
}
}
}
#[derive(Default)]
pub(crate) struct Geometry {
pub containers: HashMap<widget::Id, Rectangle>,
pub focused: Vec<(Option<widget::Id>, Rectangle, bool)>,
pub text_inputs: Vec<Rectangle>,
pub scrolls: HashMap<widget::Id, (Rectangle, Vector)>,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
self.focused.push((id.cloned(), bounds, state.is_focused()));
}
fn text_input(
&mut self,
_: Option<&widget::Id>,
bounds: Rectangle,
_: &mut dyn widget::operation::TextInput,
) {
self.text_inputs.push(bounds);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if let Some(id) = id {
self.containers.insert(id.clone(), bounds);
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if let Some(id) = id {
self.scrolls.insert(id.clone(), (bounds, offset));
}
}
}
impl Geometry {
pub fn bounds(&self, id: &str) -> Rectangle {
self.containers[&widget::Id::from(id.to_owned())]
}
}
/// Mount the actual App root, production widgets and renderer. Events are
/// delivered in order with Iced's final-batch cursor, then fed through App::update.
/// This deliberately does not seed any private canvas gesture state.
pub(crate) struct Mounted {
pub renderer: iced::Renderer,
cache: Cache,
pub operations: usize,
}
impl Mounted {
pub async fn unshown() -> Self {
use iced::advanced::renderer::Headless;
let renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
.await
.expect("production mounted tests require the supported WGPU headless renderer");
Self {
renderer,
cache: Cache::new(),
operations: 0,
}
}
pub async fn new(app: &mut App) -> Self {
let mut mounted = Self::unshown().await;
mounted.settle(app).await;
mounted
}
pub fn geometry(&mut self, app: &App) -> Geometry {
let mut geometry = Geometry::default();
self.operate(app, &mut geometry);
geometry
}
/// One operation pass, including every nested overlay, via installed Iced.
pub fn operate(&mut self, app: &App, operation: &mut dyn widget::Operation) {
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
ui.operate(&self.renderer, operation);
self.cache = ui.into_cache();
}
pub async fn settle(&mut self, app: &mut App) {
for _ in 0..3 {
self.batch(
app,
&[iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))],
mouse::Cursor::Unavailable,
)
.await;
}
}
pub async fn batch(
&mut self,
app: &mut App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
let messages = self.input(app, events, cursor);
let mut ready = vec![];
let mut pending = Pending::new();
for message in messages.iter().cloned() {
enqueue(app.update(message), &mut ready, &mut pending);
}
self.drain(app, ready, pending).await;
messages
}
pub fn input(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
self.input_with_status(app, events, cursor).0
}
pub fn input_with_status(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> (Vec<Message>, Vec<iced::event::Status>) {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let (_, statuses) = ui.update(
events,
cursor,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
self.cache = ui.into_cache();
(messages, statuses)
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
let mut pending = Pending::new();
enqueue(task, &mut ready, &mut pending);
self.drain(app, ready, pending).await;
}
async fn drain(
&mut self,
app: &mut App,
mut ready: Vec<Action<Message>>,
mut pending: Pending,
) {
loop {
let mut outputs = vec![];
if !ready.is_empty() {
// Build only after all App messages in this cycle have run.
let mut ui = UserInterface::build(
app.view(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
for action in ready.drain(..) {
match action {
Action::Output(message) => outputs.push(message),
Action::Widget(mut operation) => {
self.operations += 1;
loop {
ui.operate(&self.renderer, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(next) => operation = next,
widget::operation::Outcome::None
| widget::operation::Outcome::Some(()) => break,
}
}
}
action => {
panic!("Unexpected native action in mounted App harness: {action:?}")
}
}
}
self.cache = ui.into_cache();
}
for message in outputs {
enqueue(app.update(message), &mut ready, &mut pending);
}
if !ready.is_empty() {
continue;
}
let Some(action) = pending.next().await else {
break;
};
ready.push(action);
let waker = iced::futures::task::noop_waker();
let mut context = std::task::Context::from_waker(&waker);
while let std::task::Poll::Ready(Some(action)) = pending.poll_next_unpin(&mut context) {
ready.push(action);
}
}
}
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(),
app.window_size,
std::mem::take(&mut self.cache),
&mut self.renderer,
);
// update initializes the nested overlay layout used by draw.
let mut messages = vec![];
let _ = ui.update(
&[],
cursor,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
);
assert!(
messages.is_empty(),
"An empty draw preparation must not drop App messages"
);
ui.draw(
&mut self.renderer,
&crate::style::theme(),
&iced::advanced::renderer::Style {
text_color: crate::style::INK,
},
cursor,
);
self.cache = ui.into_cache();
Headless::screenshot(
&mut self.renderer,
Size::new(app.window_size.width as u32, app.window_size.height as u32),
1.,
crate::style::BG,
)
}
pub async fn click(&mut self, app: &mut App, point: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.await
}
pub async fn pan(&mut self, app: &mut App, start: Point, end: Point) -> Vec<Message> {
self.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: start }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
iced::Event::Mouse(mouse::Event::CursorMoved { position: end }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(end),
)
.await
}
pub fn scroll(&mut self, app: &App, id: &str, offset: f32) {
let mut operation = widget::operation::scrollable::scroll_to::<()>(
widget::Id::from(id.to_owned()),
widget::operation::scrollable::AbsoluteOffset {
x: None,
y: Some(offset),
},
);
self.operate(app, &mut operation);
}
}
pub(crate) fn key(named: iced::keyboard::key::Named) -> iced::Event {
use iced::keyboard::{self, Key};
iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(named),
modified_key: Key::Named(named),
physical_key: keyboard::key::Physical::Unidentified(
keyboard::key::NativeCode::Unidentified,
),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
})
}
#[test]
fn relative_camera_zoom_keeps_the_cursor_world_point_and_rejects_nonfinite_inputs() {
for scale in [0.05, 0.5, 1., 2.] {
let mut pan = Vector::new(-130., 75.);
let mut zoom = Some(scale);
let mut fit = false;
let at = Point::new(171., 93.);
let world = (at - Point::ORIGIN - pan) / scale;
change(
&mut pan,
&mut zoom,
&mut fit,
Event::Zoom {
factor: 1.25,
at,
base: scale,
},
);
let after = (at - Point::ORIGIN - pan) / zoom.unwrap();
assert!((after.x - world.x).abs() < 0.001 && (after.y - world.y).abs() < 0.001);
let before = (pan, zoom, fit);
for input in [
Event::Pan(Vector::new(f32::INFINITY, 0.)),
Event::Zoom {
factor: f32::NAN,
at,
base: scale,
},
Event::Zoom {
factor: 0.,
at,
base: scale,
},
] {
assert!(!change(&mut pan, &mut zoom, &mut fit, input));
assert_eq!((pan, zoom, fit), before);
}
}
}
#[test]
fn viewport_ordered_batch_uses_each_event_position_and_cancels_before_camera_motion() {
use iced::widget::Space;
let scope = Scope {
document: Id::new_v4(),
owner: Id::new_v4(),
revision: 2,
epoch: 0,
};
let mut view: Element<'_, Event, Theme, ()> = Viewport::new(
Space::new().width(Fill).height(Fill),
scope,
1.,
true,
|e| e,
)
.into();
let mut tree = widget::Tree::new(view.as_widget());
let node = view
.as_widget_mut()
.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(400., 300.)),
)
.move_to(Point::new(30., 80.));
let start = Point::new(90., 160.);
let middle = Point::new(125., 175.);
let end = Point::new(145., 185.);
let mut messages = vec![];
for event in [
mouse::Event::CursorMoved { position: start },
mouse::Event::ButtonPressed(mouse::Button::Middle),
mouse::Event::CursorMoved { position: middle },
mouse::Event::CursorMoved { position: end },
mouse::Event::ButtonReleased(mouse::Button::Middle),
] {
view.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(event),
Layout::new(&node),
mouse::Cursor::Available(end),
&(),
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::with_size(Size::new(700., 600.)),
);
}
assert!(matches!(messages.first(), Some(Event::CancelGesture)));
let total = messages
.iter()
.fold(Vector::ZERO, |total, event| match event {
Event::Pan(delta) => total + *delta,
_ => total,
});
assert_eq!(total, end - start);
}
pub(crate) fn scope(app: &App) -> Scope {
if let Some(context) = app.scenario_context() {
Scope {
document: context.document,
owner: context.scenario,
revision: context.revision,
epoch: app.camera_epoch,
}
} else {
let context = app.topology_context().expect("mounted behavior owner");
Scope {
document: context.document,
owner: context.owner,
revision: context.revision,
epoch: app.camera_epoch,
}
}
}
pub(crate) fn focused(mounted: &mut Mounted, app: &App, name: &str) -> bool {
let id = scope(app).control_id(name);
mounted
.geometry(app)
.focused
.iter()
.any(|(member, _, focused)| member.as_ref() == Some(&id) && *focused)
}
pub(crate) async fn tab_to(mounted: &mut Mounted, app: &mut App, name: &str) {
for _ in 0..32 {
if focused(mounted, app, name) {
return;
}
mounted
.batch(
app,
&[key(iced::keyboard::key::Named::Tab)],
mouse::Cursor::Unavailable,
)
.await;
}
panic!("Keyboard traversal did not reach {name}");
}
pub(crate) fn camera_delta(messages: &[Message]) -> Message {
messages.iter().find(|m| matches!(m,
Message::ScenarioCanvas(crate::behavior::scenario::CanvasEvent::Camera(_, _, Event::Pan(delta)))
| Message::TopologyCanvas(crate::behavior::topology::CanvasEvent::Camera(_, _, Event::Pan(delta))) if *delta != Vector::ZERO
)).expect("actual nonzero camera delta from a mounted gesture").clone()
}
pub(crate) async fn press_middle(mounted: &mut Mounted, app: &mut App, point: Point) {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await;
}
pub(crate) async fn release_middle(
mounted: &mut Mounted,
app: &mut App,
point: Point,
) -> Vec<Message> {
mounted
.batch(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Middle)),
],
mouse::Cursor::Available(point),
)
.await
}
pub(crate) fn assert_drawn_focus(before: &[u8], after: &[u8], size: Size, bounds: Rectangle) {
let width = size.width as usize;
assert_eq!(before.len(), width * size.height as usize * 4);
assert_eq!(after.len(), before.len());
let mut changed = 0;
for y in bounds.y.ceil() as usize..(bounds.y + bounds.height).floor() as usize {
for x in bounds.x.ceil() as usize..(bounds.x + bounds.width).floor() as usize {
if x as f32 - bounds.x > 2.
&& bounds.x + bounds.width - x as f32 > 2.
&& y as f32 - bounds.y > 2.
&& bounds.y + bounds.height - y as f32 > 2.
{
continue;
}
let start = (y * width + x) * 4;
if before[start..start + 3] != after[start..start + 3] {
changed += 1;
}
}
}
assert!(
changed >= bounds.width.min(bounds.height) as usize,
"Focus must change visible border pixels, changed={changed}, bounds={bounds:?}"
);
}
pub(crate) fn character(value: &str) -> iced::Event {
let mut event = key(iced::keyboard::key::Named::Space);
if let iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key,
modified_key,
text,
..
}) = &mut event
{
*key = iced::keyboard::Key::Character(value.into());
*modified_key = key.clone();
*text = Some(value.into());
}
event
}
/// Inspect the action without stripping the lifetime from retained packets.
pub(crate) fn intent_action(message: &Message) -> &Message {
match message {
Message::BehaviorControl(intent) => &intent.message,
message => message,
}
}
/// Deliberately retain a returned Chain to test the stronger ownership invariant.
/// Native Iced runs Chain passes synchronously; this is not a native interleaving.
pub(crate) async fn retained_focus_apply(
mounted: &mut Mounted,
app: &mut App,
) -> Box<dyn widget::Operation> {
let entry = mounted.input(
app,
&[key(iced::keyboard::key::Named::F6)],
mouse::Cursor::Unavailable,
);
assert_eq!(
entry.len(),
1,
"actual initial F6 must emit one canvas focus request"
);
let mut stream = iced_runtime::task::into_stream(app.update(entry[0].clone()))
.expect("F6 returns a widget Task");
let Some(Action::Widget(mut operation)) = stream.next().await else {
panic!("F6 first action must be Collect");
};
mounted.operate(app, &mut *operation);
match operation.finish() {
widget::operation::Outcome::Chain(apply) => apply,
_ => panic!("Live Collect must produce its guarded Apply pass"),
}
}
pub(crate) async fn overflow_last_tool(mounted: &mut Mounted, app: &mut App, last: &'static str) {
use iced::keyboard::key::Named;
let geometry = mounted.geometry(app);
let last_bounds = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert_eq!(offset.y, 0.);
assert!(
last_bounds.y >= rail.y + rail.height,
"last tool starts fully below the overflowing palette: tool={last_bounds:?}, rail={rail:?}"
);
mounted
.batch(app, &[key(Named::F6)], mouse::Cursor::Unavailable)
.await;
tab_to(mounted, app, last).await;
let geometry = mounted.geometry(app);
let mut visible = geometry.containers[&scope(app).control_id(last)];
let (rail, offset) = geometry.scrolls[&scope(app).control_id("palette-scroll")];
assert!(offset.y > 0., "keyboard traversal must scroll the palette");
visible.x -= offset.x;
visible.y -= offset.y;
assert!(
visible.y >= rail.y && visible.y + visible.height <= rail.y + rail.height + 0.5,
"the entire last tool is revealed: tool={visible:?}, rail={rail:?}"
);
let messages = mounted
.batch(app, &[key(Named::Enter)], mouse::Cursor::Unavailable)
.await;
assert!(
messages.iter().any(
|message| matches!(message, Message::BehaviorControl(intent) if intent.name == last)
),
"the revealed last tool is keyboard activated"
);
assert!(focused(mounted, app, last));
let on = mounted.pixels(app);
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
let off = mounted.pixels(app);
assert_drawn_focus(&off, &on, app.window_size, visible);
}
pub(crate) async fn retained_toolbar_boundaries(
mounted: &mut Mounted,
app: &mut App,
tool: &'static str,
) {
use iced::keyboard::key::Named;
for boundary in ["Chooser", "Dirty", "Picker"] {
let mut packets = vec![];
for name in ["Fit", tool, "Details"] {
packets.push(capture_toolbar(mounted, app, name));
}
let lifetime = app.camera_epoch;
let epoch = app.canvas_epoch;
let process = app.behavior.topology.camera_boundary();
let scenario = app.behavior.scenario.camera_boundary();
match boundary {
"Chooser" => {
let draft = app.draft.as_ref().unwrap();
let id = crate::forms::reference_chooser::trigger::field_id(
draft.reference_generation(),
"/parent",
);
let bounds = mounted
.geometry(app)
.focused
.iter()
.find(|(field, _, _)| field.as_ref() == Some(&id))
.expect("real Inspector parent reference trigger")
.1;
let request = draft.reference_open(app.project(), "/parent", bounds);
let task = app.update(Message::FieldReferenceOpen(request));
mounted.perform(app, task).await;
assert!(app.reference_input_active());
mounted
.batch(app, &[key(Named::Escape)], mouse::Cursor::Unavailable)
.await;
assert!(!app.reference_input_active());
}
"Dirty" => {
let task = app.update(Message::Field(
"/name".into(),
"Unsaved camera boundary".into(),
));
mounted.perform(app, task).await;
assert!(app.draft_dirty());
// Every retained packet is also rejected while the owner is dirty.
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert!(app.draft_dirty());
let task = app.update(Message::RevertProperties);
mounted.perform(app, task).await;
assert!(!app.draft_dirty());
}
"Picker" => {
// Public transitions, with cancellation injected at the external
// picker boundary. Never poll the native dialog future here.
let picker = app.update(Message::Save(true));
if cfg!(target_os = "linux") {
assert!(app.file_prompt.is_some());
mounted.perform(app, picker).await;
let task = app.update(Message::CancelFile);
mounted.perform(app, task).await;
} else {
assert!(app.busy, "Save As enters the real native-picker busy state");
drop(picker);
for packet in packets.iter().cloned() {
let task = app.update(packet);
mounted.perform(app, task).await;
}
let task = app.update(Message::SavePath(None));
mounted.perform(app, task).await;
assert!(!app.busy);
}
}
_ => unreachable!(),
}
assert_ne!(
app.camera_epoch, lifetime,
"{boundary} changes the presentation lifetime"
);
assert_eq!(
app.canvas_epoch, epoch,
"{boundary} cycle leaves semantic context unchanged"
);
let focus_before = mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>();
let current_lifetime = app.camera_epoch;
for packet in packets {
let task = app.update(packet);
mounted.perform(app, task).await;
}
assert_eq!(
app.camera_epoch, current_lifetime,
"{boundary} retained intents cannot start a new lifetime"
);
assert!(
app.behavior.topology.camera_boundary() == process,
"{boundary} retained toolbar packet changed process presentation"
);
assert!(
app.behavior.scenario.camera_boundary() == scenario,
"{boundary} retained toolbar packet changed scenario presentation"
);
assert_eq!(
mounted
.geometry(app)
.focused
.into_iter()
.filter(|(_, _, value)| *value)
.collect::<Vec<_>>(),
focus_before,
"{boundary} retained toolbar packet stole field focus"
);
}
}
pub(crate) fn capture_toolbar(mounted: &mut Mounted, app: &App, name: &'static str) -> Message {
let point = mounted.geometry(app).containers[&scope(app).control_id(name)].center();
mounted
.input(
app,
&[
iced::Event::Mouse(mouse::Event::CursorMoved { position: point }),
iced::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
iced::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
],
mouse::Cursor::Available(point),
)
.into_iter()
.find(|message| matches!(message, Message::BehaviorControl(intent) if intent.name == name))
.expect("actual production toolbar packet")
}
@@ -1,443 +0,0 @@
use super::{Event as CameraEvent, Scope};
use iced::advanced::{
Clipboard, Layout, Shell, Widget, layout, overlay, renderer,
widget::{Operation, Tree, operation, tree},
};
use iced::{Border, Element, Event, Length, Point, Rectangle, Size, Theme, Vector};
use iced::{keyboard, mouse};
#[derive(Default)]
struct Input {
scope: Option<Scope>,
focused: bool,
focus_pending: bool,
pointer: Option<Point>,
pan: Option<(mouse::Button, Point)>,
space: bool,
bounds: Option<Rectangle>,
}
impl operation::Focusable for Input {
fn is_focused(&self) -> bool {
self.focused
}
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;
}
}
pub struct Viewport<'a, Message, R = iced::Renderer>
where
R: renderer::Renderer,
{
content: Element<'a, Message, Theme, R>,
scope: Scope,
scale: f32,
keyboard: bool,
blank_pan: bool,
publish: Box<dyn Fn(CameraEvent) -> Message + 'a>,
}
impl<'a, Message, R: renderer::Renderer> Viewport<'a, Message, R> {
pub fn new(
content: impl Into<Element<'a, Message, Theme, R>>,
scope: Scope,
scale: f32,
keyboard: bool,
publish: impl Fn(CameraEvent) -> Message + 'a,
) -> Self {
Self {
content: content.into(),
scope,
scale,
keyboard,
blank_pan: false,
publish: Box::new(publish),
}
}
pub fn pan_background(mut self, enabled: bool) -> Self {
self.blank_pan = enabled;
self
}
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Input>()
}
fn state(&self) -> tree::State {
tree::State::new(Input {
scope: Some(self.scope),
..Default::default()
})
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let state = tree.state.downcast_mut::<Input>();
if state.scope.is_none_or(|s| {
(s.document, s.owner, s.revision)
!= (self.scope.document, self.scope.owner, self.scope.revision)
}) {
*state = Input::default();
}
if state
.scope
.is_some_and(|scope| scope.epoch != self.scope.epoch)
|| !self.keyboard
{
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
state.focus_pending = false;
}
state.scope = Some(self.scope);
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
Size::new(Length::Fill, Length::Fill)
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, 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: &R,
operation: &mut dyn Operation,
) {
if self.keyboard {
let id = self.scope.control_id("viewport");
operation.custom(
Some(&id),
layout.bounds(),
&mut super::focus::Member(self.scope),
);
operation.focusable(
Some(&id),
layout.bounds(),
tree.state.downcast_mut::<Input>(),
);
}
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: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if shell.is_event_captured() {
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 {
state.pan = None;
state.space = false;
}
if !self.keyboard {
state.focused = false;
state.focus_pending = false;
}
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
state.pointer = Some(*position);
}
let pointer = match event {
Event::Mouse(
mouse::Event::ButtonPressed(_)
| mouse::Event::ButtonReleased(_)
| mouse::Event::WheelScrolled { .. },
) => state.pointer.or_else(|| cursor.position()),
Event::Mouse(mouse::Event::CursorMoved { position }) => Some(*position),
_ => cursor.position().or(state.pointer),
};
let visible = layout.bounds().intersection(viewport);
let inside = pointer.is_some_and(|p| visible.is_some_and(|v| v.contains(p)))
&& cursor.position().is_some();
if !self.keyboard {
state.space = false;
}
let mut cancel = changed_bounds;
let mut camera = None;
let mut capture = false;
match event {
Event::Window(iced::window::Event::Unfocused | iced::window::Event::Resized(_)) => {
state.pan = None;
state.space = false;
state.focused = false;
state.focus_pending = false;
}
Event::Mouse(mouse::Event::CursorLeft) => {
state.pan = None;
state.pointer = None;
}
Event::Mouse(mouse::Event::ButtonPressed(button)) => {
if state.pan.is_some_and(|(held, _)| held != *button) || !inside {
state.pan = None;
state.space = false;
cancel = true;
}
state.focused = inside && self.keyboard;
state.focus_pending = false;
if inside
&& self.keyboard
&& (*button == mouse::Button::Middle
|| (*button == mouse::Button::Left && state.space))
{
state.pan = pointer.map(|p| (*button, p));
cancel = true;
camera = Some(CameraEvent::CancelGesture);
capture = true;
}
}
Event::Mouse(mouse::Event::CursorMoved { position }) => {
if let Some((button, previous)) = state.pan {
state.pan = Some((button, *position));
camera = Some(CameraEvent::Pan(*position - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::ButtonReleased(button)) => {
if let Some((held, previous)) = state.pan
&& held == *button
{
state.pan = None;
camera = pointer
.filter(|_| inside)
.map(|p| CameraEvent::Pan(p - previous));
capture = true;
}
}
Event::Mouse(mouse::Event::WheelScrolled { delta }) if inside && state.focused => {
state.pan = None;
cancel = true;
let y = match delta {
mouse::ScrollDelta::Lines { y, .. } => *y,
mouse::ScrollDelta::Pixels { y, .. } => *y / 40.,
};
if y != 0. {
let at = pointer.unwrap() - Vector::new(layout.bounds().x, layout.bounds().y);
camera = Some(CameraEvent::Zoom {
factor: 1.1_f32.powf(y.clamp(-8., 8.)),
at,
base: self.scale,
});
capture = true;
}
}
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::F6),
modifiers,
repeat: false,
..
}) if self.keyboard && modifiers.is_empty() => {
state.focus_pending = true;
camera = Some(CameraEvent::ToggleFocus);
capture = true;
cancel = true;
}
Event::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })
if state.focused && self.keyboard =>
{
use keyboard::{Key, key::Named};
match key.as_ref() {
Key::Named(Named::Space) => {
state.space = true;
cancel = true;
camera = Some(CameraEvent::CancelGesture);
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;
}
Key::Named(key) if modifiers.is_empty() => {
let delta = match key {
Named::ArrowLeft => Some(Vector::new(60., 0.)),
Named::ArrowRight => Some(Vector::new(-60., 0.)),
Named::ArrowUp => Some(Vector::new(0., 60.)),
Named::ArrowDown => Some(Vector::new(0., -60.)),
_ => None,
};
camera = delta.map(CameraEvent::Pan);
capture = camera.is_some();
cancel = capture;
}
_ => {}
}
}
Event::Keyboard(keyboard::Event::KeyReleased { key, .. })
if *key == keyboard::Key::Named(keyboard::key::Named::Space) =>
{
state.space = false;
}
_ => {}
}
if cancel {
self.content.as_widget_mut().update(
&mut tree.children[0],
&Event::Window(iced::window::Event::Unfocused),
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
if let Some(event) = camera {
shell.publish((self.publish)(event));
}
if capture {
shell.capture_event();
shell.request_redraw();
return;
}
if matches!(event, Event::Keyboard(_))
&& (!state.focused || !self.keyboard || state.pan.is_some() || state.space)
{
return;
}
let child_cursor = if inside {
pointer.map_or(mouse::Cursor::Unavailable, mouse::Cursor::Available)
} else {
mouse::Cursor::Unavailable
};
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
child_cursor,
renderer,
clipboard,
shell,
viewport,
);
// A semantic hit or armed creation tool gets first refusal. In Select,
// an otherwise unused blank press starts the same camera gesture.
if self.blank_pan
&& self.keyboard
&& inside
&& !shell.is_event_captured()
&& matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
)
{
state.pan = pointer.map(|p| (mouse::Button::Left, p));
shell.publish((self.publish)(CameraEvent::CancelGesture));
shell.capture_event();
shell.request_redraw();
}
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
renderer.with_layer(layout.bounds(), |renderer| {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
});
if tree.state.downcast_ref::<Input>().focused && self.keyboard {
renderer.fill_quad(
renderer::Quad {
bounds: layout.bounds(),
border: Border {
color: crate::style::TEAL,
width: 1.,
..Default::default()
},
..Default::default()
},
iced::Color::TRANSPARENT,
);
}
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
if tree.state.downcast_ref::<Input>().pan.is_some() {
mouse::Interaction::Grabbing
} else {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut Tree,
layout: Layout<'a>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
impl<'a, Message: 'a, R: renderer::Renderer + 'a> From<Viewport<'a, Message, R>>
for Element<'a, Message, Theme, R>
{
fn from(viewport: Viewport<'a, Message, R>) -> Self {
Element::new(viewport)
}
}
@@ -886,7 +886,7 @@ fn contextual_source_incomplete_recovered_kind_reports_error_without_panicking()
}
#[test]
fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_layer() {
fn contextual_source_old_empty_action_cannot_open_a_previous_owner_or_layer() {
use crate::app::{App, Message, Page};
let (mut p, a) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity);
let b = p
@@ -898,32 +898,30 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
app.behavior.active_owner = Some(a);
app.selected = a;
let context = app.scenario_context().unwrap();
let source = app.document.bundle().clone();
let begin = |context| scenario::CanvasEvent::BeginOccurrence {
context,
tool: scenario::Tool::Lifeline,
slot: scenario::Slot {
parent: a,
before: None,
},
lifeline: None,
};
app.behavior.active_owner = Some(b);
app.selected = b;
let _ = app.update(Message::ScenarioCanvas(begin(context)));
let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First(
context,
scenario::Tool::Lifeline,
)));
assert!(!app.behavior.has_pending());
assert_eq!(app.project(), &p);
app.behavior.active_owner = Some(a);
app.selected = a;
app.layer = Layer::Sa;
let _ = app.update(Message::ScenarioCanvas(begin(context)));
let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First(
context,
scenario::Tool::Lifeline,
)));
assert!(!app.behavior.has_pending());
app.layer = Layer::Oa;
let current = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(begin(current)));
let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First(
current,
scenario::Tool::Lifeline,
)));
assert_eq!(app.behavior.pending_owner(), Some(a));
assert_eq!(app.project(), &p);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
+6 -1
View File
@@ -694,7 +694,9 @@ 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",
};
@@ -705,13 +707,16 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
.padding(6)
]
.spacing(5);
if let Some(id) = selected {
if let Some(id) = selected
&& !staged_owner
{
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));
@@ -1,148 +0,0 @@
//! Keep behavior field state within one editing session and reset it on replacement.
use iced::advanced::widget::{Operation, Tree, tree};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event, Length, Rectangle, Size, Theme, Vector, mouse};
use oa_core::Id;
struct Identity(Id);
struct Session<'a, Message, R: renderer::Renderer> {
identity: Id,
content: Element<'a, Message, Theme, R>,
}
pub(super) fn reset_on_change<'a, Message: 'a, R: renderer::Renderer + 'a>(
identity: Id,
content: impl Into<Element<'a, Message, Theme, R>>,
) -> Element<'a, Message, Theme, R> {
Element::new(Session {
identity,
content: content.into(),
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Session<'_, Message, R> {
fn tag(&self) -> tree::Tag {
tree::Tag::of::<Identity>()
}
fn state(&self) -> tree::State {
tree::State::new(Identity(self.identity))
}
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
let retained = tree.state.downcast_mut::<Identity>();
if retained.0 != self.identity {
retained.0 = self.identity;
// Iced's keyed column preserves same-length child replacements.
// An editing-session replacement must instead get fresh field state.
tree.children = self.children();
} else {
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: &R, 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: &R,
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: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
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: &R,
) -> 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: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
+100 -241
View File
@@ -4,10 +4,7 @@ use super::topology::{occurrence, unique_batch};
use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
pub(crate) mod focus;
mod usage;
mod workspace;
pub(crate) use workspace::NAME_INPUT;
pub use workspace::{Shelf, view};
#[cfg(test)]
@@ -67,34 +64,6 @@ 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,
@@ -125,18 +94,17 @@ impl std::fmt::Display for CanvasTool {
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
First(Context, Tool),
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
Viewport(Context, Size),
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),
Select(Context, Id),
Remove(Context, Id),
Begin {
context: Context,
slot: Slot,
@@ -158,24 +126,24 @@ pub enum CanvasEvent {
}
#[derive(Debug, Clone, Copy)]
pub enum ScrollArea {
// The independent Details/outline scroller; viewport motion uses Camera.
Canvas,
// Retained navigation channel, now the independent Selection/outline shelf.
Page,
}
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Scrolled(context, _, _)
Self::First(context, _)
| Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
| 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::Select(context, _)
| Self::Remove(context, _)
| Self::Begin { context, .. }
| Self::BeginOccurrence { context, .. }
| Self::Reorder { context, .. } => *context,
@@ -232,14 +200,11 @@ const VALUE_PAGE_SIZE: usize = 24;
#[derive(Default)]
pub struct State {
draft: Option<Draft>,
form_session: Id,
form_mount: Id,
form_focus_pending: bool,
canvas_tool: Option<(Id, CanvasTool)>,
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
details_offset: iced::Vector,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<Caller>,
restore: Option<DetailsRestore>,
restore: Option<ViewportRestore>,
presentation: workspace::Presentation,
}
const CALLER_LIMIT: usize = 32;
@@ -249,14 +214,15 @@ pub(crate) struct Caller {
pub occurrence: Id,
target: Id,
tool: CanvasTool,
details_offset: iced::Vector,
offsets: [iced::Vector; 2],
presentation: workspace::Presentation,
}
struct DetailsRestore {
struct ViewportRestore {
document: Id,
owner: Id,
token: Id,
offset: iced::Vector,
offsets: [iced::Vector; 2],
pending: [bool; 2],
}
#[derive(Clone, Copy)]
pub(super) struct ScrollRestore {
@@ -316,17 +282,6 @@ struct Draft {
choice_page: usize,
}
impl State {
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
(
self.canvas_tool,
self.presentation.shelf,
self.presentation.viewport,
self.presentation.pan,
self.presentation.zoom,
self.presentation.fit,
)
}
pub(super) fn pending_name(&self) -> Option<&str> {
self.draft.as_ref().map(|d| d.name.as_str())
}
@@ -346,50 +301,31 @@ impl State {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
let returned = self.draft.as_mut().is_some_and(|d| d.source.back());
if returned {
self.remount_form();
}
returned
self.draft.as_mut().is_some_and(|d| d.source.back())
}
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
if self.presentation.identity != Some((document, owner)) {
self.presentation = workspace::Presentation {
identity: Some((document, owner)),
focused: self.presentation.focused,
..Default::default()
};
self.details_offset = iced::Vector::ZERO;
self.scroll_offsets = [iced::Vector::ZERO; 2];
self.restore = None;
}
}
pub(crate) fn viewport(&mut self, size: Size) -> bool {
self.presentation.viewport(size)
}
pub(crate) fn camera(&mut self, event: super::canvas_workspace::Event) -> bool {
let p = &mut self.presentation;
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 size = Scene::sequence(p, owner, owner).size;
let old = self.presentation.scale(size);
if let Some(next) = zoom {
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
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);
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);
@@ -413,11 +349,11 @@ impl State {
occurrence,
target,
tool: self.canvas_tool(context.scenario),
details_offset: self.details_offset,
offsets: self.scroll_offsets,
presentation: self.presentation.clone(),
});
self.canvas_tool = Some((target, CanvasTool::Select));
self.details_offset = iced::Vector::ZERO;
self.scroll_offsets = [iced::Vector::ZERO; 2];
self.restore = None;
Ok(target)
}
@@ -444,30 +380,34 @@ impl State {
return;
}
self.canvas_tool = Some((frame.owner, frame.tool));
self.details_offset = frame.details_offset;
self.scroll_offsets = frame.offsets;
self.presentation = frame.presentation;
self.presentation.identity = Some((context.document, context.scenario));
self.restore = Some(DetailsRestore {
self.restore = Some(ViewportRestore {
document: context.document,
owner: frame.owner,
token: Id::new_v4(),
offset: frame.details_offset,
offsets: frame.offsets,
pending: [true; 2],
});
}
pub(super) fn scroll_restore(
&self,
context: Context,
ScrollArea::Page: ScrollArea,
area: ScrollArea,
) -> Option<ScrollRestore> {
let restore = self
.restore
.as_ref()
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
};
let restore = self.restore.as_ref().filter(|r| {
r.document == context.document && r.owner == context.scenario && r.pending[index]
})?;
Some(ScrollRestore {
document: restore.document,
owner: restore.owner,
token: restore.token,
offset: restore.offset,
offset: restore.offsets[index],
})
}
pub(crate) fn restored(
@@ -477,12 +417,22 @@ impl State {
area: ScrollArea,
offset: iced::Vector,
) -> bool {
if !self.restore.as_ref().is_some_and(|r| {
r.document == context.document && r.owner == context.scenario && r.token == token
}) {
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
};
let Some(restore) = self.restore.as_mut().filter(|r| {
r.document == context.document
&& r.owner == context.scenario
&& r.token == token
&& r.pending[index]
}) else {
return false;
};
restore.pending[index] = false;
if !restore.pending.contains(&true) {
self.restore = None;
}
self.restore = None;
self.scrolled(area, offset)
}
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
@@ -495,57 +445,28 @@ impl State {
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
let session = Id::new_v4();
Ok(Self {
draft: Some(draft),
form_session: session,
form_mount: session,
form_focus_pending: true,
..Default::default()
})
}
pub(crate) fn form_session(&self) -> Option<Id> {
self.draft.as_ref().map(|_| self.form_session)
}
pub(crate) fn form_mount(&self) -> Option<Id> {
self.draft
.as_ref()
.filter(|d| !d.source.is_open())
.map(|_| self.form_mount)
}
pub(crate) fn focus_stamp(&self, context: Context) -> Option<focus::Stamp> {
(self.draft.as_ref()?.owner == context.scenario).then_some(focus::Stamp::new(
context,
self.form_session,
self.form_mount()?,
))
}
fn remount_form(&mut self) {
self.form_mount = Id::new_v4();
self.form_focus_pending = true;
}
pub(crate) fn take_form_focus(&mut self, mount: Id) -> bool {
if self.form_mount() == Some(mount) && self.form_focus_pending {
self.form_focus_pending = false;
true
} else {
false
}
}
pub(crate) fn scrolled(&mut self, ScrollArea::Page: ScrollArea, offset: iced::Vector) -> bool {
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
return false;
}
let changed = self.details_offset != offset;
self.details_offset = offset;
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
};
let changed = self.scroll_offsets[index] != offset;
self.scroll_offsets[index] = offset;
changed
}
fn scroll_offset(&self) -> iced::Vector {
// The former page scroll now belongs to a sibling outline shelf.
self.scroll_offsets[0]
}
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
let key = CallChoiceContext {
document: context.document,
@@ -586,7 +507,7 @@ impl State {
}
created(command, draft.parent)
}
pub(crate) fn canvas_tool(&self, owner: Id) -> CanvasTool {
pub(super) fn canvas_tool(&self, owner: Id) -> CanvasTool {
self.canvas_tool
.filter(|(id, _)| *id == owner)
.map(|(_, tool)| tool)
@@ -695,7 +616,6 @@ impl State {
.source
.dismiss(&event)?
{
self.remount_form();
return Ok(None);
}
let d = self
@@ -736,11 +656,7 @@ impl State {
next.target = Some(id);
next.command(p)?;
}
let returned = d.source.is_open() && !next.source.is_open();
self.draft = Some(next);
if returned {
self.remount_form();
}
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
@@ -755,9 +671,6 @@ impl State {
if p.elements.get(&owner).map(oa_core::Element::kind) != Some(Kind::Scenario) {
return Err("Choose a scenario.".into());
}
self.form_session = Id::new_v4();
self.form_mount = self.form_session;
self.form_focus_pending = true;
self.draft = Some(Draft {
source: Default::default(),
invocation: Id::new_v4(),
@@ -1955,7 +1868,7 @@ pub(crate) mod direct_tests {
epoch: 0,
}
}
pub(crate) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
pub(super) fn placement_fixture() -> (Fixture, Id, Id, Id, Id, Id) {
let mut f = fixture();
let p = &mut f.project;
let machine = p
@@ -2073,31 +1986,20 @@ pub(crate) mod direct_tests {
navigation_event(&mut app, |ctx| {
CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Reuse))
});
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
let camera_epoch = app.camera_epoch;
navigation_event(&mut app, |ctx| {
CanvasEvent::Camera(
ctx,
camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(25., 170.)),
)
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(25., 170.))
});
navigation_event(&mut app, |ctx| {
CanvasEvent::Scrolled(ctx, ScrollArea::Page, iced::Vector::new(0., 80.))
});
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, caller));
assert_eq!(app.selected, b);
assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO);
assert_eq!(app.behavior.scenario.scroll_offset(), iced::Vector::ZERO);
navigation_event(&mut app, |ctx| {
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
});
let camera_epoch = app.camera_epoch;
navigation_event(&mut app, |ctx| {
CanvasEvent::Camera(
ctx,
camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 115.)),
)
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(0., 115.))
});
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, nested));
assert_eq!(app.selected, c);
@@ -2108,7 +2010,7 @@ pub(crate) mod direct_tests {
CanvasTool::Message(MessageStyle::Timer)
);
assert_eq!(
app.behavior.scenario.presentation.pan,
app.behavior.scenario.scroll_offset(),
iced::Vector::new(0., 115.)
);
navigation_event(&mut app, CanvasEvent::Back);
@@ -2119,11 +2021,11 @@ pub(crate) mod direct_tests {
CanvasTool::Add(Tool::Reuse)
);
assert_eq!(
app.behavior.scenario.presentation.pan,
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 170.)
);
assert_eq!(
app.behavior.scenario.details_offset,
app.behavior.scenario.scroll_offsets[1],
iced::Vector::new(0., 80.),
"The caller's independent outline scroll is restored separately"
);
@@ -2301,24 +2203,22 @@ pub(crate) mod direct_tests {
let before = app.document.bundle().clone();
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
scene.interaction = Some((context, CanvasTool::Add(tool), true));
scene.camera_pan = iced::Vector::new(23., 80.);
let bounds = Rectangle::new(
Point::new(20., 40.),
Size::new(scene.size.width + 46., scene.size.height + 160.),
);
scene.scroll_offset = iced::Vector::new(23., 80.);
let bounds = Rectangle::new(Point::new(20., 40.), scene.size);
let y = scene.slots.iter().find(|s| s.slot == slot).unwrap().y;
let x = if on_lifeline {
scene.lanes[&f.sender]
} else {
scene.size.width - 35.
};
let point = Point::new(bounds.x + x, bounds.y + y) + scene.camera_pan;
assert!(bounds.contains(point));
let point = Point::new(bounds.x + x, bounds.y + y);
let mut input = SequenceInput::default();
canvas_mouse(
&scene,
&mut input,
mouse::Event::CursorMoved { position: point },
mouse::Event::CursorMoved {
position: point - scene.scroll_offset,
},
point,
bounds,
);
@@ -2494,10 +2394,10 @@ pub(crate) mod direct_tests {
assert!(!app.behavior.has_pending());
}
let old_context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
old_context,
app.camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 100.)),
ScrollArea::Canvas,
iced::Vector::new(0., 100.),
)));
let _ = app.update(Message::ScenarioCanvas(event(old_context)));
assert!(!app.behavior.has_pending());
@@ -2688,29 +2588,26 @@ pub(crate) mod direct_tests {
);
}
#[test]
fn direct_busy_save_keeps_details_current_and_camera_for_the_next_pointer_gesture() {
fn direct_busy_save_keeps_observed_scroll_current_for_the_next_pointer_gesture() {
let f = fixture();
let mut app = App::with_project(f.project.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(-25., -100.)),
)));
let context = app.scenario_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
ScrollArea::Page,
iced::Vector::new(0., 40.),
)));
for (area, offset) in [
(ScrollArea::Canvas, iced::Vector::new(25., 100.)),
(ScrollArea::Page, iced::Vector::new(0., 40.)),
] {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
assert!(app.busy);
assert_eq!(
app.behavior.scenario.details_offset,
iced::Vector::new(0., 40.)
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 100.)
);
assert_ne!(
app.scenario_context(),
@@ -2721,14 +2618,14 @@ pub(crate) mod direct_tests {
stale.document = Id::new_v4();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
stale,
ScrollArea::Page,
ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.scenario.details_offset,
iced::Vector::new(0., 40.)
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 100.)
);
let root = std::path::PathBuf::from("scenario-details-save-fixture");
let root = std::path::PathBuf::from("scenario-scroll-save-fixture");
let receipt = crate::app::FileSnapshot {
document: context.document,
revision: context.revision,
@@ -2749,17 +2646,12 @@ pub(crate) mod direct_tests {
let context = app.scenario_context().unwrap();
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true));
assert_eq!(
app.behavior.scenario.presentation.pan,
iced::Vector::new(-25., -100.)
);
scene.camera_pan = app.behavior.scenario.presentation.pan;
scene.scroll_offset = app.behavior.scenario.scroll_offset();
let slot = scene.slots.last().unwrap().slot;
let bounds = Rectangle::new(Point::new(20., 250.), scene.size);
let y = bounds.y + scene.slots.last().unwrap().y;
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) + scene.camera_pan;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) + scene.camera_pan;
assert!(bounds.contains(a) && bounds.contains(b));
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) - scene.scroll_offset;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) - scene.scroll_offset;
let mut input = super::super::SequenceInput::default();
let mut published = vec![];
for event in [
@@ -2773,7 +2665,7 @@ pub(crate) mod direct_tests {
&mut input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(b),
mouse::Cursor::Available(b + scene.scroll_offset),
)
.and_then(|action| action.into_inner().0)
{
@@ -3254,45 +3146,12 @@ 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!(
activation.finish,
scenario_activation::Finish::Reply(steps[1])
);
// 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]
scene
.boxes
.iter()
.all(|v| v.is_finite())
.any(|b| b.id == steps[0] && b.rect.width == 8.)
);
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,334 +0,0 @@
//! A delayed focus request must still own the live parent tree when executed.
//! Collect first: a missing target must not unfocus unrelated fields.
use super::Context;
use iced::advanced::widget::{Id, Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event, Length, Rectangle, Size, Task, Theme, Vector, mouse};
use oa_core::Id as ModelId;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Stamp {
document: ModelId,
owner: ModelId,
revision: u64,
session: ModelId,
mount: ModelId,
}
impl Stamp {
pub(crate) fn new(context: Context, session: ModelId, mount: ModelId) -> Self {
Self {
document: context.document,
owner: context.scenario,
revision: context.revision,
session,
mount,
}
}
}
pub(crate) fn name_id(stamp: Stamp) -> Id {
Id::from(format!(
"{}:{}:{}:{}:{}:{}",
super::NAME_INPUT,
stamp.document,
stamp.owner,
stamp.revision,
stamp.session,
stamp.mount
))
}
struct Owner(Option<Stamp>);
struct Root<'a, Message, R: renderer::Renderer> {
content: Element<'a, Message, Theme, R>,
owner: Option<Stamp>,
}
pub(crate) fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
owner: Option<Stamp>,
) -> Element<'a, Message, Theme, R> {
Element::new(Root {
content: content.into(),
owner,
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
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: &R, 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: &R,
op: &mut dyn Operation,
) {
// This is computed from the current App view, never retained widget state.
op.custom(None, layout.bounds(), &mut Owner(self.owner));
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, op);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
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: &R,
) -> 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: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[derive(Clone)]
struct Scroll {
index: usize,
bounds: Rectangle,
capacity: Vector,
offset: Vector,
}
struct Collect {
stamp: Stamp,
valid: bool,
translation: Vector,
scrolls: Vec<Scroll>,
pending: Option<Scroll>,
index: usize,
target: Option<(Rectangle, Vec<Scroll>)>,
}
pub(crate) fn task<Message: Send + 'static>(stamp: Stamp) -> Task<Message> {
iced::advanced::widget::operate(Collect {
stamp,
valid: false,
translation: Vector::ZERO,
scrolls: vec![],
pending: None,
index: 0,
target: None,
})
.discard()
}
impl Operation for Collect {
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
if let Some(owner) = state.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.stamp);
}
}
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
let previous = (self.translation, self.scrolls.len());
if let Some(scroll) = self.pending.take() {
self.translation += scroll.offset;
self.scrolls.push(scroll);
}
visit(self);
self.translation = previous.0;
self.scrolls.truncate(previous.1);
}
fn scrollable(
&mut self,
_: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn operation::Scrollable,
) {
self.pending = Some(Scroll {
index: self.index,
bounds: translated(bounds, self.translation),
capacity: Vector::new(
(content.width - bounds.width).max(0.),
(content.height - bounds.height).max(0.),
),
offset,
});
self.index += 1;
}
fn focusable(&mut self, id: Option<&Id>, bounds: Rectangle, _: &mut dyn operation::Focusable) {
if id == Some(&name_id(self.stamp)) {
self.target = Some((translated(bounds, self.translation), self.scrolls.clone()));
}
}
fn finish(&self) -> operation::Outcome<()> {
if !self.valid {
return operation::Outcome::None;
}
let Some((mut bounds, ancestors)) = self.target.clone() else {
return operation::Outcome::None;
};
let mut shifts = BTreeMap::new();
for ancestor in ancestors.iter().rev() {
let dx = reveal(
bounds.x,
bounds.width,
ancestor.bounds.x,
ancestor.bounds.width,
);
let dy = reveal(
bounds.y,
bounds.height,
ancestor.bounds.y,
ancestor.bounds.height,
);
// Drawing offsets are rounded; an already-visible axis must not
// acquire a scroll delta from a fractional capacity alone.
let delta = Vector::new(
if dx == 0. {
0.
} else {
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
},
if dy == 0. {
0.
} else {
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
},
);
if delta != Vector::ZERO {
shifts.insert(ancestor.index, delta);
}
bounds = translated(bounds, delta);
}
operation::Outcome::Chain(Box::new(Apply {
stamp: self.stamp,
valid: false,
shifts,
index: 0,
}))
}
}
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
Rectangle {
x: bounds.x - delta.x,
y: bounds.y - delta.y,
..bounds
}
}
fn reveal(start: f32, length: f32, viewport: f32, size: f32) -> f32 {
if start < viewport || length > size {
start - viewport
} else {
(start + length - viewport - size).max(0.)
}
}
struct Apply {
stamp: Stamp,
valid: bool,
shifts: BTreeMap<usize, Vector>,
index: usize,
}
impl Operation for Apply {
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
if let Some(owner) = state.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.stamp);
}
}
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
if self.valid {
if id == Some(&name_id(self.stamp)) {
state.focus();
} else {
state.unfocus();
}
}
}
fn scrollable(
&mut self,
_: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
_: Vector,
state: &mut dyn operation::Scrollable,
) {
if self.valid
&& let Some(delta) = self.shifts.get(&self.index)
{
state.scroll_by(
operation::scrollable::AbsoluteOffset {
x: delta.x,
y: delta.y,
},
bounds,
content,
);
}
self.index += 1;
}
}
@@ -1,207 +0,0 @@
//! Navigation is derived from the same opaque-reference scene that is painted.
use super::*;
use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one
/// actual character of this field, rather than an ellipsis at its start.
fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
if value.trim().is_empty() {
return false;
}
let normalized = full.split_whitespace().collect::<Vec<_>>().join(" ");
let normalized_prefix = prefix.split_whitespace().collect::<Vec<_>>().join(" ");
let start = normalized_prefix.len()
+ usize::from(prefix.ends_with(char::is_whitespace) && !normalized_prefix.is_empty());
let painted = crate::views::short(full, 32);
let retained = normalized
.chars()
.zip(painted.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
start < retained
}
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;
// they must not inflate its target count.
if !result
.iter()
.any(|u: &Usage| u.target == Target::ScenarioObject(id))
{
result.push(Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::ScenarioObject(id),
role: role.into(),
bounds,
});
}
};
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
let Some(element) = p.elements.get(&item.id) else {
continue;
};
if subject == item.id {
// A fragment's selectable header is useful even when its frame is
// much larger than the viewport.
let mut bounds = item.rect;
if item.marker == Some(Kind::Occurrence) {
bounds.height = bounds.height.min(38.);
}
add(item.id, bounds, "scenario object");
}
let represented = match element.shared() {
Some(SharedBody::Lifeline { represented }) => represented.map(|id| (id, "lifeline")),
Some(SharedBody::Occurrence { body, .. }) => match body {
OccurrenceBody::Action { action, .. } => action.map(|id| (id, "action occurrence")),
OccurrenceBody::State { state, .. } => state.map(|id| (id, "state observation")),
OccurrenceBody::SequenceReference { sequence } => {
sequence.map(|id| (id, "process reference"))
}
OccurrenceBody::ScenarioUse { scenario, .. } => {
scenario.map(|id| (id, "scenario reference"))
}
// An incomplete message fallback box only paints the occurrence
// name and a repair hint, not its exchange or operation.
_ => None,
},
_ => None,
};
if let Some((id, role)) = represented.filter(|(id, _)| *id == subject) {
let _ = id;
add(item.id, item.rect, role);
}
}
for line in scene.lines.iter().filter(|line| !line.label.is_empty()) {
let Some(element) = p.elements.get(&line.id) else {
continue;
};
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = element.shared()
else {
continue;
};
let bounds = line.label_bounds();
if subject == line.id || *exchange == Some(subject) {
add(line.id, bounds, "message occurrence");
}
// Replies derive their operation from the exact call, matching the
// operation label formatter rather than scanning raw reference fields.
if let Ok(operations::OperationResolution::Selected(operation)) =
operations::resolve(p, line.id)
&& let Some(label) = crate::data_values::operation_label(p, element)
&& let Some(prefix) = line.label.strip_suffix(&label)
{
let allocation = &p.relations[&operation.allocation];
let operation_prefix = if matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
..
},
..
})
) {
"Reply operation from Call: "
} else {
"Operation: "
};
let fields = [
(allocation.source(), format!("{prefix}{operation_prefix}")),
(
allocation.target(),
format!(
"{prefix}{operation_prefix}{} / ",
p.named(allocation.source())
),
),
(
allocation.id,
format!(
"{prefix}{operation_prefix}{} / {} · ",
p.named(allocation.source()),
p.named(allocation.target())
),
),
];
for (id, prefix) in fields {
if id == subject && label_field_visible(&line.label, &prefix, p.named(id)) {
add(line.id, bounds, "operation label");
}
}
}
}
if subject == owner {
result.insert(
0,
Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::Whole,
role: "scenario".into(),
bounds: Rectangle::new(Point::ORIGIN, scene.size),
},
);
}
result
}
impl State {
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
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(
&mut self,
p: &Project,
document: Id,
owner: Id,
target: &Target,
) -> bool {
if self.draft.is_some()
|| !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return false;
}
let id = match target {
Target::Whole => owner,
Target::ScenarioObject(id) => *id,
_ => return false,
};
let Some(usage) = self
.diagram_usages(p, owner, id)
.into_iter()
.find(|u| &u.target == target)
else {
return false;
};
// Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
self.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select));
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
self.presentation.zoom(Some(zoom));
self.presentation.pan = pan;
true
}
}
#[cfg(test)]
mod tests;
@@ -1,276 +0,0 @@
use super::*;
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let layer = p.elements[&owner].layer();
let id = p
.add_element(Kind::Occurrence, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut()
&& !steps.contains(&id)
{
steps.push(id);
}
id
}
fn targets(state: &State, p: &Project, owner: Id, subject: Id) -> BTreeSet<Id> {
state
.diagram_usages(p, owner, subject)
.into_iter()
.filter_map(|u| match u.target {
Target::ScenarioObject(id) => Some(id),
_ => None,
})
.collect()
}
#[test]
fn scenario_usage_counts_nested_occurrences_and_lifelines_but_keeps_reuse_opaque() {
for (layer, work_kind, participant_kind) in [
(Layer::Oa, Kind::Activity, Kind::Entity),
(Layer::Sa, Kind::Function, Kind::SystemActor),
] {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Scenario, layer, "Caller", None)
.unwrap();
let other = p
.add_element(Kind::Scenario, layer, "Definition", None)
.unwrap();
let work = p.add_element(work_kind, layer, "Respond", None).unwrap();
let unused = p.add_element(work_kind, layer, "Respond", None).unwrap();
let participant = p
.add_element(participant_kind, layer, "Operator", None)
.unwrap();
let lanes = ["First role", "Second role"].map(|name| {
let id = p
.add_element(Kind::Lifeline, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(participant),
};
id
});
let first = add_occurrence(
&mut p,
owner,
"First action",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[0]),
},
);
let fragment = add_occurrence(
&mut p,
owner,
"Decision",
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
);
let branch = add_occurrence(
&mut p,
fragment,
"When ready",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
let nested = add_occurrence(
&mut p,
branch,
"Again",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[1]),
},
);
let inside = add_occurrence(
&mut p,
other,
"Hidden inside definition",
OccurrenceBody::Action {
action: Some(unused),
lifeline: None,
},
);
let use_a = add_occurrence(
&mut p,
owner,
"Use A",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let use_b = add_occurrence(
&mut p,
owner,
"Use B",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let before = p.clone();
let state = State::default();
assert_eq!(
targets(&state, &p, owner, work),
BTreeSet::from([first, nested])
);
assert_eq!(
targets(&state, &p, owner, participant),
BTreeSet::from(lanes)
);
assert_eq!(
targets(&state, &p, owner, other),
BTreeSet::from([use_a, use_b])
);
assert!(targets(&state, &p, owner, unused).is_empty());
assert!(targets(&state, &p, owner, inside).is_empty());
assert_eq!(targets(&state, &p, owner, branch), BTreeSet::from([branch]));
assert_eq!(
state.diagram_usages(&p, owner, owner)[0].target,
Target::Whole
);
assert_eq!(p, before);
}
}
#[test]
fn scenario_usage_keeps_repeated_messages_and_reveal_is_read_only_and_revalidated() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let mut state = State::default();
assert_eq!(
targets(&state, &p, f.owner, f.exchange),
BTreeSet::from([f.call, f.reply])
);
let before = p.clone();
let target = Target::ScenarioObject(f.reply);
state.viewport(Size::new(900., 500.));
assert!(state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
let usage = state
.diagram_usages(&p, f.owner, f.reply)
.into_iter()
.find(|u| u.target == target)
.unwrap();
let zoom = state
.presentation
.scale(Scene::sequence(&p, f.owner, f.reply).size);
let centered = Point::new(
usage.bounds.center_x() * zoom,
usage.bounds.center_y() * zoom,
) + state.presentation.pan;
assert!((centered.x - 450.).abs() < 0.01 && (centered.y - 250.).abs() < 0.01);
assert!(zoom >= 0.5);
assert_eq!(p, before);
// Keeping an orphan semantic occurrence does not keep its painted target.
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.reply);
let camera = state.camera_boundary();
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
assert!(camera == state.camera_boundary());
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &Target::Edge(f.exchange)));
// A message whose endpoints are incomplete paints only its repair box.
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { receiver, .. },
..
}) = p.elements.get_mut(&f.call).unwrap().shared_mut()
{
*receiver = None;
}
assert!(targets(&state, &p, f.owner, f.exchange).is_empty());
assert_eq!(
targets(&state, &p, f.owner, f.call),
BTreeSet::from([f.call])
);
}
#[test]
fn scenario_operation_label_membership_excludes_ellipsis_only_and_normalizes_whitespace() {
assert!(label_field_visible(
"1. call · X · Operation: I / O",
"1. call · X · Operation: ",
"I"
));
assert!(!label_field_visible(
"1234567890123456789012345678901Ixyz",
"1234567890123456789012345678901",
"Ixyz"
));
assert!(!label_field_visible(
"A very long occurrence label · Operation: I",
"A very long occurrence label · Operation: ",
"I"
));
assert!(label_field_visible(
"call\n\t· Operation: 界",
"call\n\t· Operation: ",
"界"
));
assert!(!label_field_visible("Operation: ", "Operation: ", ""));
}
#[test]
fn scenario_usage_only_reports_the_resolved_operation_fields_actually_in_the_label() {
let mut p = persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap();
let id = |n| Id::parse_str(&format!("52060906-0000-4000-8000-{n:012x}")).unwrap();
let (owner, call, exchange, allocation) = (id(40), id(43), id(34), id(30));
let (interface, item) = (
p.relations[&allocation].source(),
p.relations[&allocation].target(),
);
for (id, name) in [(call, "x"), (interface, "I"), (item, "O")] {
p.elements.get_mut(&id).unwrap().name = name.into();
}
p.relations.get_mut(&exchange).unwrap().name = "x".into();
p.relations.get_mut(&allocation).unwrap().name = "A".into();
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
*operation = Some(OperationUse {
allocation: Some(allocation),
});
}
assert!(matches!(
operations::resolve(&p, call),
Ok(operations::OperationResolution::Selected(_))
));
let state = State::default();
let before = p.clone();
assert_eq!(
targets(&state, &p, owner, interface),
BTreeSet::from([call])
);
assert_eq!(targets(&state, &p, owner, item), BTreeSet::from([call]));
assert!(targets(&state, &p, owner, allocation).is_empty());
assert_eq!(p, before);
p.elements.get_mut(&call).unwrap().name = "This occurrence has a deliberately long name".into();
assert!(targets(&state, &p, owner, interface).is_empty());
assert!(targets(&state, &p, owner, item).is_empty());
}
@@ -1,12 +1,11 @@
//! Scenario presentation. Layout/camera controls never construct model commands.
use super::super::{form_session, scenario_scroll};
use super::super::scenario_scroll;
use super::*;
use iced::widget::{Space, column, sensor};
pub(crate) const NAME_INPUT: &str = "scenario-form-name";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
Tools,
Selection,
}
@@ -14,8 +13,8 @@ pub enum Shelf {
pub(super) struct Presentation {
pub(super) identity: Option<(Id, 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,
}
@@ -45,19 +44,23 @@ impl Presentation {
}
fn fit_scale(&self, scene: Size) -> Option<f32> {
let viewport = self.viewport?;
// Fit uses the actual mounted viewport with a small visual gutter.
// This is the measured inner Scrollable, never a window-height estimate.
// Leave room for the overlay scrollbars on both axes.
Some(
((viewport.width - 16.).max(1.) / scene.width.max(1.))
.min((viewport.height - 16.).max(1.) / scene.height.max(1.)),
)
}
pub(super) fn scale(&self, scene: Size) -> f32 {
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.)
}
}
fn fit_limited(&self, scene: Size) -> bool {
self.fit && self.fit_scale(scene).is_some_and(|scale| scale < MIN_SCALE)
}
}
pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a, Message> {
@@ -73,155 +76,249 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
let clean = !app.behavior.has_pending() && !app.draft_dirty() && !app.source_dirty();
let mut scene = Scene::sequence(p, owner, app.selected);
let scale = presentation.scale(scene.size);
let limited = presentation.fit_limited(scene.size);
scene.scale = scale;
scene.camera_pan = presentation.pan;
scene.interaction = Some((context, tool, clean));
// The outline is a sibling shelf, not an ancestor of this canvas.
scene.scroll_offset = state.scroll_offset();
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,
};
let keyboard = clean && super::super::canvas_workspace::keyboard_enabled(app);
let camera = move |e| Message::ScenarioCanvas(CanvasEvent::Camera(context, scope.epoch, e));
let canvas = 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),
)
.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 header = column![
navigation,
super::super::canvas_workspace::controls(
scope,
keyboard,
scale,
Message::ScenarioCanvas(CanvasEvent::Shelf(context, Shelf::Selection)),
move |zoom| Message::ScenarioCanvas(CanvasEvent::Zoom(context, zoom)),
camera
)
]
.spacing(4);
let entries = [
("S", "Select / reorder occurrences", CanvasTool::Select),
(
"L",
"Lifeline: click to choose represented work or participant",
CanvasTool::Add(Tool::Lifeline),
),
(
"C/R",
"Call and correlated reply",
CanvasTool::Message(MessageStyle::CallReply),
),
(
"M",
"Asynchronous message",
CanvasTool::Message(MessageStyle::Asynchronous),
),
(
"C",
"Synchronous call",
CanvasTool::Message(MessageStyle::Call),
),
(
"R",
"Reply to existing call",
CanvasTool::Message(MessageStyle::Reply),
),
(
"+L",
"Create participant",
CanvasTool::Message(MessageStyle::Create),
),
(
"-L",
"Delete participant",
CanvasTool::Message(MessageStyle::Delete),
),
("T", "Local timer", CanvasTool::Message(MessageStyle::Timer)),
(
"A",
"Action: click a sequence position",
CanvasTool::Add(Tool::Action),
),
(
"St",
"State observation: click a sequence position",
CanvasTool::Add(Tool::Observation),
),
(
"ref",
"Reuse scenario: click a sequence position",
CanvasTool::Add(Tool::Reuse),
),
(
"alt",
"Fragment: click a sequence position",
CanvasTool::Add(Tool::Fragment),
),
];
let palette = super::super::canvas_workspace::palette(
scope,
keyboard,
camera,
entries.into_iter().map(|(glyph, label, choice)| {
(
glyph,
label,
choice == tool,
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Choose(
context, choice,
))),
)
}),
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
let canvas = canvas_pane(
context,
state.scroll_restore(context, ScrollArea::Canvas),
canvas::Canvas::new(scene)
.width(size.width)
.height(size.height)
.into(),
);
let mut details = column![
text(format!("Selected: {}", p.named(app.selected))).size(12),
button(text("Properties").size(12))
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected)))
let mut navigation = row![
out.width(Fill),
button(
text(if presentation.focused {
"Restore panels"
} else {
"Focus canvas"
})
.size(12)
)
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Focus(context)))
]
.spacing(6)
.align_y(iced::Center);
if state.can_back(p, context) {
navigation = navigation.push(
button(text("Back to caller").size(12))
.padding(6)
.on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::Back(context))),
),
);
}
let mut header = column![
navigation,
row![
button(text("Tools").size(12))
.padding(6)
.style(if presentation.shelf == Some(Shelf::Tools) {
button::primary
} else {
style::nav
})
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
context,
Shelf::Tools
))),
button(text("Selection").size(12))
.padding(6)
.style(if presentation.shelf == Some(Shelf::Selection) {
button::primary
} else {
style::nav
})
.on_press(Message::ScenarioCanvas(CanvasEvent::Shelf(
context,
Shelf::Selection
))),
button(text("Fit overview").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(context, None))),
button(text("−").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(scale / 1.25)
))),
text(format!("{:.0}%", scale * 100.)).size(12),
button(text("+").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(scale * 1.25)
))),
button(text("100%").size(12))
.padding(6)
.on_press(Message::ScenarioCanvas(CanvasEvent::Zoom(
context,
Some(1.)
)))
]
.spacing(6)
.align_y(iced::Center)
.wrap(),
text(if pending {
"Pending form · the highlighted sequence position is retained".to_owned()
} else if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
"Start with a lifeline representing existing or explicitly new work / participants."
.into()
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
"Choose a first message, action or another tool; no occurrence is selected yet.".into()
} else {
let guidance = match tool {
CanvasTool::Message(_) => {
"Click a source position, then click or drag to a target · Esc cancels"
}
CanvasTool::Add(Tool::Action | Tool::Observation) => {
"Click a highlighted position · a lifeline click preselects that lifeline"
}
CanvasTool::Add(Tool::Reuse | Tool::Fragment) => {
"Click a highlighted sequence position to open its form"
}
_ => "Select a lifeline or occurrence · drag its ↕ handle to reorder",
};
format!("{tool} · {guidance}")
})
.size(11)
]
.spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
details = details.push(button(text("Open definition").size(12)).on_press_maybe(
clean.then_some(Message::ScenarioCanvas(CanvasEvent::OpenUse(
context,
app.selected,
))),
));
if !pending {
if !p.children(owner).any(|e| e.kind() == Kind::Lifeline) {
header = header.push(
row![
text(format!("No lifelines · {:?}", viewpoint(p, owner))).size(12),
button("Add first lifeline").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Lifeline))
}))
]
.spacing(8),
);
} else if !p.children(owner).any(|e| e.kind() == Kind::Occurrence) {
header = header.push(
row![
text("No occurrences yet").size(12),
button("Add first message").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Message))
})),
button("Add first action").on_press_maybe(clean.then_some({
Message::ScenarioCanvas(CanvasEvent::First(context, Tool::Action))
}))
]
.spacing(8),
);
}
}
if limited {
header = header
.push(text("Minimum zoom reached · scroll to see the remaining scenario.").size(11));
}
let details = match presentation.shelf {
Some(Shelf::Tools) => {
let choose = move |tool| Message::ScenarioCanvas(CanvasEvent::Choose(context, tool));
let tool_picker: Element<'_, Message> = if clean {
pick_list(
vec![
CanvasTool::Select,
CanvasTool::Message(MessageStyle::CallReply),
CanvasTool::Message(MessageStyle::Asynchronous),
CanvasTool::Message(MessageStyle::Call),
CanvasTool::Message(MessageStyle::Reply),
CanvasTool::Message(MessageStyle::Create),
CanvasTool::Message(MessageStyle::Delete),
CanvasTool::Message(MessageStyle::Timer),
CanvasTool::Add(Tool::Action),
CanvasTool::Add(Tool::Observation),
CanvasTool::Add(Tool::Reuse),
CanvasTool::Add(Tool::Fragment),
],
Some(tool),
choose,
)
.text_size(12)
.padding(6)
.width(Fill)
.into()
} else {
button(text(format!("{tool} · finish pending edits to change tools")).size(12))
.padding(6)
.width(Fill)
.into()
};
column![
tool_picker,
row![
button(text("+ Lifeline").size(12))
.padding(6)
.on_press_maybe(
clean.then_some(event(Event::Start(owner, Tool::Lifeline)))
),
button(text("Scenario properties").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::EditProperties(owner))),
]
.spacing(6)
.wrap(),
text(match viewpoint(p, owner) {
ScenarioViewpoint::OperationalActivity =>
"Activity scenario · lifelines represent activities",
ScenarioViewpoint::Functional =>
"Functional scenario · lifelines represent system functions",
ScenarioViewpoint::ParticipantInteraction =>
"Participant scenario · entities, actors or roles",
ScenarioViewpoint::BoundaryInterface =>
"Interface scenario · system and external actors",
ScenarioViewpoint::MixedLegacy => "Mixed scenario · work or participants",
})
.size(11),
]
.spacing(6)
}
_ => {
let mut details =
column![text(format!("Selected: {}", p.named(app.selected))).size(12)].spacing(6);
let mut navigation = row![].spacing(6);
if referenced_scenario(p, owner, app.selected).is_some() {
navigation = navigation.push(
button(text("Open definition").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::ScenarioCanvas(
CanvasEvent::OpenUse(context, app.selected),
))),
);
}
navigation = navigation.push(
button(text("Properties").size(12))
.padding(6)
.on_press_maybe(clean.then_some(Message::EditProperties(app.selected))),
);
details = details.push(navigation.wrap());
if lines(p, owner).is_empty() && p.elements[&owner].steps().is_none_or(Vec::is_empty) {
details.push(
text("Use Tools to add lifelines, then draw messages or insert occurrences.")
.size(12),
)
} else {
sequence_rows(p, context, owner, details, clean)
}
}
};
let details = details_pane(
context,
state.scroll_restore(context, ScrollArea::Page),
sequence_rows(p, context, owner, details, clean).into(),
);
let mut form_panel = editor_panel(
Id::nil(),
Space::new().height(0).into(),
Space::new().height(0).into(),
details.into(),
presentation.shelf.is_some(),
pending,
);
let mut form_panel = editor_panel(Space::new().height(0).into(), Space::new().height(0).into());
if let Some(d) = app
.behavior
.scenario
@@ -245,9 +342,6 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
let mut form = column![
text(d.tool.label()).size(16),
text_input("Name (defaults to selected source)", &d.name)
.id(focus::name_id(
state.focus_stamp(context).expect("visible parent form")
))
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
@@ -525,20 +619,10 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
action("Cancel", event(Event::Cancel))
]
.spacing(7);
let session = state.form_session().expect("visible scenario draft");
let mount = state.form_mount().expect("visible parent form");
form_panel = sensor(editor_panel(session, footer.into(), form.into()))
.key(mount)
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, mount)))
.into();
form_panel = editor_panel(footer.into(), form.into());
}
}
let editor = if pending {
Some(form_panel)
} else {
presentation.shelf.map(|_| details)
};
super::super::canvas_workspace::host(header.into(), palette, canvas, editor)
workspace_layout(header.into(), details, canvas, form_panel, pending)
}
fn sequence_action<'a>(
@@ -552,38 +636,102 @@ fn sequence_action<'a>(
.on_press_maybe(enabled.then_some(message))
}
// Permanent header/shelf/body and canvas/form slots. Zero-sized disclosures
// must remain in the tree: Column::push otherwise drops them and remounts siblings.
fn workspace_layout<'a, R: iced::advanced::text::Renderer + 'a>(
header: iced::Element<'a, Message, Theme, R>,
details: iced::Element<'a, Message, Theme, R>,
canvas: iced::Element<'a, Message, Theme, R>,
form: iced::Element<'a, Message, Theme, R>,
pending: bool,
) -> iced::Element<'a, Message, Theme, R> {
let panes = Column::from_vec(vec![
container(canvas).width(Fill).height(Fill).into(),
container(form)
.width(Fill)
.height(if pending {
Fill
} else {
iced::Length::Fixed(0.)
})
.clip(true)
.into(),
])
.spacing(if pending { 6 } else { 0 })
.width(Fill)
.height(Fill);
Column::from_vec(vec![header, details, panes.into()])
.spacing(6)
.padding(8)
.width(Fill)
.height(Fill)
.into()
}
fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
details: iced::Element<'a, Message, Theme, R>,
open: bool,
pending: bool,
) -> iced::Element<'a, Message, Theme, R> {
scenario_scroll::observe(
context,
ScrollArea::Page,
false,
restore,
scrollable(details)
.id("scenario-details")
.width(Fill)
.height(Fill),
.height(if open {
if pending { 72. } else { 100. }
} else {
0.
}),
)
}
fn canvas_pane<'a, R: iced::advanced::text::Renderer + 'a>(
context: Context,
restore: Option<ScrollRestore>,
content: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
sensor(scenario_scroll::observe(
context,
ScrollArea::Canvas,
restore,
scrollable(content)
.id("scenario-canvas")
.direction(scrollable::Direction::Both {
vertical: scrollable::Scrollbar::default(),
horizontal: scrollable::Scrollbar::default(),
})
.width(Fill)
.height(Fill),
))
// A new owner/document needs its first measurement even at the same size.
.key((context.document, context.scenario))
.on_show(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size)))
.on_resize(move |size| Message::ScenarioCanvas(CanvasEvent::Viewport(context, size))),
)
.padding(8)
.width(Fill)
.height(Fill)
.style(style::card)
.into()
}
fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
session: Id,
actions: iced::Element<'a, Message, Theme, R>,
fields: iced::Element<'a, Message, Theme, R>,
) -> iced::Element<'a, Message, Theme, R> {
container(
column![
container(actions).id("scenario-actions").width(Fill),
form_session::reset_on_change(
session,
scrollable(fields)
.id("scenario-form")
.width(Fill)
.height(Fill),
),
scrollable(fields)
.id("scenario-form")
.width(Fill)
.height(Fill),
]
.spacing(6),
)
File diff suppressed because it is too large Load Diff
@@ -1,492 +0,0 @@
//! Derived call geometry. Correlation and branch ownership come from the model;
//! columns separate overlapping drawings and do not assert an execution thread.
use super::*;
const WIDTH: f32 = 12.;
const OFFSET: f32 = 6.;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Finish {
Reply(Id),
Boundary,
/// Still open at this scope boundary; the visible span is not an isolated stub.
Continuation,
Open,
/// The receiver was deleted without answering this call.
Terminated(Id),
/// Lifetime clipping ended this visible piece before its logical terminal.
Clipped,
}
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Segment {
pub call: Id,
pub receiver: Id,
pub scope: Id,
pub rect: Rectangle,
pub column: usize,
pub finish: Finish,
}
impl Segment {
fn bottom(&self) -> f32 {
self.rect.y + self.rect.height
}
fn overlaps(&self, other: &Self) -> bool {
self.receiver == other.receiver
&& self.scope == other.scope
&& self.rect.y < other.bottom()
&& other.rect.y < self.bottom()
}
fn edge(&self, right: bool) -> f32 {
self.rect.x + if right { self.rect.width } else { 0. }
}
}
impl Scene {
pub(super) fn layout_activations(&mut self, p: &Project, lanes: &BTreeMap<Id, f32>) {
// Logical endpoints precede clipping and remain available even when a
// lifetime hides a whole execution or terminates it before its Reply.
let correlations = logical_correlations(p, &self.rows, &self.lines);
let mut calls: Vec<_> = self
.rows
.iter()
.filter_map(|(&call, &(scope, y))| {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
receiver: Some(receiver),
..
},
..
}) = p.elements.get(&call).and_then(|e| e.shared())
else {
return None;
};
Some((call, *receiver, scope, y, *lanes.get(receiver)?))
})
.collect();
// Rows are laid out in semantic order. UUID order must not choose which
// rectangle obscures another (especially after source reidentification).
calls.sort_by(|a, b| a.3.total_cmp(&b.3).then_with(|| a.0.cmp(&b.0)));
for &(call, receiver, scope, _, x) in &calls {
let first = self.activations.len();
let first_cue = self.open_calls.len();
self.activation_sequence(p, scope, call, receiver, x, None);
let column = self.activations[first..]
.iter()
.flat_map(|segment| {
self.activations[..first]
.iter()
.filter(move |other| segment.overlaps(other))
})
.map(|other| other.column + 1)
.max()
.unwrap_or(0);
for segment in &mut self.activations[first..] {
segment.column = column;
segment.rect.x += column as f32 * OFFSET;
}
for cue in &mut self.open_calls[first_cue..] {
cue.x += column as f32 * OFFSET;
}
}
// Shallow executions paint first; columns cannot collide within a scope.
self.activations.sort_by(|a, b| {
a.column
.cmp(&b.column)
.then_with(|| a.rect.y.total_cmp(&b.rect.y))
.then_with(|| a.call.cmp(&b.call))
});
self.anchor_activation_messages(p, lanes, &correlations);
}
fn activation_segment(
&mut self,
call: Id,
receiver: Id,
scope: Id,
x: f32,
span: std::ops::Range<f32>,
finish: Finish,
) {
// Cues describe unanswered logical paths, including wholly invisible
// ones. Never derive them only from the surviving paint rectangles.
if matches!(
finish,
Finish::Open | Finish::Continuation | Finish::Terminated(_)
) {
self.open_calls
.push(Point::new(x + WIDTH / 2. + 4., span.end - 7.));
}
for alive in self
.lifecycle
.alive_spans(receiver, scope, span.start, span.end)
{
self.activations.push(Segment {
call,
receiver,
scope,
rect: Rectangle::new(
Point::new(x - WIDTH / 2., alive.start),
Size::new(WIDTH, alive.end - alive.start),
),
column: 0,
finish: if alive.end == span.end {
finish
} else {
Finish::Clipped
},
});
}
}
/// Follow one exact correlation. A segment belongs to one ordered scope;
/// vertically stacked operands must not become a single chronological span.
fn activation_sequence(
&mut self,
p: &Project,
owner: Id,
call: Id,
receiver: Id,
x: f32,
mut start: Option<f32>,
) -> Outcome {
let entered_open = start.is_some();
let mut split = false;
let mut may_be_terminated = false;
for id in p.elements[&owner].steps().into_iter().flatten() {
let Some(&(_, y)) = self.rows.get(id) else {
continue;
};
if *id == call {
// A reflective call is received at the lower end of its loop.
start = Some(
self.lines
.iter()
.find(|line| line.id == call)
.and_then(|line| line.points.last())
.map_or(y, |point| point.y),
);
}
let Some(top) = start else {
continue;
};
match p.elements[id].shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Delete,
receiver: Some(deleted),
..
},
..
}) if *deleted == receiver => {
self.activation_segment(
call,
receiver,
owner,
x,
top..y,
Finish::Terminated(*id),
);
start = None;
may_be_terminated = true;
}
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(correlation),
..
},
..
}) if *correlation == call => {
// Execution ends when its reply is sent, including a self reply.
self.activation_segment(call, receiver, owner, x, top..y, Finish::Reply(*id));
start = None;
}
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { operator, branches },
..
}) => {
split = true;
self.activation_segment(call, receiver, owner, x, top..y, Finish::Boundary);
let bottom = self
.boxes
.iter()
.find(|b| b.id == *id && !b.title.is_empty())
.map(|b| b.rect.y + b.rect.height)
.unwrap_or(y);
let outcomes: Vec<_> = branches
.iter()
.filter_map(|branch| {
let &(top, _) = self.sequence_bounds.get(branch)?;
Some(self.activation_sequence(p, *branch, call, receiver, x, Some(top)))
})
.collect();
let open = match operator {
FragmentOperator::Alternative => {
outcomes.is_empty() || outcomes.iter().any(|outcome| outcome.open)
}
FragmentOperator::Parallel => outcomes.iter().all(|outcome| outcome.open),
FragmentOperator::Optional | FragmentOperator::Loop => true,
};
if outcomes.iter().any(|outcome| outcome.may_be_terminated) {
// Sibling bars retain their own entry paths, but a
// conditional death cannot become a definite parent
// execution again after a later Create.
self.open_calls
.push(Point::new(x + WIDTH / 2. + 4., bottom - 7.));
may_be_terminated = true;
start = None;
} else {
start = open.then_some(bottom);
}
}
_ => {}
}
}
if let Some(top) = start {
let bottom = if entered_open || split {
self.sequence_bounds
.get(&owner)
.map_or(top + 26., |(_, bottom)| (*bottom).max(top + 26.))
} else {
top + 26.
};
let finish = if entered_open || split {
Finish::Continuation
} else {
Finish::Open
};
self.activation_segment(call, receiver, owner, x, top..bottom, finish);
}
Outcome {
open: start.is_some(),
may_be_terminated,
}
}
fn anchor_activation_messages(
&mut self,
p: &Project,
lanes: &BTreeMap<Id, f32>,
correlations: &BTreeMap<Id, Correlation>,
) {
let segments = &self.activations;
for line in &mut self.lines {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
sender: Some(sender),
receiver: Some(receiver),
message_kind,
reply_to,
..
},
..
}) = p.elements.get(&line.id).and_then(|e| e.shared())
else {
continue;
};
if !matches!(
message_kind,
MessageKind::Call | MessageKind::Reply | MessageKind::Asynchronous
) {
continue;
}
let (Some(&from), Some(&to), Some(&(scope, _))) = (
lanes.get(sender),
lanes.get(receiver),
self.rows.get(&line.id),
) else {
continue;
};
let send_y = line.points[0].y;
let receive_y = line.points.last().unwrap().y;
let reflective = sender == receiver;
let source = if *message_kind == MessageKind::Reply {
reply_to.and_then(|call| {
segments.iter().find(|a| {
a.call == call
&& a.scope == scope
&& a.finish == Finish::Reply(line.id)
&& a.bottom() == send_y
})
})
} else {
active_segment(
p,
segments,
correlations,
*sender,
scope,
send_y,
Some(line.id),
)
};
let target = if *message_kind == MessageKind::Call {
segments
.iter()
.find(|a| a.call == line.id && a.scope == scope && a.rect.y == receive_y)
} else {
active_segment(
p,
segments,
correlations,
*receiver,
scope,
receive_y,
*reply_to,
)
};
let start = source.map_or(from, |a| a.edge(reflective || to > from));
let end = target.map_or(to, |a| a.edge(reflective || from > to));
if reflective {
let outside = start.max(end) + 60.;
line.points = vec![
Point::new(start, send_y),
Point::new(outside, send_y),
Point::new(outside, receive_y),
Point::new(end, receive_y),
];
} else {
line.points = vec![Point::new(start, send_y), Point::new(end, receive_y)];
}
line.label_at.x = start.min(end) + 12.;
}
}
}
struct Outcome {
open: bool,
may_be_terminated: bool,
}
fn logical_correlations(
p: &Project,
rows: &BTreeMap<Id, (Id, f32)>,
lines: &[Line],
) -> BTreeMap<Id, Correlation> {
let mut result = BTreeMap::new();
let points: BTreeMap<_, _> = lines.iter().map(|line| (line.id, &line.points)).collect();
for (&id, &(_, y)) in rows {
if matches!(
p.elements.get(&id).and_then(|e| e.shared()),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
},
..
})
) {
result.insert(
id,
Correlation {
start: points.get(&id).and_then(|p| p.last()).map_or(y, |p| p.y),
replies: vec![],
},
);
}
}
for (&id, &(scope, y)) in rows {
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
reply_to: Some(call),
..
},
..
}) = p.elements.get(&id).and_then(|e| e.shared())
&& let Some(correlation) = result.get_mut(call)
{
let sent = points.get(&id).and_then(|p| p.first()).map_or(y, |p| p.y);
correlation.replies.push((scope, sent));
}
}
result
}
/// A caller has no implicit execution just because it sent a message. Only an
/// actually drawn, known-duration execution can supply its anchor. Crossed
/// intervals provide no unambiguous innermost execution, so fall back to the lane.
fn active_segment<'a>(
p: &Project,
segments: &'a [Segment],
correlations: &BTreeMap<Id, Correlation>,
receiver: Id,
scope: Id,
y: f32,
exclude: Option<Id>,
) -> Option<&'a Segment> {
let candidates: Vec<_> = segments
.iter()
.filter(|a| {
a.receiver == receiver
&& a.scope == scope
&& Some(a.call) != exclude
&& a.rect.y <= y
&& y < a.bottom()
&& a.finish != Finish::Open
})
.collect();
let inner = candidates.iter().max_by_key(|a| a.column).copied()?;
candidates
.iter()
.all(|a| {
a.call == inner.call
|| correlations[&a.call].contains(p, scope, &correlations[&inner.call])
})
.then_some(inner)
}
struct Correlation {
start: f32,
replies: Vec<(Id, f32)>,
}
impl Correlation {
fn contains(&self, p: &Project, scope: Id, inner: &Self) -> bool {
// Segment clipping at a fragment can make crossed calls appear nested.
// Compare actual correlated replies on compatible scope paths instead.
let relevant = |&(reply_scope, _): &(Id, f32)| {
ancestor(p, scope, reply_scope) || ancestor(p, reply_scope, scope)
};
let mut compared = false;
for &(inner_scope, inner_end) in inner.replies.iter().filter(|reply| relevant(reply)) {
let mut matched = false;
for &(outer_scope, outer_end) in self.replies.iter().filter(|reply| relevant(reply)) {
if ancestor(p, inner_scope, outer_scope) || ancestor(p, outer_scope, inner_scope) {
// Alternative operands are not chronological. Compare only
// returns that can belong to the same enclosing branch path.
if outer_end < inner_end {
return false;
}
matched = true;
}
}
if !matched {
return false;
}
compared = true;
}
self.start <= inner.start && compared
}
}
fn ancestor(p: &Project, expected: Id, mut scope: Id) -> bool {
for _ in 0..=p.elements.len() {
if scope == expected {
return true;
}
let Some(parent) = p.elements.get(&scope).and_then(|e| e.parent) else {
return false;
};
scope = parent;
}
false
}
#[cfg(test)]
mod tests;
@@ -1,842 +0,0 @@
use super::*;
fn id(n: u128) -> Id {
Id::from_u128(0x21ca1100000040008000000000000000 + n)
}
struct Fixture {
project: Project,
owner: Id,
caller: Id,
callee: Id,
call: Id,
inner: Id,
reply: Id,
inner_reply: Id,
}
fn seed(p: &mut Project, layer: Layer, n: u128, kind: Kind, parent: Option<Id>) -> Id {
let mut e = p
.make_element(kind, layer, &format!("Element {n}"), parent)
.unwrap();
e.id = id(n);
e.alias = format!("element_{n}");
p.elements.insert(e.id, e);
id(n)
}
fn message(
p: &mut Project,
layer: Layer,
n: u128,
parent: Id,
endpoints: (Id, Id),
reply: Option<Id>,
) -> Id {
let e = seed(p, layer, n, Kind::Occurrence, Some(parent));
*p.elements.get_mut(&e).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: None,
exchange: None,
sender: Some(endpoints.0),
receiver: Some(endpoints.1),
message_kind: if reply.is_some() {
MessageKind::Reply
} else {
MessageKind::Call
},
reply_to: reply,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
e
}
fn fixture(layer: Layer, reverse_uuid_order: bool) -> Fixture {
let mut p = Project::empty();
let work = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let a = seed(&mut p, layer, 1, work, None);
let b = seed(&mut p, layer, 2, work, None);
let owner = seed(&mut p, layer, 3, Kind::Scenario, None);
if let SharedBody::Scenario { viewpoint, .. } =
p.elements.get_mut(&owner).unwrap().shared_mut().unwrap()
{
*viewpoint = if layer == Layer::Oa {
ScenarioViewpoint::OperationalActivity
} else {
ScenarioViewpoint::Functional
};
}
let caller = seed(&mut p, layer, 4, Kind::Lifeline, Some(owner));
let callee = seed(&mut p, layer, 5, Kind::Lifeline, Some(owner));
for (line, represented) in [(caller, a), (callee, b)] {
*p.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
}
let (first, second) = if reverse_uuid_order {
(40, 30)
} else {
(30, 40)
};
let call = message(&mut p, layer, first, owner, (caller, callee), None);
let inner = message(&mut p, layer, second, owner, (caller, callee), None);
let reply = message(&mut p, layer, 50, owner, (callee, caller), Some(call));
let inner_reply = message(&mut p, layer, 60, owner, (callee, caller), Some(inner));
*p.elements.get_mut(&owner).unwrap().steps_mut().unwrap() =
vec![call, inner, inner_reply, reply];
Fixture {
project: p,
owner,
caller,
callee,
call,
inner,
reply,
inner_reply,
}
}
fn scene(f: &Fixture) -> Scene {
let errors: Vec<_> = validation::validate(&f.project)
.into_iter()
.filter(|i| i.severity == validation::Severity::Error)
.collect();
assert!(errors.is_empty(), "Actual core fixture: {errors:#?}");
let source = language::format(&f.project);
let reparsed = language::parse(&source).unwrap();
assert_eq!(language::format(&reparsed), source);
let result = Scene::sequence(&reparsed, f.owner, f.owner);
assert_eq!(
language::format(&reparsed),
source,
"Projection must not write geometry into the model"
);
result
}
fn bar(scene: &Scene, call: Id) -> &Segment {
scene.activations.iter().find(|a| a.call == call).unwrap()
}
fn long_labels(f: &mut Fixture) {
for occurrence in [f.call, f.inner, f.inner_reply, f.reply] {
f.project.elements.get_mut(&occurrence).unwrap().name =
"Pressure verification with a descriptive operation label".into();
}
}
#[test]
fn activation_shoulders_under_label_backplates_select_their_call() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer, true);
long_labels(&mut f);
let scene = scene(&f);
let outer = bar(&scene, f.call);
for occurrence in [f.inner, f.inner_reply, f.reply] {
let message = line(&scene, occurrence);
let shoulder = Point::new(outer.rect.x + 1., message.label_at.y + 11.);
assert!(outer.rect.contains(shoulder));
assert!(Rectangle::new(message.label_at, Size::new(225., 22.)).contains(shoulder));
assert!(message.label.chars().count() > 32);
assert!(message.points.windows(2).all(|p| {
crate::diagram::distance_to_segment(shoulder, p[0], p[1]) > 8. / scene.scale
}));
assert_eq!(scene.hit(shoulder), Some(f.call));
assert_eq!(
scene.hit(message.label_at + iced::Vector::new(4., 4.)),
Some(occurrence),
"Label text outside activations remains selectable"
);
}
for occurrence in [f.call, f.inner, f.inner_reply, f.reply] {
let message = line(&scene, occurrence);
for endpoint in [message.points[0], *message.points.last().unwrap()] {
assert_eq!(scene.hit(endpoint), Some(occurrence));
}
}
}
}
#[tokio::test]
async fn long_message_backplates_do_not_erase_selected_activation_border_pixels() {
use iced::advanced::renderer::Headless;
use iced_runtime::user_interface::{Cache, UserInterface};
let mut mounted = crate::behavior::canvas_workspace::tests::Mounted::unshown().await;
let mut f = fixture(Layer::Oa, true);
long_labels(&mut f);
let mut scene = scene(&f);
scene.selected = f.call;
let border_x = bar(&scene, f.call).rect.x;
let label_spans: Vec<_> = [f.inner, f.inner_reply, f.reply]
.into_iter()
.map(|id| {
let message = line(&scene, id);
assert!(
Rectangle::new(message.label_at, Size::new(225., 22.))
.contains(Point::new(border_x, message.label_at.y + 11.))
);
(message.label_at.y + 3.) as usize..(message.label_at.y + 20.) as usize
})
.collect();
let dimensions = Size::new(800., 700.);
let content: Element<'_, Message> = canvas::Canvas::new(scene)
.width(dimensions.width)
.height(dimensions.height)
.into();
let mut ui = UserInterface::build(content, dimensions, Cache::new(), &mut mounted.renderer);
ui.draw(
&mut mounted.renderer,
&style::theme(),
&iced::advanced::renderer::Style {
text_color: style::INK,
},
mouse::Cursor::Unavailable,
);
let pixels = Headless::screenshot(&mut mounted.renderer, Size::new(800, 700), 1., Color::WHITE);
let expected = [style::TEAL.r, style::TEAL.g, style::TEAL.b].map(|v| v * 255.);
for span in label_spans {
for y in span {
assert!(
((border_x as usize - 2)..=(border_x as usize + 2)).any(|x| {
let offset = (y * 800 + x) * 4;
pixels[offset..offset + 3]
.iter()
.zip(expected)
.all(|(actual, target)| (*actual as f32 - target).abs() < 30.)
}),
"Selected activation border was erased at y={y}"
);
}
}
}
fn line(scene: &Scene, occurrence: Id) -> &super::super::Line {
scene.lines.iter().find(|a| a.id == occurrence).unwrap()
}
fn endpoints(p: &mut Project, occurrence: Id, from: Id, to: Id) {
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
sender, receiver, ..
},
..
} = p
.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap()
{
*sender = Some(from);
*receiver = Some(to);
}
}
#[test]
fn nested_calls_expose_both_bars_and_end_at_their_exact_replies_in_both_directions() {
for layer in [Layer::Oa, Layer::Sa] {
for reverse in [false, true] {
let mut f = fixture(layer, true);
if reverse {
for occurrence in [f.call, f.inner] {
endpoints(&mut f.project, occurrence, f.callee, f.caller);
}
for occurrence in [f.reply, f.inner_reply] {
endpoints(&mut f.project, occurrence, f.caller, f.callee);
}
}
let scene = scene(&f);
let outer = bar(&scene, f.call);
let inner = bar(&scene, f.inner);
assert!(outer.rect.y < inner.rect.y && outer.bottom() > inner.bottom());
assert_eq!(outer.column, 0);
assert_eq!(inner.column, 1);
assert!(outer.rect.x < inner.rect.x && outer.edge(true) < inner.edge(true));
for (call, reply) in [(f.call, f.reply), (f.inner, f.inner_reply)] {
let execution = bar(&scene, call);
let received = *line(&scene, call).points.last().unwrap();
let returned = line(&scene, reply).points[0];
assert_eq!(execution.rect.y, received.y);
assert_eq!(execution.bottom(), returned.y);
assert_eq!(received.x, execution.edge(reverse));
assert_eq!(returned.x, execution.edge(reverse));
assert_eq!(execution.finish, Finish::Reply(reply));
}
assert!(scene.open_calls.is_empty());
}
}
}
#[test]
fn call_uuid_order_does_not_choose_activation_columns_or_message_anchors() {
let f = fixture(Layer::Oa, false);
let g = fixture(Layer::Oa, true);
let a = scene(&f);
let b = scene(&g);
for (x, y) in [
(f.call, g.call),
(f.inner, g.inner),
(f.reply, g.reply),
(f.inner_reply, g.inner_reply),
] {
assert_eq!(line(&a, x).points, line(&b, y).points);
}
for (x, y) in [(f.call, g.call), (f.inner, g.inner)] {
assert_eq!(bar(&a, x).rect, bar(&b, y).rect);
}
}
#[test]
fn sequential_calls_reuse_column_and_crossed_replies_keep_their_correlations() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer, true);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.reply, f.inner, f.inner_reply];
let sequential = scene(&f);
assert_eq!(
bar(&sequential, f.call).rect.x,
bar(&sequential, f.inner).rect.x
);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.inner, f.reply, f.inner_reply];
let crossed = scene(&f);
assert!(bar(&crossed, f.call).bottom() < bar(&crossed, f.inner).bottom());
assert!(bar(&crossed, f.call).rect.x < bar(&crossed, f.inner).rect.x);
assert_eq!(
line(&crossed, f.reply).points[0].x,
bar(&crossed, f.call).edge(false)
);
assert_eq!(
line(&crossed, f.inner_reply).points[0].x,
bar(&crossed, f.inner).edge(false)
);
}
}
#[test]
fn self_call_receives_at_lower_loop_end_and_self_reply_returns_to_the_parent() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = fixture(layer, true);
endpoints(&mut f.project, f.inner, f.callee, f.callee);
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
let scene = scene(&f);
let parent = bar(&scene, f.call);
let child = bar(&scene, f.inner);
let call = line(&scene, f.inner);
let reply = line(&scene, f.inner_reply);
assert_eq!(call.points[0].x, parent.edge(true));
assert_eq!(call.points[3], Point::new(child.edge(true), child.rect.y));
assert_eq!(child.rect.y, scene.rows[&f.inner].1 + 24.);
assert_eq!(
reply.points[0],
Point::new(child.edge(true), child.bottom())
);
assert_eq!(reply.points[3].x, parent.edge(true));
assert!(call.points[1].x > child.edge(true));
assert!(reply.points[1].x > child.edge(true));
}
}
#[test]
fn an_inactive_self_call_has_no_fabricated_parent_and_three_nested_calls_expose_three_columns() {
let mut f = fixture(Layer::Oa, true);
endpoints(&mut f.project, f.inner, f.callee, f.callee);
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
f.project.elements.remove(&f.call);
f.project.elements.remove(&f.reply);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.inner, f.inner_reply];
let isolated = scene(&f);
assert_eq!(isolated.activations.len(), 1);
assert_eq!(bar(&isolated, f.inner).column, 0);
assert_eq!(
line(&isolated, f.inner).points[0].x,
isolated.lanes[&f.callee]
);
assert_eq!(
line(&isolated, f.inner_reply).points[3].x,
isolated.lanes[&f.callee]
);
assert_eq!(
bar(&isolated, f.inner).rect.y,
line(&isolated, f.inner).points[3].y
);
let mut f = fixture(Layer::Oa, true);
let third = message(
&mut f.project,
Layer::Oa,
20,
f.owner,
(f.caller, f.callee),
None,
);
let third_reply = message(
&mut f.project,
Layer::Oa,
21,
f.owner,
(f.callee, f.caller),
Some(third),
);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.inner, third, third_reply, f.inner_reply, f.reply];
let scene = scene(&f);
let calls: Vec<_> = scene.activations.iter().map(|a| a.call).collect();
assert_eq!(
calls,
vec![f.call, f.inner, third],
"Paint order is shallow to deep"
);
for (column, call) in [f.call, f.inner, third].into_iter().enumerate() {
let execution = bar(&scene, call);
assert_eq!(execution.column, column);
let y = (bar(&scene, third).rect.y + bar(&scene, third).bottom()) / 2.;
assert_eq!(
scene.hit(Point::new(execution.rect.x + OFFSET / 2., y)),
Some(call)
);
}
}
#[test]
fn reentrant_call_uses_the_other_receiver_without_inventing_a_caller_execution() {
let mut f = fixture(Layer::Oa, true);
endpoints(&mut f.project, f.inner, f.callee, f.caller);
endpoints(&mut f.project, f.inner_reply, f.caller, f.callee);
let scene = scene(&f);
assert_eq!(bar(&scene, f.call).column, 0);
assert_eq!(bar(&scene, f.inner).column, 0);
assert_eq!(bar(&scene, f.inner).receiver, f.caller);
assert_eq!(line(&scene, f.call).points[0].x, scene.lanes[&f.caller]);
assert_eq!(
line(&scene, f.inner).points[0].x,
bar(&scene, f.call).edge(false)
);
}
#[test]
fn actual_canvas_pointer_selects_each_exposed_shoulder_under_zoom_and_pan() {
let f = fixture(Layer::Oa, true);
let mut scene = scene(&f);
let context = scenario::direct_tests::context(f.owner);
scene.interaction = Some((context, scenario::CanvasTool::Select, true));
let y = (bar(&scene, f.inner).rect.y + bar(&scene, f.inner).bottom()) / 2.;
let outer = Point::new(bar(&scene, f.call).rect.x + OFFSET / 2., y);
let inner = Point::new(bar(&scene, f.inner).edge(true) - OFFSET / 2., y);
for scale in [1., 0.25, 0.05] {
scene.scale = scale;
scene.camera_pan = iced::Vector::new(50., 25.);
let bounds = Rectangle::new(Point::new(30., 40.), Size::new(1000., 700.));
for (point, call) in [(outer, f.call), (inner, f.inner)] {
assert_eq!(scene.hit(point), Some(call));
let cursor = bounds.position()
+ scene.camera_pan
+ iced::Vector::new(point.x * scale, point.y * scale);
let mut state = SequenceInput::default();
let _ = canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(cursor),
);
let action = canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(cursor),
)
.unwrap();
assert!(matches!(action.into_inner().0,
Some(Message::ScenarioCanvas(scenario::CanvasEvent::Select(_, selected))) if selected == call));
}
}
}
#[test]
fn missing_reply_is_a_short_explicit_open_span_and_an_uncorrelated_reply_closes_nothing() {
let mut f = fixture(Layer::Oa, true);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { reply_to, .. },
..
} = f
.project
.elements
.get_mut(&f.reply)
.unwrap()
.shared_mut()
.unwrap()
{
*reply_to = None;
}
let scene = scene(&f);
assert_eq!(bar(&scene, f.call).finish, Finish::Open);
assert_eq!(bar(&scene, f.call).rect.height, 26.);
assert_eq!(scene.open_calls.len(), 1);
assert_eq!(
bar(&scene, f.inner).column,
0,
"Unknown duration does not prove later nesting"
);
assert_eq!(bar(&scene, f.inner).finish, Finish::Reply(f.inner_reply));
}
#[test]
fn alternative_and_parallel_operand_local_calls_do_not_nest_from_screen_order() {
for layer in [Layer::Oa, Layer::Sa] {
for operator in [FragmentOperator::Alternative, FragmentOperator::Parallel] {
let mut f = fixture(layer, true);
let fragment = seed(&mut f.project, layer, 70, Kind::Occurrence, Some(f.owner));
let branches = [id(71), id(72)];
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator,
branches: branches.to_vec(),
},
precondition: String::new(),
postcondition: String::new(),
};
for (index, occurrences) in [vec![f.call, f.reply], vec![f.inner, f.inner_reply]]
.into_iter()
.enumerate()
{
let branch = seed(
&mut f.project,
layer,
71 + index as u128,
Kind::Occurrence,
Some(fragment),
);
for occurrence in &occurrences {
f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch);
}
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![fragment];
let scene = scene(&f);
assert_eq!(bar(&scene, f.call).column, 0);
assert_eq!(bar(&scene, f.inner).column, 0);
assert_ne!(bar(&scene, f.call).scope, bar(&scene, f.inner).scope);
assert!(scene.open_calls.is_empty());
}
}
}
#[test]
fn inherited_execution_anchors_a_self_call_inside_an_operand_before_returning_after_the_fragment() {
let mut f = fixture(Layer::Oa, true);
endpoints(&mut f.project, f.inner, f.callee, f.callee);
endpoints(&mut f.project, f.inner_reply, f.callee, f.callee);
let fragment = seed(
&mut f.project,
Layer::Oa,
70,
Kind::Occurrence,
Some(f.owner),
);
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![id(71), id(72)],
},
precondition: String::new(),
postcondition: String::new(),
};
for (index, occurrences) in [vec![f.inner, f.inner_reply], vec![]]
.into_iter()
.enumerate()
{
let branch = seed(
&mut f.project,
Layer::Oa,
71 + index as u128,
Kind::Occurrence,
Some(fragment),
);
for occurrence in &occurrences {
f.project.elements.get_mut(occurrence).unwrap().parent = Some(branch);
}
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, fragment, f.reply];
let scene = scene(&f);
let parent = scene
.activations
.iter()
.find(|a| a.call == f.call && a.scope == id(71))
.unwrap();
assert_eq!(parent.finish, Finish::Continuation);
assert_eq!(bar(&scene, f.inner).column, parent.column + 1);
assert_eq!(line(&scene, f.inner).points[0].x, parent.edge(true));
assert_eq!(line(&scene, f.inner_reply).points[3].x, parent.edge(true));
assert!(
scene
.activations
.iter()
.any(|a| a.call == f.call && a.finish == Finish::Reply(f.reply))
);
}
#[test]
fn a_fragment_cannot_make_crossed_calls_an_unambiguous_caller_execution() {
for placement in ["plain", "before_fragment", "in_operand"] {
let mut f = fixture(Layer::Oa, true);
let outgoing = message(
&mut f.project,
Layer::Oa,
80,
f.owner,
(f.callee, f.caller),
None,
);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
} = f
.project
.elements
.get_mut(&outgoing)
.unwrap()
.shared_mut()
.unwrap()
{
*message_kind = MessageKind::Asynchronous;
}
let mut ordered = vec![f.call, f.inner];
if placement != "in_operand" {
ordered.push(outgoing);
}
if placement != "plain" {
let fragment = seed(
&mut f.project,
Layer::Oa,
70,
Kind::Occurrence,
Some(f.owner),
);
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![id(71), id(72)],
},
precondition: String::new(),
postcondition: String::new(),
};
for index in 0..2u128 {
let branch = seed(
&mut f.project,
Layer::Oa,
71 + index,
Kind::Occurrence,
Some(fragment),
);
let occurrences = if index == 0 && placement == "in_operand" {
f.project.elements.get_mut(&outgoing).unwrap().parent = Some(branch);
vec![outgoing]
} else {
vec![]
};
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
ordered.push(fragment);
}
ordered.extend([f.reply, f.inner_reply]);
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = ordered;
let scene = scene(&f);
assert_eq!(
line(&scene, outgoing).points[0].x,
scene.lanes[&f.callee],
"{placement}"
);
}
}
#[test]
fn nested_correlations_returning_in_alternative_operands_remain_nested_before_the_fragment() {
let mut f = fixture(Layer::Oa, true);
let outgoing = message(
&mut f.project,
Layer::Oa,
80,
f.owner,
(f.callee, f.caller),
None,
);
if let SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
} = f
.project
.elements
.get_mut(&outgoing)
.unwrap()
.shared_mut()
.unwrap()
{
*message_kind = MessageKind::Asynchronous;
}
let fragment = seed(
&mut f.project,
Layer::Oa,
70,
Kind::Occurrence,
Some(f.owner),
);
*f.project
.elements
.get_mut(&fragment)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![id(71), id(72)],
},
precondition: String::new(),
postcondition: String::new(),
};
for index in 0..2u128 {
let branch = seed(
&mut f.project,
Layer::Oa,
71 + index,
Kind::Occurrence,
Some(fragment),
);
let occurrences = if index == 0 {
for reply in [f.inner_reply, f.reply] {
f.project.elements.get_mut(&reply).unwrap().parent = Some(branch);
}
vec![f.inner_reply, f.reply]
} else {
vec![
message(
&mut f.project,
Layer::Oa,
90,
branch,
(f.callee, f.caller),
Some(f.inner),
),
message(
&mut f.project,
Layer::Oa,
91,
branch,
(f.callee, f.caller),
Some(f.call),
),
]
};
*f.project
.elements
.get_mut(&branch)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Branch {
guard: format!("condition_{index}"),
occurrences,
},
precondition: String::new(),
postcondition: String::new(),
};
}
*f.project
.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap() = vec![f.call, f.inner, outgoing, fragment];
let scene = scene(&f);
let inner = scene
.activations
.iter()
.find(|a| a.call == f.inner && a.scope == f.owner)
.unwrap();
assert_eq!(line(&scene, outgoing).points[0].x, inner.edge(false));
}
@@ -1,285 +0,0 @@
//! Read-only lifecycle geometry. Operand screen order is never execution order.
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
pub(super) mod render;
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
pub(crate) struct Geometry<'a> {
pub rows: &'a BTreeMap<Id, (Id, f32)>,
pub scopes: &'a BTreeMap<Id, (f32, f32)>,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Span {
pub start: f32,
pub end: f32,
}
impl Span {
fn valid(self) -> bool {
self.start.is_finite() && self.end.is_finite() && self.start <= self.end
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Life {
Alive,
Absent,
Unresolved,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Interval {
pub scope: Id,
pub span: Span,
pub life: Life,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum MarkerKind {
Birth,
Death,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Marker {
pub occurrence: Id,
pub scope: Id,
pub y: f32,
pub kind: MarkerKind,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum UncertaintyReason {
Conditional,
Repeated,
Parallel,
MissingGeometry,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Uncertainty {
pub scope: Id,
pub reason: UncertaintyReason,
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct LaneLifetime {
pub lifeline: Id,
pub intervals: Vec<Interval>,
pub markers: Vec<Marker>,
pub uncertainties: Vec<Uncertainty>,
}
/// Stable lane-ID order; source/model/history and caller-owned geometry are untouched.
/// An opaque ScenarioUse is a local use, not a caller birth/death contract.
pub(crate) fn project_lifetimes(
project: &Project,
scenario: Id,
geometry: &Geometry<'_>,
) -> Vec<LaneLifetime> {
if project
.elements
.get(&scenario)
.is_none_or(|e| e.kind() != Kind::Scenario)
{
return vec![];
}
project
.elements
.values()
.filter(|e| e.kind() == Kind::Lifeline && e.parent == Some(scenario))
.map(|e| {
let mut projection = Projection {
project,
geometry,
lane: e.id,
visiting: BTreeSet::new(),
result: LaneLifetime {
lifeline: e.id,
intervals: vec![],
markers: vec![],
uncertainties: vec![],
},
};
let initial = if projection.contains(scenario, Some(MarkerKind::Birth)) {
Life::Unresolved
} else {
Life::Alive
};
projection.sequence(scenario, initial);
projection.result
})
.collect()
}
struct Projection<'a, 'g> {
project: &'a Project,
geometry: &'a Geometry<'g>,
lane: Id,
visiting: BTreeSet<Id>,
result: LaneLifetime,
}
impl Projection<'_, '_> {
fn body(&self, id: Id) -> Option<&OccurrenceBody> {
match self.project.elements.get(&id)?.shared()? {
SharedBody::Occurrence { body, .. } => Some(body),
_ => None,
}
}
fn event(&self, id: Id) -> Option<MarkerKind> {
match self.body(id)? {
OccurrenceBody::Exchange {
receiver: Some(line),
message_kind,
..
} if *line == self.lane => match message_kind {
MessageKind::Create => Some(MarkerKind::Birth),
MessageKind::Delete => Some(MarkerKind::Death),
_ => None,
},
_ => None,
}
}
fn contains(&self, scope: Id, kind: Option<MarkerKind>) -> bool {
// Local structural walk only: no guards, iteration simulation or reuse expansion.
let mut pending = vec![scope];
let mut seen = BTreeSet::new();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
if self
.event(id)
.is_some_and(|event| kind.is_none_or(|k| event == k))
{
return true;
}
if let Some(steps) = self.project.elements.get(&id).and_then(|e| e.steps()) {
pending.extend(steps);
}
if let Some(OccurrenceBody::Fragment { branches, .. }) = self.body(id) {
pending.extend(branches);
}
}
false
}
fn span(&self, scope: Id) -> Option<Span> {
let &(start, end) = self.geometry.scopes.get(&scope)?;
let span = Span { start, end };
span.valid().then_some(span)
}
fn uncertain(&mut self, scope: Id, reason: UncertaintyReason) {
let value = Uncertainty { scope, reason };
if !self.result.uncertainties.contains(&value) {
self.result.uncertainties.push(value);
}
}
fn interval(&mut self, scope: Id, start: f32, end: f32, life: Life) {
if start < end && start.is_finite() && end.is_finite() {
self.result.intervals.push(Interval {
scope,
span: Span { start, end },
life,
});
}
}
fn sequence(&mut self, scope: Id, entry: Life) -> Life {
let Some(span) = self.span(scope) else {
self.uncertain(scope, UncertaintyReason::MissingGeometry);
return Life::Unresolved;
};
if !self.visiting.insert(scope) {
self.uncertain(scope, UncertaintyReason::MissingGeometry);
return Life::Unresolved;
}
let steps = self
.project
.elements
.get(&scope)
.and_then(|e| e.steps())
.cloned()
.unwrap_or_default();
// A creation directly in this sequence fixes its local birth boundary.
// A creation only in a nested operand cannot fix its ancestor's entry.
let mut life = if steps
.iter()
.any(|&id| self.event(id) == Some(MarkerKind::Birth))
{
Life::Absent
} else {
entry
};
let mut cursor = span.start;
for id in steps {
let event = self.event(id);
let fragment = match self.body(id) {
Some(OccurrenceBody::Fragment { operator, branches }) => {
Some((*operator, branches.clone()))
}
_ => None,
};
if event.is_none() && fragment.is_none() {
continue;
}
let y = self
.geometry
.rows
.get(&id)
.filter(|(owner, y)| {
*owner == scope && y.is_finite() && *y >= cursor && *y <= span.end
})
.map(|(_, y)| *y);
let Some(y) = y else {
self.uncertain(id, UncertaintyReason::MissingGeometry);
life = Life::Unresolved;
continue;
};
self.interval(scope, cursor, y, life);
cursor = y;
if let Some(kind) = event {
self.result.markers.push(Marker {
occurrence: id,
scope,
y,
kind,
});
life = match kind {
MarkerKind::Birth => Life::Alive,
MarkerKind::Death => Life::Absent,
};
}
if let Some((operator, branches)) = fragment {
let entry = life;
let mut missing = false;
for branch in branches {
let bounds = self
.span(branch)
.filter(|s| s.start >= cursor && s.end <= span.end);
if let Some(bounds) = bounds {
// Every sibling inherits the same entry, never its displayed predecessor's exit.
self.sequence(branch, entry);
cursor = bounds.end;
} else {
self.uncertain(branch, UncertaintyReason::MissingGeometry);
missing = true;
}
}
if self.contains(id, None) {
let reason = match operator {
FragmentOperator::Alternative | FragmentOperator::Optional => {
UncertaintyReason::Conditional
}
FragmentOperator::Loop => UncertaintyReason::Repeated,
FragmentOperator::Parallel => UncertaintyReason::Parallel,
};
self.uncertain(id, reason);
life = Life::Unresolved;
} else if missing {
life = Life::Unresolved;
}
}
}
self.interval(scope, cursor, span.end, life);
self.visiting.remove(&scope);
life
}
}
#[cfg(test)]
#[path = "scenario_lifecycle/tests.rs"]
mod tests;

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