Compare commits

..
Author SHA1 Message Date
Codex-ytr83r 4c9bc87e4e Pin isolated ligature evaluators to licensed Noto Sans fixture 2026-09-13 04:31:01 -07:00
Codex-ytr83r 11c605f917 Strengthen unwired field geometry RTL evaluators
Assert RTL-leading full and ellipsized paragraph preconditions and complete independent rectangle sets. Record system-font limitations and add one ignored bounded 4096-scalar lookup diagnostic with exact full-coverage oracles. Format the two experiment inputs only. Format and diff checks passed; no compiler, tests, native execution or production wiring.
2026-09-13 03:14:45 -07:00
Codex-ytr83r c3ad6f130b Experiment with unwired shaped label field coverage
Preserve winning-buffer cluster geometry and exact scalar mappings. Distinguish truncated source from unresolved glyph coverage, keep ellipsis unmapped, and add ten precondition-asserted field evaluators. Source-only self-review and diff check; not formatted, compiled or executed. No production module wiring or navigation policy adoption.
2026-09-13 02:50:52 -07:00
sorokin.oleg.edu c4a2cf63f1 Sync agent instructions from main 2026-09-13 11:40:30 +03:00
Codex-ytr83r 07e611fb94 feat: preview and commit Saved Scenario spacing gestures 2026-09-13 01:32:42 -07:00
Codex-ytr83r 46ff127373 test: enter Scenario fixtures through live navigation 2026-09-13 01:20:02 -07:00
Codex-ytr83r 86a187ab4c Correct mounted scenario camera and palette assertions 2026-09-13 00:38:39 -07:00
Codex-ytr83r 28b7dc5b30 Complete canvas scope wiring and use Tokio for mounted Scenario test 2026-09-12 23:48:07 -07:00
Codex-ytr83r 457a2c28db Prototype pure saved Scenario spacing command previews 2026-09-12 23:46:57 -07:00
Codex-ytr83r 2c9cd2b3e6 Simplify Scenario geometry admission conditions for strict lint 2026-09-12 23:09:38 -07:00
Codex-ytr83r 46e106c04e Wire saved Scenario arrangements through workspace navigation 2026-09-12 23:00:52 -07:00
Codex-ytr83r aa62f42b81 Grow Scenario frames with preserved descendant padding 2026-09-12 22:12:40 -07:00
Codex-ytr83r 7f192f5dc4 Prepare complete saved Scenario display fallback adapter 2026-09-12 22:11:36 -07:00
Codex-ytr83r 68a81c3f50 Resolve saved Scenario geometry through explicit complete Scene plans 2026-09-12 21:26:33 -07:00
Codex-ytr83r 45a7fde2c1 Record spacing gesture candidate and explicit reorder usability gate 2026-09-12 21:11:32 -07:00
Codex-ytr83r bfb4b39744 Exercise saved Scenario positions through HTTP transactions and restart 2026-09-12 21:06:35 -07:00
Codex-ytr83r 20acd3238e Assert complete typed activations in authoring and offset regressions 2026-09-12 21:05:11 -07:00
Codex-ytr83r 695685a2b1 Integrate typed Scenario activations before saved geometry resolution 2026-09-12 20:52:51 -07:00
Codex-ytr83r e4e87951d7 test: read decoded model from restored source snapshot 2026-09-12 20:52:05 -07:00
Codex-ytr83r aed07176ab test: use participant viewpoints for Scenario layout fixtures 2026-09-12 19:49:18 -07:00
Codex-ytr83r 9245f8d23e fix: preserve migration context and reject unsupported SVG exports 2026-09-12 19:41:48 -07:00
Codex-ytr83r a4d1efc5f7 experiment: reserve label ink space inside the fixed corridor 2026-09-12 19:41:29 -07:00
Codex-ytr83r ecc49e0417 feat(core): prepare typed Scenario layout and source lifecycle 2026-09-12 19:27:49 -07:00
Codex-ytr83r e409107a63 experiment: prepare real WGPU Scenario label evaluator 2026-09-12 19:16:43 -07:00
Codex-ytr83r 2f90cf25d1 Verify conservative corridor rounding and stale font rejection 2026-09-12 18:49:11 -07:00
Codex-ytr83r 345f7b3d5b Keep reconstructed label corridors finite and inside blockers 2026-09-12 18:46:13 -07:00
Codex-ytr83r 6a8650669f Record passing measured label and corridor experiments 2026-09-12 18:42:10 -07:00
Codex-ytr83r 903d85dda4 Resolve label tests explicitly for isolated evaluator 2026-09-12 18:36:53 -07:00
Codex-ytr83r 9b56a337f8 Prepare measured scenario labels and clear corridor evaluators 2026-09-12 18:35:52 -07:00
Codex-ytr83r e811879857 Specify candidate saved scenario layout and source lifecycle 2026-09-12 18:29:24 -07:00
Codex-ytr83r 75cb09dade Define scenario layout identity and persistence direction 2026-09-12 18:02:38 -07:00
Codex-ytr83r 208268c8aa Combine Text alias rename with guarded diagram navigation 2026-09-12 17:27:08 -07:00
Codex-ytr83r 4db1ef543c Preserve original Windows test evidence bytes 2026-09-12 16:09:18 -07:00
Codex-ytr83r 0eca66f669 Explain raw test log whitespace preservation 2026-09-12 15:58:25 -07:00
Codex-ytr83r 2488ee5955 Record passing Windows diagram navigation tests 2026-09-12 15:50:15 -07:00
Codex-ytr83r 46b4f3c0d0 Preserve exact diagram usage identity and separate visual focus 2026-09-12 15:10:33 -07:00
Codex-ytr83r f23615ffeb Record layout policy experiment and reproduced history falsifier 2026-09-12 07:27:06 -07:00
Codex-ytr83r fdf756e6a4 Publish in-progress navigation source and independent canvas reviews 2026-09-12 06:58:08 -07:00
Codex-ytr83r 7aa007b077 Publish verified canvas and repair Windows prerelease checkpoint 2026-09-12 05:06:18 -07:00
Codex-ytr83r 0c53c21030 Record verified Windows canvas and repair package acceptance 2026-09-12 04:59:48 -07:00
Codex-ytr83r 4dd0f60c75 Record complete combined Windows gates and bounded native camera checks 2026-09-12 04:26:03 -07:00
Codex-ytr83r 60f714853f Merge commit '51846f5a2b44ae31f8e8f1127fc803f2f430fbe7' into codex/canvas-union-ytr83r-20260911 2026-09-12 04:06:16 -07:00
Codex-ytr83r 5511755a5e Preserve closed native and Unicode verification with repair integration review 2026-09-12 04:02:45 -07:00
Codex-ytr83r 33e30c1cd7 Keep repaired destinations unsaved until their accepted source is written 2026-09-12 03:55:42 -07:00
sorokin.oleg.edu 51846f5a2b Sync agent instructions from main 2026-09-12 13:43:20 +03:00
sorokin.oleg.edu 436ea4b29a Sync agent instructions from main 2026-09-12 13:40:03 +03:00
sorokin.oleg.edu 46c8783c72 Sync agent instructions from main 2026-09-12 13:15:35 +03:00
Codex-ytr83r 0d39d58ebd Record reviewed grapheme fix and closed packaged GUI evidence 2026-09-11 14:09:38 -07:00
Codex-ytr83r cb91525cfd Preserve grapheme clusters when shortening desktop labels 2026-09-11 13:48:21 -07:00
Codex-ytr83r 108b1dd208 Record combined Windows gates and native child-work evidence 2026-09-11 13:34:47 -07:00
Codex-ytr83r a57c334b30 Integrate persistent canvas controls with scenario lifetime rendering 2026-09-11 12:44:06 -07:00
Codex-ytr83r 32334c6631 Record Windows gates and partial native child-work checks 2026-09-11 12:13:33 -07:00
Codex-ytr83r 09da0d90aa Add reproducible native child-work verification fixtures 2026-09-11 11:32:15 -07:00
Codex-ytr83r 865483b4c6 Preserve responsibility assignments when creating child work 2026-09-11 11:04:12 -07:00
Codex-ytr83r f796a27277 Record integrated Windows tests and HTTP persistence checks 2026-09-11 11:04:09 -07:00
Codex-ytr83r 321897fd55 Integrate contextual source creation with lifecycle rendering 2026-09-11 10:38:05 -07:00
Codex-ytr83r 4da92ab481 Clip behavior card labels to their bounds 2026-09-11 10:23:36 -07:00
Codex-ytr83r 7831abeed5 Record native lifecycle checks and centralize live planning 2026-09-11 10:22:50 -07:00
sorokin.oleg.edu fe8364e7eb Sync agent instructions from main 2026-09-11 19:52:27 +03:00
Codex-ytr83r 3ade397008 Record Windows desktop validation and user-paused Linux setup 2026-09-11 08:40:05 -07:00
Codex-ytr83r 29084050a7 Prepare Linux delivery and correct lifecycle rendering build types 2026-09-11 07:53:42 -07:00
Codex-ytr83r cb611a71df Bound Create labels within lifeline capsules and preserve Unicode test inputs 2026-09-11 06:56:17 -07:00
Codex-ytr83r fb77b857d2 Save reboot checkpoint with interrupted build and restart instructions 2026-09-11 06:38:54 -07:00
Codex-ytr83r a9a5768144 Record bounded Linux trial and contextual source integration review 2026-09-11 06:35:30 -07:00
Codex-ytr83r d041212d17 Merge owner instruction sync while preserving exact supplied goal 2026-09-11 06:33:51 -07:00
Codex-ytr83r b96286ea37 Record current core and lifecycle evidence and refresh project entrypoints 2026-09-11 06:30:59 -07:00
sorokin.oleg.edu dd42d0ab01 Sync agent instructions from main 2026-09-11 15:55:52 +03:00
sorokin.oleg.edu 023e3a8f09 Add source-first CLI with guarded writes and reviewed evidence 2026-09-11 05:45:32 -07:00
Codex-ytr83r 52f6e176cd Render explicit scenario lifetimes and preserve marker selection 2026-09-11 05:25:04 -07:00
Codex-ytr83r b853a1eca3 Import corrected creation oracle and verified external evidence 2026-09-11 05:01:57 -07:00
sorokin.oleg.edu e207b04114 Add named behavior creation and verified textual authoring examples 2026-09-11 04:37:07 -07:00
Codex-ytr83r c81e285573 Integrate published behavior work and preserve current release goal 2026-09-11 04:27:09 -07:00
sorokin.oleg.edu ff615d0ee0 Update the project goal
Rust checks / check (windows-latest) (push) Canceled after 0s
Rust checks / check (ubuntu-latest) (push) Canceled after 0s
2026-09-10 21:32:22 +03:00
4125 changed files with 165089 additions and 720027 deletions
+47 -52
View File
@@ -22,70 +22,65 @@ harness/history/2026-09-06-ledgers-before-consolidation/** -text
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/history/2026-09-11-ytr83r-integration/** -text whitespace=cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
harness/experiments/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/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/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-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
harness/fixtures/scenario-lifecycle-native/**/*.arcadia -text whitespace=cr-at-eol
harness/experiments/lifecycle-native-ytr83r-20260911/** -text whitespace=cr-at-eol
harness/experiments/lifecycle-native-ytr83r-20260911/*.log -text -whitespace
harness/experiments/lifecycle-native-ui-oa-20260911/** -text -whitespace
harness/experiments/lifecycle-native-ui-sa-20260911/** -text -whitespace
harness/experiments/wsl-postboot-ytr83r-20260911/** -text -whitespace
# Preserve checksum-bound font notices verbatim through Git round trips.
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
scripts/windows-notices/Fira-OFL.txt -text
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
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
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
# Preserve the pinned Iced dependency bytes and upstream manifest.
crates/vendor/iced_widget/** -text whitespace=cr-at-eol
harness/experiments/lifecycle-header-overflow-before-20260911/** -text -whitespace
harness/history/2026-09-11-ytr83r-record-migration/STATE-before.md -text -whitespace
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
harness/experiments/contextual-lifecycle-integration-ytr83r-20260911/** -text -whitespace
harness/experiments/windows-runtime-321897f-20260911/** -text -whitespace
harness/child-work-native-contract.rs -text whitespace=cr-at-eol
harness/fixtures/child-work-native/** -text whitespace=cr-at-eol
harness/experiments/windows-gates-865483b-20260911/** -text -whitespace
harness/experiments/native-child-oa-865483b-20260911/** -text -whitespace
harness/experiments/packaged-server-865483b-20260911/** -text -whitespace
harness/experiments/canvas-union-a57c334-20260911/** -text -whitespace
harness/experiments/windows-gates-a57c334-20260911/** -text -whitespace
harness/experiments/native-child-continuation-865483b-20260911/** -text -whitespace
harness/experiments/lifecycle-header-correction-native-865483b-20260911/** -text -whitespace
harness/release-native-child-continuation-review-20260911.md -text -whitespace
harness/experiments/packaged-gui-close-865483b-20260911/** -text -whitespace
harness/release-grapheme-shortening-review-20260911.md -text -whitespace
harness/experiments/staged-source-native-a57c334-20260911/** -text -whitespace
harness/experiments/grapheme-windows-cb91525-20260911/** -text -whitespace
harness/experiments/canvas-native-close-a57c334-20260911/** -text -whitespace
harness/release-staged-native-a57-independent-review-20260911.md -text -whitespace
harness/release-saved-destination-independent-review-20260912.md -text -whitespace
harness/experiments/repair-union-windows-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-camera-a57c334-20260912/** -text -whitespace
harness/release-camera-a57-independent-review-20260912.md -text -whitespace
harness/experiments/canvas-repair-package-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-repair-native-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-repair-server-33e30c1-20260912/** -text -whitespace
harness/experiments/canvas-repair-publication-20260912/** -text -whitespace
Generated
+2 -22
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",
-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(())
}
@@ -1,101 +0,0 @@
//! Read-only saved-State geometry evidence for a predeclared native bend trial.
//!
//! Usage:
//! state_bend_routing_snapshot INPUT.arcadia NEW_OUTPUT.json
//!
//! Loads and validates the complete Project, then independently projects/routes
//! every saved State view through core APIs. The complete Project permits exact
//! semantic and presentation comparison; derived geometry permits declaring a
//! segment and insertion position before native actions. No desktop command,
//! planner, gesture, or pin insertion helper is called. Input is never written;
//! output uses create_new and fails rather than overwriting an existing file.
//!
//! Limits: this shares production core routing, so it is not an independent
//! mathematical test of that router, nor evidence of UI picking/gesture success.
//! It does not generate expected mutations or adapt an oracle to observed edits.
//! The acceptance recipe must declare the intended segment/index/point and exact
//! Project mutation before UI execution, and bind this executable/source/input
//! and output hashes externally. Warning strings are diagnostic Debug output,
//! not a stable interchange schema. Missing/hidden/internal transitions have no
//! routed edge; their semantic records remain in the complete Project snapshot.
use oa_core::{persistence, projection, routing, validate_candidate};
use serde_json::json;
use std::{fs::OpenOptions, io::Write, path::Path};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<_> = std::env::args_os().skip(1).collect();
if args.len() != 2 {
return Err("Expected input source/JSON and a new output JSON path".into());
}
let project = persistence::load(Path::new(&args[0]))?;
validate_candidate(&project)?;
let mut views = Vec::new();
for (diagram_id, diagram) in &project.diagrams {
let Some(machine) = diagram.projection.state_machine() else {
continue;
};
let projected = projection::project_diagram(&project, diagram);
let routed = routing::route_diagram(&project, diagram, &projected);
let edges: Vec<_> = routed
.edges
.iter()
.map(|(edge_id, edge)| {
json!({
"edge": edge_id,
"id": edge.id,
"points": edge.points,
"pins": edge.pins,
"pin_fractions": edge.pin_fractions,
"custom_suspended": edge.custom_suspended,
"warnings": edge.warnings.iter().map(|warning| format!("{warning:?}")).collect::<Vec<_>>(),
"label": {
"text": edge.label.text,
"fraction": edge.label.fraction,
"anchor": edge.label.anchor,
"center": edge.label.center,
"bounds": {
"position": edge.label.bounds.position,
"size": edge.label.bounds.size
}
}
})
})
.collect();
views.push(json!({
"diagram": diagram_id,
"machine": machine,
"projected": projected,
"routed_bounds": {
"position": routed.bounds.position,
"size": routed.bounds.size
},
"routed_edges": edges
}));
}
// Preserve the canonical typed Project serialization (including f32
// formatting). Serializing it through Value directly promotes f32 to f64.
let project_json: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&project)?)?;
let snapshot = json!({
"schema": "state-bend-routing-snapshot/v1",
"project": project_json,
"state_views": views
});
// Finish all reading, validation, routing and serialization before creating
// the new evidence file. A failed load cannot leave an apparent snapshot.
let mut bytes = serde_json::to_vec_pretty(&snapshot)?;
bytes.push(b'\n');
let mut output = OpenOptions::new()
.write(true)
.create_new(true)
.open(&args[1])?;
output.write_all(&bytes)?;
output.sync_all()?;
println!(
"Validated {} objects and {} saved views; snapshotted {} State views and the complete Project",
project.identities().len(),
project.diagrams.len(),
views.len()
);
Ok(())
}
-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());
}
+16 -50
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()))?;
@@ -1845,11 +1823,8 @@ pub(crate) fn resolve_declarations(
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
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)
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default(),"layout":null});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.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;
@@ -12,7 +11,6 @@ pub mod routing;
pub mod scenario_layout;
pub mod semantic;
pub mod session;
mod source_defaults;
pub mod source_files;
pub mod source_project;
pub mod topology_context;
-34
View File
@@ -1528,45 +1528,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 }
}
@@ -601,8 +601,6 @@ fn projection_context_and_architecture_payloads_are_explicitly_separate() {
.routes
.push(crate::EdgeRoute {
edge: f.exchange,
source_attachment: None,
target_attachment: None,
waypoints: vec![],
label: None,
});
@@ -243,60 +243,3 @@ fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values()
assert!(!cleared.bundle.files[&path].contains("layout"));
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
}
#[test]
fn compact_defaults_and_scenario_row_identity_compose_through_rename_and_clear() {
let f = fixture();
let initial = authored(&f);
let mut bundle = initial.bundle.clone();
let location = &initial.index.declarations[&f.performer];
bundle.files.get_mut(&location.path).unwrap().insert_str(
location.span.end - 1,
"\n description // explicitly default prose\n \"\";\n",
);
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
let mut expected = original.decoded.project.clone();
let performer = expected.elements.get_mut(&f.performer).unwrap();
performer.name = "Reviewed controller".into();
performer.alias = "reviewed_controller".into();
expected.elements.get_mut(&f.lane).unwrap().alias = "reviewed_lane".into();
let rows = payload(expected.diagrams.get_mut(&f.view).unwrap());
rows.lanes.swap(0, 1);
rows.messages.swap(0, 1);
rows.messages[0].send_y = 390.;
let updated = update_bundle(&original, &expected, ProjectLimits::default()).unwrap();
assert_eq!(updated.decoded.project, expected);
let element = &updated.index.declarations[&f.performer];
let text = &updated.bundle.files[&element.path][element.span.start..element.span.end];
assert!(
text.contains("description // explicitly default prose\n \"\";"),
"{text}"
);
assert!(
!text.contains("categories"),
"an omitted default must stay omitted: {text}"
);
let diagram = &updated.index.declarations[&f.view];
let text = &updated.bundle.files[&diagram.path][diagram.span.start..diagram.span.end];
assert!(
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap()
);
assert!(text.contains("reviewed_lane; x 4.20e2;"), "{text}");
assert!(text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap());
assert!(text.contains("send_y 2.00e2"), "{text}");
assert!(!text.contains("view_options"), "{text}");
expected.diagrams.get_mut(&f.view).unwrap().layout = None;
let cleared = update_bundle(&updated, &expected, ProjectLimits::default()).unwrap();
assert_eq!(cleared.decoded.project, expected);
let declaration = &cleared.index.declarations[&f.view];
let text =
&cleared.bundle.files[&declaration.path][declaration.span.start..declaration.span.end];
assert!(!text.contains("layout"), "{text}");
assert!(text.contains("// Controller spacing rationale."), "{text}");
assert_eq!(
update_bundle(&cleared, &expected, ProjectLimits::default())
.unwrap()
.bundle,
cleared.bundle,
);
}
-405
View File
@@ -1,405 +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 => {
// Admit the optional payload without restoring a synthetic null in
// CompleteForEdit: absence there distinguishes a real layout clear
// from an unrelated edit beside an authored explicit `layout none`.
fields.insert("layout".into(), Value::Null);
&[]
}
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,
@@ -1006,7 +964,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,
@@ -1065,7 +1022,7 @@ pub fn update_bundle_with_identity_map(
project,
identities,
&original_order,
&compact,
&after,
limits,
)?;
let result = compile_bundle(&bundle, limits)?;
@@ -1173,68 +1130,6 @@ 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);
}
}
_ => {}
}
}
}
// Schema context is explicit: similarly named fields in arbitrary records must
// never acquire Scenario row identity or move their authored trivia.
#[derive(Clone, Copy)]
@@ -1262,7 +1157,6 @@ 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
@@ -1271,16 +1165,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>,
@@ -1394,7 +1278,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,
);
@@ -1473,9 +1357,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))
@@ -1494,7 +1375,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)));
@@ -1623,7 +1508,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,
+2 -12
View File
@@ -750,7 +750,7 @@ pub fn plan_transition(
);
continue;
}
if !counterpart_compatible(project, *source, target, request.direction) {
if !compatible_existing(project, *source, target, request.direction) {
conflict(
&mut plan,
Some(*source),
@@ -1317,22 +1317,12 @@ fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection)
}
inferred_kind(source, direction) == Some(target)
}
/// Whether existing records have compatible layers, categories and kinds for
/// an ordinary transition. This is candidate eligibility, not approval of a
/// complete plan: properties, dependencies and structural conflicts still need
/// the normal preview and apply validation. Manual traces are not restricted
/// by this predicate.
pub fn counterpart_compatible(
fn compatible_existing(
p: &Project,
source: Id,
target: Id,
direction: TransitionDirection,
) -> bool {
if p.layer_of(source) != Some(direction.source_layer())
|| p.layer_of(target) != Some(direction.target_layer())
{
return false;
}
match (p.elements.get(&source), p.elements.get(&target)) {
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
_ => match (p.relations.get(&source), p.relations.get(&target)) {
-14
View File
@@ -644,20 +644,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,613 +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![],
layout: None,
},
);
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
@@ -71,8 +71,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,
@@ -692,8 +690,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
@@ -302,8 +302,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,
});
@@ -365,15 +363,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,
});
@@ -429,8 +423,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,
});
@@ -465,8 +457,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,
},
@@ -594,8 +584,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,
@@ -665,8 +653,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,
});
@@ -745,8 +731,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,353 +0,0 @@
//! Existing counterpart eligibility is a layer/category/kind decision, not
//! validation or acceptance of an entire transition plan. The catalog fixtures
//! below intentionally contain partial records (unowned states, etc.). The
//! final regression separately starts from a structurally valid real project.
use oa_core::{transitions::*, *};
use std::collections::{BTreeMap, BTreeSet};
fn element(p: &mut Project, layer: Layer, kind: Kind) -> Id {
let id = Id::new_v4();
let body = if kind == Kind::Port {
// Legacy ports can exist in preserved OA models. This does not offer
// new typed SA implementation ports for creation in portless OA.
ElementBody::shared_in(
layer,
SharedBody::Port {
contract: PortContract::Legacy,
legacy: None,
},
)
} else {
ElementBody::new(kind, layer).unwrap()
};
p.elements.insert(
id,
Element {
id,
alias: format!("candidate_{}", id.simple()),
name: "Identical candidate label".into(),
description: String::new(),
parent: None,
body,
},
);
id
}
#[test]
fn existing_element_candidates_obey_explicit_engineering_families_in_both_directions() {
// Contract: language.rst §5 trace families and the explicit capability /
// mission alternative. State constructors remain eligible for explicit
// review across the state family; selecting a candidate does not accept a
// constructor change or make invalid ownership legal.
let mut allowed: BTreeSet<(Kind, Kind)> = [
(Kind::Activity, Kind::Function),
(Kind::Entity, Kind::System),
(Kind::Entity, Kind::SystemActor),
(Kind::Actor, Kind::System),
(Kind::Actor, Kind::SystemActor),
(Kind::Capability, Kind::SystemCapability),
(Kind::OperationalMission, Kind::Mission),
(Kind::Capability, Kind::Mission),
(Kind::Process, Kind::FunctionalChain),
]
.into_iter()
.collect();
for kind in [
Kind::ExchangeItem,
Kind::ExchangeCategory,
Kind::DataType,
Kind::DataField,
Kind::Interface,
Kind::Port,
Kind::Scenario,
Kind::Lifeline,
Kind::Occurrence,
Kind::StateMachine,
Kind::Region,
Kind::Event,
Kind::Constraint,
] {
allowed.insert((kind, kind));
}
let vertices = [
Kind::State,
Kind::Mode,
Kind::Initial,
Kind::Final,
Kind::Choice,
Kind::Junction,
Kind::Fork,
Kind::Join,
Kind::ShallowHistory,
Kind::DeepHistory,
Kind::EntryPoint,
Kind::ExitPoint,
Kind::Terminate,
];
for oa in vertices {
for sa in vertices {
allowed.insert((oa, sa));
}
}
let mut p = Project::empty();
let oa: BTreeMap<_, _> = Kind::ALL
.into_iter()
.filter(|kind| kind.supports(Layer::Oa))
.map(|kind| (kind, element(&mut p, Layer::Oa, kind)))
.collect();
let sa: BTreeMap<_, _> = Kind::ALL
.into_iter()
.filter(|kind| kind.supports(Layer::Sa))
.map(|kind| (kind, element(&mut p, Layer::Sa, kind)))
.collect();
let original = p.clone();
for (&oa_kind, &oa_id) in &oa {
for (&sa_kind, &sa_id) in &sa {
let expected = allowed.contains(&(oa_kind, sa_kind));
assert_eq!(
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
expected,
"OA {oa_kind:?} → SA {sa_kind:?}"
);
assert_eq!(
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
expected,
"SA {sa_kind:?} → OA {oa_kind:?}"
);
}
}
assert_eq!(
p, original,
"Eligibility must not establish correspondence or edit records"
);
}
fn relation(p: &mut Project, kind: RelationKind, anchor: Id) -> Id {
let id = Id::new_v4();
// Only the semantic layer and relation category participate in candidate
// eligibility. Endpoint compatibility belongs to full plan validation.
p.relations.insert(
id,
Relation {
id,
alias: format!("relation_{}", id.simple()),
name: "Identical relation label".into(),
description: String::new(),
body: RelationBody::new(kind, anchor, anchor),
},
);
id
}
#[test]
fn existing_relation_candidates_distinguish_work_boundary_and_shared_relations() {
// Work and boundary exchanges are different families. The boundary /
// boundary pair permits preserved legacy OA boundary records; ordinary
// portless OA communication means map to SA boundary exchanges.
let mut allowed = vec![
(RelationKind::Interaction, RelationKind::FunctionalExchange),
(
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange,
),
(
RelationKind::BoundaryExchange,
RelationKind::BoundaryExchange,
),
];
let shared = [
RelationKind::Allocation,
RelationKind::InterfaceItemAllocation,
RelationKind::DataAssociation,
RelationKind::ExchangeAllocation,
RelationKind::CapabilityInvolvement,
RelationKind::CapabilityInclude,
RelationKind::CapabilityExtend,
RelationKind::CapabilityGeneralization,
RelationKind::MissionInvolvement,
RelationKind::MissionCapability,
RelationKind::SequenceLink,
RelationKind::ContextualLink,
RelationKind::ChainInvolvement,
RelationKind::InterfaceProvision,
RelationKind::InterfaceRequirement,
RelationKind::StateTransition,
RelationKind::Availability,
RelationKind::StateAction,
];
for kind in shared {
allowed.push((kind, kind));
}
let mut p = Project::empty();
let oa_anchor = element(&mut p, Layer::Oa, Kind::Activity);
let sa_anchor = element(&mut p, Layer::Sa, Kind::Function);
let oa: Vec<_> = shared
.into_iter()
.chain([
RelationKind::Interaction,
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange,
RelationKind::RoleRealization,
RelationKind::RoleAssignment,
])
.map(|kind| (kind, relation(&mut p, kind, oa_anchor)))
.collect();
let sa: Vec<_> = shared
.into_iter()
.chain([
RelationKind::FunctionalExchange,
RelationKind::BoundaryExchange,
RelationKind::PortAllocation,
])
.map(|kind| (kind, relation(&mut p, kind, sa_anchor)))
.collect();
for &(oa_kind, oa_id) in &oa {
for &(sa_kind, sa_id) in &sa {
let expected = allowed.contains(&(oa_kind, sa_kind));
assert_eq!(
counterpart_compatible(&p, oa_id, sa_id, TransitionDirection::OaToSa),
expected,
"OA {oa_kind:?} → SA {sa_kind:?}"
);
assert_eq!(
counterpart_compatible(&p, sa_id, oa_id, TransitionDirection::SaToOa),
expected,
"SA {sa_kind:?} → OA {oa_kind:?}"
);
}
}
}
#[test]
fn candidate_eligibility_rejects_wrong_layers_categories_and_missing_identities() {
let mut p = Project::empty();
let oa = element(&mut p, Layer::Oa, Kind::Activity);
let sa = element(&mut p, Layer::Sa, Kind::Function);
let oa_exchange = relation(&mut p, RelationKind::Interaction, oa);
let sa_exchange = relation(&mut p, RelationKind::FunctionalExchange, sa);
let diagram = p.ensure_diagram(Layer::Sa);
let node = p.place(diagram, sa, Position { x: 1., y: 2. });
for (direction, source, target, source_relation, target_relation) in [
(
TransitionDirection::OaToSa,
oa,
sa,
oa_exchange,
sa_exchange,
),
(
TransitionDirection::SaToOa,
sa,
oa,
sa_exchange,
oa_exchange,
),
] {
assert!(counterpart_compatible(&p, source, target, direction));
assert!(counterpart_compatible(
&p,
source_relation,
target_relation,
direction
));
for (a, b) in [
(target, source),
(source, source),
(target, target),
(target_relation, source_relation),
(source_relation, source_relation),
(source, target_relation),
(source_relation, target),
] {
assert!(!counterpart_compatible(&p, a, b, direction));
}
for absent_or_nonsemantic in [Id::nil(), Id::new_v4(), p.id, diagram, node] {
assert!(!counterpart_compatible(
&p,
source,
absent_or_nonsemantic,
direction
));
assert!(!counterpart_compatible(
&p,
absent_or_nonsemantic,
target,
direction
));
}
}
p.elements.remove(&sa);
assert!(!counterpart_compatible(
&p,
oa,
sa,
TransitionDirection::OaToSa
));
assert!(!counterpart_compatible(
&p,
sa,
oa,
TransitionDirection::SaToOa
));
// The retained relation header cannot borrow a layer from a deleted owner.
assert!(!counterpart_compatible(
&p,
oa_exchange,
sa_exchange,
TransitionDirection::OaToSa
));
}
#[test]
fn activity_picker_rejects_real_sa_port_and_agrees_with_explicit_plan_refusal() {
let p = Project::demo();
let original = p.clone();
assert!(validation::validate(&p).is_empty());
let source = p
.elements
.values()
.find(|e| e.kind() == Kind::Activity)
.unwrap()
.id;
let port = p
.elements
.values()
.find(|e| e.layer() == Layer::Sa && e.kind() == Kind::Port)
.unwrap()
.id;
let function = p
.elements
.values()
.find(|e| e.kind() == Kind::Function)
.unwrap()
.id;
assert!(counterpart_compatible(
&p,
source,
function,
TransitionDirection::OaToSa
));
assert!(!counterpart_compatible(
&p,
source,
port,
TransitionDirection::OaToSa
));
let mut request = TransitionRequest::new(TransitionDirection::OaToSa, vec![source]);
request.mappings.push(TransitionMapping {
source,
target: Some(port),
target_kind: None,
});
let plan = plan_transition(&p, &request).unwrap();
assert!(
plan.conflicts
.iter()
.any(|conflict| conflict.source == Some(source)
&& conflict.target == Some(port)
&& conflict.field.is_none())
);
assert!(apply_transition(&p, &plan).is_err());
assert_eq!(p, original);
}
@@ -1,638 +0,0 @@
//! Endpoint lifecycle acceptance for the ordinary, prose-reviewed transition API.
//! OA is portless; the SA half contains typed ports. Deletion expectations below
//! are fixture-owned identity lists, never results of the deletion implementation.
use oa_core::{
language, persistence,
source_project::{ProjectLimits, SourceBundle, compile_bundle},
transitions::*,
validation::{self, Severity},
*,
};
use std::collections::BTreeMap;
struct Side {
root: Id,
elements: Vec<Id>,
exchange: Id,
diagram: Id,
nodes: Vec<Id>,
artifacts: Vec<Id>,
}
struct Fixture {
project: Project,
source: Side,
target: Side,
trace: Id,
trace_artifact: Id,
}
fn artifact(p: &mut Project, owner: Id, label: &str) -> Id {
let id = Id::new_v4();
p.artifact_links.insert(
id,
ArtifactLink {
id,
alias: p.fresh_alias(label, "evidence", id),
name: label.into(),
description: "Independently authored rationale".into(),
owner,
relation: ArtifactRelation::References,
uri: "git:engineering/design.md".into(),
revision: Some("review-17".into()),
fragment: Some("retained-decision".into()),
},
);
id
}
fn side(p: &mut Project, layer: Layer) -> Side {
let (kind, prefix, relation) = match layer {
Layer::Oa => (Kind::Activity, "Operational", RelationKind::Interaction),
Layer::Sa => (
Kind::Function,
"Technical",
RelationKind::FunctionalExchange,
),
};
let root = p
.add_element(kind, layer, &format!("{prefix} design"), None)
.unwrap();
p.elements.get_mut(&root).unwrap().description =
format!("{prefix} independently reviewed intent");
let send = p
.add_element(kind, layer, &format!("{prefix} send"), Some(root))
.unwrap();
let receive = p
.add_element(kind, layer, &format!("{prefix} receive"), Some(root))
.unwrap();
p.elements.get_mut(&send).unwrap().description =
"Preserve the detailed sending responsibility".into();
p.elements.get_mut(&receive).unwrap().description =
"Preserve the detailed receiving responsibility".into();
// A shared, separately owned definition survives deletion of its users.
let item = p
.add_element(
Kind::ExchangeItem,
layer,
&format!("{prefix} information"),
None,
)
.unwrap();
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Information,
mechanism: ExchangeMechanism::Flow,
supertype: None,
};
p.add_element(
Kind::DataField,
layer,
&format!("{prefix} payload"),
Some(item),
)
.unwrap();
let exchange = p.relate(relation, &format!("{prefix} delivery"), send, receive);
let mut elements = vec![root, send, receive];
match &mut p.relations.get_mut(&exchange).unwrap().body {
RelationBody::Interaction { items, .. } => *items = vec![item],
RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} => {
*items = vec![item];
let (output, input) = (*source_port, *target_port);
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::FunctionOutput {
outgoing_items: vec![item],
},
legacy: None,
};
*p.elements.get_mut(&input).unwrap().shared_mut().unwrap() = SharedBody::Port {
contract: PortContract::FunctionInput {
incoming_items: vec![item],
},
legacy: None,
};
elements.extend([output, input]);
}
_ => unreachable!(),
}
p.relations.get_mut(&exchange).unwrap().description =
"Retain direction, item contract and relation identity".into();
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Decomposition;
let nodes = [root, send, receive]
.into_iter()
.enumerate()
.map(|(index, element)| {
p.place(
diagram,
element,
Position {
x: 43.25 + index as f32 * 271.,
y: 97.5 + index as f32 * 83.,
},
)
})
.collect::<Vec<_>>();
let root_node = p
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&nodes[0])
.unwrap();
root_node.size = Size {
width: 611.,
height: 319.,
};
root_node.collapsed = true;
p.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: exchange,
// Both directions must preserve authored endpoint preferences,
// including dormant preferences on the SA semantic-port edge.
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.2,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.8,
}),
waypoints: vec![Position {
x: 337.5,
y: 423.25,
}],
label: Some(EdgeLabel {
fraction: 0.7,
offset: Position { x: 11., y: -17. },
}),
});
let artifacts = [root, send, exchange, nodes[0]]
.into_iter()
.enumerate()
.map(|(index, owner)| artifact(p, owner, &format!("{prefix} evidence {index}")))
.collect();
artifact(p, item, &format!("{prefix} shared item evidence"));
Side {
root,
elements,
exchange,
diagram,
nodes,
artifacts,
}
}
fn accepted(p: &Project) {
let issues = validation::validate(p);
// This fixture deliberately isolates decomposition and correspondence from
// performer allocation and process/chain/scenario inclusion. Each surviving
// leaf (including a newly transitioned root) must have exactly those two
// incomplete-work warnings; each surviving local exchange lacks a behavior
// context. Check exact record IDs and fields, including duplicate warnings.
// The system of interest and item contracts are complete fixture facts.
assert!(
issues
.iter()
.all(|issue| issue.severity == Severity::Warning),
"{issues:?}"
);
let mut expected = Vec::new();
for element in p.elements.values().filter(|element| {
matches!(element.kind(), Kind::Activity | Kind::Function)
&& !p
.elements
.values()
.any(|child| child.parent == Some(element.id) && child.kind() == element.kind())
}) {
expected.push(("unallocated_leaf", Some(element.id), Some("allocation")));
expected.push(("work_without_behavior_context", Some(element.id), None));
}
for relation in p.relations.values().filter(|relation| {
matches!(
relation.kind(),
RelationKind::Interaction | RelationKind::FunctionalExchange
)
}) {
expected.push(("exchange_without_behavior_context", Some(relation.id), None));
}
let mut actual = issues
.iter()
.map(|issue| (issue.code.as_str(), issue.element, issue.field.as_deref()))
.collect::<Vec<_>>();
expected.sort();
actual.sort();
assert_eq!(
actual, expected,
"Unexpected fixture diagnostics: {issues:?}"
);
}
fn round_trip(p: &Project) -> String {
accepted(p);
let json = serde_json::to_vec_pretty(p).unwrap();
assert_eq!(persistence::decode(&json).unwrap(), *p);
let source = language::format(p);
let compiled = compile_bundle(
&SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), source.clone())]),
},
ProjectLimits::default(),
)
.unwrap();
assert_eq!(compiled.decoded.project, *p);
assert_eq!(language::format(&compiled.decoded.project), source);
source
}
fn explicit(direction: TransitionDirection, source: Id, target: Id) -> TransitionRequest {
let mut request = TransitionRequest::new(direction, vec![source]);
request.mappings.push(TransitionMapping {
source,
target: Some(target),
target_kind: None,
});
request
}
fn fixture(direction: TransitionDirection) -> Fixture {
let mut project = Project::empty();
project.name = "Endpoint lifecycle conservation".into();
project
.add_element(Kind::System, Layer::Sa, "System of interest", None)
.unwrap();
let oa = side(&mut project, Layer::Oa);
let sa = side(&mut project, Layer::Sa);
let (source, target) = match direction {
TransitionDirection::OaToSa => (oa, sa),
TransitionDirection::SaToOa => (sa, oa),
};
accepted(&project);
let before = project.clone();
let mut request = explicit(direction, source.root, target.root);
for field in [TransitionField::Name, TransitionField::Description] {
request.resolutions.push(FieldResolution {
source: source.root,
field,
decision: FieldDecision::KeepTarget,
});
}
let plan = plan_transition(&project, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
project = apply_transition(&project, &plan).unwrap();
let trace = assert_only_new_trace(&before, &project, direction, source.root, target.root);
let mut without_trace = project.clone();
without_trace.traces.remove(&trace);
assert_eq!(
without_trace, before,
"Initial review must only add correspondence"
);
let trace_artifact = artifact(&mut project, trace, "Correspondence rationale");
round_trip(&project);
Fixture {
project,
source,
target,
trace,
trace_artifact,
}
}
fn assert_only_new_trace(
before: &Project,
after: &Project,
direction: TransitionDirection,
source: Id,
target: Id,
) -> Id {
let added = after
.traces
.values()
.filter(|trace| !before.traces.contains_key(&trace.id))
.collect::<Vec<_>>();
assert_eq!(added.len(), 1);
assert_eq!(after.traces.len(), before.traces.len() + 1);
let trace = added[0];
let (oa, sa) = match direction {
TransitionDirection::OaToSa => (source, target),
TransitionDirection::SaToOa => (target, source),
};
assert_eq!(
(trace.oa, trace.sa, trace.kind),
(oa, sa, TraceKind::Refinement)
);
assert_eq!(
trace.baseline,
Some(TraceBaseline {
oa: after.properties(oa).unwrap(),
sa: after.properties(sa).unwrap(),
semantic: None,
})
);
assert_eq!(trace_drift(after, trace), Drift::Unchanged);
trace.id
}
fn expected_deletion(fixture: &Fixture, side: &Side) -> Project {
let mut expected = fixture.project.clone();
for id in &side.elements {
assert!(expected.elements.remove(id).is_some());
}
assert!(expected.relations.remove(&side.exchange).is_some());
assert!(expected.traces.remove(&fixture.trace).is_some());
for id in side.artifacts.iter().chain([&fixture.trace_artifact]) {
assert!(expected.artifact_links.remove(id).is_some());
}
let diagram = expected.diagrams.get_mut(&side.diagram).unwrap();
for id in &side.nodes {
assert!(diagram.nodes.remove(id).is_some());
}
// This fixture authored exactly one route, for its removed local exchange.
assert_eq!(diagram.routes.len(), 1);
assert_eq!(diagram.routes[0].edge, side.exchange);
diagram.routes.clear();
expected
}
fn unchanged_retry(p: &Project, direction: TransitionDirection, source: Id, target: Id) {
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
assert_eq!(
(
plan.changes[0].source,
plan.changes[0].target,
plan.changes[0].action
),
(source, target, TransitionAction::Reuse)
);
assert!(plan.changes[0].fields.is_empty());
assert!(plan.commands.is_empty(), "{:?}", plan.commands);
let mut session = Session::new(p.clone());
let revision = session.revision();
session
.execute(Command::Replace(apply_transition(p, &plan).unwrap()))
.unwrap();
assert_eq!(session.project(), p);
assert_eq!(session.revision(), revision);
assert!(!session.can_undo());
}
fn source_deletion(direction: TransitionDirection) {
let fixture = fixture(direction);
let original_source = round_trip(&fixture.project);
let expected = expected_deletion(&fixture, &fixture.source);
let mut session = Session::new(fixture.project.clone());
session
.execute(Command::Delete(fixture.source.root))
.unwrap();
// Whole-project equality covers every retained target child, typed SA port,
// exchange, placement, route, artifact, shared item and unchanged identity.
assert_eq!(session.project(), &expected);
let deleted_source = round_trip(session.project());
session.undo();
assert_eq!(session.project(), &fixture.project);
assert_eq!(round_trip(session.project()), original_source);
assert!(!session.can_undo(), "Deletion is one history transaction");
session.redo();
assert_eq!(session.project(), &expected);
assert_eq!(round_trip(session.project()), deleted_source);
}
fn implicit_creation(
p: &Project,
direction: TransitionDirection,
source: Id,
forbidden_target: Id,
) -> (Project, Id) {
let request = TransitionRequest::new(direction, vec![source]);
let plan = plan_transition(p, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
let change = &plan.changes[0];
assert_eq!(change.source, source);
assert_eq!(change.action, TransitionAction::Create);
let target = change.target;
assert_ne!(target, forbidden_target);
assert!(!p.contains(target));
let after = apply_transition(p, &plan).unwrap();
let trace = assert_only_new_trace(p, &after, direction, source, target);
let created = &after.elements[&target];
assert_eq!(created.id, target, "Apply preserves the preview identity");
assert_eq!(created.name, p.elements[&source].name);
assert_eq!(created.description, p.elements[&source].description);
assert_eq!(created.parent, None);
assert_eq!(
created.body,
match direction {
TransitionDirection::OaToSa => ElementBody::Sa(SaBody::Function),
TransitionDirection::SaToOa => ElementBody::Oa(OaBody::Activity),
}
);
assert!(!created.alias.is_empty());
assert!(
!p.identities()
.iter()
.any(|(_, alias)| *alias == created.alias)
);
// Remove only the independently expected two additions. Any incidental
// mutation, clone of target children or extra correspondence fails here.
let mut retained = after.clone();
retained.elements.remove(&target);
retained.traces.remove(&trace);
assert_eq!(retained, *p);
round_trip(&after);
// Replaying a creation plan after acceptance violates its snapshot, while
// a fresh unchanged request reuses the accepted counterpart.
assert!(
apply_transition(&after, &plan)
.unwrap_err()
.0
.contains("stale")
);
unchanged_retry(&after, direction, source, target);
(after, target)
}
fn target_deletion(direction: TransitionDirection) {
let fixture = fixture(direction);
let request = TransitionRequest::new(direction, vec![fixture.source.root]);
let old_plan = plan_transition(&fixture.project, &request).unwrap();
assert!(old_plan.conflicts.is_empty());
let expected = expected_deletion(&fixture, &fixture.target);
let mut session = Session::new(fixture.project.clone());
session
.execute(Command::Delete(fixture.target.root))
.unwrap();
assert_eq!(session.project(), &expected);
round_trip(session.project());
let revision = session.revision();
assert!(
apply_transition(session.project(), &old_plan)
.unwrap_err()
.0
.contains("stale")
);
let explicit_plan = plan_transition(
session.project(),
&explicit(direction, fixture.source.root, fixture.target.root),
)
.unwrap();
assert!(
explicit_plan
.conflicts
.iter()
.any(|conflict| conflict.source == Some(fixture.source.root)
&& conflict.target == Some(fixture.target.root)
&& conflict.field.is_none())
);
assert!(apply_transition(session.project(), &explicit_plan).is_err());
assert_eq!(session.project(), &expected);
assert_eq!(session.revision(), revision);
let (created, _) = implicit_creation(
session.project(),
direction,
fixture.source.root,
fixture.target.root,
);
session.execute(Command::Replace(created.clone())).unwrap();
session.undo();
assert_eq!(session.project(), &expected);
session.undo();
assert_eq!(session.project(), &fixture.project);
assert!(!session.can_undo());
session.redo();
session.redo();
assert_eq!(session.project(), &created);
round_trip(session.project());
}
fn source_recreation(direction: TransitionDirection) {
let fixture = fixture(direction);
let mut replacement = fixture.project.elements[&fixture.source.root].clone();
replacement.id = Id::new_v4();
let source = replacement.id;
// Same alias, name and prose, different UUID: name matching must never
// silently relink the retained engineering design.
assert_ne!(source, fixture.source.root);
let deleted = execute_batch(&fixture.project, &[Command::Delete(fixture.source.root)]).unwrap();
assert_eq!(deleted, expected_deletion(&fixture, &fixture.source));
let recreated =
execute_batch(&deleted, &[Command::CreateElement(replacement.clone())]).unwrap();
let mut expected = deleted.clone();
expected.elements.insert(source, replacement);
assert_eq!(recreated, expected);
round_trip(&recreated);
let (implicit, new_target) =
implicit_creation(&recreated, direction, source, fixture.target.root);
assert!(implicit.elements.contains_key(&fixture.target.root));
assert_ne!(new_target, fixture.target.root);
// The explicit branch starts from exactly the same recreated project.
let mut request = explicit(direction, source, fixture.target.root);
let unreviewed = plan_transition(&recreated, &request).unwrap();
for field in [TransitionField::Name, TransitionField::Description] {
assert!(
unreviewed
.conflicts
.iter()
.any(|conflict| conflict.source == Some(source)
&& conflict.target == Some(fixture.target.root)
&& conflict.field == Some(field))
);
}
assert!(apply_transition(&recreated, &unreviewed).is_err());
request.resolutions = vec![
FieldResolution {
source,
field: TransitionField::Name,
decision: FieldDecision::TakeSource,
},
FieldResolution {
source,
field: TransitionField::Description,
decision: FieldDecision::KeepTarget,
},
];
let plan = plan_transition(&recreated, &request).unwrap();
assert!(plan.conflicts.is_empty(), "{:?}", plan.conflicts);
assert_eq!(plan.changes.len(), 1);
assert_eq!(
(plan.changes[0].target, plan.changes[0].action),
(fixture.target.root, TransitionAction::Update)
);
assert_eq!(
plan.changes[0].fields,
vec![FieldChange {
field: TransitionField::Name,
before: recreated.elements[&fixture.target.root].name.clone(),
after: recreated.elements[&source].name.clone(),
}]
);
let relinked = apply_transition(&recreated, &plan).unwrap();
let new_trace = assert_only_new_trace(
&recreated,
&relinked,
direction,
source,
fixture.target.root,
);
assert_ne!(
new_trace, fixture.trace,
"Deleted correspondence is not silently resurrected"
);
let mut expected = recreated.clone();
expected
.elements
.get_mut(&fixture.target.root)
.unwrap()
.name = recreated.elements[&source].name.clone();
let mut without_trace = relinked.clone();
without_trace.traces.remove(&new_trace);
assert_eq!(
without_trace, expected,
"Only the reviewed target name may change; rich target design survives"
);
round_trip(&relinked);
unchanged_retry(&relinked, direction, source, fixture.target.root);
let original_source = round_trip(&recreated);
let mut session = Session::new(recreated.clone());
session.execute(Command::Replace(relinked)).unwrap();
session.undo();
assert_eq!(session.project(), &recreated);
assert_eq!(round_trip(session.project()), original_source);
}
#[test]
fn oa_source_deletion_preserves_rich_sa_design_and_exact_undo() {
source_deletion(TransitionDirection::OaToSa);
}
#[test]
fn sa_source_deletion_preserves_rich_portless_oa_design_and_exact_undo() {
source_deletion(TransitionDirection::SaToOa);
}
#[test]
fn deleted_sa_target_refuses_old_plans_and_implicit_retry_creates_once() {
target_deletion(TransitionDirection::OaToSa);
}
#[test]
fn deleted_oa_target_refuses_old_plans_and_implicit_retry_creates_once() {
target_deletion(TransitionDirection::SaToOa);
}
#[test]
fn recreated_oa_identity_requires_explicit_relink_to_retained_sa_design() {
source_recreation(TransitionDirection::OaToSa);
}
#[test]
fn recreated_sa_identity_requires_explicit_relink_to_retained_oa_design() {
source_recreation(TransitionDirection::SaToOa);
}
-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"
);
}
@@ -75,11 +75,9 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
allocation: None,
};
let mut state = crate::diagram::State::default();
let key = CanvasEvent::Keyboard(keyboard::Event::KeyPressed {
@@ -91,8 +89,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();
@@ -230,11 +230,9 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: true,
connection: None,
allocation: None,
};
let mut state = crate::diagram::State::default();
let action = canvas
@@ -307,11 +305,9 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
pan: app.pan,
context: app.route_context().unwrap(),
selected_bend: None,
selected_attachment: None,
editable: true,
placing: false,
connection: None,
allocation: None,
};
let bounds = iced::Rectangle::with_size(iced::Size::new(800., 600.));
let cursor = mouse::Cursor::Available(iced::Point::new(500., 400.));
@@ -325,11 +321,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,620 +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.
let captured = app.scenario_context().unwrap();
assert_eq!(captured.scenario, owners[0]);
assert_eq!(
app.delete_target.as_ref().unwrap().behavior_owner,
Some(owners[0])
);
app.behavior.active_owner = Some(owners[1]);
app.reconcile_scenario_workspace();
let other = app.scenario_context().unwrap();
assert_eq!(other.scenario, owners[1]);
assert_eq!(
other.representation,
crate::behavior::scenario_layout::Representation::Derived { owner: owners[1] }
);
assert_eq!(other.epoch, captured.epoch);
assert_eq!(app.selected, uses[0]);
assert!(
app.delete_target.as_ref().unwrap().snapshot.valid(&app),
"the independent owner guard must reject an otherwise current token"
);
send(&mut mounted, &mut app, Message::ConfirmDelete(token)).await;
assert_eq!(app.document.bundle(), &original);
assert!(!app.delete_pending && app.delete_target.is_none());
assert!(!app.document.can_undo() && !app.document.can_redo());
app.behavior.active_owner = Some(owners[0]);
// Restore both owner and qualified presentation before mounting pointer input.
// Passive reports from the previous owner cannot repair an inconsistent fixture.
app.reconcile_scenario_workspace();
assert_eq!(app.scenario_context().unwrap().scenario, owners[0]);
assert_eq!(app.selected, uses[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;
}
+2 -2
View File
@@ -99,8 +99,6 @@ impl App {
| Message::DiagramUsagesReady(_)
| Message::SourceSymbolsReady(_)
| Message::SourceRenameReady(_)
| Message::WorkDrag(allocation_drag::Event::KeyboardBoundary)
| Message::WorkDrag(allocation_drag::Event::Unfocused)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
@@ -126,11 +124,13 @@ impl App {
behavior::topology::CanvasEvent::Camera(..)
| behavior::topology::CanvasEvent::Zoom(..)
| behavior::topology::CanvasEvent::Scrolled(..)
| behavior::topology::CanvasEvent::Focus(..)
)
| Message::ScenarioCanvas(
behavior::scenario::CanvasEvent::Camera(..)
| behavior::scenario::CanvasEvent::Zoom(..)
| behavior::scenario::CanvasEvent::Scrolled(..)
| behavior::scenario::CanvasEvent::Focus(..)
)
| Message::StateDiagram(
behavior::state_edit::Event::Pan(..)
+22 -27
View File
@@ -145,31 +145,14 @@ impl App {
}
}
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 context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.project().contains(context.subject)
{
return Task::none();
}
if !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
return Task::none();
@@ -190,10 +173,22 @@ impl App {
iced::widget::operation::focus(filter_id),
]);
}
let panel = self
.diagram_usages
.as_ref()
.expect("admitted chooser token");
let token = match &event {
Event::Open(_) => unreachable!(),
Event::Close(t)
| Event::Search(t, _)
| Event::Page(t, _)
| Event::Move(t, _)
| Event::Activate(t)
| Event::Destination(t, _)
| Event::Choose(t, _) => *t,
};
let Some(panel) = &self.diagram_usages else {
return Task::none();
};
if panel.token != token {
return Task::none();
}
if matches!(event, Event::Close(_)) {
self.diagram_usages = None;
if self.page == Page::Text {
@@ -1029,9 +1029,10 @@ fn repeated_preview_arrows_select_the_relation_and_highlight_the_exact_caller_li
let camera = app.behavior.topology.camera_boundary();
if call == calls[1]
&& let Some((previous_call, previous)) = &previous_camera
&& *previous_call != call
{
assert!(&camera != previous);
if *previous_call != call {
assert!(&camera != previous);
}
}
if let Some(epoch) = previous_epoch {
assert_ne!(app.canvas_epoch, epoch);
@@ -1374,7 +1375,7 @@ fn whole_surface_camera_fits_huge_extent_while_target_camera_remains_readable()
};
let viewport = iced::Size::new(800., 500.);
let (zoom, pan) = usage::camera_for(&Target::Whole, bounds, viewport);
assert!((0.0001..0.05).contains(&zoom));
assert!(zoom < 0.05 && zoom >= 0.0001);
assert!(bounds.width * zoom <= viewport.width - 64. + 0.01);
assert!(bounds.height * zoom <= viewport.height - 64. + 0.01);
assert!((bounds.center_x() * zoom + pan.x - viewport.width / 2.).abs() < 0.01);
@@ -242,13 +242,7 @@ fn saved_a_b_a_rejects_old_visit_and_panel_callbacks_before_deferred_measurement
&mut app,
Message::ScenarioCanvas(CanvasEvent::Viewport(held, Size::new(900., 900.))),
);
send(
&mut app,
Message::ScenarioCanvas(CanvasEvent::Shelf(
held,
crate::behavior::scenario::Shelf::Selection,
)),
);
send(&mut app, Message::ScenarioCanvas(CanvasEvent::Focus(held)));
send(
&mut app,
Message::DiagramUsage(Event::Search(old_token, "obsolete filter".into())),
+2 -2
View File
@@ -219,7 +219,7 @@ impl App {
|| session.connection.base() != request.connection.base()
})
{
self.reset_source_navigation_session();
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
@@ -359,7 +359,7 @@ impl App {
|| session.connection.base() != connection.base()
})
{
self.reset_source_navigation_session();
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection,
+14 -31
View File
@@ -75,7 +75,11 @@ impl App {
return Task::none();
}
match input {
Event::Traverse(_) | Event::ControlFocus(_) | Event::ToggleFocus | Event::Leave => {
Event::Focus
| 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),
@@ -94,26 +98,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);
@@ -160,6 +144,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)
@@ -342,7 +330,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;
@@ -352,14 +340,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(
@@ -371,8 +354,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]
@@ -1,279 +0,0 @@
//! Actual arrangement selection and current-record Details admission.
use super::*;
use crate::behavior::canvas_workspace::tests::Mounted;
use crate::behavior::scenario::{CanvasEvent, Context, Shelf};
use crate::behavior::scenario_layout::Representation;
use iced::{Point, Size, advanced::widget};
const FIXTURE: &str = include_str!("diagram_usages/tests/saved_scenario.arcadia");
fn id(app: &App, alias: &str) -> Id {
app.project()
.identities()
.into_iter()
.find(|(_, name)| *name == alias)
.unwrap()
.0
}
fn setup() -> (App, Id, Id, Id) {
let mut app = App::with_project(oa_core::language::parse(FIXTURE).unwrap());
let a = id(&app, "incident_readable");
let b = id(&app, "incident_wide");
let owner = id(&app, "incident_response");
let _ = app.update(Message::Select(owner));
let _ = app.update(Message::Page(Page::Behavior));
choose(&mut app, Representation::Saved { diagram: a });
let _ = app.update(Message::Select(a));
app.window_size = Size::new(1440., 1000.);
(app, a, b, owner)
}
fn send(app: &mut App, event: impl FnOnce(Context) -> CanvasEvent) {
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(event(context)));
}
fn choose(app: &mut App, representation: Representation) {
send(app, |context| {
CanvasEvent::Representation(context, representation)
});
}
fn model_state(app: &App) -> (u64, bool, bool, bool) {
(
app.document.revision(),
app.document.dirty(),
app.document.can_undo(),
app.document.can_redo(),
)
}
#[tokio::test]
async fn actual_arrangement_picker_follows_selected_saved_record_and_opens_b_properties() {
let (mut app, a, b, _) = setup();
let mut mounted = Mounted::new(&mut app).await;
let snapshot = app.document.snapshot();
let before = model_state(&app);
let captured = app.scenario_context().unwrap();
let picker = mounted.geometry(&app).bounds("scenario-arrangement-picker");
assert!(
app.window_size.height - (picker.y + picker.height) > picker.y,
"fixture has room for Iced's menu below the actual picker"
);
assert_eq!(app.selected, a);
mounted.click(&mut app, picker.center()).await;
// iced_widget 0.14.2 overlay/menu.rs uses default line height plus
// vertical padding for each row. The real workspace orders Automatic,
// then the fixture's A/B/C Diagram IDs. Select B from the actual overlay.
let row_height =
f32::from(iced::advanced::text::LineHeight::default().to_absolute(12.into())) + 10.;
let b_row = Point::new(
picker.center_x(),
picker.y + picker.height + row_height * 2.5,
);
assert!(b_row.y < app.window_size.height);
let events = mounted.click(&mut app, b_row).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Representation(context, Representation::Saved { diagram }))
if *context == captured && *diagram == b)), "must exercise the actual arrangement callback");
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert_eq!(
app.draft.as_ref().unwrap().value["name"],
"B - Incident review negative and far"
);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved { diagram: b }
);
assert_ne!(app.scenario_context().unwrap().visit, captured.visit);
send(&mut app, |context| {
CanvasEvent::Shelf(context, Shelf::Selection)
});
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (details, offset) = geometry.scrolls[&widget::Id::new("scenario-details")];
let point = geometry.bounds("scenario-properties").center() - offset;
assert!(details.contains(point));
let events = mounted.click(&mut app, point).await;
assert!(events.iter().any(|event| matches!(event,
Message::ScenarioCanvas(CanvasEvent::Properties(_, selected)) if *selected == b)));
mounted.settle(&mut app).await;
let geometry = mounted.geometry(&app);
let (inspector, offset) = geometry.scrolls[&widget::Id::new("inspector")];
assert!(
geometry
.text_inputs
.iter()
.any(|bounds| inspector.contains(bounds.center() - offset)),
"current B Properties must expose an actual editable field"
);
assert!(
!geometry
.scrolls
.contains_key(&widget::Id::new("scenario-details"))
);
assert_eq!(
app.inspector_tab,
crate::navigation::InspectorTab::Properties
);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_change_preserves_independent_semantic_and_foreign_view_selection() {
for alias in [
"initial_report",
"reporter",
"incident_response",
"incident_repair",
] {
let (mut app, _, b, _) = setup();
let selected = id(&app, alias);
let _ = app.update(Message::Select(selected));
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, selected, "independent selection {alias}");
assert_eq!(app.draft.as_ref().unwrap().original, selected);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
if alias == "incident_repair" {
assert!(!behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
selected
));
}
}
}
#[test]
fn arrangement_follows_view_record_to_automatic_owner_but_not_owner_back_to_saved() {
let (mut app, _, b, owner) = setup();
let snapshot = app.document.snapshot();
let before = model_state(&app);
choose(&mut app, Representation::Derived { owner });
assert_eq!(app.selected, owner);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, None);
assert!(behavior::scenario::properties_target(
app.project(),
app.scenario_context().unwrap(),
owner
));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(
app.selected, owner,
"Scenario owner is now independent semantic selection"
);
assert_eq!(app.draft.as_ref().unwrap().original, owner);
assert_eq!(app.active_diagram, Some(b));
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
#[test]
fn arrangement_applies_only_the_old_view_draft_then_follows_b_with_one_undo() {
let (mut app, a, b, _) = setup();
let snapshot = app.document.snapshot();
let mut expected = app.project().clone();
expected.diagrams.get_mut(&a).unwrap().name = "Reviewed A".into();
let revision = app.document.revision();
let _ = app.update(Message::Field("/name".into(), "Reviewed A".into()));
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, b);
assert_eq!(app.draft.as_ref().unwrap().original, b);
assert!(!app.draft_dirty());
assert_eq!(app.document.revision(), revision + 1);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn same_arrangement_uuid_edit_follows_the_reidentified_record_and_round_trips_source() {
let (mut app, a, _, _) = setup();
let snapshot = app.document.snapshot();
let replacement = Id::parse_str("70200000-0000-4000-8000-000000000099").unwrap();
let mut expected = app.project().clone();
let mut diagram = expected.diagrams.remove(&a).unwrap();
diagram.id = replacement;
expected.diagrams.insert(replacement, diagram);
let _ = app.update(Message::Field("/id".into(), replacement.to_string()));
choose(&mut app, Representation::Saved { diagram: a });
assert_eq!(app.project(), &expected);
assert_eq!(app.selected, replacement);
assert_eq!(app.draft.as_ref().unwrap().original, replacement);
assert_eq!(app.active_diagram, Some(replacement));
assert_eq!(
app.scenario_context().unwrap().representation,
Representation::Saved {
diagram: replacement
}
);
let accepted = app.document.snapshot();
let _ = app.update(Message::Undo);
assert_eq!(app.project(), &snapshot.decoded.project);
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert!(!app.document.can_undo() && app.document.can_redo());
let _ = app.update(Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(!app.document.can_redo());
}
#[test]
fn invalid_draft_and_stale_arrangement_preserve_selection_inputs_and_accepted_state() {
let (mut app, a, b, _) = setup();
let old = app.scenario_context().unwrap();
let snapshot = app.document.snapshot();
let before = model_state(&app);
let _ = app.update(Message::Field("/id".into(), "not-a-uuid".into()));
let draft_value = app.draft.as_ref().unwrap().value.clone();
choose(&mut app, Representation::Saved { diagram: b });
assert_eq!(app.selected, a);
assert_eq!(app.active_diagram, Some(a));
assert_eq!(
app.scenario_context().unwrap().representation,
old.representation
);
assert_eq!(app.draft.as_ref().unwrap().value, draft_value);
assert_eq!(app.draft.as_ref().unwrap().inputs["/id"].raw, "not-a-uuid");
assert!(app.draft_dirty());
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(model_state(&app), before);
let _ = app.update(Message::RevertProperties);
choose(&mut app, Representation::Saved { diagram: b });
let current = app.scenario_context().unwrap();
let _ = app.update(Message::Field("/name".into(), "Retained B input".into()));
let selected = app.selected;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Representation(
old,
Representation::Saved { diagram: a },
)));
assert_eq!(app.selected, selected);
assert_eq!(app.active_diagram, Some(b));
assert_eq!(app.scenario_context().unwrap().visit, current.visit);
assert_eq!(
app.draft.as_ref().unwrap().inputs["/name"].raw,
"Retained B input"
);
assert!(Arc::ptr_eq(&snapshot, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &snapshot.bundle);
assert_eq!(model_state(&app), before);
}
@@ -86,11 +86,8 @@ fn exercise(via_guide: bool) {
}
assert_destination(&app, target);
let camera = app.camera_boundary();
// The prior saved canvas cannot open its outline after either owner route.
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Shelf(
held,
crate::behavior::scenario::Shelf::Selection,
)));
// The prior saved canvas cannot regain focus after either owner route.
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Focus(held)));
assert_destination(&app, target);
assert!(app.camera_boundary() == camera);
assert_eq!(app.project(), &accepted.decoded.project);
@@ -62,7 +62,7 @@ impl App {
// Document prepares and writes the source candidate before
// committing its snapshot. Failure leaves camera/selection
// and source/history at their accepted values.
match self.document.execute(*command) {
match self.document.execute(command) {
Ok(_) => {
self.selected = selected;
self.selected_node = None;
@@ -24,70 +24,6 @@ fn setup() -> (App, Id, Id) {
assert!(app.spacing_enabled());
(app, lane, message)
}
#[test]
fn stale_text_and_scenario_messages_do_not_retire_a_new_saved_spacing_gesture() {
let (mut app, lane, _) = setup();
let previous = app.scenario_context().unwrap();
let diagram = match previous.representation {
behavior::scenario_layout::Representation::Saved { diagram } => diagram,
_ => unreachable!(),
};
let _ = app.update(Message::Page(Page::Text));
let old_indent = Message::SourceIndent(app.source_symbol_context(), false);
let old_drawer = Message::TextWorkspace(text_workspace::Event::ShowDrawer(
app.text_drawer_context(),
text_workspace::DrawerTab::Problems,
));
let old_command = app.text_command_message(text_workspace::Command::ShowStatus);
let _ = app.update(Message::Diagram(Choice {
id: diagram,
label: "Saved".into(),
}));
let context = app.scenario_context().unwrap();
assert_ne!(context.visit, previous.visit);
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
context,
Size::new(900., 600.),
)));
let context = app.scenario_context().unwrap();
let (event, start, _) = begin(&mut app, Target::Lane(lane));
move_to(&mut app, event, start + Vector::new(24., 0.));
let accepted = app.document.snapshot();
let source = app.source_workspace.bundle();
let source_version = app.source_version;
let camera = app.camera_boundary();
let selected = app.selected;
let events = [
old_indent,
old_drawer,
old_command,
Message::ScenarioCanvas(CanvasEvent::Shelf(
previous,
behavior::scenario::Shelf::Selection,
)),
Message::ScenarioCanvas(CanvasEvent::Properties(previous, lane)),
];
for rejected in events {
let _ = app.update(rejected);
assert_eq!(app.scenario_context(), Some(context));
assert_eq!(app.behavior.scenario.spacing_token(), Some(event.token()));
assert_eq!(app.behavior.scenario.spacing_stamp(), Some(event.stamp()));
assert!(app.camera_boundary() == camera);
assert_eq!(app.selected, selected);
assert_eq!(app.source_version, source_version);
assert_eq!(app.source_workspace.bundle(), source);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.project(), &accepted.decoded.project);
assert!(app.text_workspace.drawer().is_none());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
finish(&mut app, event, start + Vector::new(24., 0.));
assert!(
app.document.can_undo(),
"the current gesture remains usable"
);
}
fn begin(app: &mut App, target: Target) -> (Event, Point, f32) {
let event = packet(
app.project(),
@@ -315,63 +251,6 @@ fn stale_finish_cannot_remove_new_same_view_deferred_reveal() {
assert!(!app.document.can_undo());
}
#[test]
fn stale_active_actions_cannot_remove_new_same_visit_deferred_reveal() {
let (mut app, lane, message) = setup();
let old = app.scenario_context().unwrap();
let behavior::scenario_layout::Representation::Saved { diagram } = old.representation else {
unreachable!()
};
let accepted = app.document.snapshot();
let source = app.source_workspace.bundle();
let _ = app.update(Message::RevealInDiagram(diagram, message));
let current = app.scenario_context().unwrap();
assert_eq!(current.visit, old.visit);
assert_eq!(current.representation, old.representation);
assert_eq!(current.revision, old.revision);
assert_ne!(current.epoch, old.epoch);
let pending = app.pending_diagram_reveal.clone();
assert!(pending.is_some());
let focus = app.diagram_navigation_focus.as_ref().unwrap();
let focus = (focus.context, focus.usage.clone(), focus.selected);
let camera = app.camera_boundary();
for event in [
CanvasEvent::Representation(old, old.representation),
CanvasEvent::Shelf(old, behavior::scenario::Shelf::Selection),
CanvasEvent::Select(old, lane),
CanvasEvent::Zoom(old, Some(1.)),
] {
let _ = app.update(Message::ScenarioCanvas(event));
assert_eq!(app.scenario_context(), Some(current));
assert_eq!(app.pending_diagram_reveal, pending);
assert_eq!(app.pending_scenario_reveal_visit, Some(current.visit));
let actual = app.diagram_navigation_focus.as_ref().unwrap();
assert_eq!(
(actual.context, actual.usage.clone(), actual.selected),
focus
);
assert!(app.camera_boundary() == camera);
assert_eq!(app.selected, message);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert_eq!(app.project(), &accepted.decoded.project);
assert_eq!(app.source_workspace.bundle(), source);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Viewport(
current,
Size::new(800., 500.),
)));
assert!(app.pending_diagram_reveal.is_none());
assert!(app.pending_scenario_reveal_visit.is_none());
assert!(
app.diagram_focus(crate::diagram_usage::Surface::Saved(diagram))
.is_some()
);
assert_eq!(app.selected, message);
assert_eq!(app.document.bundle(), &accepted.bundle);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn modal_open_cancels_preview_and_captured_begin_stays_retired_after_close() {
let (mut app, lane, _) = setup();
@@ -1,77 +0,0 @@
//! Controller acceptance: one indentation gesture is one draft transaction.
use super::*;
fn fixture(source: &str) -> App {
let mut app = App::with_project(Project::empty());
app.page = Page::Text;
app.source_workspace = Workspace::new(
&oa_core::source_project::SourceBundle {
entry: "project.arcadia".into(),
files: std::collections::BTreeMap::from([("project.arcadia".into(), source.into())]),
},
true,
);
app.show_source_file(false);
app
}
fn indent(app: &mut App, outdent: bool) {
let message = Message::SourceIndent(app.source_symbol_context(), outdent);
let _ = app.update(message);
}
#[test]
fn indentation_updates_draft_snapshot_version_and_single_undo_redo_without_model_change() {
let source = "雨\r\n🚀\r\n";
let mut app = fixture(source);
app.source_editor.perform(text_editor::Action::SelectAll);
let version = app.source_version;
let revision = app.source_workspace.revision();
let model = app.document.revision();
indent(&mut app, false);
let after = " 雨\r\n 🚀\r\n";
assert_eq!(app.source_editor.text(), after);
assert_eq!(app.source_snapshot.as_ref(), after);
assert_eq!(app.source_workspace.files["project.arcadia"].text, after);
assert!(app.source_workspace.has_edits());
assert_eq!(app.source_version, version + 1);
assert_eq!(app.source_workspace.revision(), revision + 1);
assert_eq!(app.document.revision(), model);
let _ = app.update(Message::SourceUndo);
assert_eq!(app.source_editor.text(), source);
assert!(!app.source_workspace.has_edits());
let _ = app.update(Message::SourceRedo);
assert_eq!(app.source_editor.text(), after);
assert_eq!(app.document.revision(), model);
}
#[test]
fn stale_hidden_and_modal_indentation_is_rejected_before_navigation_invalidation() {
for rejected in 0..7 {
let mut app = fixture("alpha");
let mut context = app.source_symbol_context();
match rejected {
0 => context.path = "elsewhere.arcadia".into(),
1 => context.session = Id::new_v4(),
2 => app.page = Page::Home,
3 => app.busy = true,
4 => app.delete_pending = true,
5 => app.hosted.visible = true,
_ => app.pending = Some(Pending::New),
}
let interaction = app.source_symbols.interaction;
let version = app.source_version;
let _ = app.update(Message::SourceIndent(context, false));
assert_eq!(app.source_editor.text(), "alpha");
assert_eq!(app.source_version, version);
assert_eq!(app.source_symbols.interaction, interaction);
}
}
#[test]
fn no_op_outdent_does_not_add_history_or_increment_source_version() {
let mut app = fixture("alpha");
let version = app.source_version;
indent(&mut app, true);
assert_eq!(app.source_version, version);
assert!(!app.source_workspace.undo(false));
}
+4 -39
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,
@@ -172,17 +170,6 @@ impl App {
return false;
};
match event {
Event::PreviewScrolled(invocation, approval, destination, offset) => {
*invocation == form.invocation
&& !form.details
&& form
.approval
.as_ref()
.is_some_and(|ready| ready.id == *approval)
&& *destination == form.preview_destination
&& offset.is_finite()
&& *offset >= 0.
}
Event::Preview(invocation, generation) => {
*invocation == form.invocation && *generation == form.generation
}
@@ -215,7 +202,7 @@ impl App {
}
}
pub(super) fn rename_admission(&self) -> Result<(), String> {
fn rename_admission(&self) -> Result<(), String> {
if self.page != Page::Text
|| self.busy
|| self.pending.is_some()
@@ -448,23 +435,11 @@ impl App {
}
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.
// its selected page and retained scroll; only retire delayed
// operations, without scheduling another return-to-top.
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 +516,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 +953,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 +1118,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![
@@ -4,9 +4,6 @@ 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()
}
@@ -359,7 +356,7 @@ fn document_reset_retains_all_three_worker_slots_until_their_exact_results_arriv
assert_eq!(app.diagram_lookup_in_flight, Some(diagram_job));
send(&mut app, Message::SourceSymbolsReady(source_ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(!app.source_symbols.open);
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);
@@ -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) {
@@ -375,7 +337,6 @@ async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated
let mut hidden = Geometry {
id: old_preview.clone(),
offset: None,
bounds: None,
};
mounted.operate(&app, &mut hidden);
assert!(
@@ -394,8 +355,8 @@ async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated
let show_preview = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(
mounted.execute(&app, show_preview).await,
1,
"returning restores the reported offset in the new preview subtree"
0,
"returning does not force a scroll reset"
);
let returned_visit = scroll_id(&app);
assert_ne!(returned_visit, old_preview);
@@ -422,135 +383,3 @@ async fn details_round_trip_retires_held_reset_without_changing_page_or_repeated
assert_eq!(evidence(&app), before);
}
}
#[tokio::test]
async fn details_remount_restores_wheel_offset_before_any_rescroll() {
for mount_details in [true, false] {
let mut app = paged_fixture();
let before = evidence(&app);
let form = app.source_rename.form.as_ref().unwrap();
let invocation = form.invocation;
let approval = form.approval.as_ref().unwrap().id;
let page = (form.file, form.hunk, form.line, form.segment);
let mut mounted = Mounted::new(&app);
let reports = mounted.wheel(&app);
assert_eq!(reports.len(), 1, "one actual preview scroll report");
let reported_offset = match &reports[0] {
Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) => *offset,
_ => panic!("expected an actual preview scroll report"),
};
assert!(reported_offset.is_finite() && reported_offset > 0.);
let offset = mounted.offset(&app);
// Iced reports fractional absolute offsets but exposes rounded display
// translation to widget operations. Preserve both representations exactly.
assert_eq!(reported_offset.round(), offset);
assert!(
offset > 10.,
"real wheel input must move the overflowing fixture"
);
let interaction = app.source_symbols.interaction;
let camera = app.camera_boundary();
let allocation = app.allocation_boundary();
for message in reports.iter().cloned() {
let task = app.update(message);
assert_eq!(mounted.execute(&app, task).await, 0);
}
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.camera_boundary() == camera);
assert!(app.allocation_boundary() == allocation);
assert_eq!(evidence(&app), before);
assert_eq!(
app.source_rename.form.as_ref().unwrap().preview_offset,
reported_offset
);
let details = app.update(Message::SourceRename(Event::Details(invocation, true)));
assert_eq!(mounted.execute(&app, details).await, 0);
if mount_details {
let mut hidden = Geometry {
id: scroll_id(&app),
offset: None,
bounds: None,
};
mounted.operate(&app, &mut hidden);
assert!(hidden.offset.is_none(), "Details replaced the Scrollable");
}
// A queued report from the hidden preview cannot overwrite its saved offset.
let Message::SourceRename(Event::PreviewScrolled(
old_invocation,
old_approval,
old_visit,
_,
)) = reports[0].clone()
else {
unreachable!()
};
let stale = Event::PreviewScrolled(old_invocation, old_approval, old_visit, offset + 100.);
assert!(!app.source_rename_envelope(&stale));
let task = app.update(Message::SourceRename(stale.clone()));
assert_eq!(mounted.execute(&app, task).await, 0);
let returned = app.update(Message::SourceRename(Event::Details(invocation, false)));
assert_eq!(mounted.execute(&app, returned).await, 1);
// This assertion precedes any new scroll: the former test scrolled again here.
assert_eq!(mounted.offset(&app), offset);
let form = app.source_rename.form.as_ref().unwrap();
assert_eq!((form.file, form.hunk, form.line, form.segment), page);
assert_eq!(form.approval.as_ref().unwrap().id, approval);
assert_eq!(form.preview_offset, reported_offset);
assert!(!app.source_rename_envelope(&stale));
let task = app.update(Message::SourceRename(stale));
assert_eq!(mounted.execute(&app, task).await, 0);
assert_eq!(mounted.offset(&app), offset);
let next = app.update(Message::SourceRename(Event::File(
invocation, approval, true,
)));
assert_eq!(mounted.execute(&app, next).await, 1);
assert_eq!(mounted.offset(&app), 0.);
assert_eq!(app.source_rename.form.as_ref().unwrap().preview_offset, 0.);
assert_eq!(evidence(&app), before);
}
}
#[tokio::test]
async fn diagnostic_publication_during_rename_keeps_actual_preview_report_passive() {
let mut app = paged_fixture();
app.publish_source_diagnostics(app.source_diagnostics.clone());
let publication = app.text_workspace.diagnostic_publication().unwrap();
let before = evidence(&app);
let interaction = app.source_symbols.interaction;
let camera = app.camera_boundary();
let allocation = app.allocation_boundary();
let cursor = app.source_editor.cursor();
let mut mounted = Mounted::new(&app);
let reports = mounted.wheel(&app);
assert_eq!(reports.len(), 1);
let Message::SourceRename(Event::PreviewScrolled(_, _, _, offset)) = &reports[0] else {
panic!("actual wheel must publish the retained preview measurement");
};
assert!(offset.is_finite() && *offset > 0.);
app.publish_source_diagnostics(app.source_diagnostics.clone());
let current = app.text_workspace.diagnostic_publication().unwrap();
assert_ne!(current.generation(), publication.generation());
assert_eq!(current.source(), publication.source());
assert!(app.source_annotation_snapshot().is_some());
assert!(
!app.source_navigation_available(),
"rename still owns modal input"
);
for message in reports.iter().cloned() {
let task = app.update(message);
assert_eq!(mounted.execute(&app, task).await, 0);
}
assert_eq!(
app.source_rename.form.as_ref().unwrap().preview_offset,
*offset
);
assert_eq!(mounted.offset(&app), offset.round());
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.camera_boundary() == camera);
assert!(app.allocation_boundary() == allocation);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(evidence(&app), before);
}
@@ -287,7 +287,7 @@ fn alias_editor_sync_fallback_restores_accepted_display_and_preserves_model_hist
assert_ne!(app.source_navigation.label(), "0 matches");
let mut invalid = original_source.clone();
invalid.files.insert(path.clone(), "?".into());
app.publish_source_diagnostics(source_workspace::lint(&invalid));
app.source_diagnostics = source_workspace::lint(&invalid);
assert!(!app.source_diagnostics.is_empty());
let version = app.source_version;
app.lint_requested = Some(version);
@@ -804,25 +804,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
}
@@ -839,8 +827,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
send(&mut app, Message::SourceRenameReady(ready.clone()));
}
app.source_navigation.open = true;
app.text_workspace
.show_drawer(Some(crate::app::text_workspace::DrawerTab::References));
app.source_symbols.open = true;
match other_modal {
1 => app.hosted.visible = true,
2 => {
@@ -856,18 +843,16 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
let before = evidence(&app);
let mut messages = captured_escape(&app);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
if other_modal != 0 {
assert!(app.source_rename.open(), "existing modal has priority");
let mut messages = captured_escape(&app);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
}
assert!(!app.source_rename.open());
assert!(
app.text_workspace.drawer().is_some() && app.source_navigation.open,
app.source_symbols.open && app.source_navigation.open,
"rename Escape does not fall through"
);
if pending {
@@ -876,7 +861,7 @@ fn actual_captured_escape_closes_only_its_priority_owner_and_retains_worker() {
assert!(!app.source_rename.open());
}
assert_eq!(evidence(&app), before);
app.text_workspace.show_drawer(None);
app.source_symbols.open = false;
app.source_navigation.open = false;
assert!(captured_escape(&app).is_empty());
}
+31 -145
View File
@@ -31,47 +31,6 @@ fn admission_notice(error: query::AdmissionError) -> String {
}
impl App {
pub(super) fn source_input_envelope(&self, message: &Message) -> bool {
match message {
Message::SourceIndent(context, _) => {
context == &self.source_symbol_context() && self.source_navigation_available()
}
Message::SourceSymbols(context, event) => {
context.session == self.source_workspace.session()
&& context.path == self.source_workspace.active
&& 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(),
@@ -129,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()
@@ -155,7 +100,6 @@ impl App {
&& self.model_menu.is_none()
&& self.diagram_usages.is_none()
&& !self.reference_input_active()
&& self.text_workspace.menu().is_none()
&& self.architecture.is_none()
&& !self.behavior.has_pending()
}
@@ -171,12 +115,6 @@ impl App {
))
}
/// Capture the same source-owned query used by the keyboard/menu adapter.
pub(super) fn text_diagram_usages_message(&self) -> Option<Message> {
self.source_navigation_available()
.then(|| self.source_symbol_message(nav::SymbolEvent::DiagramUsages))
}
pub fn source_reveal_ticket(&self) -> Option<Id> {
self.source_symbols
.reveal
@@ -209,10 +147,6 @@ impl App {
&& nav::exact_cursor(self.source_workspace.text(), cursor)
{
self.queue_source_reveal(cursor, false);
// This ticket restores focus to the live editor, not a saved cursor.
// Iced's Cursor omits Word/Line selection kind; moving back to that
// lossy value would collapse an actual word/line selection.
self.source_jump = None;
}
}
@@ -242,14 +176,13 @@ impl App {
if self.source_select_line && cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return nav::interaction::guarded_focus(
self.source_editor_id(),
self.source_symbols.raw_interaction.clone(),
raw_interaction,
);
}
// A valid ticket with no jump is focus-only. All intentional navigation
// still takes the cursor branch above, including explicit line selection.
nav::interaction::guarded_focus(
self.source_editor_id(),
self.source_symbols.raw_interaction.clone(),
raw_interaction,
)
Task::none()
}
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
@@ -259,8 +192,7 @@ impl App {
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.open = true;
self.source_symbols.notice = Some("Apply source before finding diagram representations. Your current text is retained.".into());
return Task::none();
}
@@ -272,28 +204,21 @@ impl App {
.map(|request| request.job);
return task;
}
nav::SymbolEvent::Close => {
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
}
nav::SymbolEvent::Back => {
self.source_symbol_back();
// A successful Back needs no empty results panel. Retain an
// already open panel, or reveal an actionable return notice.
if self.source_symbols.notice.is_some() {
self.text_workspace
.show_drawer(Some(text_workspace::DrawerTab::References));
let refs = self.text_workspace.references();
return iced::widget::operation::scroll_to(
self.text_workspace.reference_scroll_id(),
iced::widget::scrollable::AbsoluteOffset {
x: 0.,
y: refs.scroll_y(),
},
);
}
self.source_symbols.open |= self.source_symbols.notice.is_some();
}
nav::SymbolEvent::Choose {
stamp,
reference,
index,
..
} => {
let Some(results) = self.source_symbols.results.clone() else {
return Task::none();
@@ -331,8 +256,7 @@ impl App {
}
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
self.text_workspace
.show_drawer(Some(text_workspace::DrawerTab::References));
self.source_symbols.open = true;
if !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
@@ -353,7 +277,7 @@ impl App {
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
Some(serial) => serial,
None => {
self.reset_source_navigation_session();
self.source_workspace.reset_navigation_session();
self.source_symbols.back.clear();
self.source_symbols.cache = None;
0
@@ -393,12 +317,9 @@ impl App {
};
self.source_symbols.in_flight = Some(request.job);
self.source_symbols.request = Some(request.clone());
self.source_symbols.display_request = Some(request.clone());
self.text_workspace
.begin_references(request.stamp, request.path.clone());
self.source_symbols.results = None;
self.source_symbols.notice = None;
let worker = Task::perform(
Task::perform(
async move {
let worker_request = request.clone();
let result = tokio::task::spawn_blocking(move || {
@@ -429,14 +350,7 @@ impl App {
nav::Completion { request, result }
},
Message::SourceSymbolsReady,
);
Task::batch([
worker,
iced::widget::operation::scroll_to(
self.text_workspace.reference_scroll_id(),
iced::widget::scrollable::AbsoluteOffset { x: 0., y: 0. },
),
])
)
}
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
@@ -449,11 +363,11 @@ impl App {
if diagram_query {
self.source_diagram_job = None;
}
let wanted_navigation = self
let wanted = self
.source_symbols
.request
.take()
.is_some_and(|current| current == request)
.is_some_and(|current| current.job == request.job)
&& request.interaction == self.source_symbols.interaction
&& request.stamp.snapshot == self.source_key()
&& nav::interaction::is_current(
@@ -463,34 +377,15 @@ impl App {
&& request.path == self.source_workspace.active
&& request.cursor == self.source_editor.cursor()
&& self.source_navigation_available();
let wanted_display = self
.source_symbols
.display_request
.as_ref()
.is_some_and(|current| current == &request)
&& request.stamp.snapshot == self.source_key();
if !wanted_display {
if !wanted {
return Task::none();
}
match completion.result {
Ok((snapshot, results))
if self.source_snapshot_matches(&snapshot)
&& results.stamp == request.stamp
&& results.action == request.action =>
{
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
self.source_symbols.cache = Some(snapshot);
self.source_symbols.results = Some(results.clone());
self.text_workspace.install_reference_files(
results.stamp,
results
.definitions
.iter()
.map(|row| row.path.clone())
.chain(results.references.iter().map(|row| row.path.clone())),
);
if diagram_query && wanted_navigation {
if wanted_navigation
&& results.disposition == query::Disposition::Direct
if diagram_query {
if results.disposition == query::Disposition::Direct
&& results.scope.validated
&& results.omitted_definitions == 0
&& let [row] = results.definitions.as_slice()
@@ -501,7 +396,7 @@ impl App {
&& crate::diagram_usage::alias(self.project(), identity.id())
== row.declaration.alias
{
self.text_workspace.show_drawer(None);
self.source_symbols.open = false;
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
crate::diagram_usage::Context {
document: self.canvas_document,
@@ -513,8 +408,7 @@ impl App {
self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into());
return Task::none();
}
if wanted_navigation
&& results.disposition == query::Disposition::Direct
if results.disposition == query::Disposition::Direct
&& let Some(row) = results.definitions.first()
{
self.source_symbol_jump(&row.path, row.position, row.end_position);
@@ -549,9 +443,6 @@ impl App {
Some("That source location is no longer available. Run navigation again.".into());
return;
}
// The destination is admitted. Capture effective Word/Line ranges only
// now, when this editor is actually about to leave its current position.
let departure = nav::selection::capture(&mut self.source_editor);
if let Some(buffer) = self
.source_workspace
.files
@@ -561,7 +452,7 @@ impl App {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
generation: buffer.generation(),
cursor: departure,
cursor: self.source_editor.cursor(),
};
self.source_symbols
.back
@@ -571,7 +462,8 @@ impl App {
}
self.source_symbols.back.push(origin);
}
self.source_workspace.switch(path, departure);
self.source_workspace
.switch(path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
}
@@ -586,7 +478,7 @@ impl App {
return;
};
let unchanged = origin.generation == buffer.generation();
let mut cursor = if unchanged {
let cursor = if unchanged {
origin.cursor
} else if origin.path == self.source_workspace.active {
self.source_editor.cursor()
@@ -600,11 +492,8 @@ impl App {
);
return;
}
let departure = nav::selection::capture(&mut self.source_editor);
if !unchanged && origin.path == self.source_workspace.active {
cursor = departure;
}
self.source_workspace.switch(&origin.path, departure);
self.source_workspace
.switch(&origin.path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
self.source_symbols.notice =
@@ -617,6 +506,3 @@ impl App {
#[cfg(test)]
mod tests;
#[cfg(all(test, debug_assertions))]
mod focus_selection_tests;
@@ -1,481 +0,0 @@
//! The mounted CPU renderer and App use the same cosmic-text editor engine, but
//! Iced's Content is parameterized by renderer and cannot be shared between them.
//! We hit-test the initial click in the laid-out CPU widget, seed that raw caret
//! in App, and relay the real subsequent SelectWord/SelectLine and editing actions.
//! App's own Content is never replaced or cursor-normalized after selection. This
//! tests the actual controller/content boundary without a window or GPU; native
//! replay remains the check of the complete production renderer/layout path.
use crate::app::Page;
use crate::app::{App, Message};
use crate::document::Document;
use crate::source_navigation as nav;
use crate::source_workspace::{LIMITS, Workspace};
use crate::views::source_tests::FontLayout;
use iced::advanced::{Layout, Shell, layout, widget as core_widget};
use iced::futures::StreamExt;
use iced::keyboard::{self, Key, Modifiers};
use iced::widget::{button, container, row, text_editor, text_input};
use iced::{Element, Event, Font, Size, mouse};
use iced::{Point, Task, widget};
use oa_core::{Project, source_project::SourceBundle};
use std::{collections::BTreeMap, sync::Arc, sync::atomic::Ordering};
const SOURCE: &str = concat!(
"// oa_contact tail\r\n",
"arcadia 7;\r\n",
"project model \"Selection\" id \"9e8f0000-0000-4000-8000-000000000091\" {}\r\n",
);
const HOST: Size = Size::new(620., 260.);
fn fixture() -> App {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
};
let mut app = App::with_project(Project::empty());
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.selected = app.project().id;
app.refresh_draft();
app.page = Page::Text;
app.show_source_file(false);
app
}
fn ui<'a>(
app: &App,
content: &'a text_editor::Content<FontLayout>,
) -> Element<'a, Message, iced::Theme, FontLayout> {
let find: Element<'a, Message, iced::Theme, FontLayout> = if app.source_navigation.open {
row![
text_input("Find", "")
.id("source-find")
.on_input(|s| Message::SourceNavigation(nav::Event::Query(s))),
container(button("Close").on_press(app.source_find_close_message()))
.id("selection-find-close"),
]
.into()
} else {
widget::Space::new().into()
};
// Column::push drops zero-height children. Keep both paths mounted even
// while Find is hidden, as the production retained-slot layout does.
widget::Column::from_vec(vec![
container(find)
.height(if app.source_navigation.open { 32. } else { 0. })
.into(),
widget::text_editor(content)
.id(app.source_editor_id())
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(iced::Fill)
.on_action(Message::SourceEdit)
.into(),
])
.width(iced::Fill)
.height(iced::Fill)
.into()
}
struct Mounted {
content: text_editor::Content<FontLayout>,
tree: core_widget::Tree,
}
// Operations follow widget-provided traversal instead of assuming a fixed
// relationship between layout nodes, transparent containers and state trees.
struct Inspection {
editor: core_widget::Id,
focused: Option<bool>,
close_bounds: Option<iced::Rectangle>,
}
impl core_widget::Operation for Inspection {
fn traverse(&mut self, operate: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
operate(self);
}
fn focusable(
&mut self,
id: Option<&core_widget::Id>,
_: iced::Rectangle,
state: &mut dyn core_widget::operation::Focusable,
) {
if id == Some(&self.editor) {
assert!(
self.focused.replace(state.is_focused()).is_none(),
"duplicate editor ID"
);
}
}
fn container(&mut self, id: Option<&core_widget::Id>, bounds: iced::Rectangle) {
if id == Some(&core_widget::Id::new("selection-find-close")) {
assert!(
self.close_bounds.replace(bounds).is_none(),
"duplicate Close target ID"
);
}
}
}
impl Mounted {
fn new() -> Self {
Self {
content: text_editor::Content::with_text(SOURCE),
tree: core_widget::Tree::empty(),
}
}
fn events(&mut self, app: &App, events: &[(Event, mouse::Cursor)]) -> Vec<Message> {
let mut view = ui(app, &self.content);
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
let mut messages = Vec::new();
for (event, cursor) in events {
let mut shell = Shell::new(&mut messages);
view.as_widget_mut().update(
&mut self.tree,
event,
Layout::new(&node),
*cursor,
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&iced::Rectangle::with_size(HOST),
);
if !matches!(event, Event::Mouse(mouse::Event::ButtonReleased(_))) {
assert!(
shell.is_event_captured(),
"mounted input must be handled: {event:?}"
);
}
}
messages
}
fn operation(&mut self, app: &App, mut op: Box<dyn core_widget::Operation>) {
let mut view = ui(app, &self.content);
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
loop {
view.as_widget_mut().operate(
&mut self.tree,
Layout::new(&node),
&FontLayout,
op.as_mut(),
);
match op.finish() {
core_widget::operation::Outcome::Chain(next) => op = next,
core_widget::operation::Outcome::None => break,
core_widget::operation::Outcome::Some(()) => panic!("focus does not emit messages"),
}
}
}
async fn task(&mut self, app: &App, task: Task<Message>) {
if let Some(mut actions) = iced_runtime::task::into_stream(task) {
while let Some(action) = actions.next().await {
let iced_runtime::Action::Widget(op) = action else {
panic!("expected a widget operation")
};
self.operation(app, op);
}
}
}
fn inspect(&mut self, app: &App) -> Inspection {
let mut inspection = Inspection {
editor: app.source_editor_id(),
focused: None,
close_bounds: None,
};
let mut view = ui(app, &self.content);
self.tree.diff(view.as_widget());
let node = view.as_widget_mut().layout(
&mut self.tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, HOST),
);
view.as_widget_mut().operate(
&mut self.tree,
Layout::new(&node),
&FontLayout,
&mut inspection,
);
inspection
}
fn editor_focused(&mut self, app: &App) -> bool {
self.inspect(app)
.focused
.expect("exactly one editor with the requested ID")
}
fn close_message(&mut self, app: &App, escape: bool) -> Message {
if escape {
let event = Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Named(keyboard::key::Named::Escape),
modified_key: Key::Named(keyboard::key::Named::Escape),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::Escape),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
text: None,
repeat: false,
});
assert!(
self.events(app, &[(event, mouse::Cursor::Unavailable)])
.is_empty()
);
// The existing subscription tests cover the captured-Escape bridge.
// Deliver its App message after this actual input captured the key.
Message::Escape
} else {
let center = self
.inspect(app)
.close_bounds
.expect("the mounted Find Close target exists")
.center();
let cursor = mouse::Cursor::Available(center);
let mut messages = self.events(
app,
&[
(
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
cursor,
),
(
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
cursor,
),
],
);
assert_eq!(messages.len(), 1);
let close = messages.remove(0);
assert!(matches!(close, Message::SourceFindClose(..)));
close
}
}
fn select(&mut self, app: &mut App, line: bool) {
// One mounted update batch avoids delays between Iced's consecutive-click
// timestamps; content actions are applied in their emitted order below.
let at = mouse::Cursor::Available(Point::new(65., 25.));
let events = (0..if line { 3 } else { 2 })
.flat_map(|_| {
[
(
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
at,
),
(
Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
at,
),
]
})
.collect::<Vec<_>>();
let messages = self.events(app, &events);
assert_eq!(messages.len(), if line { 3 } else { 2 });
for (index, message) in messages.into_iter().enumerate() {
let Message::SourceEdit(action) = message else {
panic!("editor action")
};
if index == 0 {
assert!(matches!(action, text_editor::Action::Click(_)));
self.content.perform(action);
let caret = self.content.cursor();
assert_eq!(caret.position.line, 0);
assert!(
(4..13).contains(&caret.position.column),
"click must hit inside oa_contact: {caret:?}"
);
// App's GPU-typed Content has no public layout API. Copy only the
// initial hit-tested caret before any Word/Line selection exists.
app.source_editor.move_to(caret);
} else {
assert_eq!(
action,
if index == 1 {
text_editor::Action::SelectWord
} else {
text_editor::Action::SelectLine
}
);
self.content.perform(action.clone());
let _ = app.update(Message::SourceEdit(action));
}
}
assert_eq!(
self.content.selection().as_deref(),
Some(if line {
"// oa_contact tail"
} else {
"oa_contact"
})
);
assert_eq!(app.source_editor.selection(), self.content.selection());
assert!(self.editor_focused(app));
}
fn type_replacement(&mut self, app: &mut App) {
let event = Event::Keyboard(keyboard::Event::KeyPressed {
key: Key::Character("x".into()),
modified_key: Key::Character("x".into()),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::KeyX),
location: keyboard::Location::Standard,
modifiers: Modifiers::empty(),
text: Some("x".into()),
repeat: false,
});
let messages = self.events(app, &[(event, mouse::Cursor::Unavailable)]);
assert_eq!(messages.len(), 1);
let Message::SourceEdit(action) = messages.into_iter().next().unwrap() else {
panic!("typed edit")
};
assert_eq!(
action,
text_editor::Action::Edit(text_editor::Edit::Insert('x'))
);
self.content.perform(action.clone());
let _ = app.update(Message::SourceEdit(action));
}
}
#[tokio::test]
async fn word_and_line_selection_survive_find_close_and_escape_then_replace_exact_bytes() {
for line in [false, true] {
for escape in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
let mut mounted = Mounted::new();
mounted.select(&mut app, line);
let selected = app.source_editor.selection();
let cursor = app.source_editor.cursor();
// Word/Line selection has coincident raw endpoints. Merely checking
// cursor equality after a move_to roundtrip cannot detect the defect.
assert_eq!(cursor.selection, Some(cursor.position));
let task = app.update(Message::SourceNavigation(nav::Event::Open(false)));
mounted.task(&app, task).await;
assert!(!mounted.editor_focused(&app));
assert_eq!(app.source_editor.selection(), selected);
let close = mounted.close_message(&app, escape);
let _ = app.update(close);
let ticket = app
.source_reveal_ticket()
.expect("guarded restoration ticket");
let task = app.update(Message::SourceViewport(Some(ticket)));
mounted.task(&app, task).await;
assert!(mounted.editor_focused(&app));
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection(),
selected,
"selected bytes must survive focus restoration"
);
assert_eq!(app.source_editor.text(), SOURCE);
mounted.type_replacement(&mut app);
let expected = SOURCE.replacen(selected.as_deref().unwrap(), "x", 1);
assert_eq!(app.source_editor.text(), expected);
assert_eq!(app.source_workspace.text(), expected);
assert_eq!(mounted.content.text(), expected);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
#[test]
fn mounted_direct_typing_control_and_old_cursor_roundtrip_discriminate_selection_loss() {
for line in [false, true] {
for old_roundtrip in [false, true] {
let mut app = fixture();
let mut mounted = Mounted::new();
mounted.select(&mut app, line);
let selected = app.source_editor.selection().unwrap();
let interior = app.source_editor.cursor();
if old_roundtrip {
app.source_editor.move_to(interior);
assert_ne!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
}
mounted.type_replacement(&mut app);
let expected = if old_roundtrip {
let mut text = SOURCE.to_owned();
text.insert(interior.position.column, 'x');
text
} else {
SOURCE.replacen(&selected, "x", 1)
};
assert_eq!(app.source_editor.text(), expected);
assert_eq!(app.source_workspace.text(), expected);
assert_eq!(mounted.content.text(), SOURCE.replacen(&selected, "x", 1));
}
}
}
#[tokio::test]
async fn stale_focus_ticket_and_later_raw_input_cannot_steal_focus_or_change_selected_bytes() {
for later_raw_input in [false, true] {
let mut app = fixture();
let mut mounted = Mounted::new();
mounted.select(&mut app, false);
let selected = app.source_editor.selection();
let open = app.update(Message::SourceNavigation(nav::Event::Open(false)));
mounted.task(&app, open).await;
let close = app.source_find_close_message();
let _ = app.update(close);
let ticket = app.source_reveal_ticket().unwrap();
let focus = if later_raw_input {
app.update(Message::SourceViewport(Some(ticket)))
} else {
Task::none()
};
let reopen = app.update(Message::SourceNavigation(nav::Event::Open(false)));
mounted.task(&app, reopen).await;
if later_raw_input {
app.source_symbols
.raw_interaction
.fetch_add(1, Ordering::SeqCst);
mounted.task(&app, focus).await;
} else {
let stale = app.update(Message::SourceViewport(Some(ticket)));
mounted.task(&app, stale).await;
}
assert!(app.source_navigation.open);
assert!(!mounted.editor_focused(&app));
assert_eq!(app.source_editor.selection(), selected);
assert_eq!(app.source_editor.text(), SOURCE);
}
}
#[tokio::test]
async fn explicit_cursor_navigation_still_moves_and_selects_requested_line() {
let mut app = fixture();
let mut mounted = Mounted::new();
mounted.select(&mut app, false);
let target = text_editor::Cursor {
position: text_editor::Position { line: 1, column: 3 },
selection: None,
};
// Clear the previous semantic selection as actual show_source_file does when
// moving to another source buffer, then exercise intentional navigation.
app.show_source_file(false);
app.queue_source_reveal(target, true);
let ticket = app.source_reveal_ticket().unwrap();
let task = app.update(Message::SourceViewport(Some(ticket)));
mounted.task(&app, task).await;
assert_eq!(app.source_editor.cursor().position, target.position);
assert_eq!(app.source_editor.selection().as_deref(), Some("arcadia 7;"));
}
+79 -589
View File
@@ -1,12 +1,9 @@
use super::*;
use crate::app::text_workspace::{DrawerTab, Event as TextEvent};
use crate::source_workspace::Workspace;
use oa_core::source_project::SourceBundle;
use std::collections::BTreeMap;
use std::time::Instant;
mod departure_selection;
const DEFINITIONS: &str = "defs/活動.arcadia";
const USES: &str = "uses.arcadia";
@@ -38,7 +35,6 @@ fn fixture() -> App {
let mut app = App::with_project(Project::empty());
app.document = Document::from_bundle(fixture_bundle(), LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
app.selected = app.project().id;
app.refresh_draft();
app.page = Page::Text;
@@ -52,28 +48,6 @@ fn fixture() -> App {
app
}
fn select_effective_pump(app: &mut App, line: bool) -> String {
let source = app.source_workspace.text();
let offset = source.find("source @pump").unwrap() + "source @pu".len();
app.source_editor.move_to(text_editor::Cursor {
position: position(source, offset),
selection: None,
});
send(
app,
Message::SourceEdit(if line {
text_editor::Action::SelectLine
} else {
text_editor::Action::SelectWord
}),
);
let selected = app.source_editor.selection().unwrap();
assert!(selected.contains("pump"));
let raw = app.source_editor.cursor();
assert_eq!(raw.selection, Some(raw.position));
selected
}
fn send(app: &mut App, message: Message) {
// Dropping the returned Task does not poll its worker/lint future. Completion
// below is deterministic and still traverses the real App message handler.
@@ -85,23 +59,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,
visit: app.text_workspace.drawer_visit(),
reference,
index,
})
}
fn references_visible(app: &App) -> bool {
app.text_workspace.drawer() == Some(DrawerTab::References)
}
fn show(app: &mut App, path: &str) {
send(app, Message::SourceFile(path.into()));
reveal(app);
@@ -140,9 +97,6 @@ fn begin(app: &mut App, action: query::Action) -> nav::Request {
.clone()
.expect("request admitted");
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp));
assert!(references_visible(app));
request
}
@@ -209,8 +163,14 @@ fn valid_definition_and_reference_choice_keep_whole_model_and_source_unchanged()
"references require a choice"
);
let second = uses.references[1].clone();
let message = choice(&app, uses.stamp, true, 1);
send(&mut app, message);
symbol(
&mut app,
nav::SymbolEvent::Choose {
stamp: uses.stamp,
reference: true,
index: 1,
},
);
reveal(&mut app);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(
@@ -251,8 +211,14 @@ fn detached_duplicate_requires_an_explicit_choice_even_when_included_source_is_v
results.definitions[index].membership,
query::Membership::Detached
);
let message = choice(&app, results.stamp, false, index);
send(&mut app, message);
symbol(
&mut app,
nav::SymbolEvent::Choose {
stamp: results.stamp,
reference: false,
index,
},
);
reveal(&mut app);
assert_eq!(app.source_workspace.active, "detached.arcadia");
assert_eq!(app.source_workspace.bundle(), loaded);
@@ -300,8 +266,14 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() {
.iter()
.position(|row| row.declaration.alias == "pump")
.unwrap();
let message = choice(&app, results.stamp, false, index);
send(&mut app, message);
symbol(
&mut app,
nav::SymbolEvent::Choose {
stamp: results.stamp,
reference: false,
index,
},
);
reveal(&mut app);
symbol(&mut app, nav::SymbolEvent::Back);
reveal(&mut app);
@@ -326,12 +298,11 @@ fn invalid_unapplied_drafts_navigate_without_apply_or_losing_either_history() {
}
#[test]
fn completion_retains_current_display_but_rejects_navigation_after_context_changes() {
fn completion_rejects_file_edit_session_cursor_interaction_and_raw_input_changes() {
for change in 0..7 {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
let accepted = app.document.snapshot();
match change {
0 => show(&mut app, "project.arcadia"),
@@ -355,38 +326,23 @@ fn completion_retains_current_display_but_rejects_navigation_after_context_chang
let after = app.source_workspace.bundle();
let active = app.source_workspace.active.clone();
let cursor = app.source_editor.cursor();
let drawer = app.text_workspace.drawer();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(
app.source_symbols.in_flight.is_none(),
"case {change}: release worker slot"
);
if matches!(change, 1 | 2) {
assert!(
app.source_symbols.results.is_none(),
"case {change}: changed source/session cannot own display"
);
} else {
assert!(
Arc::ptr_eq(app.source_symbols.results.as_ref().unwrap(), &expected),
"case {change}: canceled navigation still owns current-source display"
);
}
assert_eq!(
app.text_workspace.drawer(),
drawer,
"completion cannot select a tab"
assert!(
app.source_symbols.results.is_none(),
"case {change}: stale result surfaced"
);
assert!(app.source_symbols.back.is_empty());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.active, active);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), after);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_new_results() {
let mut app = fixture();
@@ -410,41 +366,25 @@ fn unknown_job_cannot_release_current_worker_and_old_duplicate_cannot_replace_ne
}
#[test]
fn close_during_busy_retains_hidden_completion_without_reopening_or_navigation() {
fn close_during_busy_hides_panel_and_completion_does_not_reopen_it() {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
assert!(references_visible(&app));
close_drawer(&mut app);
assert!(app.text_workspace.drawer().is_none());
assert!(app.source_symbols.open);
symbol(&mut app, nav::SymbolEvent::Close);
assert!(!app.source_symbols.open);
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.text_workspace.drawer().is_none());
assert!(!app.source_symbols.open);
assert!(app.source_symbols.in_flight.is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.back.is_empty());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.results.is_none());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
let next = begin(&mut app, query::Action::References);
assert_ne!(next.job, request.job);
assert!(references_visible(&app));
assert!(
app.source_symbols.results.is_none(),
"explicit new query replaces prior display ownership"
);
assert!(app.source_symbols.open);
}
#[test]
fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal() {
let mut app = fixture();
@@ -478,7 +418,7 @@ fn matching_reveal_ticket_is_required_and_raw_input_invalidates_pending_reveal()
}
#[test]
fn escape_hides_results_and_cancels_navigation_but_retains_latest_display() {
fn escape_dismisses_results_and_cancels_busy_navigation_without_changing_sources() {
for pending in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
@@ -486,42 +426,23 @@ fn escape_hides_results_and_cancels_navigation_but_retains_latest_display() {
let cursor = app.source_editor.cursor();
let request = begin(&mut app, query::Action::References);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
if !pending {
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
assert!(app.source_symbols.results.is_some());
}
let results = app.source_symbols.results.clone();
app.source_navigation.open = true;
let visit = app.text_workspace.drawer_visit();
send(&mut app, Message::Escape);
assert!(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.open && !app.source_navigation.open);
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.notice.is_none());
assert!(app.source_symbols.request.is_none());
reveal(&mut app);
send(&mut app, Message::Escape);
assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open);
assert_eq!(app.source_symbols.results.is_some(), !pending);
assert!(app.source_symbols.request.is_none());
// Explicit Escape may restore editor focus; consume that ticket before
// delivering completion so any later focus request is discriminated.
reveal(&mut app);
if pending {
assert_eq!(app.source_symbols.in_flight, Some(request.job));
send(&mut app, Message::SourceSymbolsReady(ready));
}
assert!(app.source_symbols.in_flight.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(!app.source_symbols.open);
assert!(app.source_symbols.results.is_none());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
@@ -530,6 +451,7 @@ fn escape_hides_results_and_cancels_navigation_but_retains_latest_display() {
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn escape_dismisses_modal_before_underlying_source_results() {
let mut app = fixture();
@@ -543,17 +465,14 @@ fn escape_dismisses_modal_before_underlying_source_results() {
app.source_reveal_ticket().is_none(),
"modal dismissal must not focus the editor"
);
assert!(references_visible(&app));
assert!(app.source_symbols.open);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
send(&mut app, Message::Escape);
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert!(!app.source_symbols.open);
assert!(app.source_symbols.results.is_none());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
@@ -566,8 +485,8 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
let origin = app.source_editor.cursor();
finish(&mut app, query::Action::Definition);
reveal(&mut app);
close_drawer(&mut app);
assert!(app.text_workspace.drawer().is_none() && app.source_symbols.results.is_some());
symbol(&mut app, nav::SymbolEvent::Close);
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
if changed {
// Use the workspace edit API to give the origin a new generation.
show(&mut app, USES);
@@ -587,7 +506,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
let raw = app.source_workspace.bundle();
symbol(&mut app, nav::SymbolEvent::Back);
assert_eq!(app.source_workspace.active, USES);
assert_eq!(references_visible(&app), changed);
assert_eq!(app.source_symbols.open, changed);
assert_eq!(app.source_symbols.notice.is_some(), changed);
reveal(&mut app);
if !changed {
@@ -599,7 +518,7 @@ fn closed_results_stay_closed_on_exact_back_and_reopen_only_for_a_return_notice(
}
let mut app = fixture();
symbol(&mut app, nav::SymbolEvent::Back);
assert!(references_visible(&app) && app.source_symbols.notice.is_some());
assert!(app.source_symbols.open && app.source_symbols.notice.is_some());
}
#[test]
@@ -610,12 +529,12 @@ fn find_only_captured_escape_closes_once_and_preserves_raw_query_and_selection()
let cursor = app.source_editor.cursor();
app.source_navigation.open = true;
app.source_navigation.query = "送水 🚀".into();
assert!(!references_visible(&app));
assert!(!app.source_symbols.open);
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 && !references_visible(&app));
assert!(!app.source_navigation.open && !app.source_symbols.open);
assert_eq!(app.source_navigation.query, "送水 🚀");
assert!(app.source_reveal_ticket().is_some());
reveal(&mut app);
@@ -857,13 +776,8 @@ 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)
}
fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
use iced::advanced::{Layout, Shell, layout, subscription, widget};
use iced::futures::{StreamExt, stream, task::noop_waker_ref};
use iced::keyboard::{self, key::Named};
@@ -879,11 +793,9 @@ fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
input
.as_widget_mut()
.layout(&mut tree, &(), &layout::Limits::new(iced::Size::ZERO, size));
if focused {
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
}
tree.state
.downcast_mut::<iced::widget::text_input::State<()>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(Named::Escape),
modified_key: keyboard::Key::Named(Named::Escape),
@@ -905,8 +817,7 @@ fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
&mut shell,
&iced::Rectangle::new(iced::Point::ORIGIN, size),
);
let captured = shell.is_event_captured();
assert_eq!(captured, focused);
assert!(shell.is_event_captured());
assert!(input_messages.is_empty());
assert!(
!tree
@@ -917,11 +828,7 @@ fn input_subscription_messages(app: &App, focused: bool) -> Vec<Message> {
let event = subscription::Event::Interaction {
window: iced::window::Id::unique(),
event,
status: if captured {
iced::event::Status::Captured
} else {
iced::event::Status::Ignored
},
status: iced::event::Status::Captured,
};
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
@@ -929,7 +836,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 +844,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
}
@@ -969,9 +864,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
send(&mut app, Message::SourceSymbolsReady(ready.clone()));
}
let results = app.source_symbols.results.clone();
let expected = ready.result.as_ref().unwrap().1.clone();
app.source_navigation.open = true;
let visit = app.text_workspace.drawer_visit();
match modal {
0 => {}
1 => app.hosted.visible = true,
@@ -991,12 +884,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
send(&mut app, messages.remove(0));
if modal != 0 {
assert!(!app.hosted.visible && app.file_prompt.is_none() && app.pending.is_none());
assert!(references_visible(&app) && app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(
app.source_reveal_ticket().is_none(),
"modal Escape cannot steal editor focus"
);
assert!(app.source_symbols.open && app.source_navigation.open);
match (&results, &app.source_symbols.results) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
@@ -1007,38 +895,14 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
}
assert!(references_visible(&app) && !app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
match (&results, &app.source_symbols.results) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
_ => panic!("Find Escape must retain underlying results"),
}
assert_eq!(app.source_symbols.results.is_some(), !pending);
assert!(app.source_reveal_ticket().is_some());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
// One more actual Escape hides the remaining drawer. Both status
// routes emit exactly one App message; no second bridge is needed.
let mut messages = input_subscription_messages(&app, false);
assert_eq!(messages.len(), 1);
assert!(matches!(messages[0], Message::Escape));
send(&mut app, messages.remove(0));
assert!(!references_visible(&app) && !app.source_navigation.open);
assert!(app.source_symbols.request.is_none());
reveal(&mut app);
assert!(!app.source_symbols.open && !app.source_navigation.open);
assert!(app.source_symbols.results.is_none() && app.source_symbols.request.is_none());
if pending {
assert_eq!(app.source_symbols.in_flight, Some(request.job));
send(&mut app, Message::SourceSymbolsReady(ready));
}
assert!(app.source_symbols.in_flight.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
@@ -1055,7 +919,7 @@ fn captured_find_and_modal_escape_use_one_actual_subscription_and_preserve_pendi
#[test]
fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_routes() {
let mut app = fixture();
app.text_workspace.show_drawer(Some(DrawerTab::References));
app.source_symbols.open = true;
let project = app.project().clone();
assert!(
app.behavior
@@ -1085,7 +949,7 @@ fn captured_subscription_does_not_duplicate_existing_behavior_or_architecture_ro
assert!(app.architecture.as_ref().unwrap().inline());
// A retained Text panel must not add a second route to inline architecture.
app.page = Page::Text;
app.text_workspace.show_drawer(Some(DrawerTab::References));
app.source_symbols.open = true;
let messages = captured_input_subscription_messages(&app);
assert_eq!(messages.len(), 1);
assert!(matches!(
@@ -1305,16 +1169,7 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message])
.collect();
let results = app.source_symbols.results.clone();
let cache = app.source_symbols.cache.clone();
let panel = (
app.text_workspace.drawer(),
app.source_symbols.notice.clone(),
);
let display_request = app.source_symbols.display_request.clone();
let reference_presentation = (
app.text_workspace.references().stamp(),
app.text_workspace.drawer_visit(),
app.text_workspace.references().scroll_y(),
);
let panel = (app.source_symbols.open, app.source_symbols.notice.clone());
let key = app.source_key();
let active = app.source_workspace.active.clone();
let cursor = app.source_editor.cursor();
@@ -1379,21 +1234,9 @@ fn assert_stale_envelopes_preserve_current(app: &mut App, messages: &[Message])
back
);
assert_eq!(
(
app.text_workspace.drawer(),
app.source_symbols.notice.clone()
),
(app.source_symbols.open, app.source_symbols.notice.clone()),
panel
);
assert_eq!(app.source_symbols.display_request, display_request);
assert_eq!(
(
app.text_workspace.references().stamp(),
app.text_workspace.drawer_visit(),
app.text_workspace.references().scroll_y()
),
reference_presentation
);
match (&results, &app.source_symbols.results) {
(Some(before), Some(after)) => assert!(Arc::ptr_eq(before, after)),
(None, None) => {}
@@ -1438,8 +1281,12 @@ 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())),
choice(&app, old_results.stamp, false, 0),
app.source_symbol_message(nav::SymbolEvent::Close),
app.source_symbol_message(nav::SymbolEvent::Choose {
stamp: old_results.stamp,
reference: false,
index: 0,
}),
];
if reset {
let project = app.project().clone();
@@ -1499,9 +1346,9 @@ fn stale_envelope_trial(reset: bool) {
// Replay once more with no busy-slot fallback and visible current results.
assert_stale_envelopes_preserve_current(&mut app, &old_messages);
let interaction = app.source_symbols.interaction;
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());
assert!(!app.source_symbols.open && app.source_symbols.results.is_none());
let next = begin(&mut app, query::Action::References);
assert_ne!(
next.job, current.job,
@@ -1654,332 +1501,12 @@ fn process_memory() -> Option<(usize, usize)> {
(ok != 0).then_some((counters.working_set, counters.peak_working_set))
}
#[test]
fn delayed_definition_after_hide_or_tab_change_retains_data_without_reopening_or_focus() {
for destination in [None, Some(DrawerTab::Problems), Some(DrawerTab::Status)] {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let expected = ready.result.as_ref().unwrap().1.clone();
assert_eq!(expected.disposition, query::Disposition::Direct);
let event = destination.map_or(TextEvent::HideDrawer(app.text_drawer_context()), |tab| {
TextEvent::ShowDrawer(app.text_drawer_context(), tab)
});
send(&mut app, Message::TextWorkspace(event));
assert_eq!(app.text_workspace.drawer(), destination);
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
assert_eq!(app.source_symbols.in_flight, Some(request.job));
let visit = app.text_workspace.drawer_visit();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert_eq!(app.text_workspace.drawer(), destination);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert_eq!(app.text_workspace.references().stamp(), Some(request.stamp));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
{
let captured = Message::TextWorkspace(TextEvent::ShowDrawer(
app.text_drawer_context(),
DrawerTab::References,
));
send(&mut app, captured);
};
assert!(references_visible(&app));
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&expected
));
assert!(app.source_symbols.in_flight.is_none() && app.source_symbols.request.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_editor.cursor(), cursor);
}
}
#[test]
fn same_worker_with_changed_request_envelope_cannot_publish_or_navigate() {
for changed in 0..7 {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let mut ready = completion(&app, request.clone());
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
match changed {
0 => ready.request.stamp.serial += 1,
1 => ready.request.path = DEFINITIONS.into(),
2 => ready.request.offset += 1,
3 => ready.request.cursor.selection = None,
4 => ready.request.interaction = Id::new_v4(),
5 => ready.request.raw_interaction += 1,
_ => ready.request.action = query::Action::References,
}
assert_eq!(ready.request.job, request.job);
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(
app.source_symbols.in_flight.is_none(),
"worker completed in case {changed}"
);
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&request));
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn matching_old_worker_cannot_replace_a_newer_display_owner() {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let old = begin(&mut app, query::Action::Definition);
let ready = completion(&app, old.clone());
let mut latest = old.clone();
latest.stamp.serial += 1;
latest.job = Id::new_v4();
// Isolate display ownership from both worker ownership and navigation intent.
app.source_interaction();
app.source_symbols.display_request = Some(latest.clone());
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert_eq!(app.source_symbols.display_request.as_ref(), Some(&latest));
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.active, USES);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn hidden_completion_checks_exact_bytes_even_when_snapshot_key_was_not_advanced() {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request.clone());
let accepted = app.document.snapshot();
{
let captured = Message::TextWorkspace(TextEvent::HideDrawer(app.text_drawer_context()));
send(&mut app, captured);
};
// Deliberately violate the normal edit API's key advancement: the exact
// source witness must still reject stale bytes instead of trusting the key.
app.source_workspace
.files
.get_mut(USES)
.unwrap()
.text
.push_str("\r\n// changed bytes");
assert_eq!(app.source_key(), request.stamp.snapshot);
let changed = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(app.source_symbols.results.is_none());
assert!(app.text_workspace.drawer().is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.bundle(), changed);
assert_eq!(app.source_editor.cursor(), cursor);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn popup_escape_has_no_duplicate_bridge_and_dismissal_preserves_underlying_find_and_drawer() {
for line in [false, true] {
for captured_find_escape in [false, true] {
let mut app = fixture();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let results = finish(&mut app, query::Action::References);
let selected = select_effective_pump(&mut app, line);
let cursor = app.source_editor.cursor();
let group = TextEvent::ToggleFile {
stamp: results.stamp,
visit: app.text_workspace.drawer_visit(),
path: USES.into(),
};
send(&mut app, Message::TextWorkspace(group));
let visit = app.text_workspace.drawer_visit();
send(
&mut app,
Message::TextWorkspace(TextEvent::ReferenceScrolled {
stamp: results.stamp,
visit,
y: 96.,
}),
);
assert!(app.text_workspace.references().collapsed(USES));
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
send(&mut app, Message::SourceNavigation(nav::Event::Open(false)));
app.source_navigation.query = "Retain Find 雨".into();
let open = app.text_menu_message(iced::Point::new(30., 30.));
send(&mut app, open);
let menu = app.text_workspace_menu().expect("Text popup opened");
assert!(
captured_input_subscription_messages(&app).is_empty(),
"Popup owns captured Escape; a bridge Escape would also close its underlying UI"
);
// The popup itself publishes this tokened dismissal on Escape.
send(&mut app, menu.on_close);
assert!(app.text_workspace.menu().is_none());
assert!(references_visible(&app) && app.source_navigation.open);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
let mut next = input_subscription_messages(&app, captured_find_escape);
assert_eq!(
next.len(),
1,
"exactly one message for either Escape status"
);
assert!(matches!(next[0], Message::Escape));
send(&mut app, next.remove(0));
assert!(references_visible(&app) && !app.source_navigation.open);
assert_eq!(
app.text_workspace.drawer_visit(),
visit,
"closing Find does not replace the drawer visit"
);
assert_eq!(app.source_navigation.query, "Retain Find 雨");
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert!(app.text_workspace.references().collapsed(USES));
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
assert!(app.source_reveal_ticket().is_some() && app.source_jump.is_none());
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
let mut last = input_subscription_messages(&app, false);
assert_eq!(last.len(), 1);
assert!(matches!(last[0], Message::Escape));
send(&mut app, last.remove(0));
assert!(app.text_workspace.drawer().is_none() && !app.source_navigation.open);
assert_ne!(app.text_workspace.drawer_visit(), visit);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert!(app.text_workspace.references().collapsed(USES));
assert_eq!(app.text_workspace.references().scroll_y(), 96.);
assert_eq!(app.source_navigation.query, "Retain Find 雨");
reveal(&mut app);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(
app.source_editor.selection().as_deref(),
Some(selected.as_str())
);
assert!(captured_input_subscription_messages(&app).is_empty());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(app.source_symbols.back.is_empty());
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
#[test]
fn retained_choices_cannot_replay_after_hide_or_references_problems_references() {
for reference in [false, true] {
for hide in [false, true] {
let mut app = fixture();
let results = finish(&mut app, query::Action::References);
let index = usize::from(reference);
let old = choice(&app, results.stamp, reference, index);
let old_visit = app.text_workspace.drawer_visit();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let event = if hide {
TextEvent::HideDrawer(app.text_drawer_context())
} else {
TextEvent::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems)
};
send(&mut app, Message::TextWorkspace(event));
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert_stale_envelopes_preserve_current(&mut app, std::slice::from_ref(&old));
{
let captured = Message::TextWorkspace(TextEvent::ShowDrawer(
app.text_drawer_context(),
DrawerTab::References,
));
send(&mut app, captured);
};
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
assert_eq!(
app.source_key(),
results.stamp.snapshot,
"same source isolates the visit guard"
);
assert_stale_envelopes_preserve_current(&mut app, &[old]);
let fresh = choice(&app, results.stamp, reference, index);
send(&mut app, fresh);
assert!(
app.source_symbols.reveal.is_some(),
"freshly captured reopened choice remains usable"
);
assert_eq!(app.source_symbols.back.len(), 1);
reveal(&mut app);
let (path, span) = if reference {
let row = &results.references[index];
(&row.path, row.span)
} else {
let row = &results.definitions[index];
(&row.path, row.span)
};
assert_eq!(&app.source_workspace.active, path);
assert_eq!(
app.source_editor.cursor(),
selection(&raw.files[path], span.start, span.end, false)
);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.document.revision(), 0);
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
}
#[test]
fn combined_modal_completion_releases_worker_without_navigation_or_model_changes() {
for modal in 0..5 {
let mut app = fixture();
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request);
let expected = ready.result.as_ref().unwrap().1.clone();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
@@ -2011,11 +1538,7 @@ fn combined_modal_completion_releases_worker_without_navigation_or_model_changes
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none(), "modal {modal}");
assert!(app.source_symbols.request.is_none());
// 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.results.is_none());
assert!(app.source_symbols.back.is_empty());
assert!(app.source_reveal_ticket().is_none());
assert_eq!(app.source_workspace.active, USES);
@@ -2066,7 +1589,6 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
.move_to(selection(text, offset, offset + alias.len(), false));
let request = begin(&mut app, query::Action::Definition);
let ready = completion(&app, request);
let expected = ready.result.as_ref().unwrap().1.clone();
app.page = Page::Diagram;
send(&mut app, Message::FieldReferenceOpen(open));
assert!(app.reference_input_active(), "{}", app.status);
@@ -2079,11 +1601,7 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
let cursor = app.source_editor.cursor();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
// 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.results.is_none());
assert!(app.source_reveal_ticket().is_none());
assert!(
app.reference_input_active(),
@@ -2093,31 +1611,3 @@ fn combined_reference_chooser_blocks_navigation_and_completion_precedes_reconcil
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn same_key_changed_bytes_mark_retained_rows_stale_before_choice_boundaries() {
for path in [USES, DEFINITIONS] {
let mut app = fixture();
let results = finish(&mut app, query::Action::References);
assert!(app.source_results_current());
#[cfg(debug_assertions)]
assert!(!crate::views::captured_source_reference_stale_summary(&app));
let captured = choice(&app, results.stamp, true, 0);
let key = app.source_key();
// Isolate byte authority from revision authority in both the displayed
// and an inactive buffer. Views share this exact currentness decision.
app.source_workspace
.files
.get_mut(path)
.unwrap()
.text
.push_str("\r\n// exact bytes changed");
assert_eq!(app.source_key(), key);
assert!(!app.source_results_current());
#[cfg(debug_assertions)]
assert!(crate::views::captured_source_reference_stale_summary(&app));
app.queue_source_reveal(app.source_editor.cursor(), false);
assert!(app.source_reveal_ticket().is_some());
assert_stale_envelopes_preserve_current(&mut app, &[captured]);
}
}
@@ -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,160 +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.),
bend: None,
route_segment: None,
},
);
mounted.settle(&mut app).await;
let token = app.model_menu.as_ref().unwrap().token;
send(
&mut app,
Message::Menu(crate::model_menu::Event::Invoke(
token,
Box::new(Message::StateDiagram(Event::Choose(
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source,
},
))),
)),
);
mounted.settle(&mut app).await;
assert!(app.model_menu.is_none());
assert_eq!(
app.behavior.diagram.tool,
Tool::AdjustAttachment {
relation,
end: EdgeEnd::Source
}
);
(app, mounted, relation)
}
fn canvas_focus(mounted: &mut Mounted, app: &App) -> (iced::Rectangle, bool) {
let id = iced::advanced::widget::Id::from("diagram-keyboard-owner");
let (_, bounds, focused) = mounted
.geometry(app)
.focused
.into_iter()
.find(|(candidate, _, _)| candidate.as_ref() == Some(&id))
.unwrap();
(bounds, focused)
}
#[tokio::test]
async fn mounted_menu_armed_attachment_escape_without_canvas_click_preserves_document_and_focus() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
let history = (app.document.can_undo(), app.document.can_redo());
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
assert!(!canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(messages.iter().any(|m| matches!(m, Message::Escape)));
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(!canvas_focus(&mut mounted, &app).1);
press(&mut mounted, &mut app, Named::Delete).await;
assert!(
!app.delete_pending,
"Escape must not grant canvas Delete ownership"
);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!((app.document.can_undo(), app.document.can_redo()), history);
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
}
#[tokio::test]
async fn mounted_attachment_escape_preserves_modal_priority_and_focused_canvas_control() {
let (mut app, mut mounted, _) = armed().await;
let before = app.document.snapshot();
// Existing modal Escape consumes the first key without canceling the tool underneath.
send(&mut app, Message::Delete);
assert!(app.delete_pending && app.delete_target.is_some());
mounted.settle(&mut app).await;
press(&mut mounted, &mut app, Named::Escape).await;
assert!(!app.delete_pending);
assert!(matches!(
app.behavior.diagram.tool,
Tool::AdjustAttachment { .. }
));
// A real canvas click grants ownership; inner-canvas Escape remains live.
let bounds = canvas_focus(&mut mounted, &app).0;
mounted
.click(&mut app, iced::Point::new(bounds.x + 10., bounds.y + 10.))
.await;
assert!(canvas_focus(&mut mounted, &app).1);
let messages = press(&mut mounted, &mut app, Named::Escape).await;
assert!(
messages
.iter()
.any(|m| matches!(m, Message::StateDiagram(Event::Choose(Tool::Select))))
);
assert_eq!(app.behavior.diagram.tool, Tool::Select);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
}
@@ -1,480 +0,0 @@
use super::*;
#[test]
fn state_bend_delayed_commit_after_same_view_selection_is_inert_and_live_commit_works() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let next = app.project().relations[&relation].target();
let waypoints = vec![Position { x: 715., y: -425. }];
let delayed = Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
};
send(&mut app, Message::Select(next));
assert_eq!(app.selected, next);
assert_eq!(
app.state_context(),
Some(context),
"same-view selection does not retire the context stamp"
);
let before = app.document.snapshot();
let camera = (
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit,
);
event(&mut app, delayed);
assert_eq!(app.selected, next);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(!app.document.can_undo());
assert_eq!(
(
app.behavior.diagram.zoom,
app.behavior.diagram.pan,
app.behavior.diagram.fit
),
camera
);
send(&mut app, Message::Select(relation));
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: waypoints.clone(),
},
);
let mut expected = before.decoded.project.clone();
let route = expected
.diagrams
.get_mut(&context.diagram)
.unwrap()
.routes
.iter_mut()
.find(|route| route.edge == relation)
.unwrap();
route.waypoints = waypoints;
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
send(&mut app, Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
!app.document.can_undo(),
"live drag remains a single transaction"
);
}
#[test]
fn state_bend_menu_carries_exact_segment_and_pin_without_changing_delete_action() {
let (mut app, _, relation, _) = setup();
let context = app.state_context().unwrap();
let point = Position { x: 500., y: -400. };
event(
&mut app,
Event::ContextMenu {
context,
object: relation,
placement: None,
anchor: iced::Point::ORIGIN,
bend: Some(1),
route_segment: Some((2, point)),
},
);
let menu = crate::model_menu::popup(&app).unwrap();
let mut add = false;
let mut remove = false;
let mut delete = false;
for entry in menu.entries {
if let crate::context_menu::Entry::Action {
message: Some(Message::Menu(crate::model_menu::Event::Invoke(_, message))),
..
} = entry
{
match *message {
Message::StateDiagram(Event::AddBend {
relation: id,
segment: Some((2, at)),
..
}) => {
assert_eq!(id, relation);
assert_eq!(at, point);
add = true;
}
Message::StateDiagram(Event::RemoveBend {
relation: id,
index: 1,
..
}) => {
assert_eq!(id, relation);
remove = true;
}
Message::Delete => delete = true,
_ => {}
}
}
}
assert!(add && remove && delete);
}
fn setup() -> (App, Id, Id, Id) {
let mut f = fixture();
let diagram = create_view(&mut f).diagram;
let mut project = f.app.project().clone();
let relation = project.relate(RelationKind::StateTransition, "Bend trial", f.leaf, f.b);
let second = state_edit::create_view(&project, f.machine).unwrap();
project = prepare_command(&project, &second.command).unwrap().project;
project
.diagrams
.get_mut(&diagram)
.unwrap()
.routes
.push(EdgeRoute {
edge: relation,
waypoints: vec![
Position { x: 650., y: -400. },
Position { x: 300., y: -400. },
Position { x: 650., y: -400. },
],
label: Some(EdgeLabel {
fraction: 0.35,
offset: Position { x: 9., y: -23. },
}),
source_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Top,
offset: 0.234567,
}),
target_attachment: Some(EdgeAttachment {
side: PortAnchorSide::Bottom,
offset: 0.75,
}),
});
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
(app, diagram, relation, f.a)
}
#[test]
fn state_bend_commands_preserve_other_fields_views_and_exact_history() {
let (mut app, diagram, relation, _) = setup();
let baseline = app.document.snapshot();
let points = vec![Position { x: 715., y: 425. }];
let mut expected = app.project().clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points.clone();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points.clone(),
},
);
assert_eq!(app.project(), &expected);
assert_source_model_agree(&app);
let changed = app.document.snapshot();
let context = app.state_context().unwrap();
event(
&mut app,
Event::SetBends {
context,
relation,
waypoints: points,
},
);
assert!(
Arc::ptr_eq(&changed, &app.document.snapshot()),
"equal bends must not add history"
);
send(&mut app, Message::Undo);
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
send(&mut app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &changed.bundle);
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
assert_eq!(app.project(), &expected);
let cleared = app.document.snapshot();
let context = app.state_context().unwrap();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&cleared, &app.document.snapshot()));
}
#[test]
fn state_bend_self_loop_add_move_remove_clear_preserves_project_and_source_history() {
let (app, diagram, relation, _) = setup();
let mut project = app.project().clone();
let RelationBody::StateTransition { source, target, .. } =
&mut project.relations.get_mut(&relation).unwrap().body
else {
panic!("State transition fixture")
};
*target = *source;
project.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.clear();
oa_core::validate_candidate(&project).unwrap();
let mut app = App::with_project(project);
send(&mut app, Message::RevealInDiagram(diagram, relation));
let baseline = app.document.snapshot();
let geometry = state_edit::bend_route(app.project(), diagram, relation).unwrap();
assert!(!geometry.custom_suspended);
assert!(geometry.pins.is_empty());
assert!(
geometry.points.len() >= 4,
"actual external self-loop routing"
);
let segment = 1;
let point = Position {
x: (geometry.points[segment].x + geometry.points[segment + 1].x) / 2.,
y: (geometry.points[segment].y + geometry.points[segment + 1].y) / 2.,
};
// Each operation must alter only the complete Project's waypoint list;
// Undo and Redo must restore exact serialized source, not just geometry.
fn check(app: &mut App, diagram: Id, mutation: Event, points: Vec<Position>) {
let before = app.document.snapshot();
let mut expected = before.decoded.project.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints = points;
event(app, mutation);
assert_eq!(app.project(), &expected);
assert_source_model_agree(app);
let after = app.document.snapshot();
assert_ne!(after.bundle, before.bundle);
send(app, Message::Undo);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.bundle(), &before.bundle);
send(app, Message::Redo);
assert_eq!(app.project(), &expected);
assert_eq!(app.document.bundle(), &after.bundle);
assert_source_model_agree(app);
}
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
vec![point],
);
let moved = Position {
x: point.x + 45.,
y: point.y - 35.,
};
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::SetBends {
context,
relation,
waypoints: vec![moved],
},
vec![moved],
);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::RemoveBend {
context,
relation,
index: 0,
},
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
send(&mut app, Message::Undo);
let context = app.state_context().unwrap();
check(
&mut app,
diagram,
Event::ClearBends { context, relation },
vec![],
);
assert_eq!(app.project(), &baseline.decoded.project);
assert_source_model_agree(&app);
// Clear is a new source-preserving edit and may retain empty-list trivia.
// Exact original bytes are restored by history, not by reauthoring a value.
for _ in 0..3 {
assert!(app.document.can_undo());
send(&mut app, Message::Undo);
}
assert_eq!(app.project(), &baseline.decoded.project);
assert_eq!(app.document.bundle(), &baseline.bundle);
assert!(!app.document.can_undo());
}
#[test]
fn state_bend_insertion_uses_captured_traversal_segment_and_removal_only_changes_pins() {
let (app, diagram, relation, _) = setup();
let context = app.state_context().unwrap();
let p = app.project();
let geometry = state_edit::bend_route(p, diagram, relation).unwrap();
let original = &p.diagrams[&diagram].routes[0];
let reverse = geometry
.points
.windows(2)
.position(|pair| pair[0] == original.waypoints[0] && pair[1] == original.waypoints[1])
.unwrap();
for (segment, expected_index) in [(reverse, 1), (reverse + 1, 2)] {
let point = Position { x: 500., y: -400. };
let planned = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: Some((segment, point)),
},
)
.unwrap();
let actual = prepare_command(p, &planned.command).unwrap().project;
let mut expected = p.clone();
expected.diagrams.get_mut(&diagram).unwrap().routes[0]
.waypoints
.insert(expected_index, point);
assert_eq!(actual, expected);
let removed = state_edit::plan(
&actual,
context,
&Event::RemoveBend {
context,
relation,
index: expected_index,
},
)
.unwrap();
assert_eq!(
prepare_command(&actual, &removed.command).unwrap().project,
*p
);
}
let default = state_edit::plan(
p,
context,
&Event::AddBend {
context,
relation,
segment: None,
},
)
.unwrap();
let Command::SetEdgeRoute { route, .. } = default.command else {
panic!("route command")
};
assert_eq!(route.waypoints.len(), original.waypoints.len() + 1);
assert_eq!(route.source_attachment, original.source_attachment);
assert_eq!(route.target_attachment, original.target_attachment);
assert_eq!(route.label, original.label);
}
#[test]
fn state_bend_stale_nonfinite_capacity_hidden_and_folded_requests_are_inert() {
let (mut app, diagram, relation, composite) = setup();
let context = app.state_context().unwrap();
let baseline = app.document.snapshot();
for mutation in [
Event::SetBends {
context: Context {
epoch: context.epoch + 1,
..context
},
relation,
waypoints: vec![],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: f32::NAN, y: 0. }],
},
Event::SetBends {
context,
relation,
waypoints: vec![Position { x: 0., y: 0. }; 129],
},
Event::AddBend {
context,
relation,
segment: Some((usize::MAX, Position { x: 0., y: 0. })),
},
Event::RemoveBend {
context,
relation,
index: usize::MAX,
},
] {
event(&mut app, mutation);
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
}
let mut p = app.project().clone();
p.diagrams.get_mut(&diagram).unwrap().routes[0].waypoints =
vec![Position { x: 500., y: -400. }; 128];
assert!(
state_edit::plan(
&p,
context,
&Event::AddBend {
context,
relation,
segment: Some((1, Position { x: 500., y: -400. }))
}
)
.is_err()
);
let mut hidden = app.project().clone();
hidden
.diagrams
.get_mut(&diagram)
.unwrap()
.view_options
.hidden_edges
.push(relation);
assert!(state_edit::bend_route(&hidden, diagram, relation).is_none());
assert!(state_edit::plan(&hidden, context, &Event::ClearBends { context, relation }).is_err());
let owner = node(&app, diagram, composite);
let mut folded = app.project().clone();
folded
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&owner)
.unwrap()
.collapsed = true;
assert!(state_edit::bend_route(&folded, diagram, relation).is_none());
assert!(state_edit::plan(&folded, context, &Event::ClearBends { context, relation }).is_err());
let mut internal = app.project().clone();
internal.diagrams.get_mut(&diagram).unwrap().routes.clear();
if let RelationBody::StateTransition {
source,
target,
transition_kind,
..
} = &mut internal.relations.get_mut(&relation).unwrap().body
{
*target = *source;
*transition_kind = TransitionKind::Internal;
}
assert!(state_edit::bend_route(&internal, diagram, relation).is_none());
send(
&mut app,
Message::Field("/name".into(), "Unapplied bend owner name".into()),
);
let draft = app.draft.as_ref().unwrap().value.clone();
event(&mut app, Event::ClearBends { context, relation });
assert!(Arc::ptr_eq(&baseline, &app.document.snapshot()));
assert_eq!(app.draft.as_ref().unwrap().value, draft);
}
@@ -115,7 +115,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()
@@ -158,14 +157,10 @@ impl App {
object,
placement,
anchor,
bend,
route_segment,
} => {
let mut request = crate::model_menu::Request::object(object, anchor);
request.canvas = Some(context);
request.placement = placement;
request.bend = bend;
request.route_segment = route_segment;
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
}
Event::EditTransition(_, id) => {
@@ -177,24 +172,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();
}
@@ -233,37 +210,8 @@ impl App {
let Some(context) = self.state_context() else {
return Task::none();
};
if let Event::SetBends { relation, .. } = &mutation
&& *relation != self.selected
{
return Task::none();
}
if let Event::SetAttachment {
relation,
end,
attachment,
..
} = &mutation
{
let existing = self.project().diagrams[&context.diagram]
.routes
.iter()
.find(|route| route.edge == *relation)
.and_then(|route| route.attachment(*end));
if existing == *attachment {
return Task::none();
}
}
match state_edit::plan(self.project(), context, &mutation) {
Ok(plan) => {
if let Command::SetEdgeRoute { diagram, route } = &plan.command
&& crate::route_edit::stored_route(
&self.project().diagrams[diagram],
route.edge,
) == *route
{
return Task::none();
}
if self.execute(plan.command) {
self.behavior.diagram.diagram = Some(plan.diagram);
self.behavior.diagram.placement = plan.placement;
@@ -1,9 +1,5 @@
//! App transaction and draft boundaries for captured state-canvas messages.
use super::*;
#[path = "state_attachment_escape_tests.rs"]
mod attachment_escape;
#[path = "state_bend_tests.rs"]
mod bends;
use crate::behavior::{
self,
state_edit::{self, Context, Endpoint, Event, Tool},
-686
View File
@@ -1,686 +0,0 @@
//! Text-only presentation and captured command envelopes. Source/model authority
//! remains in the existing App controllers; dispatch this envelope before the
//! generic source_interaction invalidation in App::update.
use super::{App, Message};
use crate::{context_menu, source_navigation as nav};
use iced::{
Point, Task,
widget::{self, text_editor::Cursor},
};
use nav::query::{QueryStamp, SnapshotKey};
use oa_core::{Id, source_project::SourceLocation};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DrawerTab {
References,
Problems,
Status,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Command {
Find,
GoToLine,
Definition,
References,
RenameAlias,
Back,
UndoText,
RedoText,
UndoModel,
RedoModel,
UndoRename,
RevertFile,
ShowProblems,
ShowStatus,
ShowDiagramUsages,
}
impl Command {
fn shortcut(self) -> Option<&'static str> {
match self {
Self::Find => Some("Ctrl F"),
Self::GoToLine => Some("Ctrl G"),
Self::Definition => Some("F12"),
Self::References => Some("Shift F12"),
Self::RenameAlias => Some("F2"),
Self::Back => Some("Alt ←"),
Self::UndoText => Some("Ctrl Z"),
Self::RedoText => Some("Ctrl Y"),
_ => None,
}
}
fn label(self) -> &'static str {
match self {
Self::Find => "Find in file",
Self::GoToLine => "Go to line",
Self::Definition => "Go to definition",
Self::References => "Find references",
Self::RenameAlias => "Rename source alias",
Self::Back => "Back in source",
Self::UndoText => "Undo text edit",
Self::RedoText => "Redo text edit",
Self::UndoModel => "Undo model change",
Self::RedoModel => "Redo model change",
Self::UndoRename => "Undo source rename",
Self::RevertFile => "Revert current file",
Self::ShowProblems => "Source problems",
Self::ShowStatus => "Source status",
Self::ShowDiagramUsages => "Show diagram usages",
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CommandContext {
document: Id,
document_revision: u64,
source: SnapshotKey,
path: String,
cursor: Cursor,
interaction: Id,
}
#[derive(Debug, Clone)]
pub struct CommandRequest {
context: CommandContext,
command: Command,
}
#[derive(Debug, Clone)]
pub struct MenuRequest {
context: CommandContext,
anchor: Point,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiagnosticPublication {
generation: Id,
source: SnapshotKey,
}
impl DiagnosticPublication {
pub fn generation(self) -> Id {
self.generation
}
pub fn source(self) -> SnapshotKey {
self.source
}
pub fn target(self, visit: Id, location: SourceLocation) -> DiagnosticTarget {
DiagnosticTarget {
publication: self,
visit,
location,
}
}
}
#[derive(Debug, Clone)]
pub struct DiagnosticTarget {
publication: DiagnosticPublication,
visit: Id,
location: SourceLocation,
}
/// Authority captured by the rendered drawer, independent of its source rows.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DrawerContext {
pub document: Id,
pub session: Id,
pub visit: Id,
}
#[derive(Debug, Clone)]
pub enum Event {
ShowDrawer(DrawerContext, DrawerTab),
HideDrawer(DrawerContext),
ResizeDrawer(DrawerContext, f32),
ToggleFile {
stamp: QueryStamp,
visit: Id,
path: String,
},
ReferenceScrolled {
stamp: QueryStamp,
visit: Id,
y: f32,
},
Command(CommandRequest),
OpenMenu(MenuRequest),
DismissMenu(Id),
Invoke {
token: Id,
command: Command,
},
Diagnostic(DiagnosticTarget),
}
#[derive(Debug, Clone)]
struct FrozenEntry {
command: Command,
action: Option<Action>,
}
#[derive(Debug, Clone)]
enum Action {
Dispatch(Box<Message>),
Drawer(DrawerTab),
}
#[derive(Debug, Clone)]
pub struct Menu {
token: Id,
popup_token: u64,
anchor: Point,
context: CommandContext,
entries: Vec<FrozenEntry>,
}
impl Menu {
pub fn token(&self) -> Id {
self.token
}
}
#[derive(Debug)]
pub struct ReferencePresentation {
stamp: Option<QueryStamp>,
origin: String,
files: BTreeSet<String>,
installed: bool,
collapsed_files: BTreeSet<String>,
scroll_y: f32,
}
impl Default for ReferencePresentation {
fn default() -> Self {
Self {
stamp: None,
origin: String::new(),
files: BTreeSet::new(),
installed: false,
collapsed_files: BTreeSet::new(),
scroll_y: 0.,
}
}
}
impl ReferencePresentation {
pub fn stamp(&self) -> Option<QueryStamp> {
self.stamp
}
pub fn scroll_y(&self) -> f32 {
self.scroll_y
}
pub fn collapsed(&self, path: &str) -> bool {
self.collapsed_files.contains(path)
}
}
#[derive(Debug)]
pub struct State {
drawer: Option<DrawerTab>,
drawer_visit: Id,
drawer_height: f32,
references: ReferencePresentation,
menu: Option<Menu>,
diagnostic_publication: Option<DiagnosticPublication>,
}
impl Default for State {
fn default() -> Self {
Self {
drawer: None,
drawer_visit: Id::new_v4(),
drawer_height: 180.,
references: ReferencePresentation::default(),
menu: None,
diagnostic_publication: None,
}
}
}
impl State {
pub fn drawer_visit(&self) -> Id {
self.drawer_visit
}
pub fn reference_scroll_id(&self) -> widget::Id {
widget::Id::from(format!("text-references-{}", self.drawer_visit))
}
pub fn drawer(&self) -> Option<DrawerTab> {
self.drawer
}
pub fn drawer_height(&self) -> f32 {
self.drawer_height
}
pub fn references(&self) -> &ReferencePresentation {
&self.references
}
pub fn menu(&self) -> Option<&Menu> {
self.menu.as_ref()
}
pub fn diagnostic_publication(&self) -> Option<DiagnosticPublication> {
self.diagnostic_publication
}
/// Call for EVERY diagnostics replacement or clear, including equal bytes.
pub fn publish_diagnostics(&mut self, source: SnapshotKey) -> DiagnosticPublication {
let publication = DiagnosticPublication {
generation: Id::new_v4(),
source,
};
self.diagnostic_publication = Some(publication);
publication
}
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn dismiss_menu(&mut self) {
self.menu = None;
}
pub fn begin_references(&mut self, stamp: QueryStamp, origin: String) {
self.references = ReferencePresentation {
stamp: Some(stamp),
origin,
..ReferencePresentation::default()
};
self.show_drawer(Some(DrawerTab::References));
}
/// Install presentation groups only for the current completed query. Repeated
/// delivery of the same completion cannot undo the user's group choices.
pub fn install_reference_files(
&mut self,
stamp: QueryStamp,
paths: impl IntoIterator<Item = String>,
) {
let refs = &mut self.references;
if refs.stamp != Some(stamp) || refs.installed {
return;
}
refs.files = paths.into_iter().collect();
refs.collapsed_files = refs.files.clone();
let expanded = if refs.files.contains(&refs.origin) {
Some(refs.origin.clone())
} else {
refs.files.first().cloned()
};
if let Some(path) = expanded {
refs.collapsed_files.remove(&path);
}
refs.installed = true;
}
pub(super) fn show_drawer(&mut self, drawer: Option<DrawerTab>) {
self.drawer = drawer;
self.drawer_visit = Id::new_v4();
}
fn toggle_file(&mut self, stamp: QueryStamp, visit: Id, path: String) -> bool {
let refs = &mut self.references;
if self.drawer != Some(DrawerTab::References)
|| self.drawer_visit != visit
|| refs.stamp != Some(stamp)
|| !refs.files.contains(&path)
{
return false;
}
if !refs.collapsed_files.remove(&path) {
refs.collapsed_files.insert(path);
}
// Collapsing a group can replace a pressed row at the same position.
self.drawer_visit = Id::new_v4();
true
}
fn reference_scrolled(&mut self, stamp: QueryStamp, visit: Id, y: f32) {
let refs = &mut self.references;
if self.drawer == Some(DrawerTab::References)
&& refs.stamp == Some(stamp)
&& self.drawer_visit == visit
&& y.is_finite()
&& y >= 0.
{
refs.scroll_y = y;
}
}
}
impl App {
pub fn text_drawer_context(&self) -> DrawerContext {
DrawerContext {
document: self.canvas_document,
session: self.source_workspace.session(),
visit: self.text_workspace.drawer_visit(),
}
}
fn text_command_context(&self) -> CommandContext {
CommandContext {
document: self.canvas_document,
document_revision: self.document.revision(),
source: self.source_key(),
path: self.source_workspace.active.clone(),
cursor: self.source_editor.cursor(),
interaction: self.source_symbols.interaction,
}
}
pub fn text_command_message(&self, command: Command) -> Message {
Message::TextWorkspace(Event::Command(CommandRequest {
context: self.text_command_context(),
command,
}))
}
pub fn text_menu_message(&self, anchor: Point) -> Message {
Message::TextWorkspace(Event::OpenMenu(MenuRequest {
context: self.text_command_context(),
anchor,
}))
}
pub fn text_workspace_menu(&self) -> Option<context_menu::Menu<Message>> {
let menu = self.text_workspace.menu.as_ref()?;
Some(context_menu::Menu {
token: menu.popup_token,
anchor: menu.anchor,
entries: menu
.entries
.iter()
.flat_map(|entry| {
let mut rows = Vec::with_capacity(2);
if matches!(entry.command, Command::UndoText | Command::ShowProblems) {
rows.push(context_menu::Entry::separator());
}
rows.push(context_menu::Entry::Action {
label: entry.command.label().into(),
shortcut: entry.command.shortcut().map(str::to_owned),
message: entry.action.as_ref().map(|_| {
Message::TextWorkspace(Event::Invoke {
token: menu.token(),
command: entry.command,
})
}),
});
rows
})
.collect(),
on_close: Message::TextWorkspace(Event::DismissMenu(menu.token())),
})
}
pub fn text_diagnostic_message(&self, location: SourceLocation) -> Option<Message> {
let publication = self.text_workspace.diagnostic_publication?;
(publication.source == self.source_key()
&& self.text_workspace.drawer() == Some(DrawerTab::Problems))
.then(|| {
Message::TextWorkspace(Event::Diagnostic(
publication.target(self.text_workspace.drawer_visit(), location),
))
})
}
pub(crate) fn source_annotation_snapshot(
&self,
) -> Option<crate::source_annotations::Snapshot<'_>> {
let publication = self.text_workspace.diagnostic_publication?;
let buffer = self
.source_workspace
.files
.get(&self.source_workspace.active)?;
if publication.source != self.source_key() || buffer.text != self.source_snapshot.as_ref() {
return None;
}
Some(crate::source_annotations::Snapshot {
publication: publication.generation,
source: self.source_snapshot.clone(),
path: &self.source_workspace.active,
diagnostics: &self.source_diagnostics,
})
}
fn text_command_action(&self, command: Command) -> Option<Action> {
// Menu has been taken before dispatch-time admission. This intentionally
// uses navigation admission, not rename admission, for Find/text history.
if !self.source_navigation_available() {
return None;
}
let message =
match command {
Command::Find => Message::SourceNavigation(nav::Event::Open(false)),
Command::GoToLine => Message::SourceNavigation(nav::Event::Open(true)),
Command::Definition => self
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::Definition)),
Command::References => self
.source_symbol_message(nav::SymbolEvent::Query(nav::query::Action::References)),
Command::RenameAlias => {
self.rename_admission().ok()?;
self.source_rename_message()
}
Command::Back => {
if !self
.source_symbols
.back
.iter()
.any(|entry| entry.session == self.source_workspace.session())
{
return None;
}
self.source_symbol_message(nav::SymbolEvent::Back)
}
Command::UndoText => Message::SourceUndo,
Command::RedoText => Message::SourceRedo,
Command::UndoModel | Command::RedoModel => {
if self.source_dirty() || self.draft_dirty() {
return None;
}
if command == Command::UndoModel {
if !self.document.can_undo() {
return None;
}
Message::Undo
} else {
if !self.document.can_redo() {
return None;
}
Message::Redo
}
}
Command::UndoRename => self.source_rename_undo_message()?,
Command::RevertFile => Message::RevertSource,
Command::ShowProblems => return Some(Action::Drawer(DrawerTab::Problems)),
Command::ShowStatus => return Some(Action::Drawer(DrawerTab::Status)),
Command::ShowDiagramUsages => self.text_diagram_usages_message()?,
};
Some(Action::Dispatch(Box::new(message)))
}
pub(super) fn text_workspace_event(&mut self, event: Event) -> Task<Message> {
match event {
Event::OpenMenu(request) => {
if request.context != self.text_command_context()
|| !self.source_navigation_available()
|| self.text_workspace.menu.is_some()
|| !request.anchor.x.is_finite()
|| !request.anchor.y.is_finite()
{
return Task::none();
}
let Some(popup_token) = self.menu_sequence.checked_add(1) else {
return Task::none();
};
self.text_presentation_boundary(Event::OpenMenu(request.clone()));
self.source_interaction();
let commands = [
Command::Find,
Command::GoToLine,
Command::Definition,
Command::References,
Command::RenameAlias,
Command::Back,
Command::UndoText,
Command::RedoText,
Command::UndoRename,
Command::UndoModel,
Command::RedoModel,
Command::RevertFile,
Command::ShowProblems,
Command::ShowStatus,
Command::ShowDiagramUsages,
];
let entries = commands
.into_iter()
.filter_map(|command| {
let action = self.text_command_action(command);
if command == Command::ShowDiagramUsages && action.is_none() {
None
} else {
Some(FrozenEntry { command, action })
}
})
.collect();
self.menu_sequence = popup_token;
self.text_workspace.menu = Some(Menu {
token: Id::new_v4(),
popup_token,
anchor: request.anchor,
context: self.text_command_context(),
entries,
});
}
Event::DismissMenu(token) => {
if self
.text_workspace
.menu
.as_ref()
.is_some_and(|menu| menu.token == token)
{
self.text_presentation_boundary(Event::DismissMenu(token));
self.text_workspace.menu = None;
self.source_interaction();
}
}
Event::Invoke { token, command } => {
if !self.text_workspace.menu.as_ref().is_some_and(|menu| {
menu.token == token && menu.context == self.text_command_context()
}) {
return Task::none();
}
// Validate before closing; consume this invocation only once.
// Never resolve a frozen row against a newly generated menu.
let menu = self.text_workspace.menu.take().unwrap();
let Some(action) = menu
.entries
.into_iter()
.find(|entry| entry.command == command)
.and_then(|entry| entry.action)
else {
return Task::none();
};
if self.text_command_action(command).is_none() {
return Task::none();
}
return self.dispatch_text_action(action);
}
Event::Command(request) => {
if request.context != self.text_command_context() {
return Task::none();
}
if let Some(action) = self.text_command_action(request.command) {
return self.dispatch_text_action(action);
}
}
Event::ShowDrawer(context, tab) => {
if context != self.text_drawer_context() || !self.source_navigation_available() {
return Task::none();
}
self.text_presentation_boundary(Event::ShowDrawer(context, tab));
return self.show_text_drawer(Some(tab));
}
Event::HideDrawer(context) => {
if context != self.text_drawer_context() || !self.source_navigation_available() {
return Task::none();
}
self.text_presentation_boundary(Event::HideDrawer(context));
return self.show_text_drawer(None);
}
Event::ResizeDrawer(context, height) => {
if context == self.text_drawer_context()
&& self.source_navigation_available()
&& height.is_finite()
{
// The view applies the measured editor-allocation bound.
let height = height.clamp(180., 1200.);
if height != self.text_workspace.drawer_height {
self.text_presentation_boundary(Event::ResizeDrawer(context, height));
self.source_interaction();
self.text_workspace.drawer_height = height;
}
}
}
Event::ToggleFile { stamp, visit, path } => {
let captured = Event::ToggleFile {
stamp,
visit,
path: path.clone(),
};
if self.source_navigation_available()
&& self.text_workspace.toggle_file(stamp, visit, path)
{
self.text_presentation_boundary(captured);
self.source_interaction();
}
}
Event::ReferenceScrolled { stamp, visit, y } => {
// Passive geometry must neither invalidate pending navigation
// nor reset the editor identity, even while commands are blocked.
self.text_workspace.reference_scrolled(stamp, visit, y);
}
Event::Diagnostic(target) => {
if !self.source_navigation_available()
|| self.text_workspace.drawer() != Some(DrawerTab::Problems)
|| self.text_workspace.drawer_visit() != target.visit
|| self.text_workspace.diagnostic_publication != Some(target.publication)
|| target.publication.source != self.source_key()
|| !self
.source_workspace
.files
.get(&target.location.path)
.is_some_and(|file| valid_span(&file.text, &target.location))
{
return Task::none();
}
self.text_presentation_boundary(Event::Diagnostic(target.clone()));
self.source_interaction();
self.jump_to_source(target.location);
}
}
Task::none()
}
fn dispatch_text_action(&mut self, action: Action) -> Task<Message> {
match action {
Action::Dispatch(message) => self.update(*message),
Action::Drawer(tab) => {
self.text_presentation_boundary(Event::ShowDrawer(self.text_drawer_context(), tab));
self.show_text_drawer(Some(tab))
}
}
}
/// Local Text presentation changes do not mutate a camera, document or route.
/// After their own authority check, they still supersede pending navigation
/// and allocation through the root's existing input boundary hooks. Source
/// commands use update instead; passive measurements never enter this hook.
fn text_presentation_boundary(&mut self, event: Event) {
let message = Message::TextWorkspace(event);
self.reconcile_pending_diagram_reveal(&message);
self.allocation_before(&message);
}
pub(super) fn show_text_drawer(&mut self, drawer: Option<DrawerTab>) -> Task<Message> {
self.source_interaction();
self.text_workspace.show_drawer(drawer);
if drawer == Some(DrawerTab::References) {
let refs = self.text_workspace.references();
widget::operation::scroll_to(
self.text_workspace.reference_scroll_id(),
widget::scrollable::AbsoluteOffset {
x: 0.,
y: refs.scroll_y(),
},
)
} else {
Task::none()
}
}
}
fn valid_span(text: &str, location: &SourceLocation) -> bool {
let span = location.span;
span.start <= span.end
&& span.end <= text.len()
&& text.is_char_boundary(span.start)
&& text.is_char_boundary(span.end)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod lifecycle_tests;
@@ -1,611 +0,0 @@
//! Real lifecycle receipts, with all disk writes confined to unique temp roots.
//! Returned App worker tasks are not polled; completions use the normal captured
//! query input and are delivered deterministically through App::update.
use super::*;
use crate::app::{Page, read_snapshot, write_snapshot};
use crate::document::Document;
use crate::source_workspace::{LIMITS, Workspace};
use oa_core::{
Project,
language::{Diagnostic, Span},
source_project::{SourceBundle, SourceDiagnostic},
validation::Severity,
};
use std::{collections::BTreeMap, path::Path, sync::Arc};
const VALID: &str = concat!(
"arcadia 7;\r\n",
"project model \"Lifecycle 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
"}\r\n",
);
fn fixture() -> App {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), VALID.into())]),
};
let mut app = App::with_project(Project::empty());
// with_project uses in-memory Home metadata and no recovery directory.
assert!(app.recovery.directory.is_none());
app.document = Document::from_bundle(bundle, LIMITS).unwrap();
app.source_workspace = Workspace::new(app.document.bundle(), true);
app.selected = app.project().id;
app.refresh_draft();
app.page = Page::Text;
app.show_source_file(false);
app.publish_source_diagnostics(app.document.compiled().diagnostics.clone());
app
}
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn save(app: &mut App, path: &Path) {
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.to_owned(),
app.disk_project.clone(),
)
.unwrap();
send(app, Message::Saved(Ok(Some(receipt))));
}
fn reveal(app: &mut App) {
if let Some(ticket) = app.source_reveal_ticket() {
send(app, Message::SourceViewport(Some(ticket)));
}
}
fn query(app: &mut App, action: nav::query::Action) -> nav::Completion {
let message = app.source_symbol_message(nav::SymbolEvent::Query(action));
send(app, message);
let request = app.source_symbols.request.clone().expect("admitted query");
assert_eq!(app.source_symbols.in_flight, Some(request.job));
let snapshot = Arc::new(app.with_source_input(|input| {
nav::query::admit_owned(input, nav::query::Limits::default())
.unwrap()
.capture()
.unwrap()
}));
let results = Arc::new(
snapshot
.query(nav::query::Query {
stamp: request.stamp,
path: &request.path,
byte_offset: request.offset,
action,
})
.unwrap(),
);
nav::Completion {
request,
result: Ok((snapshot, results)),
}
}
fn seed_navigation(app: &mut App, pending: bool) -> Option<nav::Completion> {
let text = app.source_workspace.text();
let start = text.find("oa_activity assess").unwrap() + "oa_activity ".len();
let prefix = &text[..start];
let position = widget::text_editor::Position {
line: prefix.bytes().filter(|byte| *byte == b'\n').count(),
column: prefix.rsplit_once('\n').unwrap().1.len(),
};
app.source_editor.move_to(Cursor {
position,
selection: None,
});
let ready = query(app, nav::query::Action::Definition);
send(app, Message::SourceSymbolsReady(ready));
reveal(app);
assert_eq!(app.source_symbols.back.len(), 1);
let ready = query(app, nav::query::Action::References);
send(app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.results.is_some() && app.source_symbols.cache.is_some());
assert!(app.source_symbols.display_request.is_some());
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
if pending {
Some(query(app, nav::query::Action::Definition))
} else {
None
}
}
fn warning(app: &App) -> SourceDiagnostic {
let source = app.source_workspace.text();
let primary = source.find("model").unwrap();
let related = source.find("assess").unwrap();
SourceDiagnostic {
path: app.source_workspace.active.clone(),
diagnostic: Diagnostic {
severity: Severity::Warning,
code: "lifecycle_warning".into(),
object_id: None,
field: None,
line: 2,
column: 9,
message: "Retained warning with a related source location".into(),
span: Span {
start: primary,
end: primary + 5,
},
},
related: vec![SourceLocation {
path: app.source_workspace.active.clone(),
span: Span {
start: related,
end: related + 6,
},
}],
}
}
fn warning_messages(app: &App) -> [Message; 2] {
let warning = &app.source_diagnostics[0];
[
app.text_diagnostic_message(SourceLocation {
path: warning.path.clone(),
span: warning.diagnostic.span,
})
.unwrap(),
app.text_diagnostic_message(warning.related[0].clone())
.unwrap(),
]
}
fn assert_cleared(app: &App, job: Option<Id>) {
assert_eq!(app.source_symbols.in_flight, job);
assert!(app.source_symbols.request.is_none() && app.source_symbols.display_request.is_none());
assert!(app.source_symbols.results.is_none() && app.source_symbols.cache.is_none());
assert!(app.source_symbols.back.is_empty());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.text_workspace.drawer().is_none() && app.text_workspace.menu().is_none());
assert!(app.text_workspace.references().stamp().is_none());
}
#[test]
fn saved_new_destination_republishes_current_warning_and_refuses_old_primary_and_related() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
let mut app = fixture();
save(&mut app, &first_path);
let ready = seed_navigation(&mut app, true).unwrap();
let job = ready.request.job;
app.publish_source_diagnostics(vec![warning(&app)]);
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
let old = warning_messages(&app);
let warnings = app.source_diagnostics.clone();
let publication = app.text_workspace.diagnostic_publication().unwrap();
let source = app.source_key();
let accepted = app.document.snapshot();
let revision = app.document.revision();
let raw = app.source_workspace.bundle();
save(&mut app, &second_path);
assert_eq!(app.path.as_deref(), Some(second_path.as_path()));
assert_ne!(app.source_key().workspace, source.workspace);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert_eq!(app.document.revision(), revision);
assert_eq!(std::fs::read_to_string(&first_path).unwrap(), VALID);
assert_eq!(std::fs::read_to_string(&second_path).unwrap(), VALID);
assert_eq!(app.source_diagnostics, warnings);
let current = app
.text_workspace
.diagnostic_publication()
.expect("identity-only save retained current warnings");
assert_eq!(current.source(), app.source_key());
assert_ne!(current.generation(), publication.generation());
assert_cleared(&app, Some(job));
for message in old {
let interaction = app.source_symbols.interaction;
send(&mut app, message);
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
}
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
for message in warning_messages(&app) {
send(&mut app, message);
assert!(
app.source_symbols.reveal.is_some(),
"fresh primary and related warnings remain actionable"
);
reveal(&mut app);
}
let cursor = app.source_editor.cursor();
send(&mut app, Message::SourceSymbolsReady(ready));
assert!(app.source_symbols.in_flight.is_none());
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn invalid_repair_open_clears_prior_presentation_and_publishes_current_problems_preserving_worker()
{
for pending in [false, true] {
let directory = tempfile::tempdir().unwrap();
let path = directory
.path()
.canonicalize()
.unwrap()
.join("broken.arcadia");
let invalid = format!(
"{} include \"missing.arcadia\";\r\n}}\r\n",
VALID.strip_suffix("}\r\n").unwrap()
);
std::fs::write(&path, &invalid).unwrap();
let mut app = fixture();
let ready = seed_navigation(&mut app, pending);
let job = ready.as_ref().map(|ready| ready.request.job);
app.publish_source_diagnostics(vec![warning(&app)]);
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
let old = warning_messages(&app);
let old_publication = app.text_workspace.diagnostic_publication().unwrap();
let old_source = app.source_key();
let accepted = app.document.snapshot();
let open_menu = app.text_menu_message(Point::new(20., 20.));
send(&mut app, open_menu);
assert!(app.text_workspace.menu().is_some());
let loaded = read_snapshot(path.clone()).unwrap();
send(&mut app, Message::Loaded(Ok(Some(loaded))));
assert!(!app.source_workspace.applied);
assert_eq!(app.path.as_deref(), Some(path.as_path()));
assert_ne!(app.source_key().workspace, old_source.workspace);
assert_eq!(app.source_workspace.text(), invalid);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert_cleared(&app, job);
assert!(
app.source_diagnostics
.iter()
.any(|issue| issue.diagnostic.severity == Severity::Error)
);
let publication = app.text_workspace.diagnostic_publication().unwrap();
assert_eq!(publication.source(), app.source_key());
assert_ne!(publication.generation(), old_publication.generation());
for message in old {
send(&mut app, message);
}
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
let issue = app
.source_diagnostics
.iter()
.find(|issue| {
issue.path == app.source_workspace.active
&& issue.diagnostic.span.end <= invalid.len()
})
.unwrap();
let current = app
.text_diagnostic_message(SourceLocation {
path: issue.path.clone(),
span: issue.diagnostic.span,
})
.unwrap();
send(&mut app, current);
assert!(app.source_symbols.reveal.is_some());
reveal(&mut app);
if let Some(ready) = ready {
send(&mut app, Message::SourceSymbolsReady(ready));
}
assert!(app.source_symbols.in_flight.is_none());
assert!(app.source_symbols.results.is_none());
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.text(), invalid);
assert_eq!(std::fs::read_to_string(&path).unwrap(), invalid);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn canceled_or_failed_open_that_installs_nothing_retains_current_presentation_and_diagnostics() {
for failed in [false, true] {
let mut app = fixture();
seed_navigation(&mut app, false);
app.publish_source_diagnostics(vec![warning(&app)]);
let results = app.source_symbols.results.clone().unwrap();
let cache = app.source_symbols.cache.clone().unwrap();
let display = app.source_symbols.display_request.clone();
let back = app
.source_symbols
.back
.iter()
.map(|entry| {
(
entry.session,
entry.path.clone(),
entry.generation,
entry.cursor,
)
})
.collect::<Vec<_>>();
let key = app.source_key();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let publication = app.text_workspace.diagnostic_publication();
let diagnostics = app.source_diagnostics.clone();
let visit = app.text_workspace.drawer_visit();
let result = if failed {
Err("Fixture read failed before installing a workspace".into())
} else {
Ok(None)
};
send(&mut app, Message::Loaded(result));
assert_eq!(app.source_key(), key);
assert_eq!(app.text_workspace.diagnostic_publication(), publication);
assert_eq!(app.source_diagnostics, diagnostics);
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::References));
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(Arc::ptr_eq(
app.source_symbols.results.as_ref().unwrap(),
&results
));
assert!(Arc::ptr_eq(
app.source_symbols.cache.as_ref().unwrap(),
&cache
));
assert_eq!(app.source_symbols.display_request, display);
assert_eq!(
app.source_symbols
.back
.iter()
.map(|entry| (
entry.session,
entry.path.clone(),
entry.generation,
entry.cursor
))
.collect::<Vec<_>>(),
back
);
assert_eq!(app.source_workspace.bundle(), raw);
assert_eq!(app.source_editor.cursor(), cursor);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
}
}
#[test]
fn saved_new_destination_does_not_revalidate_already_stale_warning_locations() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let second_path = second
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
let mut app = fixture();
save(&mut app, &first_path);
app.publish_source_diagnostics(vec![warning(&app)]);
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
let old = warning_messages(&app);
let publication = app.text_workspace.diagnostic_publication().unwrap();
let draft = format!("{VALID}// unsaved current draft\r\n");
app.source_workspace
.edit(draft.clone(), app.source_editor.cursor());
app.show_source_file(false);
assert_ne!(publication.source(), app.source_key());
let accepted = app.document.snapshot();
save(&mut app, &second_path);
assert!(
app.text_workspace.diagnostic_publication().is_none(),
"identity-only reset cannot turn an old source span into a current warning"
);
{
let captured = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, captured);
};
assert!(
app.text_diagnostic_message(SourceLocation {
path: app.source_diagnostics[0].path.clone(),
span: app.source_diagnostics[0].diagnostic.span,
})
.is_none()
);
for message in old {
send(&mut app, message);
}
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert_eq!(app.source_workspace.text(), draft);
assert!(app.source_dirty());
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert_eq!(
std::fs::read_to_string(&second_path).unwrap(),
VALID,
"receipt records accepted bytes while the newer draft remains unsaved"
);
}
/// Use an actual pending-save journal and the real recovery worker. The optional
/// reload error is injected only after recovery succeeded, so both receipt arms
/// exercise the same matching target without relying on filesystem races.
fn recover_warning_fixture(stale: bool, reload_error: bool) {
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
let journal = root.join(".arcadia-save-project.arcadia.journal");
let mut app = fixture();
save(&mut app, &path);
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
serde_json::to_vec(&serde_json::json!({
"version": 1, "entry": "project.arcadia", "files": [{
"path": "project.arcadia", "before": true, "write": true
}]
}))
.unwrap(),
)
.unwrap();
std::fs::write(journal.join("0.before"), VALID).unwrap();
std::fs::write(
journal.join("0.after"),
format!("{VALID}// interrupted save\r\n"),
)
.unwrap();
let disk = oa_core::source_files::load_project(&path, LIMITS).unwrap();
app.pending_recovery = crate::app::recovery_target(&disk);
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
let pending = disk
.diagnostics
.into_iter()
.find(|issue| issue.diagnostic.code == "pending_save")
.unwrap();
// Current-warning control includes newer unsaved bytes too. Recovery of an
// applied workspace must retain its drafts, not reload over them.
let draft = format!("{VALID}// retained draft\r\n");
if !stale {
app.source_workspace
.edit(draft.clone(), app.source_editor.cursor());
app.show_source_file(false);
}
let retained_warning = warning(&app);
app.publish_source_diagnostics(vec![retained_warning.clone(), pending]);
let show = Message::TextWorkspace(Event::ShowDrawer(
app.text_drawer_context(),
DrawerTab::Problems,
));
send(&mut app, show);
let old = warning_messages(&app);
let publication = app.text_workspace.diagnostic_publication().unwrap();
if stale {
// This is the existing workspace-key departure seam: drafts advance
// the key while a previous diagnostic Vec can still await lint.
app.source_workspace
.edit(draft.clone(), app.source_editor.cursor());
app.show_source_file(false);
assert_ne!(publication.source(), app.source_key());
} else {
assert_eq!(publication.source(), app.source_key());
}
assert!(app.source_workspace.applied && app.source_workspace.has_edits());
reveal(&mut app);
let accepted = app.document.snapshot();
let revision = app.document.revision();
let raw = app.source_workspace.bundle();
let key = app.source_key();
let cursor = app.source_editor.cursor();
let visit = app.text_workspace.drawer_visit();
send(&mut app, Message::SourceRecover);
assert!(
app.busy,
"normal recovery request admitted with retained applied drafts"
);
let mut receipt = crate::app::recover_snapshot(path.clone()).unwrap();
assert_eq!(receipt.target, path);
assert!(receipt.disk.is_ok());
assert!(!journal.exists(), "real recovery consumed the journal");
if reload_error {
receipt.disk = Err("injected reload failure after successful recovery".into());
}
send(&mut app, Message::SourceRecovered(Ok(receipt)));
assert!(!app.busy && app.pending_recovery.is_none());
assert_eq!(app.source_diagnostics, vec![retained_warning]);
assert_eq!(app.source_key(), key);
assert_eq!(app.source_workspace.bundle(), raw);
assert_eq!(app.source_workspace.text(), draft);
assert_eq!(app.source_editor.cursor(), cursor);
assert_eq!(app.text_workspace.drawer(), Some(DrawerTab::Problems));
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert_eq!(app.document.revision(), revision);
assert!(!app.document.can_undo() && !app.document.can_redo());
assert_eq!(std::fs::read_to_string(&path).unwrap(), VALID);
assert!(app.status.contains(if reload_error {
"files could not be reloaded"
} else {
"Current model and drafts are retained"
}));
if stale {
assert_eq!(
app.text_workspace.diagnostic_publication(),
Some(publication)
);
assert!(app.source_annotation_snapshot().is_none());
assert!(
app.text_diagnostic_message(SourceLocation {
path: app.source_diagnostics[0].path.clone(),
span: app.source_diagnostics[0].diagnostic.span,
})
.is_none(),
"recovery must not manufacture a fresh stale-warning action"
);
} else {
let current = app.text_workspace.diagnostic_publication().unwrap();
assert_eq!(current.source(), key);
assert_ne!(current.generation(), publication.generation());
assert!(app.source_annotation_snapshot().is_some());
}
for message in old {
let interaction = app.source_symbols.interaction;
send(&mut app, message);
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
}
if !stale {
for message in warning_messages(&app) {
send(&mut app, message);
assert!(
app.source_symbols.reveal.is_some(),
"current primary and related remain live"
);
reveal(&mut app);
}
}
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&app.document.snapshot(), &accepted));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
#[test]
fn recovery_retains_current_warning_authority_with_drafts_and_reload_failure() {
for reload_error in [false, true] {
recover_warning_fixture(false, reload_error);
}
}
#[test]
fn recovery_does_not_republish_stale_warnings_after_workspace_key_departure() {
for reload_error in [false, true] {
recover_warning_fixture(true, reload_error);
}
}
@@ -1,877 +0,0 @@
use super::*;
use crate::app::Page;
use oa_core::{Project, language::Span};
use std::sync::Arc;
fn fixture() -> App {
let mut app = App::with_project(Project::empty());
app.page = Page::Text;
let diagnostics = app.source_diagnostics.clone();
app.publish_source_diagnostics(diagnostics);
assert!(app.source_navigation_available());
app
}
fn send(app: &mut App, event: Event) {
// Exercise the real envelope path; worker/lint futures are deliberately not
// polled here. The parent owns serialized compile/runtime verification.
let _ = app.update(Message::TextWorkspace(event));
}
fn toggle(app: &App, stamp: QueryStamp, path: String) -> Event {
Event::ToggleFile {
stamp,
visit: app.text_workspace.drawer_visit(),
path,
}
}
fn open(app: &mut App) -> Id {
let message = app.text_menu_message(Point::new(10., 20.));
let _ = app.update(message);
app.text_workspace.menu().unwrap().token()
}
fn invoke(app: &mut App, token: Id, command: Command) {
send(app, Event::Invoke { token, command });
}
fn enabled(app: &App, command: Command) -> bool {
app.text_workspace
.menu
.as_ref()
.unwrap()
.entries
.iter()
.find(|entry| entry.command == command)
.is_some_and(|entry| entry.action.is_some())
}
fn back_entry(app: &App) -> nav::HistoryEntry {
nav::HistoryEntry {
session: app.source_workspace.session(),
path: app.source_workspace.active.clone(),
generation: app.source_workspace.files[&app.source_workspace.active].generation(),
cursor: app.source_editor.cursor(),
}
}
fn stamp(app: &App, serial: u64) -> QueryStamp {
QueryStamp {
snapshot: app.source_key(),
serial,
}
}
fn location(app: &App, start: usize, end: usize) -> SourceLocation {
SourceLocation {
path: app.source_workspace.active.clone(),
span: Span { start, end },
}
}
#[test]
fn menu_own_open_invalidation_does_not_stale_find_and_invoke_is_once() {
let mut app = fixture();
let old_interaction = app.source_symbols.interaction;
let token = open(&mut app);
assert_ne!(old_interaction, app.source_symbols.interaction);
assert_eq!(
app.text_workspace.menu.as_ref().unwrap().context,
app.text_command_context()
);
assert!(enabled(&app, Command::Find));
invoke(&mut app, token, Command::Find);
assert!(app.source_navigation.open);
assert!(app.text_workspace.menu().is_none());
let instance = app.source_find_instance;
invoke(&mut app, token, Command::Find);
assert_eq!(
app.source_find_instance, instance,
"replay must not reopen Find"
);
}
#[test]
fn disabled_entry_cannot_become_a_new_action_under_the_same_menu_token() {
let mut app = fixture();
let token = open(&mut app);
assert!(!enabled(&app, Command::Back));
app.source_symbols.back.push(back_entry(&app));
assert!(
!enabled(&app, Command::Back),
"frozen presentation must remain disabled"
);
invoke(&mut app, token, Command::Back);
assert_eq!(
app.source_symbols.back.len(),
1,
"a newly enabled action cannot be substituted"
);
assert!(app.source_symbols.reveal.is_none());
}
#[test]
fn enabled_entry_is_readmitted_at_dispatch_and_blocked_by_busy() {
let mut app = fixture();
let token = open(&mut app);
assert!(enabled(&app, Command::Find));
app.busy = true;
assert!(enabled(&app, Command::Find));
invoke(&mut app, token, Command::Find);
assert!(!app.source_navigation.open);
assert!(app.text_workspace.menu().is_none());
}
#[test]
fn old_dismiss_and_invoke_do_not_consume_a_newer_menu() {
let mut app = fixture();
let old = open(&mut app);
send(&mut app, Event::DismissMenu(old));
let new = open(&mut app);
send(&mut app, Event::DismissMenu(old));
invoke(&mut app, old, Command::Find);
assert_eq!(app.text_workspace.menu().unwrap().token(), new);
assert!(!app.source_navigation.open);
}
#[test]
fn menu_source_caret_document_path_and_interaction_changes_refuse_stale_find() {
for change in 0..6 {
let mut app = fixture();
let token = open(&mut app);
match change {
0 => {
let value = format!("{}\n// changed", app.source_workspace.text());
app.source_workspace.edit(value, app.source_editor.cursor());
}
1 => {
let mut cursor = app.source_editor.cursor();
cursor.position.column += 1;
app.source_editor.move_to(cursor);
}
2 => app.canvas_document = Id::new_v4(),
3 => app.source_workspace.active = "other.arcadia".into(),
4 => app.source_symbols.interaction = Id::new_v4(),
_ => {
let mut bundle = app.document.bundle().clone();
bundle
.files
.get_mut(&bundle.entry)
.unwrap()
.push_str("\n// accepted revision");
assert!(app.document.apply_bundle(bundle).unwrap());
}
}
invoke(&mut app, token, Command::Find);
assert!(!app.source_navigation.open, "stale context case {change}");
}
}
#[test]
fn source_and_property_drafts_keep_find_and_text_undo_without_model_application() {
let mut app = fixture();
let accepted = app.document.bundle().clone();
let revision = app.document.revision();
let pending_name = "Pending property draft";
// Use the actual property input route before source edits coexist. Changing
// Draft::value alone is overwritten by its authoritative raw input buffer.
let _ = app.update(Message::Field("/name".into(), pending_name.into()));
let assert_pending_property = |app: &App| {
let draft = app.draft.as_ref().unwrap();
assert!(draft.dirty());
assert_eq!(draft.inputs["/name"].raw, pending_name);
assert_eq!(draft.inputs["/name"].editor.text(), pending_name);
assert_eq!(
draft.materialize().unwrap()["name"].as_str(),
Some(pending_name)
);
};
assert_pending_property(&app);
let initial = app.source_workspace.text().to_owned();
app.source_workspace
.edit(format!("{initial}\n// draft"), app.source_editor.cursor());
app.show_source_file(false);
assert!(app.source_dirty() && app.draft_dirty());
let token = open(&mut app);
assert!(enabled(&app, Command::Find) && enabled(&app, Command::UndoText));
assert!(!enabled(&app, Command::RenameAlias) && !enabled(&app, Command::UndoModel));
invoke(&mut app, token, Command::Find);
assert!(app.source_navigation.open);
assert_pending_property(&app);
let token = open(&mut app);
invoke(&mut app, token, Command::UndoText);
assert_eq!(app.source_workspace.text(), initial);
assert_pending_property(&app);
assert_eq!(app.document.revision(), revision);
assert_eq!(app.document.bundle(), &accepted);
}
#[test]
fn queued_toolbar_request_cannot_retarget_after_source_change() {
let mut app = fixture();
let request = app.text_command_message(Command::Find);
app.source_workspace
.edit("// changed".into(), app.source_editor.cursor());
let _ = app.update(request);
assert!(!app.source_navigation.open);
}
#[test]
fn equal_diagnostic_republication_invalidates_primary_and_related_targets() {
let mut app = fixture();
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
let primary = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
let related = app.text_diagnostic_message(location(&app, 1, 2)).unwrap();
let previous = app.text_workspace.diagnostic_publication().unwrap();
app.publish_source_diagnostics(app.source_diagnostics.clone());
let current = app.text_workspace.diagnostic_publication().unwrap();
assert_eq!(previous.source(), current.source());
assert_ne!(previous.generation(), current.generation());
for message in [primary, related] {
let interaction = app.source_symbols.interaction;
let _ = app.update(message);
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.source_symbols.reveal.is_none());
}
}
#[test]
fn current_diagnostic_target_navigates_but_changed_source_or_clear_refuses_it() {
let mut app = fixture();
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_some());
app.source_interaction();
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
app.source_workspace
.edit("// newer".into(), app.source_editor.cursor());
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_none());
assert!(app.text_diagnostic_message(location(&app, 0, 1)).is_none());
app.publish_source_diagnostics(Vec::new());
let message = app.text_diagnostic_message(location(&app, 0, 1)).unwrap();
app.publish_source_diagnostics(Vec::new());
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_none());
}
#[test]
fn diagnostic_span_must_be_loaded_ordered_and_utf8_exact() {
let mut app = fixture();
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
app.source_workspace
.edit("a雨b".into(), app.source_editor.cursor());
app.show_source_file(false);
app.publish_source_diagnostics(Vec::new());
let mut missing = location(&app, 0, 1);
missing.path = "missing.arcadia".into();
for target in [
location(&app, 3, 2),
location(&app, 0, 6),
location(&app, 2, 4),
location(&app, 1, 3),
missing,
] {
let message = app.text_diagnostic_message(target).unwrap();
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_none());
}
let message = app.text_diagnostic_message(location(&app, 1, 4)).unwrap();
let _ = app.update(message);
assert!(app.source_symbols.reveal.is_some());
}
#[test]
fn reference_groups_and_scroll_survive_tabs_and_reject_old_visits_and_queries() {
let mut app = fixture();
let first = stamp(&app, 1);
app.text_workspace
.begin_references(first, "b/use.arcadia".into());
app.text_workspace
.install_reference_files(first, ["a/use.arcadia".into(), "b/use.arcadia".into()]);
assert!(app.text_workspace.references().collapsed("a/use.arcadia"));
assert!(!app.text_workspace.references().collapsed("b/use.arcadia"));
let event = toggle(&app, first, "a/use.arcadia".into());
send(&mut app, event);
let old_visit = app.text_workspace.drawer_visit();
let old_scroll_id = app.text_workspace.reference_scroll_id();
let interaction = app.source_symbols.interaction;
send(
&mut app,
Event::ReferenceScrolled {
stamp: first,
visit: old_visit,
y: 123.,
},
);
assert_eq!(
app.source_symbols.interaction, interaction,
"scroll is passive"
);
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Status);
send(&mut app, captured);
};
send(
&mut app,
Event::ReferenceScrolled {
stamp: first,
visit: old_visit,
y: 0.,
},
);
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
send(&mut app, captured);
};
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
send(
&mut app,
Event::ReferenceScrolled {
stamp: first,
visit: old_visit,
y: 0.,
},
);
assert_eq!(app.text_workspace.references().scroll_y(), 123.);
assert_ne!(app.text_workspace.reference_scroll_id(), old_scroll_id);
let second = stamp(&app, 2);
app.text_workspace
.begin_references(second, "absent.arcadia".into());
app.text_workspace
.install_reference_files(first, ["old.arcadia".into()]);
app.text_workspace
.install_reference_files(second, ["b/use.arcadia".into(), "a/use.arcadia".into()]);
assert_eq!(app.text_workspace.references().scroll_y(), 0.);
assert!(!app.text_workspace.references().collapsed("a/use.arcadia"));
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
let event = toggle(&app, first, "b/use.arcadia".into());
send(&mut app, event);
assert!(app.text_workspace.references().collapsed("b/use.arcadia"));
}
#[test]
fn reset_removes_ui_and_publication_authority_and_resize_rejects_nan() {
let mut app = fixture();
open(&mut app);
app.text_workspace.reset();
assert!(app.text_workspace.menu().is_none());
assert!(app.text_workspace.diagnostic_publication().is_none());
assert!(app.text_workspace.drawer().is_none());
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
send(&mut app, captured);
};
assert_eq!(app.text_workspace.drawer_height(), 180.);
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::MAX);
send(&mut app, captured);
};
assert_eq!(app.text_workspace.drawer_height(), 1200.);
}
#[test]
fn only_valid_explicit_group_and_resize_intent_cancel_pending_navigation() {
let mut app = fixture();
let current = stamp(&app, 1);
app.text_workspace
.begin_references(current, "uses.arcadia".into());
app.text_workspace
.install_reference_files(current, ["uses.arcadia".into()]);
let request = nav::Request {
job: Id::new_v4(),
interaction: app.source_symbols.interaction,
raw_interaction: 0,
stamp: current,
path: app.source_workspace.active.clone(),
offset: 0,
cursor: app.source_editor.cursor(),
action: nav::query::Action::References,
};
app.source_symbols.request = Some(request.clone());
app.source_symbols.in_flight = Some(request.job);
let interaction = app.source_symbols.interaction;
let event = toggle(&app, current, "not-a-group.arcadia".into());
send(&mut app, event);
let old = QueryStamp {
serial: 0,
..current
};
let event = toggle(&app, old, "uses.arcadia".into());
send(&mut app, event);
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), f32::NAN);
send(&mut app, captured);
};
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), 180.);
send(&mut app, captured);
};
assert_eq!(app.source_symbols.interaction, interaction);
assert!(app.source_symbols.request.is_some());
let event = toggle(&app, current, "uses.arcadia".into());
send(&mut app, event);
assert!(app.source_symbols.request.is_none());
assert_ne!(app.source_symbols.interaction, interaction);
assert_eq!(
app.source_symbols.in_flight,
Some(request.job),
"intent does not release an occupied worker"
);
app.source_symbols.request = Some(request.clone());
{
let captured = Event::ResizeDrawer(app.text_drawer_context(), 240.);
send(&mut app, captured);
};
assert!(app.source_symbols.request.is_none());
app.source_symbols.request = Some(request.clone());
{
let captured = Event::HideDrawer(app.text_drawer_context());
send(&mut app, captured);
};
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert_eq!(app.text_workspace.references().stamp(), Some(current));
}
#[test]
fn stale_retained_group_can_expand_without_navigating_or_applying_drafts() {
let mut app = fixture();
let current = stamp(&app, 1);
let path = app.source_workspace.active.clone();
app.text_workspace.begin_references(current, path.clone());
app.text_workspace
.install_reference_files(current, [path.clone()]);
let event = toggle(&app, current, path.clone());
send(&mut app, event);
assert!(app.text_workspace.references().collapsed(&path));
let accepted = app.document.snapshot();
let _ = app.update(Message::SourceEdit(widget::text_editor::Action::Edit(
widget::text_editor::Edit::Insert(' '),
)));
assert_ne!(current.snapshot, app.source_key());
let raw = app.source_workspace.bundle();
let cursor = app.source_editor.cursor();
let event = toggle(&app, current, path.clone());
send(&mut app, event);
assert!(!app.text_workspace.references().collapsed(&path));
assert_eq!(app.source_workspace.active, path);
assert_eq!(app.source_workspace.bundle(), raw);
assert_eq!(app.source_editor.cursor(), cursor);
assert!(app.source_symbols.reveal.is_none() && app.source_symbols.back.is_empty());
assert!(std::sync::Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
#[test]
fn document_reset_preserves_popup_token_sequence_and_drops_old_menu_authority() {
let mut app = fixture();
let old = open(&mut app);
let sequence = app.text_workspace_menu().unwrap().token;
app.reset(Project::empty(), None);
assert!(app.text_workspace.menu().is_none());
app.page = Page::Text;
let fresh = open(&mut app);
assert_ne!(old, fresh);
assert!(app.text_workspace_menu().unwrap().token > sequence);
invoke(&mut app, old, Command::Find);
assert!(!app.source_navigation.open);
assert_eq!(app.text_workspace.menu().unwrap().token(), fresh);
}
#[test]
fn retained_primary_and_related_targets_cannot_replay_after_hide_or_problems_references_problems() {
for hide in [false, true] {
let mut app = fixture();
{
let captured = Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
let old_visit = app.text_workspace.drawer_visit();
let publication = app.text_workspace.diagnostic_publication();
let targets = [location(&app, 0, 1), location(&app, 1, 2)];
let old = targets
.iter()
.cloned()
.map(|target| app.text_diagnostic_message(target).unwrap())
.collect::<Vec<_>>();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
let history = (
app.document.revision(),
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
let event = if hide {
Event::HideDrawer(app.text_drawer_context())
} else {
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References)
};
send(&mut app, event);
for reopened in [false, true] {
if reopened {
{
let captured =
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
} else {
assert!(app.text_diagnostic_message(targets[0].clone()).is_none());
}
assert_ne!(app.text_workspace.drawer_visit(), old_visit);
assert_eq!(
app.text_workspace.diagnostic_publication(),
publication,
"same list and source isolate the visit guard from publication freshness"
);
let context = app.text_command_context();
let drawer = app.text_workspace.drawer();
let visit = app.text_workspace.drawer_visit();
for message in old.iter().cloned() {
let _ = app.update(message);
assert_eq!(
app.text_command_context(),
context,
"stale diagnostic must fail before interaction invalidation"
);
assert_eq!(app.text_workspace.drawer(), drawer);
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(
(
app.document.revision(),
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
}
}
for target in targets {
let fresh = app.text_diagnostic_message(target).unwrap();
let _ = app.update(fresh);
assert!(
app.source_symbols.reveal.is_some(),
"fresh primary and related targets remain usable"
);
app.source_interaction();
}
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn queued_group_toggle_cannot_mutate_a_reopened_or_rotated_visit() {
for replacement in 0..3 {
let mut app = fixture();
let current = stamp(&app, 1);
app.text_workspace
.begin_references(current, "a.arcadia".into());
app.text_workspace
.install_reference_files(current, ["a.arcadia".into(), "b.arcadia".into()]);
let old = toggle(&app, current, "a.arcadia".into());
let old_visit = app.text_workspace.drawer_visit();
match replacement {
0 => {
{
let captured = Event::HideDrawer(app.text_drawer_context());
send(&mut app, captured);
};
{
let captured =
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
send(&mut app, captured);
};
}
1 => {
{
let captured =
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::Problems);
send(&mut app, captured);
};
{
let captured =
Event::ShowDrawer(app.text_drawer_context(), DrawerTab::References);
send(&mut app, captured);
};
}
_ => {
let event = toggle(&app, current, "b.arcadia".into());
send(&mut app, event);
}
}
let visit = app.text_workspace.drawer_visit();
assert_ne!(visit, old_visit);
send(
&mut app,
Event::ReferenceScrolled {
stamp: current,
visit,
y: 84.,
},
);
let scroll_id = app.text_workspace.reference_scroll_id();
let request = nav::Request {
job: Id::new_v4(),
interaction: app.source_symbols.interaction,
raw_interaction: 0,
stamp: current,
path: app.source_workspace.active.clone(),
offset: 0,
cursor: app.source_editor.cursor(),
action: nav::query::Action::References,
};
app.source_symbols.request = Some(request.clone());
app.source_symbols.in_flight = Some(request.job);
let groups =
["a.arcadia", "b.arcadia"].map(|path| app.text_workspace.references().collapsed(path));
let context = app.text_command_context();
let accepted = app.document.snapshot();
let raw = app.source_workspace.bundle();
for _ in 0..2 {
send(&mut app, old.clone());
assert_eq!(app.text_workspace.drawer_visit(), visit);
assert_eq!(app.text_workspace.references().scroll_y(), 84.);
assert_eq!(app.text_workspace.reference_scroll_id(), scroll_id);
assert_eq!(app.text_workspace.references().stamp(), Some(current));
assert_eq!(
["a.arcadia", "b.arcadia"]
.map(|path| app.text_workspace.references().collapsed(path)),
groups,
);
assert_eq!(app.text_command_context(), context);
assert_eq!(app.source_symbols.request.as_ref(), Some(&request));
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
}
let fresh = toggle(&app, current, "a.arcadia".into());
send(&mut app, fresh);
assert_eq!(
app.text_workspace.references().collapsed("a.arcadia"),
!groups[0]
);
assert_ne!(app.text_workspace.drawer_visit(), visit);
assert_ne!(app.source_symbols.interaction, context.interaction);
assert!(app.source_symbols.request.is_none());
assert_eq!(app.source_symbols.in_flight, Some(request.job));
assert!(app.source_symbols.reveal.is_none() && app.source_jump.is_none());
assert!(app.source_symbols.back.is_empty());
assert_eq!(app.source_workspace.bundle(), raw);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.document.can_undo() && !app.document.can_redo());
}
}
#[test]
fn annotation_snapshot_requires_exact_current_bytes_and_publication() {
let mut app = fixture();
let original_model = app.document.snapshot();
let original_source = app.source_snapshot.clone();
let publication = app.source_annotation_snapshot().unwrap().publication;
let initial = app.source_annotation_snapshot().unwrap();
assert!(Arc::ptr_eq(&initial.source, &original_source));
assert_eq!(initial.path, app.source_workspace.active);
assert!(std::ptr::eq(
initial.diagnostics,
app.source_diagnostics.as_slice()
));
// Republish identical diagnostics: old cached geometry must get a new identity.
app.publish_source_diagnostics(app.source_diagnostics.clone());
assert_ne!(
app.source_annotation_snapshot().unwrap().publication,
publication
);
app.source_workspace
.edit("// newer draft\n".into(), app.source_editor.cursor());
assert!(
app.source_annotation_snapshot().is_none(),
"old publication after a real draft edit"
);
// A current source key alone must not bind new ranges to the old displayed text.
app.publish_source_diagnostics(Vec::new());
assert!(
app.source_annotation_snapshot().is_none(),
"displayed snapshot still contains old bytes"
);
app.show_source_file(false);
let current = app.source_annotation_snapshot().unwrap();
assert_eq!(current.source.as_ref(), "// newer draft\n");
assert!(current.diagnostics.is_empty());
assert!(Arc::ptr_eq(&original_model, &app.document.snapshot()));
app.text_workspace.reset();
assert!(app.source_annotation_snapshot().is_none());
}
#[test]
fn annotation_snapshot_tracks_real_file_round_trips_without_changing_model() {
let mut project = Project::empty();
let element = project
.add_element(
oa_core::Kind::Activity,
oa_core::Layer::Oa,
"Assess incident",
None,
)
.unwrap();
let mut app = App::with_project(project);
app.page = Page::Text;
app.publish_source_diagnostics(app.source_diagnostics.clone());
let model = app.document.snapshot();
let entry = app.source_workspace.entry.clone();
let fragment = app.document.compiled().index.declarations[&element]
.path
.clone();
assert_ne!(entry, fragment);
for path in [&entry, &fragment, &entry] {
let _ = app.update(Message::SourceFile(path.clone()));
let current = app.source_annotation_snapshot().unwrap();
assert_eq!(current.path, path);
assert_eq!(
current.source.as_ref(),
app.source_workspace.files[path].text
);
assert!(Arc::ptr_eq(&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());
}
}
+8 -33
View File
@@ -684,7 +684,7 @@ mod scenario_scroll {
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if !(2..4).contains(&change) {
vec![if change < 2 || change >= 4 {
Vector::ZERO
} else {
Vector::new(0., 180.)
@@ -1314,12 +1314,6 @@ impl SequenceInput {
}
}
impl Scene {
fn removal_target(&self, owner: Id, id: Id) -> bool {
id != owner
&& (self.boxes.iter().any(|item| item.id == id)
|| self.lines.iter().any(|item| item.id == id))
}
// The integrated removal guard also needs the derived semantic hit set.
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
Self::sequence_geometry(p, owner, selected, None, None)
}
@@ -1513,7 +1507,11 @@ impl Scene {
SceneHitRole::Box
},
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
title: name,
title: if scenario {
format!("Lane · {name}")
} else {
name
},
detail,
marker: None,
});
@@ -2063,29 +2061,6 @@ impl canvas::Program<Message> for Scene {
state.pointer = cursor.position();
cursor
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Delete),
modifiers,
repeat: false,
..
}) if editable
&& tool == CanvasTool::Select
&& (modifiers.is_empty() || *modifiers == iced::keyboard::Modifiers::SHIFT)
&& state.press.is_none()
&& state.source.is_none() =>
{
let context = context?;
if self.removal_target(context.scenario, self.selected) {
return Some(
Action::publish(Message::ScenarioCanvas(CanvasEvent::Remove(
context,
self.selected,
)))
.and_capture(),
);
}
return None;
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
..
@@ -2394,7 +2369,7 @@ impl canvas::Program<Message> for Scene {
.with_color(if activation.call == self.selected {
style::TEAL
} else {
style::BOUNDARY
style::LINE
})
.with_width(if activation.call == self.selected {
2.5
@@ -2411,7 +2386,7 @@ impl canvas::Program<Message> for Scene {
let color = if b.id == self.selected {
style::TEAL
} else {
style::BOUNDARY
style::LINE
};
if outline {
f.stroke(
@@ -10,12 +10,8 @@ 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.busy
&& !app.hosted.visible
&& app.workflow.is_none()
&& app.file_prompt.is_none()
&& !app.delete_pending
@@ -30,6 +26,7 @@ pub fn controls<'a>(
keyboard: bool,
scale: f32,
details: Message,
focus: Option<Message>,
zoom: impl Fn(Option<f32>) -> Message,
publish: impl Fn(Event) -> Message + Copy + 'a,
) -> Element<'a, Message> {
@@ -46,21 +43,17 @@ pub fn controls<'a>(
publish,
)
};
let controls = row![
row![
control("Details", "Details", Some(details)),
tooltip(control("Canvas", "Canvas", focus),
container(text("F6: enter/leave canvas controls · Tab: next · Enter/Space: activate · Esc: leave").size(12)).padding(7).style(crate::style::card),
tooltip::Position::Bottom),
control("Fit", "Fit", Some(zoom(None))),
control("ZoomOut", "−", Some(zoom(Some(scale / 1.25)))),
text(zoom_label(scale)).size(12),
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()
].spacing(4).align_y(iced::Center).into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -131,6 +124,7 @@ pub enum Event {
CancelGesture,
Pan(Vector),
Zoom { factor: f32, at: Point, base: f32 },
Focus,
Traverse(bool),
ControlFocus(&'static str),
ToggleFocus,
@@ -70,6 +70,7 @@ impl<T: Send + 'static> Operation<T> for Collect {
return operation::Outcome::None;
}
let target = match self.event {
Event::Focus => Some(canvas),
Event::ControlFocus(name) => Some(self.scope.control_id(name)),
Event::ToggleFocus if self.focused.is_none() => Some(canvas),
Event::ToggleFocus | Event::Leave if self.focused.is_some() => None,
@@ -158,14 +158,6 @@ impl Mounted {
events: &[iced::Event],
cursor: mouse::Cursor,
) -> Vec<Message> {
self.input_with_status(app, events, cursor).0
}
pub fn input_with_status(
&mut self,
app: &App,
events: &[iced::Event],
cursor: mouse::Cursor,
) -> (Vec<Message>, Vec<iced::event::Status>) {
let mut messages = vec![];
let mut ui = UserInterface::build(
app.view(),
@@ -173,7 +165,7 @@ impl Mounted {
std::mem::take(&mut self.cache),
&mut self.renderer,
);
let (_, statuses) = ui.update(
let _ = ui.update(
events,
cursor,
&mut self.renderer,
@@ -181,7 +173,7 @@ impl Mounted {
&mut messages,
);
self.cache = ui.into_cache();
(messages, statuses)
messages
}
pub async fn perform(&mut self, app: &mut App, task: iced::Task<Message>) {
let mut ready = vec![];
@@ -244,9 +236,6 @@ impl Mounted {
}
}
pub fn pixels(&mut self, app: &App) -> Vec<u8> {
self.pixels_at(app, mouse::Cursor::Unavailable)
}
pub fn pixels_at(&mut self, app: &App, cursor: mouse::Cursor) -> Vec<u8> {
use iced::advanced::renderer::Headless;
let mut ui = UserInterface::build(
app.view(),
@@ -258,7 +247,7 @@ impl Mounted {
let mut messages = vec![];
let _ = ui.update(
&[],
cursor,
mouse::Cursor::Unavailable,
&mut self.renderer,
&mut iced::advanced::clipboard::Null,
&mut messages,
@@ -273,7 +262,7 @@ impl Mounted {
&iced::advanced::renderer::Style {
text_color: crate::style::INK,
},
cursor,
mouse::Cursor::Unavailable,
);
self.cache = ui.into_cache();
Headless::screenshot(
@@ -639,7 +628,7 @@ pub(crate) async fn retained_toolbar_boundaries(
use iced::keyboard::key::Named;
for boundary in ["Chooser", "Dirty", "Picker"] {
let mut packets = vec![];
for name in ["Fit", tool, "Details"] {
for name in ["Fit", tool, "Details", "Panels", "Canvas"] {
packets.push(capture_toolbar(mounted, app, name));
}
let lifetime = app.camera_epoch;
@@ -10,7 +10,6 @@ use iced::{keyboard, mouse};
struct Input {
scope: Option<Scope>,
focused: bool,
focus_pending: bool,
pointer: Option<Point>,
pan: Option<(mouse::Button, Point)>,
space: bool,
@@ -22,11 +21,9 @@ impl operation::Focusable for Input {
}
fn focus(&mut self) {
self.focused = true;
self.focus_pending = false;
}
fn unfocus(&mut self) {
self.focused = false;
self.focus_pending = false;
self.pan = None;
self.space = false;
}
@@ -95,7 +92,6 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
}
if !self.keyboard {
state.focused = false;
state.focus_pending = false;
}
state.scope = Some(self.scope);
tree.diff_children(std::slice::from_ref(&self.content));
@@ -147,11 +143,6 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
return;
}
let state = tree.state.downcast_mut::<Input>();
// A traversal operation is applied after this Iced event batch. Keep
// its focus witness, but retire keyboard admission immediately.
if state.focus_pending && matches!(event, Event::Keyboard(_)) {
return;
}
let changed_bounds = state.bounds.is_some_and(|bounds| bounds != layout.bounds());
state.bounds = Some(layout.bounds());
if changed_bounds || !self.keyboard {
@@ -160,7 +151,6 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
}
if !self.keyboard {
state.focused = false;
state.focus_pending = false;
}
if let Event::Mouse(mouse::Event::CursorMoved { position }) = event {
state.pointer = Some(*position);
@@ -188,7 +178,6 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
state.pan = None;
state.space = false;
state.focused = false;
state.focus_pending = false;
}
Event::Mouse(mouse::Event::CursorLeft) => {
state.pan = None;
@@ -201,7 +190,6 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
cancel = true;
}
state.focused = inside && self.keyboard;
state.focus_pending = false;
if inside
&& self.keyboard
&& (*button == mouse::Button::Middle
@@ -254,7 +242,6 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
repeat: false,
..
}) if self.keyboard && modifiers.is_empty() => {
state.focus_pending = true;
camera = Some(CameraEvent::ToggleFocus);
capture = true;
cancel = true;
@@ -271,14 +258,12 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
capture = true;
}
Key::Named(Named::Tab) => {
state.focus_pending = true;
camera = Some(CameraEvent::Traverse(modifiers.shift()));
capture = true;
}
Key::Named(Named::Escape) => {
state.pan = None;
state.space = false;
state.focus_pending = true;
camera = Some(CameraEvent::Leave);
capture = true;
}
@@ -324,9 +309,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Viewport<'_,
shell.request_redraw();
return;
}
if matches!(event, Event::Keyboard(_))
&& (!state.focused || !self.keyboard || state.pan.is_some() || state.space)
{
if matches!(event, Event::Keyboard(_)) && (!state.focused || !self.keyboard) {
return;
}
let child_cursor = if inside {
+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));
+28 -84
View File
@@ -18,14 +18,6 @@ mod workspace;
pub(crate) use workspace::NAME_INPUT;
pub use workspace::{Shelf, view};
// A picker may select the Saved diagram record itself. Another view of the
// same Scenario is not the property target of this displayed representation.
pub(crate) fn properties_target(p: &Project, context: Context, id: Id) -> bool {
super::selected_behavior(p, id) == Some(context.scenario)
|| matches!(context.representation, Representation::Saved { diagram } if diagram == id)
&& presentation::checked_owner(p, context.representation) == Some(context.scenario)
}
#[cfg(test)]
#[path = "scenario_operation_tests.rs"]
mod operation_tests;
@@ -83,34 +75,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,
@@ -150,14 +114,13 @@ pub enum CanvasEvent {
FormShown(Context, Id),
Zoom(Context, Option<f32>),
Camera(Context, u64, super::canvas_workspace::Event),
Focus(Context),
Shelf(Context, Shelf),
Properties(Context, Id),
OpenUse(Context, Id),
Back(Context),
Choose(Context, CanvasTool),
Representation(Context, Representation),
Select(Context, Id),
Remove(Context, Id),
Begin {
context: Context,
slot: Slot,
@@ -192,14 +155,13 @@ impl CanvasEvent {
| Self::FormShown(context, _)
| Self::Zoom(context, _)
| Self::Camera(context, ..)
| Self::Focus(context)
| Self::Shelf(context, _)
| Self::Properties(context, _)
| Self::OpenUse(context, _)
| Self::Back(context)
| Self::Choose(context, _)
| Self::Representation(context, _)
| Self::Select(context, _)
| Self::Remove(context, _)
| Self::Begin { context, .. }
| Self::BeginOccurrence { context, .. }
| Self::Reorder { context, .. } => *context,
@@ -349,6 +311,7 @@ impl State {
pub(crate) fn camera_boundary(&self) -> impl PartialEq + use<> {
(
self.canvas_tool,
self.presentation.focused,
self.presentation.shelf,
self.presentation.viewport,
self.presentation.pan,
@@ -403,6 +366,7 @@ impl State {
self.presentation = workspace::Presentation {
identity: Some((document, owner)),
representation: Some(representation),
focused: self.presentation.focused,
fit: matches!(representation, Representation::Saved { .. }),
..Default::default()
};
@@ -528,14 +492,15 @@ impl State {
self.zoom(None)
}
}
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);
}
pub(crate) fn show_properties(&mut self) {
// Panels remain available. Dismiss the competing outline without
// replacing this view or its camera.
self.presentation.shelf = None;
}
pub(crate) fn open_use(
&mut self,
p: &Project,
@@ -745,7 +710,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)
@@ -2116,7 +2081,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
@@ -3415,45 +3380,24 @@ 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.activations,
vec![super::super::scenario_activation::Segment {
call: steps[0],
receiver,
scope: owner,
rect: Rectangle::new(
Point::new(scene.lanes[&receiver] - 6., 148.),
Size::new(12., 92.),
),
column: 0,
finish: super::super::scenario_activation::Finish::Reply(steps[1]),
}]
);
// Scene::draw paints this typed rectangle directly. Check its extent
// against the actual message endpoints, without fixing a style width.
let rect = activation.rect;
assert!(
[rect.x, rect.y, rect.width, rect.height]
.iter()
.all(|v| v.is_finite())
);
assert!(rect.width > 0. && rect.height > 0.);
let call_line = scene.lines.iter().find(|l| l.id == steps[0]).unwrap();
let reply_line = scene.lines.iter().find(|l| l.id == steps[1]).unwrap();
let received = *call_line.points.last().unwrap();
let returned = reply_line.points[0];
assert_eq!(rect.y, received.y);
assert_eq!(rect.y + rect.height, returned.y);
assert_eq!(received.x, returned.x);
assert!(received.x == rect.x || received.x == rect.x + rect.width);
assert!(!scene.boxes.iter().any(|b| b.id == steps[0]));
assert!(scene.open_calls.is_empty());
let interior = rect.center();
assert!(scene.lines.iter().filter(|l| !l.label.is_empty()).all(|l| {
l.points.windows(2).all(|p| {
crate::diagram::distance_to_segment(interior, p[0], p[1]) > 8. / scene.scale
}) && !Rectangle::new(
l.label_at,
Size::new((l.label.chars().count() as f32 * 6.8 + 8.).min(225.), 25.),
)
.contains(interior)
}));
assert_eq!(scene.hit(interior), Some(steps[0]));
let call_line = scene.lines.iter().find(|line| line.id == steps[0]).unwrap();
let reply_line = scene.lines.iter().find(|line| line.id == steps[1]).unwrap();
assert_eq!(call_line.points.last().unwrap().y, 148.);
assert_eq!(reply_line.points.first().unwrap().y, 240.);
let mut reply = State::default();
reply
.update(result, Event::Start(owner, Tool::Message))
@@ -183,9 +183,8 @@ mod tests {
#[test]
fn tiny_fit_zoom_in_out_preserves_pointer_world_anchor_without_floor_jump() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let start = fit(bounds(), size).unwrap();
let viewport = Some(Size::new(700., 500.));
let start = fit(bounds(), viewport.unwrap()).unwrap();
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
let at = Point::new(210., 180.);
let world = Point::new(
@@ -219,8 +218,7 @@ mod tests {
#[test]
fn pan_leaves_fit_from_displayed_position_and_zero_motion_preserves_fit() {
let size = Size::new(700., 500.);
let viewport = Some(size);
let viewport = Some(Size::new(700., 500.));
let (mut pan, mut zoom, mut fitting) = (Vector::ZERO, None, true);
for event in [
Event::Pan(Vector::ZERO),
@@ -240,7 +238,7 @@ mod tests {
));
assert_eq!((pan, zoom, fitting), (Vector::ZERO, None, true));
}
let start = fit(bounds(), size).unwrap();
let start = fit(bounds(), viewport.unwrap()).unwrap();
assert!(change(
&mut pan,
&mut zoom,
@@ -188,93 +188,28 @@ fn complete_fallback_scene_selects_but_cannot_capture_or_finish_coordinate_gestu
);
assert!(input.press.is_none() && input.source.is_none());
}
// Capture from an actual Ready presentation, then revoke coordinate input
// with the complete fallback frame of the same Saved representation.
let Representation::Saved { diagram } = rep else {
unreachable!()
};
let mut ready_project = f.project.clone();
ready_project.diagrams.get_mut(&diagram).unwrap().layout =
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
let ready_original = ready_project.clone();
let ready = state.display(&ready_project, f.owner, f.call).unwrap();
assert!(matches!(
ready.status(),
presentation::PresentationStatus::Ready
));
let mut ready_scene = ready.into_scene().unwrap();
let line = ready_scene
.lines
.iter()
.find(|line| line.id == f.call)
.unwrap();
let ready_point = Point::new(
(line.points[0].x + line.points.last().unwrap().x) / 2.,
line.points[0].y,
);
let hit = ready_scene.hit_item(ready_point).unwrap();
assert_eq!(hit.id, f.call);
assert_eq!(hit.role, SceneHitRole::MessageBody);
let stamp = spacing::Stamp {
context,
camera_epoch: 17,
};
ready_scene.coordinate_input = true;
ready_scene.interaction = Some((context, CanvasTool::Select, true));
ready_scene.spacing_stamp = Some(stamp);
// A gesture already captured in a Ready frame is cancelled when capability is revoked.
scene.coordinate_input = true;
scene.interaction = Some((context, CanvasTool::Select, true));
let mut input = SequenceInput::default();
let begin = canvas::Program::update(
&ready_scene,
let _ = canvas::Program::update(
&scene,
&mut input,
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.unwrap();
let Message::ScenarioCanvas(CanvasEvent::Spacing(spacing::Event::Begin {
stamp: captured_stamp,
token,
target: spacing::Target::Message(target),
..
})) = begin
else {
panic!("Ready Saved message must begin a stamped spacing gesture");
};
assert_eq!(captured_stamp, stamp);
assert_eq!(target, f.call);
assert!(matches!(input.press, Some(SequencePress::Spacing {
stamp: held_stamp, token: held_token
}) if held_stamp == stamp && held_token == token));
scene.interaction = Some((context, CanvasTool::Select, true));
assert!(!scene.coordinate_input && scene.spacing_stamp.is_none());
let release = canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left));
mouse::Cursor::Available(point),
);
assert!(input.press.is_some());
scene.coordinate_input = false;
let result = canvas::Program::update(
&scene,
&mut input,
&release,
&canvas::Event::Mouse(mouse::Event::ButtonReleased(mouse::Button::Left)),
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0);
assert!(
matches!(result, Some(Message::ScenarioCanvas(CanvasEvent::Spacing(
spacing::Event::Cancel { stamp: cancelled_stamp, token: cancelled_token }
))) if cancelled_stamp == stamp && cancelled_token == token)
mouse::Cursor::Available(point),
);
assert!(input.press.is_none() && input.source.is_none());
assert!(
canvas::Program::update(
&scene,
&mut input,
&release,
bounds,
mouse::Cursor::Available(ready_point),
)
.and_then(|action| action.into_inner().0)
.is_none()
);
assert_eq!(ready_project, ready_original);
assert!(result.and_then(|action| action.into_inner().0).is_none());
assert!(input.press.is_none());
assert_eq!(f.project, original);
}
@@ -89,7 +89,7 @@ pub(crate) enum Finish {
Click(Id),
Noop,
Commit {
command: Box<Command>,
command: Command,
selected: Id,
scale: f32,
pan: Vector,
@@ -285,7 +285,7 @@ impl State {
Ok(match session.preview {
None => Finish::Noop,
Some(preview) => Finish::Commit {
command: Box::new(preview.command),
command: preview.command,
selected: session.drag.target.id(),
scale: session.scale,
pan: session.pan,

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