Compare commits

..
Author SHA1 Message Date
sorokin.oleg.edu fad1f5f33b Sync agent instructions from main 2026-09-13 11:38:37 +03:00
sorokin.oleg.edu 9d7e311320 Sync agent instructions from main 2026-09-12 13:43:14 +03:00
sorokin.oleg.edu 9cc8194d7b Sync agent instructions from main 2026-09-12 13:39:57 +03:00
sorokin.oleg.edu 3ee5aa6809 Sync agent instructions from main 2026-09-12 13:15:27 +03:00
sorokin.oleg.edu ca19ad277a Merge commit '113fb8b7a98c6a63bf67e77ebe5974555d033c62' into codex/behavior-canvas-20260911 2026-09-11 11:14:23 -07:00
sorokin.oleg.edu fb1da912c9 Implement reviewed shared behavior canvas controls and focus 2026-09-11 10:34:29 -07:00
sorokin.oleg.edu 113fb8b7a9 Sync agent instructions from main 2026-09-11 19:51:38 +03:00
Codex e511c4153f WIP checkpoint shared behavior canvas before user reboot 2026-09-11 06:44:13 -07:00
sorokin.oleg.edu c0e66f94e3 Stage explicit prerequisite sources inside behavior creation forms 2026-09-11 06:26:16 -07:00
Codex 7f081ad457 Integrate scoped reference chooser and scenario form focus and reveal 2026-09-11 06:24:20 -07:00
Codex c7ed894900 Merge shared instruction checkpoint preserving exact authoritative goal 2026-09-11 06:14:11 -07:00
Codex 98427b9620 Adopt shared Gitea and harness maintenance instructions 2026-09-11 06:14:05 -07:00
sorokin.oleg.edu cab01c3d7f Sync agent instructions from main 2026-09-11 15:55:34 +03:00
Codex 7d14bd0dd1 Add exact analysis numbers units and bounded request work 2026-09-11 05:50:45 -07:00
Codex dc0f2e12f3 Import corrected creation oracle and record exact SA action history 2026-09-11 05:41:06 -07:00
sorokin.oleg.edu bc311bc076 Add source-first CLI with guarded writes and reviewed evidence 2026-09-11 05:27:16 -07:00
Codex a34558d3be Record OA native history and ratified analysis library decisions 2026-09-11 05:21:37 -07:00
Codex 2cc4d115d0 Specify baseline analysis and verified library contract compatibility 2026-09-11 04:48:44 -07:00
sorokin.oleg.edu 1516949567 Add named behavior creation and verified textual authoring examples 2026-09-11 04:26:56 -07:00
Codex c4e1580bb0 Exercise bounded reference chooser with mounted input tests 2026-09-11 02:29:15 -07:00
1824 changed files with 16139 additions and 177235 deletions
+16 -48
View File
@@ -22,65 +22,33 @@ harness/history/2026-09-06-ledgers-before-consolidation/** -text
harness/history/2026-09-10-ledgers-before-continuation-refresh/** -text
harness/history/2026-09-10-ledgers-before-second-consolidation/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/textual-authoring-20260911-local/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/history/2026-09-11-ytr83r-integration/** -text whitespace=cr-at-eol
crates/oa-desktop/src/recovery/fixtures/*.json -text
crates/oa-core/src/analysis_numeric/fixtures/** -text whitespace=cr-at-eol
harness/fixtures/source7-native/** -text
harness/fixtures/basic-editing/** -text
harness/fixtures/scenario-authoring/base/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/** -text whitespace=cr-at-eol
harness/experiments/textual-authoring-20260911/observed/split-entry.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/analysis-context-20260911/** -text whitespace=cr-at-eol
harness/experiments/constructor-defaults-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/constructor-defaults-20260911/results/*.arcadia whitespace=-blank-at-eol,cr-at-eol
harness/experiments/behavior-creation-native-20260911/** -text whitespace=blank-at-eol,blank-at-eof,space-before-tab,cr-at-eol
harness/experiments/analysis-baseline-20260911/** -text whitespace=cr-at-eol
harness/experiments/library-compatibility-20260911/** -text whitespace=cr-at-eol
harness/experiments/refined-contracts-review-20260911/** -text whitespace=cr-at-eol
harness/behavior-creation-contract.rs -text whitespace=cr-at-eol
harness/experiments/source-cli-independent-20260911/** -text whitespace=cr-at-eol
harness/fixtures/scenario-lifecycle-native/**/*.arcadia -text whitespace=cr-at-eol
harness/experiments/lifecycle-native-ytr83r-20260911/** -text whitespace=cr-at-eol
harness/experiments/lifecycle-native-ytr83r-20260911/*.log -text -whitespace
harness/experiments/lifecycle-native-ui-oa-20260911/** -text -whitespace
harness/experiments/lifecycle-native-ui-sa-20260911/** -text -whitespace
harness/experiments/wsl-postboot-ytr83r-20260911/** -text -whitespace
harness/experiments/authoring-prerelease-20260911/** -text whitespace=cr-at-eol
harness/experiments/cli-packaging-20260911/** -text whitespace=cr-at-eol
harness/experiments/cli-prerelease-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-20260911/** -text whitespace=cr-at-eol
harness/experiments/contextual-source-20260911/**/*.log whitespace=-blank-at-eof,cr-at-eol
harness/fixtures/staged-source-authoring/** -text whitespace=cr-at-eol
harness/staged-source-authoring-contract.rs -text whitespace=cr-at-eol
# Preserve checksum-bound font notices verbatim through Git round trips.
scripts/windows-notices/GNOME-Cantarell-61362600e104-COPYING -text
scripts/windows-notices/Fira-OFL.txt -text
scripts/windows-notices/FiraSans-Regular-NOTICE.txt -text
scripts/windows-notices/llvm-6dfe1677ab8d-libunwind-LICENSE.TXT -text
harness/experiments/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
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
Generated
+22 -1
View File
@@ -2107,6 +2107,25 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "num-bigint"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93e7820bc0a80a0238e650327316f929ba18d5be054b647490a3a6a339f3e7c0"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -2153,6 +2172,9 @@ name = "oa-core"
version = "0.2.0"
dependencies = [
"libc",
"num-bigint",
"num-integer",
"num-traits",
"same-file",
"serde",
"serde_json",
@@ -2176,7 +2198,6 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"unicode-segmentation",
]
[[package]]
+3
View File
@@ -12,6 +12,9 @@ uuid.workspace = true
thiserror.workspace = true
tempfile = "3"
same-file = "1"
num-bigint = "0.5.1"
num-integer = "0.1.47"
num-traits = "0.2.19"
[target.'cfg(target_os = "linux")'.dependencies]
libc = "0.2"
+103
View File
@@ -0,0 +1,103 @@
//! Exact numeric foundation for analysis; no model, expression, or package resolver.
//!
//! Values use reduced rational coordinates. All nontrivial arithmetic takes one
//! request-local [WorkMeter]. The future evaluator must also charge *expanded*
//! expression/call/property/list/target visits and retain the same logical walk on
//! cache hits. Stored AST size is not a dynamic execution limit.
//!
//! Approximate engineering functions/irrational unit scales require a subsequent
//! explicit numeric profile; rationals do not implement degree/radian conversion
//! or transcendental mathematics.
mod dimension;
mod exact;
mod meter;
mod quantity;
pub use dimension::{Dimension, DimensionAxis};
pub use exact::{Exact, ExactWire};
pub use meter::{
EvaluationStep, NumericOperation, WorkExhaustion, WorkLimits, WorkMeter, WorkResource,
WorkSite, WorkUsage,
};
pub use quantity::{NumericType, NumericValue, ResolvedKind, Unit};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Profile identity includes the accounting rules, not crate-internal timing.
pub const WORK_PROFILE: &str = "Q1-logical-work-1";
pub const MAX_COMPONENT_BITS: u64 = 4096;
/// Cross products require 8192 bits; their sum can require one additional bit.
pub const MAX_INTERMEDIATE_BITS: u64 = 8193;
pub const MAX_COMPONENT_DIGITS: usize = 1234;
pub const MAX_DECIMAL_EXPONENT: i32 = 128;
pub const MAX_INTEGER_POWER: i8 = 16;
/// Includes ratio punctuation/whitespace without permitting unbounded scanning.
pub const MAX_LITERAL_BYTES: usize = 2 * MAX_COMPONENT_DIGITS + 32;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NumberComponent {
Numerator,
Denominator,
}
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
pub enum NumericLimit {
#[error("request work exhausted: {0}")]
Work(WorkExhaustion),
#[error("{component:?} uses {bits} bits, above the {limit}-bit {stage} limit")]
Bits {
component: NumberComponent,
bits: u64,
limit: u64,
stage: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
pub enum NumericError {
#[error("numeric limit: {0}")]
Limit(NumericLimit),
#[error("invalid numeric literal")]
InvalidLiteral,
#[error("numeric literal exceeds its digit or byte limit")]
LiteralTooLong,
#[error("decimal exponent is outside -128 through 128")]
DecimalExponent,
#[error("wire rational is not in canonical reduced form")]
NonCanonicalWire,
#[error("rational denominator is zero")]
ZeroDenominator,
#[error("division by zero")]
DivisionByZero,
#[error("zero to the power zero is undefined in this profile")]
ZeroPowerZero,
#[error("integer power is outside -16 through 16")]
IntegerPower,
#[error("dimension exponent at {axis:?} is outside -32 through 32: {value}")]
DimensionExponent { axis: DimensionAxis, value: i32 },
#[error("unit scale must be strictly positive")]
UnitScale,
#[error("a linear unit must have zero offset")]
LinearOffset,
#[error("an affine point must have a distinct linear difference kind")]
DifferenceKind,
#[error("numeric kinds or dimensions are incompatible")]
IncompatibleTypes,
#[error("operation is not defined for affine points")]
AffineOperation,
#[error("AsKind requires an anonymous matching dimension and a linear target")]
KindAssertion,
#[error("request work limits exceed the provisional reference ceilings")]
InvalidWorkLimits,
}
impl NumericError {
pub fn is_terminal_work_exhaustion(&self) -> bool {
matches!(self, Self::Limit(NumericLimit::Work(_)))
}
}
#[cfg(test)]
mod tests;
@@ -0,0 +1,107 @@
use super::NumericError;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DimensionAxis {
Length,
Mass,
Time,
ElectricCurrent,
Temperature,
Amount,
LuminousIntensity,
}
impl DimensionAxis {
pub const ALL: [Self; 7] = [
Self::Length,
Self::Mass,
Self::Time,
Self::ElectricCurrent,
Self::Temperature,
Self::Amount,
Self::LuminousIntensity,
];
}
/// SI base-dimension vector in DimensionAxis::ALL order, each entry -32..=32.
/// This is a dimension, never a nominal quantity-kind compatibility proof.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "[i16; 7]", into = "[i16; 7]")]
pub struct Dimension([i16; 7]);
impl Dimension {
pub const ZERO: Self = Self([0; 7]);
pub fn new(exponents: [i16; 7]) -> Result<Self, NumericError> {
for (index, value) in exponents.iter().copied().enumerate() {
if !(-32..=32).contains(&value) {
return Err(NumericError::DimensionExponent {
axis: DimensionAxis::ALL[index],
value: i32::from(value),
});
}
}
Ok(Self(exponents))
}
pub fn exponents(self) -> [i16; 7] {
self.0
}
pub fn is_dimensionless(self) -> bool {
self == Self::ZERO
}
pub fn checked_product(self, rhs: Self) -> Result<Self, NumericError> {
self.combine(rhs, 1)
}
pub fn checked_quotient(self, rhs: Self) -> Result<Self, NumericError> {
self.combine(rhs, -1)
}
pub fn checked_power(self, exponent: i8) -> Result<Self, NumericError> {
if !(-super::MAX_INTEGER_POWER..=super::MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
let mut output = [0; 7];
for (index, value) in self.0.iter().enumerate() {
output[index] = Self::checked_exponent(index, i32::from(*value) * i32::from(exponent))?;
}
Ok(Self(output))
}
fn combine(self, rhs: Self, sign: i32) -> Result<Self, NumericError> {
let mut output = [0; 7];
for (index, value) in self.0.iter().enumerate() {
output[index] =
Self::checked_exponent(index, i32::from(*value) + sign * i32::from(rhs.0[index]))?;
}
Ok(Self(output))
}
fn checked_exponent(index: usize, value: i32) -> Result<i16, NumericError> {
if !(-32..=32).contains(&value) {
return Err(NumericError::DimensionExponent {
axis: DimensionAxis::ALL[index],
value,
});
}
Ok(value as i16)
}
}
impl TryFrom<[i16; 7]> for Dimension {
type Error = NumericError;
fn try_from(value: [i16; 7]) -> Result<Self, Self::Error> {
Self::new(value)
}
}
impl From<Dimension> for [i16; 7] {
fn from(value: Dimension) -> Self {
value.0
}
}
@@ -0,0 +1,469 @@
use super::meter::{normalization_cost, parse_cost, product_cost, sum_cost, words};
use super::{
MAX_COMPONENT_BITS, MAX_COMPONENT_DIGITS, MAX_DECIMAL_EXPONENT, MAX_INTEGER_POWER,
MAX_INTERMEDIATE_BITS, MAX_LITERAL_BYTES, NumberComponent, NumericError, NumericLimit,
NumericOperation, WorkLimits, WorkMeter,
};
use num_bigint::BigInt;
use num_integer::Integer;
use num_traits::{One, Signed, Zero};
use serde::{Deserialize, Serialize};
use std::cmp::Ordering;
/// The interchange representation deliberately does not use num-bigint's serde
/// format or JSON numeric values. Duplicate/missing/unknown fields reject.
/// Its derived decoder allocates String fields before numeric validation; a
/// transport/document adapter must bound the encoded envelope and decoded text.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExactWire {
pub numerator: String,
pub denominator: String,
}
/// Reduced rational with positive denominator and <=4096-bit components.
///
/// There are no unmetered arithmetic/ordering traits. Eq is structural equality
/// of already normalized bounded values, not an expression comparison API.
/// Standalone serde admission bounds the subsequent BigInt validation, not the
/// preceding String decoding. Document adapters must bound encoded/decoded input
/// and aggregate admission work. Evaluation uses one explicit WorkMeter.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "ExactWire", into = "ExactWire")]
pub struct Exact {
numerator: BigInt,
denominator: BigInt,
}
impl Exact {
pub fn from_integer(value: i64) -> Self {
Self {
numerator: BigInt::from(value),
denominator: BigInt::one(),
}
}
pub fn is_zero(&self) -> bool {
self.numerator.is_zero()
}
pub fn is_positive(&self) -> bool {
self.numerator.is_positive()
}
pub fn component_bits(&self) -> (u64, u64) {
(self.numerator.bits(), self.denominator.bits())
}
pub fn to_wire(&self) -> ExactWire {
ExactWire {
numerator: self.numerator.to_string(),
denominator: self.denominator.to_string(),
}
}
/// Canonical *literal* spelling, not a generic expression formatter.
/// Negative literal nodes use ratio(-n,d), even when d=1. A leading minus
/// belongs to the future expression parser's UnaryNegate node instead.
pub fn canonical_literal(&self) -> String {
if self.denominator.is_one() && !self.numerator.is_negative() {
self.numerator.to_string()
} else {
format!("ratio({}, {})", self.numerator, self.denominator)
}
}
pub fn from_wire(wire: &ExactWire, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let n_digits = wire_digits(&wire.numerator, true)?;
let d_digits = wire_digits(&wire.denominator, false)?;
let n_bound = decimal_bits(n_digits.len());
let d_bound = decimal_bits(d_digits.len());
meter.numeric(
NumericOperation::ParseWire,
parse_cost(n_digits.len(), n_bound)
+ parse_cost(d_digits.len(), d_bound)
+ normalization_cost(n_bound, d_bound),
)?;
let numerator = parse_integer(&wire.numerator)?;
let denominator = parse_integer(&wire.denominator)?;
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "wire")?;
if denominator.is_zero() {
return Err(NumericError::ZeroDenominator);
}
if !numerator.gcd(&denominator).is_one() {
return Err(NumericError::NonCanonicalWire);
}
Ok(Self {
numerator,
denominator,
})
}
/// Reads one unsigned decimal/integer atom or a ratio(n,d) literal only.
/// It does not parse expressions, unary operators, units, calls or names.
/// Source ratios/decimals may reduce; wire values must already be canonical.
pub fn parse_literal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if source.len() > MAX_LITERAL_BYTES {
return Err(NumericError::LiteralTooLong);
}
let source = trim_ascii(source);
if let Some(body) = source.strip_prefix("ratio(") {
let body = body.strip_suffix(')').ok_or(NumericError::InvalidLiteral)?;
let (n, d) = body.split_once(',').ok_or(NumericError::InvalidLiteral)?;
return Self::parse_ratio(trim_ascii(n), trim_ascii(d), meter);
}
Self::parse_decimal(source, meter)
}
fn parse_ratio(n: &str, d: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let n_digits = source_integer_digits(n, true)?;
let d_digits = source_integer_digits(d, false)?;
let n_bound = decimal_bits(n_digits.len());
let d_bound = decimal_bits(d_digits.len());
Self::check_estimates(n_bound, d_bound)?;
meter.numeric(
NumericOperation::ParseLiteral,
parse_cost(n_digits.len(), n_bound)
+ parse_cost(d_digits.len(), d_bound)
+ normalization_cost(n_bound, d_bound),
)?;
Self::normalize_precharged(parse_integer(n)?, parse_integer(d)?)
}
fn parse_decimal(source: &str, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let mut parts = source.split(['e', 'E']);
let mantissa = parts.next().ok_or(NumericError::InvalidLiteral)?;
let exponent = match parts.next() {
Some(text) => parse_exponent(text)?,
None => 0,
};
if parts.next().is_some() {
return Err(NumericError::InvalidLiteral);
}
let (whole, fraction) = match mantissa.split_once('.') {
Some((whole, fraction)) if !fraction.is_empty() => (whole, fraction),
Some(_) => return Err(NumericError::InvalidLiteral),
None => (mantissa, ""),
};
source_integer_digits(whole, false)?;
if !fraction.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
let digit_count = whole.len() + fraction.len();
if digit_count > MAX_COMPONENT_DIGITS {
return Err(NumericError::LiteralTooLong);
}
let adjustment = exponent - fraction.len() as i32;
let power = adjustment.unsigned_abs();
let mantissa_bound = decimal_bits(digit_count);
let scale_bound = decimal_bits(power as usize + 1);
let (n_bound, d_bound) = if adjustment >= 0 {
(mantissa_bound + scale_bound, 1)
} else {
(mantissa_bound, scale_bound)
};
Self::check_estimates(n_bound, d_bound)?;
// Fixed logical allowance for constructing 10^power; independent of
// num-bigint's selected multiplication/exponentiation implementation.
let power_steps = 1 + 2 * u64::from(u32::BITS - power.leading_zeros());
meter.numeric(
NumericOperation::ParseLiteral,
parse_cost(digit_count, mantissa_bound)
+ power_steps * product_cost(scale_bound, scale_bound)
+ product_cost(mantissa_bound, scale_bound)
+ normalization_cost(n_bound, d_bound),
)?;
let digits = format!("{whole}{fraction}");
let mut numerator = parse_integer(&digits)?;
let scale = BigInt::from(10_u8).pow(power);
let denominator = if adjustment >= 0 {
numerator *= scale;
BigInt::one()
} else {
scale
};
Self::normalize_precharged(numerator, denominator)
}
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
self.add_or_subtract(rhs, false, meter)
}
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
self.add_or_subtract(rhs, true, meter)
}
fn add_or_subtract(
&self,
rhs: &Self,
subtract: bool,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
let left_bound = a + d;
let right_bound = c + b;
let n_bound = left_bound.max(right_bound) + 1;
let d_bound = b + d;
Self::check_estimates(n_bound, d_bound)?;
meter.numeric(
if subtract {
NumericOperation::Subtract
} else {
NumericOperation::Add
},
product_cost(a, d)
+ product_cost(c, b)
+ sum_cost(left_bound, right_bound)
+ product_cost(b, d)
+ normalization_cost(n_bound, d_bound),
)?;
let left = &self.numerator * &rhs.denominator;
let right = &rhs.numerator * &self.denominator;
let numerator = if subtract { left - right } else { left + right };
let denominator = &self.denominator * &rhs.denominator;
Self::normalize_precharged(numerator, denominator)
}
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates(a + c, b + d)?;
meter.numeric(
NumericOperation::Multiply,
product_cost(a, c) + product_cost(b, d) + normalization_cost(a + c, b + d),
)?;
Self::normalize_precharged(
&self.numerator * &rhs.numerator,
&self.denominator * &rhs.denominator,
)
}
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates(a + d, b + c)?;
meter.numeric(
NumericOperation::Divide,
product_cost(a, d) + product_cost(b, c) + normalization_cost(a + d, b + c),
)?;
if rhs.is_zero() {
return Err(NumericError::DivisionByZero);
}
Self::normalize_precharged(
&self.numerator * &rhs.denominator,
&self.denominator * &rhs.numerator,
)
}
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
let (a, b) = self.component_bits();
let (c, d) = rhs.component_bits();
Self::check_estimates((a + d).max(c + b), 1)?;
meter.numeric(
NumericOperation::Compare,
product_cost(a, d) + product_cost(c, b) + sum_cost(a + d, c + b),
)?;
Ok((&self.numerator * &rhs.denominator).cmp(&(&rhs.numerator * &self.denominator)))
}
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Negate, self.copy_cost())?;
Ok(Self {
numerator: -&self.numerator,
denominator: self.denominator.clone(),
})
}
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Absolute, self.copy_cost())?;
Ok(Self {
numerator: self.numerator.abs(),
denominator: self.denominator.clone(),
})
}
pub fn reciprocal(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Reciprocal, self.copy_cost())?;
if self.is_zero() {
return Err(NumericError::DivisionByZero);
}
Ok(Self {
numerator: if self.numerator.is_negative() {
-&self.denominator
} else {
self.denominator.clone()
},
denominator: self.numerator.abs(),
})
}
/// Fixed left-to-right repeated multiplication, at most 16 multiplications.
/// Each reduced stage obeys the same component bound; no large power is
/// allocated and subsequently discarded after discovering it is oversized.
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.numeric(NumericOperation::Power, self.copy_cost())?;
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
if self.is_zero() && exponent == 0 {
return Err(NumericError::ZeroPowerZero);
}
let factor = if exponent < 0 {
self.reciprocal(meter)?
} else {
self.clone()
};
let mut value = Self::from_integer(1);
for _ in 0..exponent.unsigned_abs() {
value = value.multiply(&factor, meter)?;
}
Ok(value)
}
pub(super) fn copy_cost(&self) -> u64 {
let (n, d) = self.component_bits();
1 + words(n) + words(d)
}
fn normalize_precharged(
mut numerator: BigInt,
mut denominator: BigInt,
) -> Result<Self, NumericError> {
Self::check_bits(
&numerator,
&denominator,
MAX_INTERMEDIATE_BITS,
"intermediate",
)?;
if denominator.is_zero() {
return Err(NumericError::ZeroDenominator);
}
if denominator.is_negative() {
numerator = -numerator;
denominator = -denominator;
}
let gcd = numerator.gcd(&denominator);
numerator /= &gcd;
denominator /= gcd;
Self::check_bits(&numerator, &denominator, MAX_COMPONENT_BITS, "reduced")?;
Ok(Self {
numerator,
denominator,
})
}
fn check_estimates(n: u64, d: u64) -> Result<(), NumericError> {
Self::check_bit_count(
n,
NumberComponent::Numerator,
MAX_INTERMEDIATE_BITS,
"predicted",
)?;
Self::check_bit_count(
d,
NumberComponent::Denominator,
MAX_INTERMEDIATE_BITS,
"predicted",
)
}
fn check_bits(n: &BigInt, d: &BigInt, limit: u64, stage: &str) -> Result<(), NumericError> {
Self::check_bit_count(n.bits(), NumberComponent::Numerator, limit, stage)?;
Self::check_bit_count(d.bits(), NumberComponent::Denominator, limit, stage)
}
fn check_bit_count(
bits: u64,
component: NumberComponent,
limit: u64,
stage: &str,
) -> Result<(), NumericError> {
if bits > limit {
return Err(NumericError::Limit(NumericLimit::Bits {
component,
bits,
limit,
stage: stage.to_owned(),
}));
}
Ok(())
}
}
impl TryFrom<ExactWire> for Exact {
type Error = NumericError;
fn try_from(value: ExactWire) -> Result<Self, Self::Error> {
// BigInt validation is bounded after ExactWire strings have decoded.
// The adapter owns envelope/text/aggregate admission bounds and must not
// reset evaluation meters.
Self::from_wire(&value, &mut WorkMeter::new(WorkLimits::REFERENCE))
}
}
impl From<Exact> for ExactWire {
fn from(value: Exact) -> Self {
value.to_wire()
}
}
fn trim_ascii(text: &str) -> &str {
text.trim_matches(|ch: char| ch.is_ascii_whitespace())
}
fn source_integer_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
let digits = if signed {
text.strip_prefix('-').unwrap_or(text)
} else {
text
};
if digits.len() > MAX_COMPONENT_DIGITS {
return Err(NumericError::LiteralTooLong);
}
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
Ok(digits)
}
fn wire_digits(text: &str, signed: bool) -> Result<&str, NumericError> {
let digits = source_integer_digits(text, signed).map_err(|error| match error {
NumericError::LiteralTooLong => error,
_ => NumericError::NonCanonicalWire,
})?;
if (digits.len() > 1 && digits.starts_with('0')) || (text.starts_with('-') && digits == "0") {
return Err(NumericError::NonCanonicalWire);
}
Ok(digits)
}
fn parse_integer(text: &str) -> Result<BigInt, NumericError> {
BigInt::parse_bytes(text.as_bytes(), 10).ok_or(NumericError::InvalidLiteral)
}
fn parse_exponent(text: &str) -> Result<i32, NumericError> {
let (negative, digits) = if let Some(digits) = text.strip_prefix('-') {
(true, digits)
} else {
(false, text.strip_prefix('+').unwrap_or(text))
};
if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(NumericError::InvalidLiteral);
}
let mut value = 0_i32;
for digit in digits.bytes() {
value = value * 10 + i32::from(digit - b'0');
if value > MAX_DECIMAL_EXPONENT {
return Err(NumericError::DecimalExponent);
}
}
Ok(if negative { -value } else { value })
}
fn decimal_bits(digits: usize) -> u64 {
// 3.322 exceeds log2(10); dimensions are bounded before this conversion.
(digits as u64 * 3322).div_ceil(1000).max(1)
}
@@ -0,0 +1,32 @@
[
{
"canonical_source": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000",
"expression": {
"Number": {
"denominator": "1",
"numerator": "1000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
}
},
"name": "ten_to_300"
},
{
"canonical_source": "ratio(1, 522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168)",
"expression": {
"Number": {
"denominator": "522194440706576253345876355358312191289982124523691890192116741641976953985778728424413405967498779170445053357219631418993786719092896803631618043925682638972978488271854999170180795067191859157214035005927973113188159419698856372836167342172293308748403954352901852035642024370059304557233988891799014503343469488440893892973452815095130470299789726716411734651513348221529512507986199933857107770846917779942645743159118957217248367043905936319748237550094520674504208530837546834166925275516486044134775384991808184705966507606898412918594045916828375610659246423184062775112999150206172392431297837246097308511903252956622805412865917690043804311051417135098849101156584508839003337597742539960818209685142687562392007453579567729991395256699805775897135553415567045292136442139895777424891477161767258532611634530697452993846501061481697843891439474220308003706472837459911525285821188577408160690315522951458068463354171428220365223949985950890732881736611925133626529949897998045399734600887312408859224933727829625089164535236559716582775403784110923285873186648442456409760158728501220463308455437074192539205964902261490928669488824051563042951500651206733594863336608245755565801460390869016718045121902354170201577095168",
"numerator": "1"
}
},
"name": "one_over_two_to_4095"
},
{
"canonical_source": "ratio(-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335, 1)",
"expression": {
"Number": {
"denominator": "1",
"numerator": "-1044388881413152506691752710716624382579964249047383780384233483283953907971557456848826811934997558340890106714439262837987573438185793607263236087851365277945956976543709998340361590134383718314428070011855946226376318839397712745672334684344586617496807908705803704071284048740118609114467977783598029006686938976881787785946905630190260940599579453432823469303026696443059025015972399867714215541693835559885291486318237914434496734087811872639496475100189041349008417061675093668333850551032972088269550769983616369411933015213796825837188091833656751221318492846368125550225998300412344784862595674492194617023806505913245610825731835380087608622102834270197698202313169017678006675195485079921636419370285375124784014907159135459982790513399611551794271106831134090584272884279791554849782954323534517065223269061394905987693002122963395687782878948440616007412945674919823050571642377154816321380631045902916136926708342856440730447899971901781465763473223850267253059899795996090799469201774624817718449867455659250178329070473119433165550807568221846571746373296884912819520317457002440926616910874148385078411929804522981857338977648103126085903001302413467189726673216491511131602920781738033436090243804708340403154190335"
}
},
"name": "negative_4096_bit_bound"
}
]
@@ -0,0 +1,240 @@
use super::{NumericError, NumericLimit};
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Every event is charged once per dynamic occurrence, before entering it.
///
/// A Call expression therefore charges Expression and CalculationEntry; a
/// Property read charges Expression and PropertyBinding. List element visits
/// are additional to evaluation of their expressions. Only executed branches
/// are charged. Targets are sorted by category (properties, then checks), then
/// stable UUID bytes, by the integrating evaluator.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EvaluationStep {
Target,
Expression,
CalculationEntry,
PropertyBinding,
ListElement,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum NumericOperation {
ParseWire,
ParseLiteral,
Add,
Subtract,
Multiply,
Divide,
Compare,
Negate,
Absolute,
Reciprocal,
Power,
KindAssertion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkResource {
DynamicEvents,
NumericUnits,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum WorkSite {
Evaluation(EvaluationStep),
Numeric(NumericOperation),
}
/// Failed reservations consume no partial charge. The first failure is latched.
#[derive(Debug, Clone, PartialEq, Eq, Error, Serialize, Deserialize)]
#[error("{resource:?}: used {used}, requested {requested}, limit {limit}, at {site:?}")]
pub struct WorkExhaustion {
pub resource: WorkResource,
pub used: u64,
pub requested: u64,
pub limit: u64,
pub site: WorkSite,
}
/// Provisional calibration ceilings, not measured execution-time guarantees.
///
/// Effective limits, profile identity, capture, targets and context must all be
/// recorded in request/result/frozen identity. A caller may lower these ceilings.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "WorkLimitsWire", into = "WorkLimitsWire")]
pub struct WorkLimits {
dynamic_events: u64,
numeric_units: u64,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct WorkLimitsWire {
dynamic_events: u64,
numeric_units: u64,
}
impl WorkLimits {
pub const REFERENCE: Self = Self {
dynamic_events: 1_000_000,
numeric_units: 50_000_000,
};
pub fn new(dynamic_events: u64, numeric_units: u64) -> Result<Self, NumericError> {
if dynamic_events > Self::REFERENCE.dynamic_events
|| numeric_units > Self::REFERENCE.numeric_units
{
return Err(NumericError::InvalidWorkLimits);
}
Ok(Self {
dynamic_events,
numeric_units,
})
}
pub fn dynamic_events(self) -> u64 {
self.dynamic_events
}
pub fn numeric_units(self) -> u64 {
self.numeric_units
}
}
impl TryFrom<WorkLimitsWire> for WorkLimits {
type Error = NumericError;
fn try_from(value: WorkLimitsWire) -> Result<Self, Self::Error> {
Self::new(value.dynamic_events, value.numeric_units)
}
}
impl From<WorkLimits> for WorkLimitsWire {
fn from(value: WorkLimits) -> Self {
Self {
dynamic_events: value.dynamic_events,
numeric_units: value.numeric_units,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkUsage {
pub dynamic_events: u64,
pub numeric_units: u64,
}
/// Intentionally neither Clone nor Default: pass the same mutable meter through
/// every target, numeric operation and expanded call in one evaluation request.
///
/// Exhaustion is terminal. Integrators must stop traversing siblings/targets and
/// mark already collected causes as incomplete. Replaying an aggregate cache
/// cost is insufficient if it changes the exact exhaustion location; initially
/// retain logical traversal even when numerical values come from a cache.
#[derive(Debug)]
pub struct WorkMeter {
limits: WorkLimits,
usage: WorkUsage,
exhausted: Option<WorkExhaustion>,
}
impl WorkMeter {
pub fn new(limits: WorkLimits) -> Self {
Self {
limits,
usage: WorkUsage::default(),
exhausted: None,
}
}
pub fn limits(&self) -> WorkLimits {
self.limits
}
pub fn usage(&self) -> WorkUsage {
self.usage
}
pub fn exhaustion(&self) -> Option<&WorkExhaustion> {
self.exhausted.as_ref()
}
pub fn ensure_active(&self) -> Result<(), NumericError> {
match &self.exhausted {
Some(failure) => Err(NumericError::Limit(NumericLimit::Work(failure.clone()))),
None => Ok(()),
}
}
pub fn visit(&mut self, step: EvaluationStep) -> Result<(), NumericError> {
self.reserve(WorkResource::DynamicEvents, 1, WorkSite::Evaluation(step))
}
pub(super) fn numeric(
&mut self,
operation: NumericOperation,
units: u64,
) -> Result<(), NumericError> {
self.reserve(
WorkResource::NumericUnits,
units,
WorkSite::Numeric(operation),
)
}
fn reserve(
&mut self,
resource: WorkResource,
requested: u64,
site: WorkSite,
) -> Result<(), NumericError> {
self.ensure_active()?;
let (used, limit) = match resource {
WorkResource::DynamicEvents => (self.usage.dynamic_events, self.limits.dynamic_events),
WorkResource::NumericUnits => (self.usage.numeric_units, self.limits.numeric_units),
};
// Subtraction is safe: only successful reservations update usage.
if requested > limit - used {
let failure = WorkExhaustion {
resource,
used,
requested,
limit,
site,
};
self.exhausted = Some(failure.clone());
return Err(NumericError::Limit(NumericLimit::Work(failure)));
}
match resource {
WorkResource::DynamicEvents => self.usage.dynamic_events += requested,
WorkResource::NumericUnits => self.usage.numeric_units += requested,
}
Ok(())
}
}
// Logical 64-bit words are platform independent. These are deterministic
// accounting weights, not a claim about num-bigint's internal algorithm/timing.
pub(super) fn words(bits: u64) -> u64 {
bits.max(1).div_ceil(64)
}
pub(super) fn product_cost(a_bits: u64, b_bits: u64) -> u64 {
1 + words(a_bits) * words(b_bits)
}
pub(super) fn sum_cost(a_bits: u64, b_bits: u64) -> u64 {
1 + words(a_bits.max(b_bits))
}
pub(super) fn normalization_cost(n_bits: u64, d_bits: u64) -> u64 {
let width = words(n_bits.max(d_bits));
// 64*w² for GCD plus 2*w² for the two quotient operations.
1 + 66 * width * width
}
pub(super) fn parse_cost(digits: usize, estimated_bits: u64) -> u64 {
1 + digits as u64 * words(estimated_bits)
}
@@ -0,0 +1,378 @@
use super::{Dimension, Exact, MAX_INTEGER_POWER, NumericError, NumericOperation, WorkMeter};
use std::cmp::Ordering;
/// K is supplied by the common resolver's verified nominal compatibility
/// relation. It is not inferred from dimension/name/symbol/archive bytes here.
/// Coordinate basis/algebra/difference-kind closure must be part of that proof.
/// Exact definition references and pins remain the caller's provenance.
/// K must be a bounded, cheap-to-clone/compare verified handle. Constructing or
/// comparing an unbounded rooted contract graph is resolver admission work and
/// must not be hidden inside K's Clone/Eq implementations. Rust trait bounds do
/// not enforce this resource contract.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedKind<K> {
key: K,
dimension: Dimension,
difference: Option<K>,
}
impl<K> ResolvedKind<K> {
pub fn linear(key: K, dimension: Dimension) -> Self {
Self {
key,
dimension,
difference: None,
}
}
pub fn key(&self) -> &K {
&self.key
}
pub fn dimension(&self) -> Dimension {
self.dimension
}
pub fn is_affine(&self) -> bool {
self.difference.is_some()
}
}
impl<K: Clone + Eq> ResolvedKind<K> {
pub fn affine(key: K, difference: &Self) -> Result<Self, NumericError> {
if difference.is_affine() || key == difference.key {
return Err(NumericError::DifferenceKind);
}
Ok(Self {
key,
dimension: difference.dimension,
difference: Some(difference.key.clone()),
})
}
pub fn difference_kind(&self) -> Option<Self> {
self.difference
.as_ref()
.map(|key| Self::linear(key.clone(), self.dimension))
}
}
/// Canonical coordinate = magnitude * scale + offset.
/// Unit symbols and display preferences are outside this numeric representation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Unit<K> {
kind: ResolvedKind<K>,
scale: Exact,
offset: Exact,
}
impl<K: Clone + Eq> Unit<K> {
pub fn new(kind: ResolvedKind<K>, scale: Exact, offset: Exact) -> Result<Self, NumericError> {
if !scale.is_positive() {
return Err(NumericError::UnitScale);
}
if !kind.is_affine() && !offset.is_zero() {
return Err(NumericError::LinearOffset);
}
Ok(Self {
kind,
scale,
offset,
})
}
pub fn kind(&self) -> &ResolvedKind<K> {
&self.kind
}
pub fn scale(&self) -> &Exact {
&self.scale
}
pub fn offset(&self) -> &Exact {
&self.offset
}
/// Fixed multiply-then-add path. Each reduced arithmetic stage is bounded.
/// A mathematically cancelling wider intermediate still reports NumericLimit;
/// replacing this path with a fused operation requires a new work profile.
pub fn to_canonical(
&self,
magnitude: &Exact,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
magnitude
.multiply(&self.scale, meter)?
.add(&self.offset, meter)
}
/// Fixed subtract-then-divide path; never rounds a coordinate.
pub fn from_canonical(
&self,
coordinate: &Exact,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
coordinate
.subtract(&self.offset, meter)?
.divide(&self.scale, meter)
}
pub fn convert(
&self,
magnitude: &Exact,
target: &Self,
meter: &mut WorkMeter,
) -> Result<Exact, NumericError> {
meter.ensure_active()?;
if self.kind != target.kind {
return Err(NumericError::IncompatibleTypes);
}
target.from_canonical(&self.to_canonical(magnitude, meter)?, meter)
}
}
/// Internal numerical types. Boolean/list/check semantics belong to the common
/// expression evaluator. Anonymous zero dimension normalizes to Number.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NumericType<K> {
Number,
Named(ResolvedKind<K>),
Anonymous(Dimension),
}
impl<K> NumericType<K> {
pub fn dimension(&self) -> Dimension {
match self {
Self::Number => Dimension::ZERO,
Self::Named(kind) => kind.dimension,
Self::Anonymous(dimension) => *dimension,
}
}
pub fn is_affine(&self) -> bool {
matches!(self, Self::Named(kind) if kind.is_affine())
}
fn anonymous(dimension: Dimension) -> Self {
if dimension.is_dimensionless() {
Self::Number
} else {
Self::Anonymous(dimension)
}
}
fn normalized(self) -> Self {
match self {
Self::Anonymous(dimension) => Self::anonymous(dimension),
other => other,
}
}
}
/// A canonical rational coordinate with an internal numerical type.
///
/// Derived Eq compares stored representation. Formal equality/ordering must call
/// compare(), which rejects mismatched nominal kinds instead of treating them
/// as a false predicate. Public property output must still check its declared
/// type and anchor the result to that declaration's exact definition reference.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumericValue<K> {
value_type: NumericType<K>,
coordinate: Exact,
}
impl<K: Clone + Eq> NumericValue<K> {
pub fn new(value_type: NumericType<K>, coordinate: Exact) -> Self {
Self {
value_type: value_type.normalized(),
coordinate,
}
}
pub fn number(coordinate: Exact) -> Self {
Self::new(NumericType::Number, coordinate)
}
pub fn from_unit(
magnitude: &Exact,
unit: &Unit<K>,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
meter.ensure_active()?;
Ok(Self::new(
NumericType::Named(unit.kind.clone()),
unit.to_canonical(magnitude, meter)?,
))
}
pub fn value_type(&self) -> &NumericType<K> {
&self.value_type
}
pub fn coordinate(&self) -> &Exact {
&self.coordinate
}
pub fn display_in(&self, unit: &Unit<K>, meter: &mut WorkMeter) -> Result<Exact, NumericError> {
meter.ensure_active()?;
if !matches!(&self.value_type, NumericType::Named(kind) if kind == &unit.kind) {
return Err(NumericError::IncompatibleTypes);
}
unit.from_canonical(&self.coordinate, meter)
}
pub fn add(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.additive_type(rhs, false)?;
Ok(Self::new(
output,
self.coordinate.add(&rhs.coordinate, meter)?,
))
}
pub fn subtract(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.additive_type(rhs, true)?;
Ok(Self::new(
output,
self.coordinate.subtract(&rhs.coordinate, meter)?,
))
}
fn additive_type(&self, rhs: &Self, subtract: bool) -> Result<NumericType<K>, NumericError> {
use NumericType::{Anonymous, Named, Number};
match (&self.value_type, &rhs.value_type) {
(Number, Number) => Ok(Number),
(Anonymous(left), Anonymous(right)) if left == right => Ok(Anonymous(*left)),
(Named(left), Named(right)) => match (&left.difference, &right.difference) {
(None, None) if left == right => Ok(Named(left.clone())),
(Some(difference), Some(_)) if subtract && left == right => Ok(Named(
ResolvedKind::linear(difference.clone(), left.dimension),
)),
(Some(difference), None)
if difference == &right.key && left.dimension == right.dimension =>
{
Ok(Named(left.clone()))
}
(None, Some(difference))
if !subtract
&& difference == &left.key
&& left.dimension == right.dimension =>
{
Ok(Named(right.clone()))
}
_ => Err(NumericError::IncompatibleTypes),
},
_ => Err(NumericError::IncompatibleTypes),
}
}
pub fn multiply(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.multiplicative_type(rhs, false)?;
Ok(Self::new(
output,
self.coordinate.multiply(&rhs.coordinate, meter)?,
))
}
pub fn divide(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
let output = self.multiplicative_type(rhs, true)?;
Ok(Self::new(
output,
self.coordinate.divide(&rhs.coordinate, meter)?,
))
}
fn multiplicative_type(
&self,
rhs: &Self,
divide: bool,
) -> Result<NumericType<K>, NumericError> {
use NumericType::Number;
if self.value_type.is_affine() || rhs.value_type.is_affine() {
return Err(NumericError::AffineOperation);
}
match (&self.value_type, &rhs.value_type) {
(Number, Number) => Ok(Number),
(_, Number) => Ok(self.value_type.clone()),
(Number, _) if !divide => Ok(rhs.value_type.clone()),
_ => {
let left = self.value_type.dimension();
let right = rhs.value_type.dimension();
let dimension = if divide {
left.checked_quotient(right)?
} else {
left.checked_product(right)?
};
Ok(NumericType::anonymous(dimension))
}
}
}
pub fn negated(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if self.value_type.is_affine() {
return Err(NumericError::AffineOperation);
}
Ok(Self::new(
self.value_type.clone(),
self.coordinate.negated(meter)?,
))
}
pub fn absolute(&self, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if self.value_type.is_affine() {
return Err(NumericError::AffineOperation);
}
Ok(Self::new(
self.value_type.clone(),
self.coordinate.absolute(meter)?,
))
}
pub fn power(&self, exponent: i8, meter: &mut WorkMeter) -> Result<Self, NumericError> {
meter.ensure_active()?;
if !(-MAX_INTEGER_POWER..=MAX_INTEGER_POWER).contains(&exponent) {
return Err(NumericError::IntegerPower);
}
if self.value_type.is_affine() {
return Err(NumericError::AffineOperation);
}
let output = match exponent {
0 => NumericType::Number,
1 => self.value_type.clone(),
_ => NumericType::anonymous(self.value_type.dimension().checked_power(exponent)?),
};
Ok(Self::new(output, self.coordinate.power(exponent, meter)?))
}
pub fn compare(&self, rhs: &Self, meter: &mut WorkMeter) -> Result<Ordering, NumericError> {
meter.ensure_active()?;
if self.value_type != rhs.value_type {
return Err(NumericError::IncompatibleTypes);
}
self.coordinate.compare(&rhs.coordinate, meter)
}
pub fn as_kind(
&self,
target: &ResolvedKind<K>,
meter: &mut WorkMeter,
) -> Result<Self, NumericError> {
meter.ensure_active()?;
if target.is_affine()
|| matches!(self.value_type, NumericType::Named(_))
|| self.value_type.dimension() != target.dimension
{
return Err(NumericError::KindAssertion);
}
meter.numeric(NumericOperation::KindAssertion, self.coordinate.copy_cost())?;
Ok(Self::new(
NumericType::Named(target.clone()),
self.coordinate.clone(),
))
}
}
@@ -0,0 +1,804 @@
use super::*;
use num_bigint::BigInt;
use num_traits::One;
use std::cmp::Ordering;
fn meter() -> WorkMeter {
WorkMeter::new(WorkLimits::REFERENCE)
}
fn exact(n: impl ToString, d: impl ToString) -> Exact {
Exact::from_wire(
&ExactWire {
numerator: n.to_string(),
denominator: d.to_string(),
},
&mut meter(),
)
.unwrap()
}
fn source(text: &str) -> Exact {
Exact::parse_literal(text, &mut meter()).unwrap()
}
fn assert_fraction(value: &Exact, n: impl ToString, d: impl ToString) {
assert_eq!(
value.to_wire(),
ExactWire {
numerator: n.to_string(),
denominator: d.to_string(),
}
);
}
// Independent bounded i128 oracle. It does not call the production Exact/GCD
// implementation when deriving expected arithmetic or ordering.
fn small_fraction(mut n: i128, mut d: i128) -> (i128, i128) {
assert_ne!(d, 0);
if d < 0 {
n = -n;
d = -d;
}
let (mut a, mut b) = (n.abs(), d);
while b != 0 {
(a, b) = (b, a % b);
}
(n / a, d / a)
}
#[test]
fn arithmetic_matches_independent_small_integer_oracle() {
for a in [-5_i128, -2, 0, 1, 4] {
for b in [1_i128, 2, 3, 7] {
for c in [-5_i128, -2, 0, 1, 4] {
for d in [1_i128, 2, 3, 7] {
let (an, ad) = small_fraction(a, b);
let (bn, bd) = small_fraction(c, d);
let left = exact(an, ad);
let right = exact(bn, bd);
let mut work = meter();
let (n, den) = small_fraction(a * d + c * b, b * d);
assert_fraction(&left.add(&right, &mut work).unwrap(), n, den);
let (n, den) = small_fraction(a * d - c * b, b * d);
assert_fraction(&left.subtract(&right, &mut work).unwrap(), n, den);
let (n, den) = small_fraction(a * c, b * d);
assert_fraction(&left.multiply(&right, &mut work).unwrap(), n, den);
if c != 0 {
let (n, den) = small_fraction(a * d, b * c);
assert_fraction(&left.divide(&right, &mut work).unwrap(), n, den);
} else {
assert_eq!(
left.divide(&right, &mut work),
Err(NumericError::DivisionByZero)
);
}
assert_eq!(
left.compare(&right, &mut work).unwrap(),
(a * d).cmp(&(c * b))
);
}
}
}
}
}
#[test]
fn decimals_are_exact_and_source_ratios_reduce() {
assert_fraction(
&source("0.1").add(&source("0.2"), &mut meter()).unwrap(),
3,
10,
);
assert_fraction(&source("0.50"), 1, 2);
assert_fraction(&source("12.50e-2"), 1, 8);
assert_fraction(&source("1e+3"), 1000, 1);
assert_fraction(&source("0001.2500"), 5, 4);
assert_fraction(&source(" ratio( -6, 8 ) "), -3, 4);
assert_fraction(&source("ratio(-0, 27)"), 0, 1);
assert_eq!(source("0.5").canonical_literal(), "ratio(1, 2)");
assert_eq!(source("42").canonical_literal(), "42");
}
#[test]
fn powers_and_signs_have_explicit_domains() {
let x = exact(-2, 3);
assert_fraction(&x.power(3, &mut meter()).unwrap(), -8, 27);
assert_fraction(&x.power(-3, &mut meter()).unwrap(), -27, 8);
assert_fraction(&x.power(0, &mut meter()).unwrap(), 1, 1);
assert_fraction(&x.absolute(&mut meter()).unwrap(), 2, 3);
assert_fraction(&x.negated(&mut meter()).unwrap(), 2, 3);
assert_fraction(&x.reciprocal(&mut meter()).unwrap(), -3, 2);
let zero = Exact::from_integer(0);
assert_eq!(
zero.power(0, &mut meter()),
Err(NumericError::ZeroPowerZero)
);
assert_eq!(
zero.power(-1, &mut meter()),
Err(NumericError::DivisionByZero)
);
assert_eq!(x.power(17, &mut meter()), Err(NumericError::IntegerPower));
assert_eq!(
x.power(i8::MIN, &mut meter()),
Err(NumericError::IntegerPower)
);
}
#[test]
fn canonical_wire_rejects_both_component_noncanonical_forms() {
for (n, d) in [
("+1", "1"),
("01", "1"),
("-0", "1"),
("1", "+1"),
("1", "-1"),
("1", "01"),
("2", "4"),
("0", "3"),
("", "1"),
("1", ""),
("1.0", "1"),
("1", "1.0"),
("1e1", "1"),
("1", "1e1"),
(" 1", "1"),
("1", "1 "),
] {
assert!(
Exact::from_wire(
&ExactWire {
numerator: n.into(),
denominator: d.into()
},
&mut meter(),
)
.is_err(),
"accepted {n}/{d}"
);
}
assert_eq!(
Exact::from_wire(
&ExactWire {
numerator: "1".into(),
denominator: "0".into()
},
&mut meter()
),
Err(NumericError::ZeroDenominator)
);
assert_fraction(&exact(0, 1), 0, 1);
assert_fraction(&exact(-1, 2), -1, 2);
}
#[test]
fn serde_uses_closed_canonical_string_wire() {
let value = exact(-3, 7);
let encoded = serde_json::to_value(&value).unwrap();
assert_eq!(
encoded,
serde_json::json!({"numerator":"-3","denominator":"7"})
);
assert_eq!(serde_json::from_value::<Exact>(encoded).unwrap(), value);
for invalid in [
r#"{"numerator":1,"denominator":"2"}"#,
r#"{"numerator":"1","denominator":2}"#,
r#"{"numerator":"1","denominator":"2","other":0}"#,
r#"{"numerator":"1","numerator":"2","denominator":"3"}"#,
r#"{"numerator":"1"}"#,
r#"{"numerator":"2","denominator":"4"}"#,
] {
assert!(serde_json::from_str::<Exact>(invalid).is_err(), "{invalid}");
}
}
#[test]
fn literal_reader_does_not_fold_expression_unary_minus() {
assert_eq!(
Exact::parse_literal("-2", &mut meter()),
Err(NumericError::InvalidLiteral)
);
let negative_literal = source("ratio(-2, 1)");
assert_eq!(negative_literal.canonical_literal(), "ratio(-2, 1)");
assert_eq!(source("2").negated(&mut meter()).unwrap(), negative_literal);
// Equality of coordinates does not claim AST equality. The expression
// parser must preserve UnaryNegate independently of its operand literal.
assert_eq!(source("2").canonical_literal(), "2");
}
#[test]
fn literal_syntax_and_decimal_exponent_are_bounded() {
for invalid in [
"",
"-",
"+1",
".5",
"1.",
"1.2.3",
"1e",
"1ee2",
"1e+-2",
"1_000",
"NaN",
"Infinity",
"1/2",
"ratio(1)",
"ratio(1,2,3)",
"ratio(1,-2)",
"ratio(+1,2)",
"ratio(1,2) + 1",
"12",
] {
assert!(
Exact::parse_literal(invalid, &mut meter()).is_err(),
"{invalid}"
);
}
for invalid in ["1e129", "1e-129", "1e9999999999999999999999"] {
assert_eq!(
Exact::parse_literal(invalid, &mut meter()),
Err(NumericError::DecimalExponent)
);
}
assert!(Exact::parse_literal("1e128", &mut meter()).is_ok());
assert!(Exact::parse_literal("1e-128", &mut meter()).is_ok());
assert_eq!(
Exact::parse_literal(&"1".repeat(MAX_COMPONENT_DIGITS + 1), &mut meter()),
Err(NumericError::LiteralTooLong)
);
assert_eq!(
Exact::parse_literal(&" ".repeat(MAX_LITERAL_BYTES + 1), &mut meter()),
Err(NumericError::LiteralTooLong)
);
}
#[test]
fn full_independent_r7_packets_round_trip_as_literals_and_wire() {
let packets: serde_json::Value =
serde_json::from_str(include_str!("fixtures/r7-literal-boundaries.json")).unwrap();
for packet in packets.as_array().unwrap() {
let wire: ExactWire =
serde_json::from_value(packet["expression"]["Number"].clone()).unwrap();
let expected = Exact::from_wire(&wire, &mut meter()).unwrap();
let spelling = packet["canonical_source"].as_str().unwrap();
assert_eq!(expected.canonical_literal(), spelling);
assert_eq!(
Exact::parse_literal(spelling, &mut meter()).unwrap(),
expected
);
let serialized = serde_json::to_string(&expected).unwrap();
assert_eq!(
serde_json::from_str::<Exact>(&serialized).unwrap(),
expected
);
assert_eq!(expected.to_wire(), wire);
}
}
#[test]
fn full_8193_bit_sum_intermediate_can_reduce_to_admitted_value() {
// N=(2^4096)-1, D=N-1. gcd(N,D)=1 and D is even.
// N/D + N/D = N/(D/2), although its un-reduced numerator needs 8193 bits.
let n = (BigInt::one() << 4096_usize) - 1_u8;
let d = &n - 1_u8;
let value = exact(&n, &d);
let result = value.add(&value, &mut meter()).unwrap();
assert_fraction(&result, &n, &d / 2_u8);
}
#[test]
fn cross_products_cancel_before_reduced_component_limit() {
let n = BigInt::one() << 4095_usize;
let d = &n - 1_u8;
let left = exact(&n, &d);
let right = exact(&d, &n);
assert_fraction(&left.multiply(&right, &mut meter()).unwrap(), 1, 1);
assert_fraction(
&left
.add(&left.negated(&mut meter()).unwrap(), &mut meter())
.unwrap(),
0,
1,
);
}
#[test]
fn both_reduced_component_overflows_are_reported() {
let n = BigInt::one() << 4095_usize;
let mut work = meter();
let failure = exact(&n, 1)
.multiply(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(matches!(
failure,
NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Numerator,
bits: 4097,
..
})
));
assert!(work.exhaustion().is_none());
let failure = exact(1, &n)
.divide(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(matches!(
failure,
NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Denominator,
bits: 4097,
..
})
));
// Value overflow is a nonterminal supported-operation failure; fuel
// exhaustion, in contrast, terminates the entire request.
assert_fraction(&source("1").add(&source("1"), &mut work).unwrap(), 2, 1);
}
#[test]
fn wire_rejects_4097_bits_before_gcd_can_reduce_them() {
let oversized = (BigInt::one() << 4096_usize).to_string();
for wire in [
ExactWire {
numerator: oversized.clone(),
denominator: "1".into(),
},
ExactWire {
numerator: "1".into(),
denominator: oversized.clone(),
},
ExactWire {
numerator: oversized.clone(),
denominator: oversized,
},
] {
assert!(matches!(
Exact::from_wire(&wire, &mut meter()),
Err(NumericError::Limit(NumericLimit::Bits { stage, .. })) if stage == "wire"
));
}
}
#[test]
fn numeric_precharge_precedes_big_integer_result_allocation() {
let n = BigInt::one() << 4095_usize;
let left = exact(&n, 1);
let mut work = WorkMeter::new(WorkLimits::new(10, 0).unwrap());
let failure = left
.multiply(&Exact::from_integer(2), &mut work)
.unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage().numeric_units, 0);
assert!(matches!(
work.exhaustion().unwrap().site,
WorkSite::Numeric(NumericOperation::Multiply)
));
// With available fuel the same operation reaches the 4097-bit result check.
assert!(matches!(
left.multiply(&Exact::from_integer(2), &mut meter()),
Err(NumericError::Limit(NumericLimit::Bits { .. }))
));
let too_wide_wire = ExactWire {
numerator: (BigInt::one() << 4096_usize).to_string(),
denominator: "1".into(),
};
let mut parse_work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
assert!(
Exact::from_wire(&too_wide_wire, &mut parse_work)
.unwrap_err()
.is_terminal_work_exhaustion()
);
}
#[test]
fn provisional_limits_and_effective_limits_round_trip() {
let limits = WorkLimits::new(500, 2000).unwrap();
let encoded = serde_json::to_value(limits).unwrap();
assert_eq!(
encoded,
serde_json::json!({"dynamic_events":500,"numeric_units":2000})
);
assert_eq!(
serde_json::from_value::<WorkLimits>(encoded).unwrap(),
limits
);
assert_eq!(
WorkLimits::new(1_000_001, 0),
Err(NumericError::InvalidWorkLimits)
);
assert_eq!(
WorkLimits::new(0, 50_000_001),
Err(NumericError::InvalidWorkLimits)
);
assert!(
serde_json::from_str::<WorkLimits>(r#"{"dynamic_events":1000001,"numeric_units":0}"#)
.is_err()
);
}
#[test]
fn exact_accounting_boundary_and_terminal_failure_are_stable() {
// One-word addition: three products at 2, one sum at 2,
// normalization at 67 => 75 logical numeric units.
let mut work = WorkMeter::new(WorkLimits::new(4, 75).unwrap());
assert_fraction(&exact(1, 2).add(&exact(1, 3), &mut work).unwrap(), 5, 6);
assert_eq!(work.usage().numeric_units, 75);
assert!(work.exhaustion().is_none());
let first = Exact::from_integer(1).negated(&mut work).unwrap_err();
let at_failure = work.usage();
assert_eq!(
work.exhaustion().unwrap(),
&WorkExhaustion {
resource: WorkResource::NumericUnits,
used: 75,
requested: 3,
limit: 75,
site: WorkSite::Numeric(NumericOperation::Negate),
}
);
assert_eq!(work.visit(EvaluationStep::Target).unwrap_err(), first);
assert_eq!(
Exact::from_integer(1)
.divide(&Exact::from_integer(0), &mut work)
.unwrap_err(),
first
);
assert_eq!(
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
first
);
assert_eq!(work.usage(), at_failure);
}
#[test]
fn zero_cost_and_maximum_reservation_do_not_underflow_or_reactivate() {
let mut work = WorkMeter::new(WorkLimits::new(0, 0).unwrap());
work.numeric(NumericOperation::Compare, 0).unwrap();
let failure = work
.numeric(NumericOperation::Multiply, u64::MAX)
.unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage(), WorkUsage::default());
assert_eq!(
work.numeric(NumericOperation::Compare, 0).unwrap_err(),
failure
);
let mut dynamic = WorkMeter::new(WorkLimits::new(0, 5).unwrap());
assert!(dynamic.visit(EvaluationStep::Target).is_err());
assert_eq!(dynamic.usage(), WorkUsage::default());
}
#[test]
fn multiple_targets_share_one_aggregate_meter() {
let mut work = WorkMeter::new(WorkLimits::new(3, 50).unwrap());
work.visit(EvaluationStep::Target).unwrap();
work.visit(EvaluationStep::Expression).unwrap();
work.visit(EvaluationStep::Target).unwrap();
let failure = work.visit(EvaluationStep::Expression).unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(work.usage().dynamic_events, 3);
}
// A direct bounded call-tree workload, not an expression interpreter/dialect.
fn expanded_calls(
depth: u8,
x: u64,
work: &mut WorkMeter,
entered: &mut u64,
) -> Result<u64, NumericError> {
work.visit(EvaluationStep::CalculationEntry)?;
*entered += 1;
if depth == 0 {
return Ok(x);
}
let left = expanded_calls(depth - 1, 2 * x, work, entered)?;
let right = expanded_calls(depth - 1, 2 * x + 1, work, entered)?;
Ok(left + right)
}
#[test]
fn c1_expanded_call_witness_terminates_at_dynamic_limit() {
let mut work = WorkMeter::new(WorkLimits::new(10_000, 0).unwrap());
let mut entered = 0;
let failure = expanded_calls(30, 0, &mut work, &mut entered).unwrap_err();
assert!(failure.is_terminal_work_exhaustion());
assert_eq!(entered, 10_000);
assert_eq!(work.usage().dynamic_events, 10_000);
assert_eq!(
work.exhaustion().unwrap().site,
WorkSite::Evaluation(EvaluationStep::CalculationEntry)
);
// Nearby finite control: 63 invocations, leaves 0..31, sum 496.
let mut bounded = WorkMeter::new(WorkLimits::new(63, 0).unwrap());
let mut entered = 0;
assert_eq!(
expanded_calls(5, 0, &mut bounded, &mut entered).unwrap(),
496
);
assert_eq!(entered, 63);
assert!(bounded.exhaustion().is_none());
}
fn dim(value: [i16; 7]) -> Dimension {
Dimension::new(value).unwrap()
}
fn linear(key: &str, dimension: Dimension) -> ResolvedKind<String> {
ResolvedKind::linear(key.to_owned(), dimension)
}
fn named(n: i64, kind: &ResolvedKind<String>) -> NumericValue<String> {
NumericValue::new(NumericType::Named(kind.clone()), Exact::from_integer(n))
}
#[test]
fn dimensions_use_checked_seven_axis_integer_algebra() {
let length = dim([1, 0, 0, 0, 0, 0, 0]);
let mass = dim([0, 1, 0, 0, 0, 0, 0]);
let time = dim([0, 0, 1, 0, 0, 0, 0]);
assert_eq!(
mass.checked_product(length)
.unwrap()
.checked_quotient(time.checked_power(2).unwrap())
.unwrap(),
dim([1, 1, -2, 0, 0, 0, 0])
);
assert!(time.checked_quotient(time).unwrap().is_dimensionless());
assert!(matches!(
dim([32, 0, 0, 0, 0, 0, 0]).checked_product(length),
Err(NumericError::DimensionExponent {
axis: DimensionAxis::Length,
value: 33
})
));
assert!(dim([0, 0, -32, 0, 0, 0, 0]).checked_quotient(time).is_err());
assert!(length.checked_power(i8::MIN).is_err());
assert!(Dimension::new([33, 0, 0, 0, 0, 0, 0]).is_err());
assert_eq!(
serde_json::to_value(time).unwrap(),
serde_json::json!([0, 0, 1, 0, 0, 0, 0])
);
for invalid in ["[0,0,33,0,0,0,0]", "[0,0,1]", "[0,0,1,0,0,0,0,0]"] {
assert!(serde_json::from_str::<Dimension>(invalid).is_err());
}
}
#[test]
fn duration_unit_coordinates_are_exact_and_nominal() {
let duration = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
let s = Unit::new(duration.clone(), source("1"), source("0")).unwrap();
let ms = Unit::new(duration.clone(), source("0.001"), source("0")).unwrap();
assert_fraction(&ms.convert(&source("750"), &s, &mut meter()).unwrap(), 3, 4);
let receive = NumericValue::from_unit(&source("750"), &ms, &mut meter()).unwrap();
let dispatch = NumericValue::from_unit(&source("1"), &s, &mut meter()).unwrap();
let total = receive.add(&dispatch, &mut meter()).unwrap();
assert_fraction(total.coordinate(), 7, 4);
assert_fraction(&total.display_in(&ms, &mut meter()).unwrap(), 1750, 1);
let different = Unit::new(
linear("different-kind", duration.dimension()),
source("1"),
source("0"),
)
.unwrap();
assert_eq!(
ms.convert(&source("750"), &different, &mut meter()),
Err(NumericError::IncompatibleTypes)
);
let inconsistent = Unit::new(
linear("duration", dim([1, 0, 0, 0, 0, 0, 0])),
source("1"),
source("0"),
)
.unwrap();
assert!(
ms.convert(&source("1"), &inconsistent, &mut meter())
.is_err()
);
assert!(Unit::new(duration.clone(), source("0"), source("0")).is_err());
assert!(Unit::new(duration.clone(), source("ratio(-1,1)"), source("0")).is_err());
assert_eq!(
Unit::new(duration, source("1"), source("1")).unwrap_err(),
NumericError::LinearOffset
);
}
#[test]
fn affine_point_and_difference_arithmetic_has_distinct_kinds() {
let interval = linear("temperature-difference", dim([0, 0, 0, 0, 1, 0, 0]));
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
let celsius = Unit::new(point.clone(), source("1"), source("273.15")).unwrap();
let fahrenheit = Unit::new(
point.clone(),
source("ratio(5,9)"),
source("ratio(45967,180)"),
)
.unwrap();
let kelvin_interval = Unit::new(interval.clone(), source("1"), source("0")).unwrap();
assert_fraction(
&celsius
.convert(&source("0"), &fahrenheit, &mut meter())
.unwrap(),
32,
1,
);
assert_fraction(
&fahrenheit
.convert(&source("70"), &celsius, &mut meter())
.unwrap(),
190,
9,
);
let p20 = NumericValue::from_unit(&source("20"), &celsius, &mut meter()).unwrap();
let p10 = NumericValue::from_unit(&source("10"), &celsius, &mut meter()).unwrap();
let difference = p20.subtract(&p10, &mut meter()).unwrap();
assert_eq!(
difference.value_type(),
&NumericType::Named(interval.clone())
);
assert_fraction(
&difference
.display_in(&kelvin_interval, &mut meter())
.unwrap(),
10,
1,
);
let delta5 = named(5, &interval);
let p25 = p20.add(&delta5, &mut meter()).unwrap();
assert_fraction(&p25.display_in(&celsius, &mut meter()).unwrap(), 25, 1);
assert_eq!(delta5.add(&p20, &mut meter()).unwrap(), p25);
assert!(p20.add(&p10, &mut meter()).is_err());
assert!(delta5.subtract(&p20, &mut meter()).is_err());
assert_eq!(
p20.power(1, &mut meter()),
Err(NumericError::AffineOperation)
);
assert_eq!(
p20.negated(&mut meter()),
Err(NumericError::AffineOperation)
);
assert_eq!(p20.compare(&p10, &mut meter()).unwrap(), Ordering::Greater);
assert!(p20.display_in(&kelvin_interval, &mut meter()).is_err());
assert!(ResolvedKind::affine(interval.key().clone(), &interval).is_err());
assert!(ResolvedKind::affine("other".to_owned(), &point).is_err());
}
#[test]
fn affine_conversion_rejects_a_wider_stage_even_when_the_final_value_would_fit() {
// A fused coordinate calculation 2*m-m equals m, where m=2^4095 fits.
// The fixed multiply stage produces 2^4096, so it must fail before offset
// cancellation. The inverse path likewise rejects (m-(-m))/2 at subtraction.
let large = BigInt::one() << 4095_usize;
let magnitude = exact(&large, 1);
let interval = linear("temperature-interval", dim([0, 0, 0, 0, 1, 0, 0]));
let point = ResolvedKind::affine("temperature-point".to_owned(), &interval).unwrap();
let unit = Unit::new(point, Exact::from_integer(2), exact(-&large, 1)).unwrap();
let mut work = meter();
for outcome in [
unit.to_canonical(&magnitude, &mut work),
unit.from_canonical(&magnitude, &mut work),
] {
assert!(matches!(
outcome,
Err(NumericError::Limit(NumericLimit::Bits {
component: NumberComponent::Numerator,
bits: 4097,
limit: MAX_COMPONENT_BITS,
..
}))
));
}
assert!(work.exhaustion().is_none());
let half = exact(&large >> 1_usize, 1);
assert_fraction(&unit.to_canonical(&half, &mut work).unwrap(), 0, 1);
assert_eq!(
unit.from_canonical(&Exact::from_integer(0), &mut work)
.unwrap(),
half
);
}
#[test]
fn r6_anonymous_compound_energy_needs_one_outer_assertion() {
let force = linear("force", dim([1, 1, -2, 0, 0, 0, 0]));
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
let a = named(3, &force)
.multiply(&named(4, &length), &mut meter())
.unwrap();
let b = named(2, &force)
.multiply(&named(7, &length), &mut meter())
.unwrap();
assert_eq!(a.value_type(), &NumericType::Anonymous(energy.dimension()));
let sum = a.add(&b, &mut meter()).unwrap();
let result = sum.as_kind(&energy, &mut meter()).unwrap();
assert_fraction(result.coordinate(), 26, 1);
assert_eq!(result.value_type(), &NumericType::Named(energy.clone()));
assert_eq!(a.compare(&b, &mut meter()).unwrap(), Ordering::Less);
assert!(result.add(&sum, &mut meter()).is_err());
assert_eq!(
result.as_kind(&energy, &mut meter()),
Err(NumericError::KindAssertion)
);
}
#[test]
fn anonymous_product_then_quotient_closes_and_preserves_scaling() {
let mass = linear("mass", dim([0, 1, 0, 0, 0, 0, 0]));
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let time = linear("duration", dim([0, 0, 1, 0, 0, 0, 0]));
let momentum = linear("momentum", dim([1, 1, -1, 0, 0, 0, 0]));
let result = named(2, &mass)
.multiply(&named(3, &length), &mut meter())
.unwrap()
.divide(&named(2, &time), &mut meter())
.unwrap()
.as_kind(&momentum, &mut meter())
.unwrap();
assert_fraction(result.coordinate(), 3, 1);
let scalar = NumericValue::number(source("2"));
let scaled = scalar.multiply(&named(3, &time), &mut meter()).unwrap();
assert_eq!(scaled.value_type(), &NumericType::Named(time.clone()));
assert_fraction(scaled.coordinate(), 6, 1);
assert_eq!(
named(3, &time)
.divide(&scalar, &mut meter())
.unwrap()
.value_type(),
&NumericType::Named(time.clone())
);
assert_eq!(
scalar
.divide(&named(3, &time), &mut meter())
.unwrap()
.value_type(),
&NumericType::Anonymous(dim([0, 0, -1, 0, 0, 0, 0]))
);
}
#[test]
fn nominal_kinds_survive_comparison_but_explicit_quotients_can_erase_dimension() {
let energy = linear("energy", dim([2, 1, -2, 0, 0, 0, 0]));
let torque = linear("torque", energy.dimension());
let left = named(7, &energy);
let right = named(7, &torque);
assert!(left.add(&right, &mut meter()).is_err());
assert!(left.compare(&right, &mut meter()).is_err());
let ratio = left.divide(&right, &mut meter()).unwrap();
assert_eq!(ratio.value_type(), &NumericType::Number);
assert_fraction(ratio.coordinate(), 1, 1);
let named_ratio = linear("chosen-ratio", Dimension::ZERO);
let asserted = ratio.as_kind(&named_ratio, &mut meter()).unwrap();
assert_eq!(
asserted.value_type(),
&NumericType::Named(named_ratio.clone())
);
assert!(asserted.add(&ratio, &mut meter()).is_err());
assert_eq!(
NumericValue::<String>::new(NumericType::Anonymous(Dimension::ZERO), source("1")),
NumericValue::number(source("1"))
);
}
#[test]
fn numerical_power_types_and_dimension_overflow_are_checked() {
let length = linear("length", dim([1, 0, 0, 0, 0, 0, 0]));
let value = named(2, &length);
assert_eq!(
value.power(0, &mut meter()).unwrap().value_type(),
&NumericType::Number
);
assert_eq!(
value.power(1, &mut meter()).unwrap().value_type(),
value.value_type()
);
assert_eq!(
value.power(-2, &mut meter()).unwrap().value_type(),
&NumericType::Anonymous(dim([-2, 0, 0, 0, 0, 0, 0]))
);
let large_dimension = linear("large-dimension", dim([32, 0, 0, 0, 0, 0, 0]));
assert!(
named(1, &large_dimension)
.multiply(&value, &mut meter())
.is_err()
);
assert!(named(1, &large_dimension).power(2, &mut meter()).is_err());
}
+27 -160
View File
@@ -142,87 +142,12 @@ pub struct Analysis {
pub declarations: Vec<DeclarationSpan>,
}
/// Check the decoded spelling required by declaration-header UUID clauses.
/// This is the shared 36-byte/hyphen shape rule, not a hexadecimal parser:
/// callers must additionally parse the UUID. Uppercase hex and JSON escapes
/// that decode to this spelling remain accepted. URN/braced/compact forms do not.
pub fn has_uuid_header_shape(decoded: &str) -> bool {
decoded.len() == 36
&& [8, 13, 18, 23]
.into_iter()
.all(|index| decoded.as_bytes()[index] == b'-')
}
/// Budgets for one original source buffer. Policy is caller-owned; these
/// limits do not change the grammar or perform model admission.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimits {
pub max_source_bytes: usize,
pub max_tokens: usize,
pub max_declarations: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OutlineResource {
SourceBytes,
Tokens,
Declarations,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutlineLimit {
pub resource: OutlineResource,
pub limit: usize,
pub observed_at_least: usize,
/// Original byte offset where admission stopped; never a rewritten offset.
pub offset: usize,
}
/// Roles discovered by the existing tolerant declaration walk. Keeping the
/// wrapper preserves DeclarationSpan and Analysis as existing public APIs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OutlineDeclaration {
pub declaration: DeclarationSpan,
pub kind_span: Span,
pub alias_span: Span,
/// Whole quoted header ID token, even if it is not a valid UUID. In that
/// case declaration.id is None. Missing/non-string ID values have no span.
pub id_literal_span: Option<Span>,
}
/// A full-file tolerant scan, not a successful parse or validated model. Invalid
/// text remains available. Lexical errors and unmatched braces are facts only:
/// their absence does not establish syntactic or semantic validity.
///
/// A budget failure returns Err and no partial Outline. Include reachability,
/// project completeness and editor revision are intentionally not inferred here.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outline<'a> {
pub source: &'a str,
pub tokens: Vec<TokenSpan>,
pub declarations: Vec<OutlineDeclaration>,
pub has_lexical_errors: bool,
pub has_unmatched_braces: bool,
}
/// Tolerant highlighting tokens cover the whole input, including incomplete
/// strings and unexpected characters. No model is created or modified.
pub fn tokenize(source: &str) -> Vec<TokenSpan> {
tokenize_with_limit(source, usize::MAX).expect("unlimited token scan")
}
fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>, OutlineLimit> {
let mut tokens = Vec::new();
let mut cursor = 0;
while cursor < source.len() {
if tokens.len() == max_tokens {
return Err(OutlineLimit {
resource: OutlineResource::Tokens,
limit: max_tokens,
observed_at_least: tokens.len().saturating_add(1),
offset: cursor,
});
}
let start = cursor;
let ch = source[cursor..].chars().next().expect("in bounds");
let class = if matches!(ch, ' ' | '\t' | '\r' | '\n') {
@@ -299,7 +224,7 @@ fn tokenize_with_limit(source: &str, max_tokens: usize) -> Result<Vec<TokenSpan>
span: Span { start, end: cursor },
});
}
Ok(tokens)
tokens
}
pub fn lint(source: &str) -> Vec<Diagnostic> {
@@ -1305,7 +1230,11 @@ impl Parser<'_> {
self.cursor += 1;
let offset = self.current().span.start;
let value = self.quoted()?;
if !has_uuid_header_shape(&value) {
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|i| value.as_bytes()[i] == b'-')
{
return Err(Error {
code: "invalid_source",
offset,
@@ -2033,49 +1962,8 @@ fn take_required_id(fields: &mut Map<String, Value>, key: &str) -> Result<Id, St
/// Analyze an incomplete source buffer without adopting a candidate. Declaration
/// spans remain available even when a later declaration contains errors.
/// Invalid compact/braced/URN header IDs retain no authored identity, matching
/// the declaration parser rather than the UUID crate's broader accepted forms.
pub fn analyze(source: &str) -> Analysis {
let tokens = tokenize(source);
let (declarations, _) =
declaration_outline(source, &tokens, usize::MAX).expect("unlimited declaration walk");
Analysis {
tokens,
diagnostics: lint(source),
declarations: declarations.into_iter().map(|d| d.declaration).collect(),
}
}
/// Read exact current text without lint, parsing a model, stabilization or I/O.
/// Source bytes are admitted before token allocation; token/declaration limits
/// are checked before lexing the next token or materializing the next record.
pub fn outline(source: &str, limits: OutlineLimits) -> Result<Outline<'_>, OutlineLimit> {
if source.len() > limits.max_source_bytes {
return Err(OutlineLimit {
resource: OutlineResource::SourceBytes,
limit: limits.max_source_bytes,
observed_at_least: source.len(),
offset: 0,
});
}
let tokens = tokenize_with_limit(source, limits.max_tokens)?;
let has_lexical_errors = tokens.iter().any(|token| token.class == TokenClass::Error);
let (declarations, has_unmatched_braces) =
declaration_outline(source, &tokens, limits.max_declarations)?;
Ok(Outline {
source,
tokens,
declarations,
has_lexical_errors,
has_unmatched_braces,
})
}
fn declaration_outline(
source: &str,
tokens: &[TokenSpan],
max_declarations: usize,
) -> Result<(Vec<OutlineDeclaration>, bool), OutlineLimit> {
let significant = tokens
.iter()
.filter(|t| !matches!(t.class, TokenClass::Whitespace | TokenClass::Comment))
@@ -2084,21 +1972,17 @@ fn declaration_outline(
// again would make tolerant navigation quadratic on deeply nested drafts.
let mut body_ends = vec![None; significant.len()];
let mut bodies = Vec::new();
let mut unmatched_close = false;
for (index, token) in significant.iter().enumerate() {
match &source[token.span.start..token.span.end] {
"{" => bodies.push(index),
"}" => {
if let Some(open) = bodies.pop() {
body_ends[open] = Some(token.span.end);
} else {
unmatched_close = true;
}
}
_ => {}
}
}
let has_unmatched_braces = unmatched_close || !bodies.is_empty();
let mut declarations = Vec::new();
for (i, t) in significant.iter().enumerate() {
let word = &source[t.span.start..t.span.end];
@@ -2123,16 +2007,7 @@ fn declaration_outline(
}
j += 1;
}
if declarations.len() == max_declarations {
return Err(OutlineLimit {
resource: OutlineResource::Declarations,
limit: max_declarations,
observed_at_least: declarations.len().saturating_add(1),
offset: t.span.start,
});
}
let mut id = None;
let mut id_literal_span = None;
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "id")
@@ -2140,51 +2015,39 @@ fn declaration_outline(
if let Some(value) = significant.get(j + 1)
&& value.class == TokenClass::String
{
id_literal_span = Some(value.span);
let text =
serde_json::from_str::<String>(&source[value.span.start..value.span.end])
.unwrap();
if has_uuid_header_shape(&text) {
id = Id::parse_str(&text).ok();
}
id = Id::parse_str(&text).ok();
}
j += 2;
}
// A recognized header ID at EOF must remain inside the whole span.
// Keep the old next-token/body behavior; only the EOF fallback changes.
let header_end = significant
.get(j.saturating_sub(1))
.or_else(|| significant.last())
.map_or(alias_token.span.end, |token| token.span.end);
let mut end = significant.get(j).map_or(header_end, |t| t.span.end);
let mut end = significant
.get(j)
.map_or(alias_token.span.end, |t| t.span.end);
if significant
.get(j)
.is_some_and(|t| &source[t.span.start..t.span.end] == "{")
{
end = body_ends[j].unwrap_or(source.len());
}
declarations.push(OutlineDeclaration {
declaration: DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
},
declarations.push(DeclarationSpan {
alias: alias.into(),
id,
kind: word.into(),
span: Span {
start: t.span.start,
end,
},
kind_span: t.span,
alias_span: alias_token.span,
id_literal_span,
});
}
Ok((declarations, has_unmatched_braces))
Analysis {
tokens,
diagnostics: lint(source),
declarations,
}
}
#[cfg(test)]
#[path = "language_outline_tests.rs"]
mod outline_tests;
#[cfg(test)]
#[path = "language_nesting_tests.rs"]
mod nesting_tests;
@@ -2579,7 +2442,11 @@ pub(crate) mod legacy {
let value = self.quoted()?;
// Require the portable 8-4-4-4-12 spelling, not the UUID crate's URN,
// braces, or compact alternatives. Uppercase hexadecimal is accepted.
if !super::has_uuid_header_shape(&value) {
if value.len() != 36
|| ![8, 13, 18, 23]
.into_iter()
.all(|index| value.as_bytes()[index] == b'-')
{
return Err(Error {
code: "invalid_source",
offset,
@@ -1,386 +0,0 @@
//! Prepared outline tests. Explicit compatibility tests additionally exercise
//! the existing parsers/analyze; the outline entry itself never calls them.
use super::*;
fn limits() -> OutlineLimits {
OutlineLimits {
max_source_bytes: 1024 * 1024,
max_tokens: 100_000,
max_declarations: 1_000,
}
}
fn declaration<'a>(result: &'a Outline<'_>, alias: &str) -> &'a OutlineDeclaration {
result
.declarations
.iter()
.find(|d| d.declaration.alias == alias)
.unwrap()
}
fn text(source: &str, span: Span) -> &str {
&source[span.start..span.end]
}
#[test]
fn outline_roles_preserve_unicode_crlf_and_exact_escaped_uuid_literal() {
let source = "// 検査 🚒\r\noa_actor responder \"救助\" id \"\\u00300000000-0000-0000-0000-000000000001\" {}\r\nsource @ // 使用\r\n responder;";
let before = source.as_bytes().to_vec();
let result = outline(source, limits()).unwrap();
assert_eq!(result.source.as_bytes(), before);
assert_eq!(result.source.as_ptr(), source.as_ptr());
let d = declaration(&result, "responder");
assert_eq!(text(source, d.kind_span), "oa_actor");
assert_eq!(text(source, d.alias_span), "responder");
assert_eq!(
text(source, d.id_literal_span.unwrap()),
"\"\\u00300000000-0000-0000-0000-000000000001\""
);
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.start, source.find("oa_actor").unwrap());
assert_eq!(d.declaration.span.end, source.find("{}\r\n").unwrap() + 2);
let mut end = 0;
for token in &result.tokens {
assert_eq!(token.span.start, end);
assert!(token.span.start < token.span.end);
assert!(source.is_char_boundary(token.span.start));
assert!(source.is_char_boundary(token.span.end));
end = token.span.end;
}
assert_eq!(end, source.len());
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
}
#[test]
fn closed_id_at_eof_is_inside_the_declaration_extent() {
for source in [
"oa_actor a \"Actor\" id \"00000000-0000-0000-0000-000000000001\"",
"node placed id \"00000000-0000-0000-0000-000000000001\"",
] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
let literal = d.id_literal_span.unwrap();
assert_eq!(d.declaration.id, Some(Id::from_u128(1)));
assert_eq!(d.declaration.span.end, source.len());
assert!(d.declaration.span.start <= literal.start);
assert!(literal.end <= d.declaration.span.end);
}
for source in ["oa_actor a \"Actor\"", "oa_actor a \"Actor\" id"] {
let result = outline(source, limits()).unwrap();
let d = &result.declarations[0];
assert_eq!(d.declaration.span.end, source.len());
assert_eq!(d.declaration.id, None);
assert_eq!(d.id_literal_span, None);
}
}
#[test]
fn missing_bad_and_historical_ids_never_allocate_authored_identity() {
let source = "oa_actor missing \"No ID\" {}\noa_actor bad \"Bad ID\" id \"not-a-uuid\" {}\noa_actor historical \"00000000-0000-0000-0000-000000000001\" { history uuid \"00000000-0000-0000-0000-000000000001\"; }";
let first = outline(source, limits()).unwrap();
let second = outline(source, limits()).unwrap();
assert_eq!(first, second);
assert!(
first
.declarations
.iter()
.all(|d| d.declaration.id.is_none())
);
assert_eq!(declaration(&first, "missing").id_literal_span, None);
assert_eq!(declaration(&first, "historical").id_literal_span, None);
assert_eq!(
text(source, declaration(&first, "bad").id_literal_span.unwrap()),
"\"not-a-uuid\""
);
// Balanced tokens are not a successful syntax/semantic validation claim.
assert!(!first.has_lexical_errors);
assert!(!first.has_unmatched_braces);
}
#[test]
fn invalid_full_scan_keeps_closed_children_and_reports_lexical_body_facts() {
let source = "oa_entity outer \"Outer\" {\n oa_actor ready \"Ready\" { description \"} stays text\"; }\n oa_actor pending \"Pending\" { description \"unfinished 🌊";
let result = outline(source, limits()).unwrap();
assert_eq!(result.source, source);
assert_eq!(result.tokens.last().unwrap().span.end, source.len());
assert!(result.has_lexical_errors);
assert!(result.has_unmatched_braces);
let ready = declaration(&result, "ready");
assert_eq!(
text(source, ready.declaration.span),
"oa_actor ready \"Ready\" { description \"} stays text\"; }"
);
assert_eq!(
declaration(&result, "outer").declaration.span.end,
source.len()
);
assert_eq!(
declaration(&result, "pending").declaration.span.end,
source.len()
);
let unmatched_close = outline("}\n// brace in comment {\n", limits()).unwrap();
assert!(unmatched_close.has_unmatched_braces);
assert!(!unmatched_close.has_lexical_errors);
}
#[test]
fn missing_body_syntax_is_unchecked_even_when_known_error_flags_are_clear() {
let source = "oa_actor a \"A\" id \"00000000-0000-0000-0000-000000000001\"";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 1);
assert!(!result.has_lexical_errors);
assert!(!result.has_unmatched_braces);
// There is deliberately no `valid` flag: the known-complete fact is scanning
// these bytes, not the missing body becoming legal or the draft being applied.
assert_eq!(result.source, source);
}
#[test]
fn keyword_alias_and_collisions_keep_every_source_ordered_header() {
let source = "project root_model \"Model\" {\n oa_actor oa_activity \"First\" id \"00000000-0000-0000-0000-000000000001\" {}\n oa_actor oa_activity \"Second\" id \"00000000-0000-0000-0000-000000000001\" {}\n diagram view_one \"View\" {}\n node placement { diagram @view_one; }\n}";
let result = outline(source, limits()).unwrap();
assert_eq!(result.declarations.len(), 5);
let duplicates: Vec<_> = result
.declarations
.iter()
.filter(|d| d.declaration.alias == "oa_activity")
.collect();
assert_eq!(duplicates.len(), 2);
assert!(duplicates[0].alias_span.start < duplicates[1].alias_span.start);
assert_eq!(duplicates[0].declaration.id, duplicates[1].declaration.id);
assert!(
result
.tokens
.iter()
.any(|t| t.span == duplicates[0].alias_span && t.class == TokenClass::Keyword)
);
assert_eq!(
text(source, declaration(&result, "placement").kind_span),
"node"
);
assert_eq!(declaration(&result, "placement").declaration.id, None);
}
#[test]
fn bounds_refuse_before_returning_a_partial_outline() {
let source = "oa_actor a \"A\" {}\noa_actor b \"B\" {}";
let admitted = outline(source, limits()).unwrap();
let exact = OutlineLimits {
max_source_bytes: source.len(),
max_tokens: admitted.tokens.len(),
max_declarations: 2,
};
assert_eq!(outline(source, exact).unwrap(), admitted);
for resource in [
OutlineResource::SourceBytes,
OutlineResource::Tokens,
OutlineResource::Declarations,
] {
let mut lower = exact;
match resource {
OutlineResource::SourceBytes => lower.max_source_bytes -= 1,
OutlineResource::Tokens => lower.max_tokens -= 1,
OutlineResource::Declarations => lower.max_declarations -= 1,
}
let error = outline(source, lower).unwrap_err();
assert_eq!(error.resource, resource);
assert!(error.observed_at_least > error.limit);
assert!(source.is_char_boundary(error.offset));
if resource == OutlineResource::Declarations {
assert_eq!(error.offset, source.find("oa_actor b").unwrap());
}
}
let empty = outline(
"",
OutlineLimits {
max_source_bytes: 0,
max_tokens: 0,
max_declarations: 0,
},
)
.unwrap();
assert!(empty.tokens.is_empty() && empty.declarations.is_empty());
let unicode = "// 🌊\r\noa_actor a \"A\" {}";
let error = outline(
unicode,
OutlineLimits {
max_source_bytes: 4,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::SourceBytes);
assert_eq!(error.offset, 0);
let error = outline(
unicode,
OutlineLimits {
max_tokens: 1,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Tokens);
assert_eq!(error.offset, unicode.find('\n').unwrap());
}
#[test]
fn outline_ranges_follow_current_bytes_without_identity_stabilization() {
let old = "oa_actor a \"A\" {}\nsource @a;";
let current = format!("// 航海 🚒\r\n{old}");
let old_outline = outline(old, limits()).unwrap();
let current_outline = outline(&current, limits()).unwrap();
let shift = current.len() - old.len();
let old_role = &old_outline.declarations[0];
let current_role = &current_outline.declarations[0];
assert_eq!(
current_role.alias_span.start,
old_role.alias_span.start + shift
);
assert_eq!(
current_role.kind_span.start,
old_role.kind_span.start + shift
);
assert_eq!(
current_role.declaration.span.end,
old_role.declaration.span.end + shift
);
assert_eq!(current_outline.source, current);
assert_eq!(old_outline.source, old);
assert_eq!(current_role.declaration.id, None);
assert!(!current_outline.source.contains(" id "));
// Workspace revision/stale-handle rejection belongs to the query/adapter,
// not a claim that an old owned span can be applied to any other string.
}
#[test]
fn deeply_nested_bounds_use_the_same_iterative_walk_and_declaration_budget() {
let mut source = String::new();
for index in 0..600 {
source.push_str(&format!("oa_activity work_{index} \"Work\" {{\n"));
}
source.push_str(&"}\n".repeat(600));
let result = outline(&source, limits()).unwrap();
assert_eq!(result.declarations.len(), 600);
assert!(!result.has_unmatched_braces);
assert_eq!(
result.declarations[0].declaration.span.end,
source.len() - 1
);
let error = outline(
&source,
OutlineLimits {
max_declarations: 599,
..limits()
},
)
.unwrap_err();
assert_eq!(error.resource, OutlineResource::Declarations);
assert_eq!(error.observed_at_least, 600);
assert_eq!(error.offset, source.find("oa_activity work_599").unwrap());
}
#[test]
fn noncanonical_uuid_spellings_have_no_outline_or_analysis_identity() {
for raw in [
"00000000000000000000000000000001",
"{00000000-0000-0000-0000-000000000001}",
"urn:uuid:00000000-0000-0000-0000-000000000001",
] {
assert!(
Id::parse_str(raw).is_ok(),
"The UUID crate alone is too permissive"
);
assert!(!has_uuid_header_shape(raw));
let literal = format!("\"{raw}\"");
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let before = modern.clone();
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(modern, before);
let errors = parse(&modern).unwrap_err();
assert!(
errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12")
&& error.span.start == modern.find(&literal).unwrap())
);
// Intentional compatibility correction: old analyze yielded Some(Id(1))
// here despite lint rejecting the header. It now shares outline's None.
let analysis = analyze(&modern);
assert_eq!(
analysis
.declarations
.iter()
.find(|d| d.alias == "model")
.unwrap()
.id,
None
);
assert!(
analysis
.diagnostics
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
let legacy_errors = legacy::parse(&legacy_source).unwrap_err();
assert!(
legacy_errors
.iter()
.any(|error| error.message.contains("8-4-4-4-12"))
);
}
}
#[test]
fn uppercase_and_json_escaped_header_ids_survive_both_parsers_and_outline() {
let expected = Id::from_u128(0xabcdef);
let canonical = expected.to_string();
let uppercase = canonical.to_uppercase();
assert_ne!(uppercase, canonical);
for raw in [uppercase.clone(), format!("\\u0030{}", &uppercase[1..])] {
let literal = format!("\"{raw}\"");
let decoded: String = serde_json::from_str(&literal).unwrap();
assert!(has_uuid_header_shape(&decoded));
let modern = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&modern, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, Some(expected));
assert_eq!(text(&modern, d.id_literal_span.unwrap()), literal);
assert_eq!(result.source.as_bytes(), modern.as_bytes());
assert_eq!(parse(&modern).unwrap().id, expected);
let analysis = analyze(&modern);
assert_eq!(analysis.declarations[0].id, Some(expected));
assert!(
!analysis
.diagnostics
.iter()
.any(|error| error.severity == crate::validation::Severity::Error)
);
let legacy_source = format!("arcadia 1; project \"Legacy\" id {literal} {{}}");
assert_eq!(legacy::parse(&legacy_source).unwrap().id, expected);
}
}
#[test]
fn header_shape_still_requires_valid_hexadecimal_uuid_data() {
let raw = "00000000-0000-0000-0000-00000000000Z";
assert!(has_uuid_header_shape(raw));
assert!(Id::parse_str(raw).is_err());
let literal = format!("\"{raw}\"");
let source = format!("arcadia 7; project model \"Model\" id {literal} {{}}");
let result = outline(&source, limits()).unwrap();
let d = declaration(&result, "model");
assert_eq!(d.declaration.id, None);
assert_eq!(text(&source, d.id_literal_span.unwrap()), literal);
assert!(
parse(&source)
.unwrap_err()
.iter()
.any(|error| error.message == "Invalid UUID identity.")
);
assert_eq!(analyze(&source).declarations[0].id, None);
}
+1
View File
@@ -1,4 +1,5 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod analysis_numeric;
pub mod data;
pub mod language;
pub mod model;
-1
View File
@@ -18,7 +18,6 @@ serde_json.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
tokio = { version = "1", features = ["rt", "time"] }
tempfile = "3"
unicode-segmentation = "1.13.3"
[dev-dependencies]
iced_runtime = "0.14"
+82 -347
View File
@@ -2,15 +2,12 @@ pub(crate) mod architecture_authoring;
#[cfg(test)]
mod architecture_authoring_tests;
mod checkpoints;
mod diagram_reveal;
#[cfg(test)]
mod diagram_selection_tests;
mod diagram_usages;
mod hosted;
mod reference_authoring;
#[cfg(test)]
mod review_navigation_tests;
mod source_symbols;
#[cfg(test)]
mod transition_context_tests;
#[cfg(test)]
@@ -61,7 +58,7 @@ mod direct_scenario_tests {
}
}
#[test]
fn direct_scenario_camera_details_and_real_resize_invalidate_only_pointer_context() {
fn direct_scenario_scroll_and_real_resize_invalidate_only_pointer_context() {
let (mut app, f) = setup();
let original = app.document.bundle().clone();
let event = begin(&app, &f);
@@ -77,26 +74,18 @@ mod direct_scenario_tests {
let _ = app.update(Message::ScenarioCanvas(event));
assert!(!app.behavior.has_pending());
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(30., 150.)),
)));
assert_ne!(app.scenario_context(), Some(context));
let context = app.scenario_context().unwrap();
for offset in [iced::Vector::new(0., 90.), iced::Vector::new(0., 150.)] {
for (area, offset) in [
(scenario::ScrollArea::Canvas, iced::Vector::new(30., 150.)),
(scenario::ScrollArea::Page, iced::Vector::new(0., 90.)),
] {
let before = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
scenario::ScrollArea::Page,
offset,
context, area, offset,
)));
assert_ne!(app.scenario_context(), Some(before));
let current = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
scenario::ScrollArea::Page,
offset,
context, area, offset,
)));
assert_eq!(
app.scenario_context(),
@@ -125,7 +114,7 @@ mod direct_scenario_tests {
}
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
stale,
scenario::ScrollArea::Page,
scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(app.scenario_context(), Some(current));
@@ -430,18 +419,9 @@ pub enum Message {
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
SourceDiagnostic(usize),
SourceRelated(SourceLocation),
SourceViewport(Option<Id>),
SourceSymbols(
crate::source_navigation::SymbolContext,
crate::source_navigation::SymbolEvent,
),
SourceSymbolsReady(crate::source_navigation::Completion),
DiagramUsage(crate::diagram_usage::Event),
DiagramUsagesReady(crate::diagram_usage::Completion),
DiagramRevealViewport(crate::diagram_usage::Context, u64, iced::Size),
SourceViewport,
SourceForSelection,
SourceNavigation(crate::source_navigation::Event),
SourceFindClose(crate::source_navigation::SymbolContext, Id),
Save(bool),
SavePath(Option<PathBuf>),
Saved(Result<Option<FileSnapshot>, SaveFailure>),
@@ -519,17 +499,6 @@ pub struct App {
pub source_jump: Option<text_editor::Cursor>,
source_select_line: bool,
pub source_navigation: crate::source_navigation::State,
source_find_instance: Id,
pub source_symbols: crate::source_navigation::SymbolState,
pub(crate) diagram_usages: Option<crate::diagram_usage::Panel>,
pub(crate) diagram_navigation_focus: Option<crate::diagram_usage::Focus>,
source_diagram_job: Option<Id>,
diagram_lookup_in_flight: Option<Id>,
pending_diagram_reveal: Option<(
crate::diagram_usage::Context,
crate::diagram_usage::Usage,
u64,
)>,
pub model_issues: Vec<validation::Issue>,
pub migration: Option<persistence::MigrationReport>,
source_version: u64,
@@ -638,13 +607,6 @@ impl App {
source_jump: None,
source_select_line: false,
source_navigation: crate::source_navigation::State::default(),
source_find_instance: Id::new_v4(),
source_symbols: crate::source_navigation::SymbolState::default(),
diagram_usages: None,
diagram_navigation_focus: None,
source_diagram_job: None,
diagram_lookup_in_flight: None,
pending_diagram_reveal: None,
model_issues,
migration: None,
source_version: 0,
@@ -698,7 +660,6 @@ impl App {
self.lint_requested = None;
}
fn show_source_file(&mut self, restore_cursor: bool) {
self.source_symbols.reveal = None;
self.source_tree.reveal(&self.source_workspace.active);
self.source_select_line = false;
let source = self.source_workspace.text().to_owned();
@@ -713,9 +674,6 @@ impl App {
.map(|b| b.cursor)
})
.flatten();
if let Some(cursor) = self.source_jump {
self.queue_source_reveal(cursor, false);
}
}
fn selected_architecture_placement(&self) -> Option<Id> {
self.active_diagram
@@ -969,8 +927,6 @@ impl App {
let canvas_size = self.canvas_size;
let source_version = self.source_version + 1;
let lint_running = self.lint_running;
let navigation_in_flight = self.source_symbols.in_flight;
let diagram_lookup_in_flight = self.diagram_lookup_in_flight;
let guide = std::mem::take(&mut self.guide);
let mut hosted = std::mem::take(&mut self.hosted);
hosted.leave_document();
@@ -982,8 +938,6 @@ impl App {
self.canvas_size = canvas_size;
self.source_version = source_version;
self.lint_running = lint_running;
self.source_symbols.in_flight = navigation_in_flight;
self.diagram_lookup_in_flight = diagram_lookup_in_flight;
self.guide = guide;
self.path = path;
self.status = "Project ready".into();
@@ -1084,70 +1038,19 @@ impl App {
crate::model_menu::popup(self),
)
.into();
crate::source_navigation::interaction::observe(
crate::behavior::scenario::focus::root(
behavior::canvas_workspace::focus_root(
crate::hosted::view::overlay(
self,
self.reference_overlay(crate::diagram_usage::view::overlay(self, base)),
),
behavior::canvas_workspace::Scope::input_owner(self),
),
self.scenario_focus_owner(),
),
self.source_symbols.raw_interaction.clone(),
behavior::canvas_workspace::focus_root(
crate::hosted::view::overlay(self, self.reference_overlay(base)),
behavior::canvas_workspace::Scope::input_owner(self),
)
}
pub fn subscription(&self) -> Subscription<Message> {
Subscription::batch([
self.diagram_usages
.as_ref()
.map_or_else(Subscription::none, |panel| {
iced::event::listen_with(|event, status, _| {
let iced::Event::Keyboard(keyboard::Event::KeyPressed {
key,
modifiers,
..
}) = event
else {
return None;
};
if !modifiers.is_empty() {
return None;
}
match key {
keyboard::Key::Named(keyboard::key::Named::ArrowUp) => Some(-1i8),
keyboard::Key::Named(keyboard::key::Named::ArrowDown) => Some(1i8),
keyboard::Key::Named(keyboard::key::Named::Enter)
if status == iced::event::Status::Ignored =>
{
Some(0i8)
}
_ => None,
}
})
.with(panel.token)
.map(|(token, step)| {
Message::DiagramUsage(if step == 0 {
crate::diagram_usage::Event::Activate(token)
} else {
crate::diagram_usage::Event::Move(token, step)
})
})
}),
iced::time::every(std::time::Duration::from_secs(10)).map(|_| Message::CheckpointTick),
window::close_requests().map(|id| Message::Request(Pending::Close(id))),
window::resize_events().map(|(_, size)| Message::WindowResized(size)),
architecture_authoring::subscription(self),
// Use one captured-Escape bridge: a focused Find input consumes
// Escape, while modal priority remains in the ordinary handler.
behavior::creation::subscription(
self.behavior.creation.has_pending()
|| self.diagram_usages.is_some()
|| self.behavior.source_active()
|| (self.page == Page::Text
&& (self.source_symbols.open || self.source_navigation.open)
&& self.architecture.is_none()),
self.behavior.creation.has_pending() || self.behavior.source_active(),
)
.map(|()| Message::Escape),
iced::event::listen_with(|event, status, _| {
@@ -1194,17 +1097,6 @@ impl App {
])
}
pub fn update(&mut self, message: Message) -> Task<Message> {
let usage_viewport = self.diagram_reveal_viewport(&message);
let window_resized = matches!(&message, Message::WindowResized(_));
// Reject stale measurements before legacy viewport handlers can change
// dimensions or invalidate the exact pending destination epoch.
if self.pending_diagram_reveal.is_some()
&& Self::is_diagram_viewport(&message)
&& usage_viewport.is_none()
{
return Task::none();
}
self.reconcile_pending_diagram_reveal(&message);
if let Message::BehaviorControl(intent) = message {
return self.behavior_control(intent);
}
@@ -1215,19 +1107,6 @@ impl App {
);
let before = self.camera_boundary();
let task = self.update_inner(message);
if window_resized && let Some((_, _, epoch)) = &mut self.pending_diagram_reveal {
*epoch = self.canvas_epoch;
}
if let Some(surface) = usage_viewport {
self.finish_diagram_reveal(surface);
}
if self
.diagram_navigation_focus
.as_ref()
.is_some_and(|focus| self.diagram_focus(focus.usage.surface).is_none())
{
self.diagram_navigation_focus = None;
}
if !camera && self.camera_boundary() != before {
self.camera_epoch = self.camera_epoch.wrapping_add(1);
self.camera_boundary_epoch = self.canvas_epoch;
@@ -1317,75 +1196,6 @@ impl App {
)
}
fn update_inner(&mut self, message: Message) -> Task<Message> {
let message = match message {
Message::SourceFindClose(context, instance) => {
if context != self.source_symbol_context()
|| instance != self.source_find_instance
|| !self.source_navigation.open
|| !self.source_navigation_available()
{
return Task::none();
}
// Only the checked envelope may enter the legacy Close handler.
Message::SourceNavigation(crate::source_navigation::Event::Close)
}
Message::SourceNavigation(crate::source_navigation::Event::Close) => {
return Task::none();
}
message => message,
};
if let Message::SourceSymbols(context, _) = &message
&& (context.session != self.source_workspace.session()
|| context.path != self.source_workspace.active)
{
return Task::none();
}
// Internal results and geometry do not constitute a new user intent.
// Process query completion even behind a modal to release its worker slot;
// its handler independently refuses navigation outside the live editor.
if let Message::SourceSymbolsReady(completion) = message {
return self.source_symbols_ready(completion);
}
if let Message::DiagramUsagesReady(completion) = message {
return self.diagram_usages_ready(completion);
}
if !matches!(
&message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
) {
self.source_interaction();
}
if let Message::DiagramUsage(event) = message {
return self.diagram_usage_event(event);
}
if let Some(panel) = &self.diagram_usages {
if matches!(&message, Message::Request(Pending::Close(_))) {
self.diagram_usages = None;
return self.update(message);
}
if matches!(&message, Message::Escape) {
return self.diagram_usage_event(crate::diagram_usage::Event::Close(panel.token));
}
if !matches!(
&message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport(_)
) {
return Task::none();
}
}
if let Some(task) = self.reference_message(&message) {
return task;
}
@@ -1453,7 +1263,7 @@ impl App {
| Message::CanvasResized(_)
| Message::Lint(_)
| Message::Linted(_, _)
| Message::SourceViewport(_)
| Message::SourceViewport
| Message::Request(Pending::Close(_))
)
{
@@ -1499,7 +1309,7 @@ impl App {
| Message::Exported(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport(_)
| Message::SourceViewport
)
{
return Task::none();
@@ -1537,7 +1347,7 @@ impl App {
| Message::CheckpointLoaded(..)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::SourceViewport(_)
| Message::SourceViewport
| Message::Lint(_)
| Message::Linted(_, _)
| Message::Saved(_)
@@ -1679,20 +1489,6 @@ impl App {
epoch: self.canvas_epoch,
})
}
pub(crate) fn scenario_focus_owner(&self) -> Option<crate::behavior::scenario::focus::Stamp> {
if self.busy
|| self.hosted.visible
|| self.workflow.is_some()
|| self.architecture.is_some()
|| self.model_menu.is_some()
|| self.file_prompt.is_some()
|| self.delete_pending
|| self.pending.is_some()
{
return None;
}
self.behavior.scenario.focus_stamp(self.scenario_context()?)
}
fn handle(&mut self, message: Message) -> Task<Message> {
match message {
Message::BehaviorControl(intent) => return self.behavior_control(intent),
@@ -1711,15 +1507,14 @@ impl App {
Message::ScenarioCanvas(event) => {
use crate::behavior::scenario::{self, CanvasEvent};
let context = event.context();
if let CanvasEvent::FormShown(_, mount) = event {
if let CanvasEvent::FormShown(_, session) = event {
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
&& current.revision == context.revision
}) && self.scenario_focus_owner().is_some()
&& self.behavior.scenario.take_form_focus(mount)
}) && self.behavior.scenario.take_form_focus(session)
{
return scenario::focus::task(self.scenario_focus_owner().unwrap());
return iced::widget::operation::focus(scenario::NAME_INPUT);
}
return Task::none();
}
@@ -1747,9 +1542,9 @@ impl App {
return Task::none();
}
if let CanvasEvent::Scrolled(_, area, offset) = event {
// Details may report more than once in a frame. Accept the
// current owner's passive clamps across an epoch bump while
// still rejecting another document, owner or revision.
// Canvas and outline notifications may come from the
// same frame. Accept both across the first epoch bump,
// while still rejecting another owner/document/revision.
if self.scenario_context().is_some_and(|current| {
current.document == context.document
&& current.scenario == context.scenario
@@ -1904,6 +1699,37 @@ impl App {
return Task::none();
}
match mutation {
CanvasEvent::First(_, tool) => {
let empty = match tool {
scenario::Tool::Lifeline => !self
.project()
.children(context.scenario)
.any(|e| e.kind() == Kind::Lifeline),
scenario::Tool::Message | scenario::Tool::Action => {
self.project()
.children(context.scenario)
.any(|e| e.kind() == Kind::Lifeline)
&& !self
.project()
.children(context.scenario)
.any(|e| e.kind() == Kind::Occurrence)
}
_ => false,
};
if !empty {
self.status="This scenario already has content. Use its current authoring tools.".into();
return Task::none();
}
let mut state = std::mem::take(&mut self.behavior.scenario);
let result = state.update(
self.project(),
scenario::Event::Start(context.scenario, tool),
);
self.behavior.scenario = state;
if let Err(error) = result {
self.status = error;
}
}
CanvasEvent::BeginOccurrence {
tool,
slot,
@@ -2148,8 +1974,6 @@ impl App {
return self.update(Message::Add(child));
}
Message::Escape => {
let restore_source_focus = self.source_navigation_available()
&& (self.source_navigation.open || self.source_symbols.open);
if self.behavior.source_back() {
self.status =
"Returned to the unchanged parent form; no source was created.".into();
@@ -2166,12 +1990,6 @@ impl App {
self.status = "Drawing cancelled · Select / move is active".into();
}
self.source_navigation.open = false;
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
if restore_source_focus {
self.restore_source_editor_focus();
}
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
}
@@ -2470,7 +2288,7 @@ impl App {
if let crate::workflow::Workflow::Semantic(state) = &mut workflow {
match event {
crate::workflow::Event::Semantic(event) => {
state.update(self.project(), *event)
state.update(self.project(), event)
}
crate::workflow::Event::Apply => {
let focus = state.target(self.project());
@@ -3223,21 +3041,12 @@ impl App {
if self.path.is_none()
|| self.path.as_ref() == Some(&receipt.target)
{
if self.path.as_ref() != Some(&receipt.target) {
self.source_workspace.reset_navigation_session();
}
self.path = Some(receipt.target);
self.document.bind_repair_baseline(disk.reachable_bundle());
self.disk_project = Some(disk);
}
self.status = "Interrupted disk save recovered. Current model and drafts are retained; Save all can be retried.".into();
}
Err(error) => {
if self.path.is_none()
|| self.path.as_ref() == Some(&receipt.target)
{
self.document.invalidate_saved_baseline();
}
self.status = format!(
"Disk recovery completed; files could not be reloaded: {error}"
)
@@ -3294,28 +3103,13 @@ impl App {
}
}
Message::SourceRelated(location) => self.jump_to_source(location),
Message::SourceViewport(ticket) => return self.source_viewport(ticket),
Message::SourceFindClose(..) => return Task::none(), // update checks and unwraps it
Message::SourceSymbols(_, event) => return self.source_symbol_event(event),
Message::SourceSymbolsReady(completion) => {
return self.source_symbols_ready(completion);
}
Message::DiagramUsage(event) => return self.diagram_usage_event(event),
Message::DiagramUsagesReady(completion) => {
return self.diagram_usages_ready(completion);
}
Message::DiagramRevealViewport(context, epoch, size) => {
if self
.pending_diagram_reveal
.as_ref()
.is_some_and(|(c, _, e)| *c == context && *e == epoch)
&& self
.diagram_reveal_viewport(&Message::DiagramRevealViewport(
context, epoch, size,
))
.is_some()
{
return self.handle(Message::CanvasResized(size));
Message::SourceViewport => {
if let Some(cursor) = self.source_jump.take() {
self.source_editor.move_to(cursor);
if self.source_select_line && cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return iced::widget::operation::focus("source-editor");
}
}
Message::ApplySource => {
@@ -3362,7 +3156,6 @@ impl App {
return Task::none();
}
self.page = Page::Text;
self.source_find_instance = Id::new_v4();
self.source_navigation.open = true;
self.source_navigation.refresh(&self.source_editor.text());
return iced::widget::operation::focus(if go_to_line {
@@ -3371,22 +3164,13 @@ impl App {
"source-find"
});
}
Event::Close => {
let was_open = self.source_navigation.open;
self.source_navigation.open = false;
if was_open {
self.restore_source_editor_focus();
}
}
Event::Close => self.source_navigation.open = false,
Event::Query(query) => {
self.source_navigation.query = query;
self.source_navigation.refresh(&self.source_editor.text());
}
Event::Next(backwards) if self.page == Page::Text => {
self.source_jump = self.source_navigation.next(backwards);
if let Some(cursor) = self.source_jump {
self.queue_source_reveal(cursor, false);
}
if self.source_jump.is_none() {
self.status = "No matching source text.".into();
}
@@ -3397,7 +3181,8 @@ impl App {
.destination(self.source_editor.line_count())
{
Ok(cursor) => {
self.queue_source_reveal(cursor, true);
self.source_jump = Some(cursor);
self.source_select_line = true;
}
Err(error) => self.status = error,
},
@@ -3480,9 +3265,6 @@ impl App {
}
self.sync_source();
}
if self.path.as_ref() != Some(&receipt.path) {
self.source_workspace.reset_navigation_session();
}
self.disk_project = Some(disk);
self.path = Some(receipt.path);
self.hosted.session = None;
@@ -3694,52 +3476,6 @@ impl App {
id,
));
}
// Refining a leaf moves its explicit responsibilities to the new leaf.
// Later children inherit only the existing unambiguous performer summary.
if self.create_kind.is_work()
&& let Some(owner) = parent
&& self
.project()
.elements
.get(&owner)
.is_some_and(|e| e.layer() == self.layer && e.kind() == self.create_kind)
{
let performer = oa_core::validation::allocation_summary(self.project(), owner);
let leaf = !self
.project()
.children(owner)
.any(|e| e.kind() == self.create_kind);
let responsibilities: Vec<_> = self
.project()
.relations
.values()
.filter(|r| {
r.target() == owner
&& matches!(
r.kind(),
RelationKind::Allocation | RelationKind::RoleAssignment
)
})
.collect();
if leaf && !responsibilities.is_empty() {
for original in responsibilities {
let mut responsibility = original.clone();
match &mut responsibility.body {
RelationBody::Allocation { target, .. }
| RelationBody::RoleAssignment { target, .. } => *target = id,
_ => unreachable!(),
}
commands.push(Command::EditRelation(original.id, responsibility));
}
} else if let Some(performer) = performer {
commands.push(Command::Connect(
RelationKind::Allocation,
"Performs".into(),
performer,
id,
));
}
}
if self.create_kind == Kind::Occurrence
&& let Some(owner) = parent.and_then(|id| self.project().elements.get(&id))
&& let Some(steps) = owner.steps()
@@ -3757,13 +3493,19 @@ impl App {
.get(&diagram)
.is_none_or(|d| d.projection.includes(self.create_kind))
{
let mut p = match execute_batch(self.project(), &commands) {
Ok(candidate) => candidate,
Err(error) => {
self.status = error.to_string();
return;
let mut p = self.project().clone();
for command in &commands {
if let Command::CreateDiagram(d) = command {
p.diagrams.insert(d.id, d.clone());
}
};
}
p.elements.insert(
id,
match &commands[0] {
Command::CreateElement(e) => e.clone(),
_ => unreachable!(),
},
);
let owner = if allocated {
Some(self.selected)
} else {
@@ -4081,9 +3823,8 @@ impl App {
self.canvas_document = Id::new_v4();
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.path = Some(path);
self.document.bind_repair_baseline(disk.reachable_bundle());
self.source_workspace = Workspace::new(&disk.bundle, false);
self.source_workspace.set_reachable(disk.reachable.clone());
self.source_workspace.reachable = disk.reachable.clone();
self.show_source_file(false);
self.source_diagnostics = error.diagnostics().to_vec();
self.source_version += 1;
@@ -4135,13 +3876,11 @@ impl App {
let column = prefix
.rfind('\n')
.map_or(offset, |start| offset - start - 1);
self.queue_source_reveal(
text_editor::Cursor {
position: text_editor::Position { line, column },
selection: None,
},
true,
);
self.source_jump = Some(text_editor::Cursor {
position: text_editor::Position { line, column },
selection: None,
});
self.source_select_line = true;
}
fn create_source_file(&mut self) -> Result<(), String> {
@@ -4751,10 +4490,6 @@ fn read_snapshot(path: PathBuf) -> Result<LoadedSnapshot, String> {
#[path = "app/save_as_tests.rs"]
mod save_as_tests;
#[cfg(test)]
#[path = "app/repair_save_state_tests.rs"]
mod repair_save_state_tests;
#[cfg(test)]
mod source_project_tests {
use super::*;
@@ -70,7 +70,6 @@ fn architecture_f2_routes_selected_node_to_inline_rename_and_exact_undo() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -225,7 +225,6 @@ fn architecture_canvas_left_click_uses_world_coordinate_once_at_zoom_and_pan() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -300,7 +299,6 @@ fn architecture_f2_rename_preserves_identity_placement_and_atomic_history() {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: element,
navigation_focus: None,
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
-145
View File
@@ -1,145 +0,0 @@
//! Complete a navigation camera after the destination reports its real viewport.
use super::*;
use crate::diagram_usage::{Context, Surface};
#[cfg(test)]
mod tests;
impl App {
pub(super) fn is_diagram_viewport(message: &Message) -> bool {
matches!(
message,
Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
)
}
pub(crate) fn architecture_reveal_stamp(&self, diagram: Id) -> Option<(Context, u64)> {
self.pending_diagram_reveal
.as_ref()
.filter(|(_, u, _)| u.surface == Surface::Saved(diagram))
.map(|(context, _, epoch)| (*context, *epoch))
}
pub(super) fn diagram_reveal_viewport(&self, message: &Message) -> Option<Surface> {
let (context, usage, epoch) = self.pending_diagram_reveal.as_ref()?;
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| *epoch != self.canvas_epoch
{
return None;
}
let (surface, size, valid) = match message {
Message::DiagramRevealViewport(c, e, size) => (
usage.surface,
*size,
c == context
&& e == epoch
&& self.page == Page::Diagram
&& Some(usage.surface.id()) == self.active_diagram,
),
Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(c, size)) => (
Surface::Process(c.owner),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(c, size)) => (
Surface::Scenario(c.scenario),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
Message::StateDiagram(behavior::state_edit::Event::Viewport(c, size)) => (
Surface::Saved(c.diagram),
*size,
c.document == context.document
&& c.revision == context.revision
&& c.epoch == *epoch,
),
_ => return None,
};
(valid
&& surface == usage.surface
&& size.width.is_finite()
&& size.height.is_finite()
&& size.width > 0.
&& size.height > 0.)
.then_some(surface)
}
pub(super) fn reconcile_pending_diagram_reveal(&mut self, message: &Message) {
if !matches!(
message,
Message::CheckpointTick
| Message::CheckpointWritten(..)
| Message::Lint(_)
| Message::Linted(..)
| Message::SourceViewport(_)
| Message::WindowResized(_)
| Message::CanvasResized(_)
| Message::DiagramRevealViewport(..)
| Message::DiagramUsage(_)
| Message::DiagramUsagesReady(_)
| Message::SourceSymbolsReady(_)
| Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(..))
| Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(..))
| Message::StateDiagram(behavior::state_edit::Event::Viewport(..))
) {
self.pending_diagram_reveal = None;
if !Self::is_navigation_camera(message) {
self.diagram_navigation_focus = None;
}
}
}
fn is_navigation_camera(message: &Message) -> bool {
if let Message::BehaviorControl(intent) = message {
return Self::is_navigation_camera(&intent.message);
}
matches!(
message,
Message::Pan(_)
| Message::Zoom(_)
| Message::Fit
| Message::TopologyCanvas(
behavior::topology::CanvasEvent::Camera(..)
| behavior::topology::CanvasEvent::Zoom(..)
| behavior::topology::CanvasEvent::Scrolled(..)
| 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(..)
| behavior::state_edit::Event::Zoom(..)
| behavior::state_edit::Event::Fit(..)
)
)
}
pub(super) fn finish_diagram_reveal(&mut self, surface: Surface) {
let Some((context, usage, _)) = self.pending_diagram_reveal.take() else {
return;
};
if context.document == self.canvas_document
&& context.revision == self.document.revision()
&& usage.surface == surface
&& !self.draft_dirty()
&& !self.source_dirty()
&& !self.behavior.has_pending()
{
// The normal viewport handler has now stored the measured size.
// Target identity was validated for this revision before navigation.
self.reveal_diagram_usage(&usage);
}
self.pending_diagram_reveal = None;
}
}
@@ -1,403 +0,0 @@
//! Independent first-layout navigation oracles, through the real App boundary.
use super::*;
use crate::diagram_usage::{self as usage, Target, Usage};
use iced::{Size, Vector};
use std::collections::BTreeMap;
#[derive(Clone, Copy, Debug)]
enum Canvas {
Architecture,
Process,
Scenario,
State,
}
const CANVASES: [Canvas; 4] = [
Canvas::Architecture,
Canvas::Process,
Canvas::Scenario,
Canvas::State,
];
const WIDE: Size = Size::new(1200., 700.);
const ACTUAL: Size = Size::new(500., 300.);
fn send(app: &mut App, message: Message) {
let _ = app.update(message);
}
fn fixture(canvas: Canvas) -> (App, Usage) {
let mut p = Project::empty();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let (surface, subject) = match canvas {
Canvas::Architecture => {
let diagram = p.ensure_diagram(Layer::Oa);
p.place(
diagram,
work,
Position {
x: 12_000.,
y: 9_000.,
},
);
(Surface::Saved(diagram), work)
}
Canvas::Process | Canvas::Scenario => {
let kind = if matches!(canvas, Canvas::Process) {
Kind::Process
} else {
Kind::Scenario
};
let owner = p.add_element(kind, Layer::Oa, "Response", None).unwrap();
let occurrence = p
.add_element(Kind::Occurrence, Layer::Oa, "Respond here", Some(owner))
.unwrap();
*p.elements
.get_mut(&occurrence)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
precondition: String::new(),
postcondition: String::new(),
};
let steps = p.elements.get_mut(&owner).unwrap().steps_mut().unwrap();
if !steps.contains(&occurrence) {
steps.push(occurrence);
}
(
if matches!(canvas, Canvas::Process) {
Surface::Process(owner)
} else {
Surface::Scenario(owner)
},
occurrence,
)
}
Canvas::State => {
let machine = p
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
.unwrap();
let region = p
.add_element(Kind::Region, Layer::Oa, "Main", Some(machine))
.unwrap();
let state = p
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
.unwrap();
let diagram = Id::new_v4();
p.diagrams.insert(
diagram,
Diagram {
id: diagram,
alias: "ready_view".into(),
name: "Ready view".into(),
description: String::new(),
layer: Layer::Oa,
projection: ProjectionKind::StateMachine { machine },
nodes: BTreeMap::new(),
routes: vec![],
view_options: Default::default(),
},
);
p.place(
diagram,
state,
Position {
x: 8_000.,
y: 11_000.,
},
);
(Surface::Saved(diagram), state)
}
};
let mut app = App::with_project(p);
let found = usage::collect(
app.project(),
subject,
&app.behavior.topology,
&app.behavior.scenario,
)
.into_iter()
.find(|u| u.surface == surface && u.target != Target::Whole)
.unwrap();
app.canvas_size = Some(WIDE);
app.behavior.diagram.viewport = Some(WIDE);
if let Surface::Process(owner) = surface {
app.behavior.topology.enter(app.canvas_document, owner);
app.behavior.topology.viewport(WIDE);
}
if let Surface::Scenario(owner) = surface {
app.behavior.scenario.enter(app.canvas_document, owner);
app.behavior.scenario.viewport(WIDE);
}
app.page = Page::Text;
(app, found)
}
fn measurement(
canvas: Canvas,
context: Context,
epoch: u64,
surface: Surface,
size: Size,
) -> Message {
match canvas {
Canvas::Architecture => Message::DiagramRevealViewport(context, epoch, size),
Canvas::Process => Message::TopologyCanvas(behavior::topology::CanvasEvent::Viewport(
behavior::topology::Context {
document: context.document,
owner: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::Scenario => Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Viewport(
behavior::scenario::Context {
document: context.document,
scenario: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Viewport(
behavior::state_edit::Context {
document: context.document,
diagram: surface.id(),
revision: context.revision,
epoch,
},
size,
)),
}
}
fn stamp(app: &App) -> (Context, u64) {
let (context, _, epoch) = app
.pending_diagram_reveal
.as_ref()
.expect("reveal awaits measured destination");
(*context, *epoch)
}
fn assert_centered(app: &App, canvas: Canvas, target: &Usage) {
// All independently authored fixture targets fit ACTUAL at the readable cap.
// This oracle does not call the production camera/reveal implementation.
let zoom = 1.25;
let pan = Vector::new(
250. - target.bounds.center_x() * zoom,
150. - target.bounds.center_y() * zoom,
);
match canvas {
Canvas::Architecture => {
assert_eq!(app.canvas_size, Some(ACTUAL));
assert_eq!(app.zoom, zoom);
assert_eq!(app.pan, pan);
assert!(!app.fit_to_view);
}
Canvas::State => {
assert_eq!(app.behavior.diagram.viewport, Some(ACTUAL));
assert_eq!(app.behavior.diagram.zoom, zoom);
assert_eq!(app.behavior.diagram.pan, pan);
assert!(!app.behavior.diagram.fit);
}
Canvas::Process => {
let mut expected = behavior::topology::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.topology.camera_boundary() == expected.camera_boundary());
}
Canvas::Scenario => {
let mut expected = behavior::scenario::State::default();
expected.enter(app.canvas_document, target.surface.id());
expected.choose_tool(target.surface.id(), behavior::scenario::CanvasTool::Select);
expected.viewport(ACTUAL);
expected.zoom(Some(zoom));
expected.camera(behavior::canvas_workspace::Event::Pan(pan));
assert!(app.behavior.scenario.camera_boundary() == expected.camera_boundary());
}
}
}
#[test]
fn all_reveal_surfaces_recenter_once_after_the_real_viewport_and_preserve_authored_state() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
let drafts = app.source_workspace.bundle();
let revision = app.document.revision();
let history = (
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty(),
);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.source_workspace.bundle(), drafts);
assert_eq!(app.document.revision(), revision);
assert_eq!(
(
app.document.can_undo(),
app.document.can_redo(),
app.document.dirty()
),
history
);
// The exact delivered callback is a replay after the one-shot is consumed.
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(app.camera_boundary() == camera);
assert_centered(&app, canvas, &target);
}
}
#[test]
fn malformed_or_wrong_stamp_measurements_cannot_poison_a_pending_reveal() {
for canvas in CANVASES {
for invalid in 0..6 {
let (mut app, target) = fixture(canvas);
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let camera = app.camera_boundary();
let mut stale = context;
let mut wrong_epoch = epoch;
let mut size = Size::new(111., 122.);
match invalid {
0 => stale.document = Id::new_v4(),
1 => stale.revision = stale.revision.wrapping_add(1),
2 => wrong_epoch = wrong_epoch.wrapping_add(1),
3 => size.width = f32::NAN,
4 => size.height = f32::INFINITY,
_ => size.width = 0.,
}
send(
&mut app,
measurement(canvas, stale, wrong_epoch, target.surface, size),
);
assert!(
app.pending_diagram_reveal.is_some(),
"{canvas:?} invalid case {invalid}"
);
assert_eq!(stamp(&app), (context, epoch));
assert_eq!(app.canvas_epoch, epoch);
assert!(
app.camera_boundary() == camera,
"{canvas:?} invalid case {invalid}"
);
send(
&mut app,
measurement(canvas, context, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
}
}
}
#[test]
fn actual_window_resize_before_first_measurement_retains_focus_for_the_new_context() {
for canvas in CANVASES {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, old_epoch) = stamp(&app);
send(&mut app, Message::WindowResized(Size::new(1100., 700.)));
let (current, epoch) = stamp(&app);
assert_eq!(current, context);
assert_eq!(epoch, app.canvas_epoch);
if matches!(canvas, Canvas::Process | Canvas::Scenario) {
assert_ne!(epoch, old_epoch);
let camera = app.camera_boundary();
send(
&mut app,
measurement(canvas, context, old_epoch, target.surface, ACTUAL),
);
assert_eq!(stamp(&app), (current, epoch));
assert!(app.camera_boundary() == camera);
}
send(
&mut app,
measurement(canvas, current, epoch, target.surface, ACTUAL),
);
assert!(app.pending_diagram_reveal.is_none());
assert_centered(&app, canvas, &target);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
}
}
#[test]
fn user_camera_edit_and_navigation_cancel_deferred_focus_without_late_snapping() {
for canvas in CANVASES {
for action in 0..3 {
let (mut app, target) = fixture(canvas);
let accepted = app.document.snapshot();
assert!(app.reveal_diagram_usage(&target));
let (context, epoch) = stamp(&app);
let event = match action {
0 => match canvas {
Canvas::Architecture => Message::Pan(Vector::new(31., -17.)),
Canvas::State => Message::StateDiagram(behavior::state_edit::Event::Pan(
app.state_context().unwrap(),
Vector::new(31., -17.),
)),
Canvas::Process => {
Message::TopologyCanvas(behavior::topology::CanvasEvent::Camera(
app.topology_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
Canvas::Scenario => {
Message::ScenarioCanvas(behavior::scenario::CanvasEvent::Camera(
app.scenario_context().unwrap(),
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(Vector::new(31., -17.)),
))
}
},
1 => Message::Field("/name".into(), "Pending title edit".into()),
_ => Message::Page(Page::Catalog),
};
send(&mut app, event);
assert!(
app.pending_diagram_reveal.is_none(),
"{canvas:?} action {action}"
);
let camera = app.camera_boundary();
let page = app.page;
// A stale layout callback at the already retained size cannot
// restore the old navigation camera after deliberate user input.
send(
&mut app,
measurement(canvas, context, epoch, target.surface, WIDE),
);
assert!(app.pending_diagram_reveal.is_none());
assert!(
app.camera_boundary() == camera,
"{canvas:?} action {action}"
);
assert_eq!(app.page, page);
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
if action == 1 {
assert!(app.draft_dirty());
}
}
}
}
-445
View File
@@ -1,445 +0,0 @@
//! Navigation-only adapter: validates destinations before changing presentation.
use super::*;
use crate::diagram_usage::{self as usage, Context, Event, Panel, Surface, Target, Usage};
#[cfg(test)]
mod tests;
impl App {
pub(crate) fn diagram_usage_message(&self, subject: Id) -> Message {
Message::DiagramUsage(Event::Open(Context {
document: self.canvas_document,
revision: self.document.revision(),
subject,
}))
}
fn usage_navigation_available(&self) -> bool {
!self.busy
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.architecture.is_none()
&& !self.reference_input_active()
&& !self.behavior.has_pending()
&& !self.draft_dirty()
&& !self.source_dirty()
}
/// Admit at most one expensive lookup, including one accepted Project clone.
/// A replacement panel waits for the previous worker's exact completion.
fn start_diagram_lookup(&mut self) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if !panel.loading || self.diagram_lookup_in_flight.is_some() {
return Task::none();
}
let (token, context) = (panel.token, panel.context);
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.usage_navigation_available()
{
let panel = self.diagram_usages.as_mut().unwrap();
panel.loading = false;
panel.notice = Some(
"The model or pending edits changed. Reopen diagram navigation when ready.".into(),
);
return Task::none();
}
let project = self.project().clone();
let presentation = self.behavior.topology.usage_snapshot();
self.diagram_lookup_in_flight = Some(token);
Task::perform(
async move {
let result = tokio::task::spawn_blocking(move || {
let process = presentation.into_query_state();
let scenario = behavior::scenario::State::default();
usage::collect(&project, context.subject, &process, &scenario)
})
.await
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
usage::Completion {
token,
context,
result,
}
},
Message::DiagramUsagesReady,
)
}
fn refresh_diagram_lookup(&mut self, notice: &str) -> Task<Message> {
let Some(panel) = self.diagram_usages.as_mut() else {
return Task::none();
};
panel.token = Id::new_v4();
panel.context.revision = self.document.revision();
panel.usages.clear();
panel.loading = true;
panel.page = 0;
panel.cursor = 0;
panel.destination = None;
panel.notice = Some(notice.into());
self.start_diagram_lookup()
}
/// Must run before modal/input dispatch. A closed or replaced panel still
/// owns its worker slot until this exact completion arrives.
pub(super) fn diagram_usages_ready(&mut self, completion: usage::Completion) -> Task<Message> {
if self.diagram_lookup_in_flight != Some(completion.token) {
return Task::none();
}
self.diagram_lookup_in_flight = None;
let Some(panel) = self.diagram_usages.as_ref() else {
return Task::none();
};
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.token != completion.token {
// A newer Open/refresh waited without cloning or spawning a second job.
return self.start_diagram_lookup();
}
let panel = self.diagram_usages.as_mut().unwrap();
if panel.context != completion.context || !panel.loading {
panel.loading = false;
panel.notice = Some(
"This lookup no longer matches the requested model. Close and try again.".into(),
);
return Task::none();
}
panel.loading = false;
match completion.result {
Ok(usages) => panel.usages = usages,
Err(error) => panel.notice = Some(error),
}
Task::none()
}
/// Selection validates one destination only; opening the chooser does the
/// whole-project scan on its worker, never on the GUI thread.
fn current_surface_usages(&self, subject: Id, surface: Surface) -> Vec<Usage> {
match surface {
Surface::Saved(diagram) => {
usage::saved_usages_for(self.project(), subject, Some(diagram))
}
Surface::Process(owner) => {
self.behavior
.topology
.diagram_usages(self.project(), owner, subject)
}
Surface::Scenario(owner) => {
self.behavior
.scenario
.diagram_usages(self.project(), owner, subject)
}
}
}
pub(super) fn diagram_usage_event(&mut self, event: Event) -> Task<Message> {
if let Event::Open(context) = event {
if context.document != self.canvas_document
|| context.revision != self.document.revision()
|| !self.project().contains(context.subject)
{
return Task::none();
}
if !self.usage_navigation_available() {
self.status = "Apply or revert pending edits before opening diagram navigation. Your inputs are retained.".into();
return Task::none();
}
let mut panel = Panel::new(context, Vec::new());
panel.loading = true;
let filter_id = format!("diagram-usage-filter-{}", panel.token);
self.diagram_usages = Some(panel);
self.model_menu = None;
// Only an accepted new chooser supersedes the previous reveal.
// Rejected/stale panel envelopes leave its first measurement intact.
self.pending_diagram_reveal = None;
self.diagram_navigation_focus = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
return Task::batch([
self.start_diagram_lookup(),
iced::widget::operation::focus(filter_id),
]);
}
let 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 {
self.restore_source_editor_focus();
}
return Task::none();
}
if panel.context.document != self.canvas_document {
self.diagram_usages = None;
return Task::none();
}
if panel.context.revision != self.document.revision() {
return self
.refresh_diagram_lookup("The model changed. Review the refreshed destinations.");
}
if panel.loading {
// Search and Close remain useful while loading; queued activation
// must not perform a synchronous fallback or choose an old row.
if let Event::Search(_, query) = event {
self.diagram_usages.as_mut().unwrap().search = query;
}
return Task::none();
}
match event {
Event::Move(_, direction) => {
let panel = self.diagram_usages.as_ref().unwrap();
let count = panel.choices(self.project()).len();
let panel = self.diagram_usages.as_mut().unwrap();
if count > 0 {
panel.cursor = if direction < 0 {
(panel.cursor + count - 1) % count
} else {
(panel.cursor + 1) % count
};
panel.page = panel.cursor / 6;
}
}
Event::Activate(_) => {
let panel = self.diagram_usages.as_ref().unwrap();
if let Some(event) = panel.choices(self.project()).get(panel.cursor).cloned() {
return self.diagram_usage_event(event);
}
}
Event::Search(_, query) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.search = query;
panel.page = 0;
panel.cursor = 0;
}
Event::Page(_, page) => {
let panel = self.diagram_usages.as_mut().unwrap();
panel.page = page;
panel.cursor = page.saturating_mul(6);
}
Event::Destination(_, surface) => {
let panel = self.diagram_usages.as_mut().unwrap();
if surface.is_none() || panel.usages.iter().any(|u| Some(u.surface) == surface) {
panel.destination = surface;
panel.page = 0;
panel.cursor = 0;
}
}
Event::Choose(_, requested) => {
if !self.usage_navigation_available() {
return Task::none();
}
let subject = self.diagram_usages.as_ref().unwrap().context.subject;
let fresh = self
.current_surface_usages(subject, requested.surface)
.into_iter()
.find(|u| u.subject == requested.subject && u.target == requested.target);
let Some(fresh) = fresh else {
return self.refresh_diagram_lookup(
"This representation changed. Review the refreshed destinations.",
);
};
if self.reveal_diagram_usage(&fresh) {
self.diagram_usages = None;
self.status = format!(
"{} · {} in {}",
self.project().named(subject),
fresh.role,
self.project().named(fresh.surface.id())
);
return Task::batch(["model-explorer", "model-browser", "inspector"].map(
|id| {
iced::widget::operation::snap_to(
id,
iced::widget::scrollable::RelativeOffset::START,
)
},
));
}
}
Event::Open(_) | Event::Close(_) => unreachable!(),
}
Task::none()
}
pub(super) fn reveal_diagram_usage(&mut self, usage: &Usage) -> bool {
if !self.usage_navigation_available() {
return false;
}
let previous_layer = self.layer;
let p = self.document.project();
let (selected, placement) = match &usage.target {
Target::Whole => (usage.surface.id(), None),
Target::Placement(id) => {
let Some(node) = p
.diagrams
.get(&usage.surface.id())
.and_then(|d| d.nodes.get(id))
else {
return false;
};
(node.element, Some(*id))
}
Target::Port { port, placement } => (*port, Some(*placement)),
Target::StateRow {
placement,
relation,
} => (*relation, Some(*placement)),
Target::Edge(id)
| Target::ProcessLink(id)
| Target::ScenarioObject(id)
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessPreviewLink { relation: id, .. } => (*id, None),
Target::ProcessOccurrence(endpoint) => (
endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence),
None,
),
};
match usage.surface {
Surface::Saved(id) => {
let Some(diagram) = p.diagrams.get(&id) else {
return false;
};
self.layer = diagram.layer;
self.active_diagram = Some(id);
if let Some(machine) = diagram.projection.state_machine() {
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
(self.window_size.width - 610.).max(280.),
(self.window_size.height - 320.).max(240.),
));
let (zoom, pan) = usage::camera_for(&usage.target, usage.bounds, viewport);
self.behavior.diagram = behavior::state_view::State {
diagram: Some(id),
placement,
viewport: Some(viewport),
zoom,
pan,
fit: false,
..Default::default()
};
self.behavior.active_owner = Some(machine);
self.page = Page::Behavior;
} else {
let viewport = self.canvas_size.unwrap_or(iced::Size::new(
(self.window_size.width - 550.).max(280.),
(self.window_size.height - 284.).max(240.),
));
(self.zoom, self.pan) =
usage::camera_for(&usage.target, usage.bounds, viewport);
self.fit_to_view = false;
self.page = Page::Diagram;
}
}
Surface::Process(owner) => {
if !self.behavior.topology.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
Surface::Scenario(owner) => {
if !self.behavior.scenario.reveal_usage(
p,
self.canvas_document,
owner,
&usage.target,
) {
return false;
}
self.layer = p.elements[&owner].layer();
self.behavior.active_owner = Some(owner);
self.page = Page::Behavior;
}
}
self.selected = selected;
self.selected_node = placement;
self.model_menu = None;
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
self.selected_bend = None;
self.connection = None;
self.last_workspace_page = self.page;
if previous_layer != self.layer {
self.data_state = Default::default();
self.explorer_index = crate::explorer::Index::new(self.project(), self.layer);
self.explorer_focus = None;
self.explorer_group = None;
self.explorer_page = 0;
}
self.normalize_creation();
self.reveal_explorer_selection();
self.refresh_draft();
self.diagram_navigation_focus = Some(usage::Focus {
context: Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage: usage.clone(),
selected,
});
self.pending_diagram_reveal = Some((
Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: usage.subject,
},
usage.clone(),
self.canvas_epoch,
));
true
}
/// Validated presentation identity. Edit selection and Delete capture remain
/// semantic; a port owner or allocation work box is only a visual marker.
pub(crate) fn diagram_focus(&self, surface: Surface) -> Option<&Usage> {
let focus = self.diagram_navigation_focus.as_ref()?;
let current_surface = match surface {
Surface::Saved(id) => {
self.active_diagram == Some(id)
&& matches!(self.page, Page::Diagram | Page::Behavior)
}
Surface::Process(owner) | Surface::Scenario(owner) => {
self.page == Page::Behavior && self.behavior.active_owner == Some(owner)
}
};
(current_surface
&& focus.usage.surface == surface
&& focus.context.document == self.canvas_document
&& focus.context.revision == self.document.revision()
&& focus.selected == self.selected)
.then_some(&focus.usage)
}
}
File diff suppressed because it is too large Load Diff
-16
View File
@@ -213,14 +213,6 @@ impl App {
self.pending = None;
return Task::none();
}
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != request.connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection: request.connection.clone(),
resource_id: remote.resource_id,
@@ -353,14 +345,6 @@ impl App {
match self.document.apply_compiled((*remote.compiled).clone()) {
Ok(_) => {
self.document.mark_saved(remote.compiled.clone());
if self.path.is_some()
|| self.hosted.session.as_ref().is_none_or(|session| {
session.resource_id != remote.resource_id
|| session.connection.base() != connection.base()
})
{
self.source_workspace.reset_navigation_session();
}
self.hosted.session = Some(Session {
connection,
resource_id: remote.resource_id,
+20 -19
View File
@@ -569,30 +569,32 @@ mod tests {
assert!(!app.behavior.has_pending());
}
#[test]
fn direct_process_app_save_details_resize_and_passive_geometry_keep_drafts_and_camera() {
fn direct_process_app_save_scroll_resize_and_passive_geometry_keep_drafts_and_current_origin() {
let (mut app, f) = setup();
let context = app.topology_context().unwrap();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
behavior::canvas_workspace::Event::Pan(iced::Vector::new(25., 100.)),
)));
let context = app.topology_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context,
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
)));
for (area, offset) in [
(
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(25., 100.),
),
(
behavior::scenario::ScrollArea::Page,
iced::Vector::new(0., 40.),
),
] {
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Viewport(
context,
iced::Size::new(500., 300.),
)));
let camera = app.behavior.topology.camera_boundary();
assert_eq!(
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
assert_ne!(app.topology_context(), Some(context));
assert!(app.busy);
@@ -600,12 +602,12 @@ mod tests {
stale.document = Id::new_v4();
let _ = app.update(Message::TopologyCanvas(CanvasEvent::Scrolled(
stale,
behavior::scenario::ScrollArea::Page,
behavior::scenario::ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.topology.details_offset(),
iced::Vector::new(0., 40.)
app.behavior.topology.scroll_offset(),
iced::Vector::new(25., 140.)
);
let root = PathBuf::from("process-scroll-save-fixture");
let receipt = FileSnapshot {
@@ -625,7 +627,6 @@ mod tests {
assert!(!app.busy);
assert!(!app.document.can_undo());
assert_eq!(app.project(), &f.p);
assert!(app.behavior.topology.camera_boundary() == camera);
let context = app.topology_context().unwrap();
let size = app.window_size;
let _ = app.update(Message::WindowResized(size));
@@ -26,7 +26,7 @@ impl Host {
match self {
Self::Inspector => Message::FieldReferenceEvent(event),
Self::Review { address, facet } => {
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row {
address: *address,
facet: Some(facet.clone()),
event: RowEvent::ReferenceEvent(event),
@@ -210,15 +210,11 @@ impl App {
Message::FieldReferenceEvent(event) => {
Some(self.reference_event(Host::Inspector, event.clone()))
}
Message::Workflow(workflow::Event::Semantic(review_event)) => {
let semantic_review::Event::Row {
address,
facet: Some(facet),
event,
} = review_event.as_ref()
else {
return None;
};
Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row {
address,
facet: Some(facet),
event,
})) => {
let host = Host::Review {
address: *address,
facet: facet.clone(),
@@ -74,7 +74,7 @@ fn open(app: &mut App, host: &Host, path: &str) {
let message = match host {
Host::Inspector => Message::FieldReferenceOpen(request),
Host::Review { address, facet } => {
Message::Workflow(workflow::Event::semantic(semantic_review::Event::Row {
Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row {
address: *address,
facet: Some(facet.clone()),
event: RowEvent::ReferenceOpen(request),
@@ -125,7 +125,7 @@ fn review(app: &App) -> &semantic_review::State {
}
fn send_review(app: &mut App, event: RowEvent) {
let addressed = review(app).address_event(event);
let _ = app.update(Message::Workflow(workflow::Event::semantic(addressed)));
let _ = app.update(Message::Workflow(workflow::Event::Semantic(addressed)));
}
fn review_host(app: &App) -> Host {
let semantic_review::Event::Row {
@@ -306,13 +306,8 @@ fn chooser_rejects_old_results_after_context_recovery_reload_or_modal_and_keeps_
)));
}
_ => {
app.source_workspace.edit(
"unfinished source".into(),
iced::widget::text_editor::Cursor {
position: iced::widget::text_editor::Position { line: 0, column: 0 },
selection: None,
},
);
app.source_workspace
.edit("unfinished source".into(), Default::default());
}
}
let expected = raw(&app);
@@ -425,25 +420,6 @@ fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning
};
let mut app = App::with_project(p.clone());
let _ = app.update(Message::StartSemanticReview(Id::from_u128(100)));
if mode == 2 {
// A paired attachment is a real catalog facet only after explicit
// correspondence; selecting an unstaged synthetic pair is rejected.
for (id, oa) in [(103, true), (104, false)] {
let choice = crate::app::Choice {
id: Id::from_u128(id),
label: format!("Record · @object_{id}"),
};
send_review(
&mut app,
if oa {
RowEvent::ReferenceOa(choice)
} else {
RowEvent::ReferenceSa(choice)
},
);
}
send_review(&mut app, RowEvent::AddReference);
}
send_review(&mut app, RowEvent::Select(facet.clone()));
send_review(
&mut app,
@@ -455,19 +431,7 @@ fn review_reference_facets_groups_and_attachment_hosts_browse_without_replanning
} else {
Id::from_u128(101)
};
assert_eq!(
app.reference_draft(&host, path)
.unwrap_or_else(|| {
panic!(
"Reference fixture mode={mode}, facet={facet:?}, selected={:?}, decisions={:?}, error={:?}",
review(&app).selected,
review(&app).decisions,
review(&app).error
)
})
.original,
record
);
assert_eq!(app.reference_draft(&host, path).unwrap().original, record);
let before = review(&app).recovery_snapshot().unwrap();
let plan = serde_json::to_value(&review(&app).plan).unwrap();
let proposed = review(&app).proposed.clone();
@@ -563,7 +527,7 @@ fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_
}),
semantic_review::Event::OpenTrace,
] {
let _ = app.update(Message::Workflow(workflow::Event::semantic(event)));
let _ = app.update(Message::Workflow(workflow::Event::Semantic(event)));
}
assert_eq!(review(&app).target(app.project()), Some(Id::from_u128(111)));
let expected = review(&app).recovery_snapshot().unwrap();
@@ -572,7 +536,7 @@ fn review_reference_cannot_reopen_or_write_a_banked_row_after_another_row_takes_
let Host::Review { address, facet } = host else {
unreachable!()
};
let _ = app.update(Message::Workflow(workflow::Event::semantic(
let _ = app.update(Message::Workflow(workflow::Event::Semantic(
semantic_review::Event::Row {
address,
facet: Some(facet),
@@ -3,7 +3,7 @@
use super::*;
use iced::futures::StreamExt;
use iced::{
Element, Event as Input, Point, Size, Theme, Vector,
Event as Input, Point, Size, Theme, Vector,
advanced::{
Layout, Shell, layout,
widget::{Id as WidgetId, Operation, Tree, operation},
@@ -11,7 +11,6 @@ use iced::{
keyboard, mouse,
widget::{Space, column, container, opaque, scrollable, stack, text_input},
};
use oa_core::transitions::TransitionDirection;
#[derive(Default)]
struct Clipboard;
@@ -109,7 +108,7 @@ fn view(
text_input("Required engineering rationale", raw)
.id("review-rationale")
.on_input(|raw| {
Message::Workflow(workflow::Event::semantic(
Message::Workflow(workflow::Event::Semantic(
state.address_event(RowEvent::OperationRationale(raw)),
))
}),
@@ -119,7 +118,7 @@ fn view(
adapter::field(draft, app.project(), "/parent").map(|message| {
match message {
Message::FieldReferenceOpen(request) => {
Message::Workflow(workflow::Event::semantic(
Message::Workflow(workflow::Event::Semantic(
state.address_event(RowEvent::ReferenceOpen(request)),
))
}
@@ -128,7 +127,7 @@ fn view(
}),
draft.reference_session(),
));
opaque(container(trigger::modal(content)).width(300).height(180))
opaque(container(trigger::modal(content)).width(300).height(180)).into()
}
None => Space::new().into(),
}
@@ -724,7 +723,7 @@ async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input
let messages = mount.click(&app, Point::new(359., 319.));
assert!(matches!(
messages.as_slice(),
[Message::Workflow(workflow::Event::Semantic(event))] if matches!(event.as_ref(),
[Message::Workflow(workflow::Event::Semantic(
semantic_review::Event::Row {
event: RowEvent::ReferenceEvent(chooser::Event {
action: Action::Close(Close::Outside),
@@ -732,7 +731,7 @@ async fn mounted_review_pointer_open_and_outside_dismissal_keep_background_input
}),
..
}
)
))]
));
for message in messages {
let task = app.update(message);
@@ -760,34 +759,22 @@ async fn mounted_reverse_review_required_rationale_remains_editable_beside_candi
let sa = p
.elements
.values()
.find(|element| element.alias == "operation_probe_43")
.find(|element| {
element.layer() == Layer::Sa
&& matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
operation: Some(_),
message_kind: MessageKind::Call | MessageKind::Asynchronous,
..
},
..
})
)
})
.unwrap()
.id;
let allocation = p
.relations
.values()
.find(|relation| relation.alias == "operation_probe_30")
.unwrap()
.id;
// The boundary fixture deliberately has no selected operation. Author one
// through the validated command path before testing reverse omission.
let mut message = p.elements[&sa].clone();
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = message.shared_mut()
else {
panic!("The operation fixture must contain an Exchange call")
};
*operation = Some(OperationUse {
allocation: Some(allocation),
});
p = execute_batch(&p, &[Command::EditElement(sa, message)]).unwrap();
assert!(matches!(
oa_core::operations::resolve(&p, sa).unwrap(),
oa_core::operations::OperationResolution::Selected(value)
if value.allocation == allocation
));
let inspector_id = Id::new_v4();
element(
&mut p,
@@ -833,7 +820,7 @@ async fn mounted_reverse_review_required_rationale_remains_editable_beside_candi
let source = app.document.bundle().clone();
let inspector_raw = raw(&app);
let _ = app.update(Message::StartSemanticReview(trace_id));
let _ = app.update(Message::Workflow(workflow::Event::semantic(
let _ = app.update(Message::Workflow(workflow::Event::Semantic(
semantic_review::Event::Direction(TransitionDirection::SaToOa),
)));
let facet = FacetSelector::Header(HeaderFacet::Parent);
@@ -848,14 +835,7 @@ async fn mounted_reverse_review_required_rationale_remains_editable_beside_candi
let candidate = serde_json::to_value(
review(&app)
.reference_form(app.project())
.unwrap_or_else(|| {
panic!(
"Reverse review candidate: selected={:?}, decisions={:?}, error={:?}",
review(&app).selected,
review(&app).decisions,
review(&app).error
)
})
.unwrap()
.recovery_snapshot(),
)
.unwrap();
@@ -877,10 +857,10 @@ async fn mounted_reverse_review_required_rationale_remains_editable_beside_candi
);
assert!(messages.iter().all(|message| matches!(
message,
Message::Workflow(workflow::Event::Semantic(event)) if matches!(event.as_ref(), semantic_review::Event::Row {
Message::Workflow(workflow::Event::Semantic(semantic_review::Event::Row {
event: RowEvent::OperationRationale(_),
..
})
}))
)));
mount.dispatch(&mut app, messages).await;
assert_eq!(
@@ -1,263 +0,0 @@
use super::*;
const VALID: &str = concat!(
"arcadia 7;\r\n",
"project repair \"Repair 救援\" id \"9e8f0000-0000-4000-8000-000000000001\" {\r\n",
" oa_activity assess \"Assess\" id \"9e8f0000-0000-4000-8000-000000000002\" {}\r\n",
"}\r\n",
);
fn open(app: &mut App, path: &std::path::Path) {
let _ = app.update(Message::Loaded(Ok(Some(
read_snapshot(path.to_owned()).unwrap(),
))));
}
fn edit(app: &mut App, text: &str) {
let _ = app.update(Message::SourceEdit(text_editor::Action::SelectAll));
let _ = app.update(Message::SourceEdit(text_editor::Action::Edit(
text_editor::Edit::Paste(Arc::new(text.into())),
)));
}
fn save(app: &mut App, path: &std::path::Path) {
let receipt = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.to_owned(),
app.disk_project.clone(),
)
.unwrap();
let _ = app.update(Message::Saved(Ok(Some(receipt))));
}
#[test]
fn repair_to_previous_saved_bytes_requires_save_at_new_destination_and_guards_close() {
for extra in [
" include \"missing.arcadia\";\r\n",
" oa_activity assess \"Duplicate\" id \"9e8f0000-0000-4000-8000-000000000003\" {}\r\n",
] {
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 invalid = VALID.strip_suffix("}\r\n").unwrap().to_owned() + extra + "}\r\n";
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&second_path, &invalid).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
assert!(!app.dirty());
let before = app.document.snapshot();
let revision = app.document.revision();
open(&mut app, &second_path);
assert!(!app.source_workspace.applied);
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
edit(&mut app, VALID);
assert!(app.apply_source(), "{}", app.status);
assert!(app.source_workspace.applied);
assert!(!app.source_dirty());
assert_eq!(app.document.bundle(), &before.bundle);
assert_eq!(app.project(), &before.decoded.project);
assert_eq!(app.document.revision(), revision);
assert!(
!app.document.can_undo(),
"Equal content creates no history step"
);
assert!(app.document.dirty());
assert_eq!(app.hosted_label(), "Unsaved changes");
assert_eq!(std::fs::read(&second_path).unwrap(), invalid.as_bytes());
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
let close = window::Id::unique();
let task = app.update(Message::Request(Pending::Close(close)));
assert!(iced_runtime::task::into_stream(task).is_none());
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == close));
let _ = app.update(Message::Cancel);
save(&mut app, &second_path);
assert!(!app.dirty());
assert_eq!(app.hosted_label(), "All changes saved");
assert_eq!(std::fs::read(&second_path).unwrap(), VALID.as_bytes());
assert_eq!(std::fs::read(&first_path).unwrap(), VALID.as_bytes());
}
}
#[test]
fn repair_history_compares_to_new_disk_baseline_and_then_successful_save() {
let directory = tempfile::tempdir().unwrap();
let path = directory
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
std::fs::write(&path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &path);
let before = app.document.snapshot();
let invalid = format!("{VALID} unfinished {{");
std::fs::write(&path, &invalid).unwrap();
open(&mut app, &path);
let repaired = format!("{VALID}// Repaired rationale\r\n");
edit(&mut app, &repaired);
assert!(app.apply_source());
assert!(app.dirty());
let _ = app.update(Message::Undo);
assert_eq!(app.document.bundle(), &before.bundle);
assert!(
app.dirty(),
"Old saved bytes are not saved at the repair destination"
);
let _ = app.update(Message::Redo);
assert!(app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), invalid.as_bytes());
std::fs::write(&path, "external edit").unwrap();
let failed = write_snapshot(
app.canvas_document,
app.document.revision(),
app.document.snapshot(),
path.clone(),
app.disk_project.clone(),
)
.unwrap_err();
let _ = app.update(Message::Saved(Err(failed)));
assert!(app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), b"external edit");
std::fs::write(&path, &invalid).unwrap();
save(&mut app, &path);
assert!(!app.dirty());
let _ = app.update(Message::Undo);
assert!(app.dirty());
assert_eq!(app.document.bundle(), &before.bundle);
let _ = app.update(Message::Redo);
assert!(!app.dirty(), "Redo to the new saved snapshot is clean");
assert_eq!(std::fs::read(&path).unwrap(), repaired.as_bytes());
}
#[test]
fn valid_equal_content_at_distinct_destinations_stays_clean() {
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");
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&second_path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
let before = app.document.bundle().clone();
open(&mut app, &second_path);
assert_eq!(app.document.bundle(), &before);
assert_eq!(app.path.as_ref(), Some(&second_path));
assert!(!app.dirty());
assert_eq!(app.hosted_label(), "All changes saved");
}
#[test]
fn recovering_applied_repair_rebinds_rolled_back_disk_bytes_without_changing_model() {
let first = tempfile::tempdir().unwrap();
let second = tempfile::tempdir().unwrap();
let first_path = first.path().canonicalize().unwrap().join("project.arcadia");
let root = second.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
let before = VALID.replace("Repair 救援", "Before 救援");
std::fs::write(&first_path, VALID).unwrap();
std::fs::write(&path, VALID).unwrap();
let journal = root.join(".arcadia-save-project.arcadia.journal");
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":true,"write":true}]}"#,
)
.unwrap();
std::fs::write(journal.join("0.before"), &before).unwrap();
std::fs::write(journal.join("0.after"), VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &first_path);
let accepted = app.document.snapshot();
open(&mut app, &path);
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
assert!(!app.source_workspace.applied);
assert!(app.apply_source());
assert!(
!app.dirty(),
"Current bytes still match disk before rollback"
);
let _ = app.update(Message::SourceRecovered(recover_snapshot(path.clone())));
assert!(app.pending_recovery.is_none());
assert!(!journal.exists());
assert_eq!(std::fs::read(&path).unwrap(), before.as_bytes());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert!(!app.source_dirty());
assert!(app.dirty(), "Recovery changed disk, not the accepted model");
assert_eq!(app.hosted_label(), "Unsaved changes");
save(&mut app, &path);
assert!(!app.dirty());
assert_eq!(std::fs::read(&path).unwrap(), VALID.as_bytes());
}
#[test]
fn recovery_missing_entry_or_failed_reload_preserves_current_destination_save_authority() {
for (current_destination, failed_reload) in
[(true, false), (false, false), (true, true), (false, true)]
{
let directory = tempfile::tempdir().unwrap();
let root = directory.path().canonicalize().unwrap();
let path = root.join("project.arcadia");
std::fs::write(&path, VALID).unwrap();
let mut app = App::with_project(Project::empty());
open(&mut app, &path);
let accepted = app.document.snapshot();
let journal = root.join(".arcadia-save-project.arcadia.journal");
std::fs::create_dir(&journal).unwrap();
std::fs::write(
journal.join("journal.json"),
br#"{"version":1,"entry":"project.arcadia","files":[{"path":"project.arcadia","before":false,"write":true}]}"#,
)
.unwrap();
std::fs::write(journal.join("0.after"), VALID).unwrap();
open(&mut app, &path);
assert!(app.apply_source());
assert!(!app.dirty());
let destination = tempfile::tempdir().unwrap();
let saved_elsewhere = destination
.path()
.canonicalize()
.unwrap()
.join("project.arcadia");
if !current_destination {
save(&mut app, &saved_elsewhere);
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
assert_eq!(app.pending_recovery.as_ref(), Some(&path));
}
let mut receipt = recover_snapshot(path.clone()).unwrap();
assert!(
receipt.disk.as_ref().unwrap().bundle.files.is_empty(),
"Missing entry reloads as an empty repair workspace"
);
if failed_reload {
// Separately exercise the adapter's successful-recovery/failed-read
// boundary; first-save rollback itself does not cause a read error.
receipt.disk = Err("Injected post-recovery reload failure".into());
}
let _ = app.update(Message::SourceRecovered(Ok(receipt)));
assert!(!path.exists());
assert!(!journal.exists());
assert!(app.pending_recovery.is_none());
assert!(Arc::ptr_eq(&accepted, &app.document.snapshot()));
assert_eq!(app.dirty(), current_destination);
if current_destination {
let _ = app.update(Message::Request(Pending::New));
assert!(matches!(app.pending, Some(Pending::New)));
} else {
assert_eq!(app.path.as_ref(), Some(&saved_elsewhere));
assert_eq!(std::fs::read(&saved_elsewhere).unwrap(), VALID.as_bytes());
}
}
}
@@ -16,7 +16,7 @@ fn state(app: &App) -> &State {
}
fn row_event(app: &mut App, event: RowEvent) {
let event = state(app).address_event(event);
send(app, Message::Workflow(Event::semantic(event)));
send(app, Message::Workflow(Event::Semantic(event)));
}
fn fixture(reverse: bool, destination_view: bool) -> (App, Id, Id, Id, Option<Id>) {
let mut p = Project::empty();
-507
View File
@@ -1,507 +0,0 @@
//! Current draft navigation, isolated from model application and disk discovery.
use super::*;
use crate::source_navigation::{self as nav, query};
use std::sync::atomic::Ordering;
fn admission_notice(error: query::AdmissionError) -> String {
use nav::symbols::{BuildError, InputProblem, Resource};
let (limit, resource) = match error {
query::AdmissionError::Index(BuildError::Limit { limit, resource, .. }) => (limit, resource),
query::AdmissionError::Outline { error, .. } => (error.limit, match error.resource {
language::OutlineResource::SourceBytes => Resource::SourceBytes,
language::OutlineResource::Tokens => Resource::Tokens,
language::OutlineResource::Declarations => Resource::Records,
}),
query::AdmissionError::Index(BuildError::Input { problem, .. }) => return match problem {
InputProblem::NonCanonicalPath | InputProblem::DuplicateOrCaseCollidingPath =>
"Navigation needs distinct relative source paths. Check file names and include paths.".into(),
_ => "Navigation could not verify the current source locations. Correct source errors and try again.".into(),
},
};
let unit = match resource {
Resource::Files => "loaded files",
Resource::SourceBytes => "source bytes",
Resource::PathBytes => "bytes in file paths",
Resource::Tokens => "source tokens",
Resource::Records => "declarations and references",
};
format!(
"Navigation reached its limit of {limit} {unit}. Reduce the loaded source size and try again."
)
}
impl App {
pub fn source_symbol_context(&self) -> nav::SymbolContext {
nav::SymbolContext {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
}
}
pub fn source_symbol_message(&self, event: nav::SymbolEvent) -> Message {
Message::SourceSymbols(self.source_symbol_context(), event)
}
pub fn source_find_close_message(&self) -> Message {
Message::SourceFindClose(self.source_symbol_context(), self.source_find_instance)
}
pub fn source_key(&self) -> query::SnapshotKey {
query::SnapshotKey {
workspace: self.source_workspace.session(),
revision: self.source_workspace.revision(),
}
}
fn with_source_input<T>(&self, use_input: impl FnOnce(query::Input<'_>) -> T) -> T {
let files: Vec<_> = self
.source_workspace
.files
.iter()
.map(|(path, buffer)| query::DraftFile {
path,
text: &buffer.text,
})
.collect();
let accepted = self.document.bundle();
let validated: Vec<_> = accepted
.files
.iter()
.map(|(path, text)| query::DraftFile { path, text })
.collect();
use_input(query::Input {
key: self.source_key(),
entry: &self.source_workspace.entry,
files: &files,
applied: self.source_workspace.applied,
validation: self
.source_workspace
.applied
.then_some(query::ValidatedSources {
entry: &accepted.entry,
files: &validated,
}),
})
}
fn source_snapshot_matches(&self, snapshot: &query::Snapshot) -> bool {
snapshot.key() == self.source_key()
&& self.with_source_input(|input| snapshot.matches(input))
}
pub fn source_navigation_available(&self) -> bool {
self.page == Page::Text
&& !self.busy
&& self.pending.is_none()
&& self.file_prompt.is_none()
&& !self.delete_pending
&& self.workflow.is_none()
&& !self.hosted.visible
&& self.model_menu.is_none()
&& self.diagram_usages.is_none()
&& !self.reference_input_active()
&& self.architecture.is_none()
&& !self.behavior.has_pending()
}
/// Mounted widget IDs change when another interaction supersedes a reveal.
/// A queued focus operation therefore cannot find a subsequently reused editor.
pub fn source_editor_id(&self) -> iced::widget::Id {
iced::widget::Id::from(format!(
"source-editor-{}-{}-{}",
self.source_workspace.session(),
self.source_workspace.active,
self.source_symbols.interaction
))
}
pub fn source_reveal_ticket(&self) -> Option<Id> {
self.source_symbols
.reveal
.as_ref()
.map(|reveal| reveal.ticket)
}
pub(super) fn source_interaction(&mut self) {
self.source_symbols.interaction = Id::new_v4();
self.source_symbols.request = None;
self.source_symbols.reveal = None;
self.source_jump = None;
}
pub(super) fn queue_source_reveal(&mut self, cursor: text_editor::Cursor, select_line: bool) {
self.source_jump = Some(cursor);
self.source_select_line = select_line;
self.source_symbols.reveal = Some(nav::Reveal {
ticket: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
snapshot: self.source_key(),
path: self.source_workspace.active.clone(),
});
}
pub(super) fn restore_source_editor_focus(&mut self) {
let cursor = self.source_editor.cursor();
if self.source_navigation_available()
&& nav::exact_cursor(self.source_workspace.text(), cursor)
{
self.queue_source_reveal(cursor, false);
}
}
pub(super) fn source_viewport(&mut self, ticket: Option<Id>) -> Task<Message> {
let Some(reveal) = &self.source_symbols.reveal else {
return Task::none();
};
if Some(reveal.ticket) != ticket
|| reveal.interaction != self.source_symbols.interaction
|| !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
reveal.raw_interaction,
)
|| reveal.snapshot != self.source_key()
|| reveal.path != self.source_workspace.active
|| !self.source_navigation_available()
{
return Task::none();
}
let raw_interaction = reveal.raw_interaction;
self.source_symbols.reveal = None;
if let Some(cursor) = self.source_jump.take() {
if !nav::exact_cursor(self.source_workspace.text(), cursor) {
return Task::none();
}
self.source_editor.move_to(cursor);
if self.source_select_line && cursor.selection.is_none() {
self.source_editor.perform(text_editor::Action::SelectLine);
}
return nav::interaction::guarded_focus(
self.source_editor_id(),
self.source_symbols.raw_interaction.clone(),
raw_interaction,
);
}
Task::none()
}
pub(super) fn source_symbol_event(&mut self, event: nav::SymbolEvent) -> Task<Message> {
if !self.source_navigation_available() {
return Task::none();
}
match event {
nav::SymbolEvent::DiagramUsages => {
if self.source_dirty() || !self.source_workspace.applied {
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();
}
let task = self.start_source_query(query::Action::Definition);
self.source_diagram_job = self
.source_symbols
.request
.as_ref()
.map(|request| request.job);
return task;
}
nav::SymbolEvent::Close => {
self.source_symbols.open = false;
self.source_symbols.results = None;
self.source_symbols.notice = None;
}
nav::SymbolEvent::Back => {
self.source_symbol_back();
// A successful Back needs no empty results panel. Retain an
// already open panel, or reveal an actionable return notice.
self.source_symbols.open |= self.source_symbols.notice.is_some();
}
nav::SymbolEvent::Choose {
stamp,
reference,
index,
} => {
let Some(results) = self.source_symbols.results.clone() else {
return Task::none();
};
if results.stamp != stamp
|| stamp.snapshot != self.source_key()
|| self
.source_symbols
.cache
.as_ref()
.is_none_or(|cache| !self.source_snapshot_matches(cache))
{
self.source_symbols.notice =
Some("Source changed. Run navigation again for current locations.".into());
return Task::none();
}
let destination = if reference {
results
.references
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
} else {
results
.definitions
.get(index)
.map(|row| (&row.path, row.position, row.end_position))
};
if let Some((path, start, end)) = destination {
self.source_symbol_jump(path, start, end);
}
}
nav::SymbolEvent::Query(action) => return self.start_source_query(action),
}
Task::none()
}
fn start_source_query(&mut self, action: query::Action) -> Task<Message> {
self.source_symbols.open = true;
if !nav::interaction::is_current(
&self.source_symbols.raw_interaction,
self.source_symbols.raw_interaction.load(Ordering::SeqCst),
) {
self.source_symbols.notice = Some("Reopen this workspace to reset navigation.".into());
return Task::none();
}
if self.source_symbols.in_flight.is_some() {
self.source_symbols.notice = Some("Navigation is finishing. Try again shortly.".into());
return Task::none();
}
let cursor = self.source_editor.cursor();
let Some(offset) = nav::cursor_offset(self.source_workspace.text(), cursor) else {
self.source_symbols.notice =
Some("Place the cursor on an alias or its @ reference.".into());
return Task::none();
};
self.source_symbols.serial = match self.source_symbols.serial.checked_add(1) {
Some(serial) => serial,
None => {
self.source_workspace.reset_navigation_session();
self.source_symbols.back.clear();
self.source_symbols.cache = None;
0
}
};
let request = nav::Request {
job: Id::new_v4(),
interaction: self.source_symbols.interaction,
raw_interaction: self.source_symbols.raw_interaction.load(Ordering::SeqCst),
stamp: query::QueryStamp {
snapshot: self.source_key(),
serial: self.source_symbols.serial,
},
path: self.source_workspace.active.clone(),
offset,
cursor,
action,
};
let cache = self
.source_symbols
.cache
.as_ref()
.filter(|cache| self.source_snapshot_matches(cache))
.cloned();
let input = if cache.is_none() {
match self
.with_source_input(|input| query::admit_owned(input, query::Limits::default()))
{
Ok(input) => Some(input),
Err(error) => {
self.source_symbols.notice = Some(admission_notice(error));
return Task::none();
}
}
} else {
None
};
self.source_symbols.in_flight = Some(request.job);
self.source_symbols.request = Some(request.clone());
self.source_symbols.results = None;
self.source_symbols.notice = None;
Task::perform(
async move {
let worker_request = request.clone();
let result = tokio::task::spawn_blocking(move || {
let snapshot = match cache {
Some(cache) => cache,
None => Arc::new(
input
.expect("admitted input for uncached request")
.capture()
.map_err(admission_notice)?,
),
};
let results = snapshot
.query(query::Query {
stamp: worker_request.stamp,
path: &worker_request.path,
byte_offset: worker_request.offset,
action: worker_request.action,
})
.map_err(|error| match error {
query::QueryError::ResultEnvelopeLimit => "These result details exceed the display limit. Try literal Find for this alias.".to_owned(),
_ => "Navigation could not verify this location. Place the cursor on an alias and try again.".to_owned(),
})?;
Ok((snapshot, Arc::new(results)))
})
.await
.unwrap_or_else(|error| Err(format!("Navigation task failed: {error}")));
nav::Completion { request, result }
},
Message::SourceSymbolsReady,
)
}
pub(super) fn source_symbols_ready(&mut self, completion: nav::Completion) -> Task<Message> {
let request = completion.request;
if self.source_symbols.in_flight != Some(request.job) {
return Task::none();
}
self.source_symbols.in_flight = None;
let diagram_query = self.source_diagram_job == Some(request.job);
if diagram_query {
self.source_diagram_job = None;
}
let wanted = self
.source_symbols
.request
.take()
.is_some_and(|current| current.job == request.job)
&& request.interaction == self.source_symbols.interaction
&& request.stamp.snapshot == self.source_key()
&& nav::interaction::is_current(
&self.source_symbols.raw_interaction,
request.raw_interaction,
)
&& request.path == self.source_workspace.active
&& request.cursor == self.source_editor.cursor()
&& self.source_navigation_available();
if !wanted {
return Task::none();
}
match completion.result {
Ok((snapshot, results)) if self.source_snapshot_matches(&snapshot) => {
self.source_symbols.cache = Some(snapshot);
self.source_symbols.results = Some(results.clone());
if diagram_query {
if results.disposition == query::Disposition::Direct
&& results.scope.validated
&& results.omitted_definitions == 0
&& let [row] = results.definitions.as_slice()
&& let query::Identity::Unique(identity) = row.declaration.identity
&& row.membership == query::Membership::Included
&& row.evidence == query::Evidence::Validated
&& self.project().contains(identity.id())
&& crate::diagram_usage::alias(self.project(), identity.id())
== row.declaration.alias
{
self.source_symbols.open = false;
return self.diagram_usage_event(crate::diagram_usage::Event::Open(
crate::diagram_usage::Context {
document: self.canvas_document,
revision: self.document.revision(),
subject: identity.id(),
},
));
}
self.source_symbols.notice = Some("Place the caret on a uniquely resolved alias in applied source to find diagram representations.".into());
return Task::none();
}
if results.disposition == query::Disposition::Direct
&& let Some(row) = results.definitions.first()
{
self.source_symbol_jump(&row.path, row.position, row.end_position);
}
}
Ok(_) => {
self.source_symbols.notice = Some("Source changed. Run navigation again.".into())
}
Err(error) => self.source_symbols.notice = Some(error),
}
Task::none()
}
fn source_symbol_jump(&mut self, path: &str, start: query::Position, end: query::Position) {
let cursor = text_editor::Cursor {
position: text_editor::Position {
line: start.line,
column: start.byte_column,
},
selection: Some(text_editor::Position {
line: end.line,
column: end.byte_column,
}),
};
if self
.source_workspace
.files
.get(path)
.is_none_or(|buffer| !nav::exact_cursor(&buffer.text, cursor))
{
self.source_symbols.notice =
Some("That source location is no longer available. Run navigation again.".into());
return;
}
if let Some(buffer) = self
.source_workspace
.files
.get(&self.source_workspace.active)
{
let origin = nav::HistoryEntry {
session: self.source_workspace.session(),
path: self.source_workspace.active.clone(),
generation: buffer.generation(),
cursor: self.source_editor.cursor(),
};
self.source_symbols
.back
.retain(|entry| entry.session == origin.session);
if self.source_symbols.back.len() == 64 {
self.source_symbols.back.remove(0);
}
self.source_symbols.back.push(origin);
}
self.source_workspace
.switch(path, self.source_editor.cursor());
self.show_source_file(false);
self.queue_source_reveal(cursor, false);
}
fn source_symbol_back(&mut self) {
while let Some(origin) = self.source_symbols.back.pop() {
if origin.session != self.source_workspace.session() {
continue;
}
let Some(buffer) = self.source_workspace.files.get(&origin.path) else {
self.source_symbols.notice = Some("The previous file is no longer loaded.".into());
return;
};
let unchanged = origin.generation == buffer.generation();
let cursor = if unchanged {
origin.cursor
} else if origin.path == self.source_workspace.active {
self.source_editor.cursor()
} else {
buffer.cursor
};
if !nav::exact_cursor(&buffer.text, cursor) {
self.source_symbols.notice = Some(
"The previous location changed. Open the file to choose a current position."
.into(),
);
return;
}
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 =
(!unchanged).then(|| "This draft changed; its current cursor was restored.".into());
return;
}
self.source_symbols.notice = Some("No previous source location in this workspace.".into());
}
}
#[cfg(test)]
mod tests;
File diff suppressed because it is too large Load Diff
+261 -391
View File
@@ -20,6 +20,8 @@ mod contextual_source_tests;
pub(crate) mod creation;
mod form_session;
pub(crate) mod scenario;
// Projection acceptance precedes the coordinated scenario renderer integration.
#[cfg(test)]
mod scenario_lifecycle;
pub(crate) mod source_draft;
pub(crate) mod state_canvas;
@@ -391,96 +393,97 @@ mod scenario_scroll {
std::time::Instant::now(),
))
};
let area = ScrollArea::Page;
for returned_height in [600., 250.] {
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
let mut app = App::with_project(project.clone());
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let source = app.document.bundle().clone();
let mut view = observer(600., app.scenario_context().unwrap());
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
for message in update(&mut view, &mut tree, redraw()).0 {
let _ = app.update(message);
for area in [ScrollArea::Page] {
for returned_height in [600., 250.] {
let (project, [a, _, _], [use_id, _, _]) = navigation_fixture();
let mut app = App::with_project(project.clone());
let _ = app.update(Message::Select(a));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let source = app.document.bundle().clone();
let mut view = observer(600., app.scenario_context().unwrap());
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
for message in update(&mut view, &mut tree, redraw()).0 {
let _ = app.update(message);
}
for message in update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
}),
)
.0
{
let _ = app.update(message);
}
assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.));
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id));
view = observer(600., app.scenario_context().unwrap());
view.area = area;
view.diff(&mut tree);
for message in update(&mut view, &mut tree, redraw()).0 {
let _ = app.update(message);
}
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
navigation_event(&mut app, CanvasEvent::Back);
assert_eq!(app.selected, use_id);
let context = app.scenario_context().unwrap();
let request = app.behavior.scenario.scroll_restore(context, area).unwrap();
view = observer(returned_height, context);
view.area = area;
view.restore = Some(request);
view.diff(&mut tree);
let expected = Vector::new(0., (returned_height - 200.).min(180.));
let messages = update(&mut view, &mut tree, redraw()).0;
assert!(
matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected)
);
assert_eq!(actual_offset(&mut view, &mut tree), expected);
for message in messages {
let _ = app.update(message);
}
assert!(
app.behavior
.scenario
.scroll_restore(app.scenario_context().unwrap(), area)
.is_none()
);
// A delayed receipt leaves the same request in the view; later input must not snap back.
for message in update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. },
}),
)
.0
{
let _ = app.update(message);
}
let moved = expected - Vector::new(0., 20.);
assert_eq!(actual_offset(&mut view, &mut tree), moved);
view.context = app.scenario_context().unwrap();
view.diff(&mut tree);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
assert_eq!(actual_offset(&mut view, &mut tree), moved);
navigation_event(&mut app, |ctx| {
CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.))
});
let late_context = app.scenario_context().unwrap();
let _ = app.update(Message::Page(crate::app::Page::Diagram));
let epoch = app.canvas_epoch;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored(
late_context,
request.token,
area,
Vector::ZERO,
)));
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
for message in update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: -180. },
}),
)
.0
{
let _ = app.update(message);
}
assert_eq!(actual_offset(&mut view, &mut tree), Vector::new(0., 180.));
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, use_id));
view = observer(600., app.scenario_context().unwrap());
view.area = area;
view.diff(&mut tree);
for message in update(&mut view, &mut tree, redraw()).0 {
let _ = app.update(message);
}
assert_eq!(actual_offset(&mut view, &mut tree), Vector::ZERO);
navigation_event(&mut app, CanvasEvent::Back);
assert_eq!(app.selected, use_id);
let context = app.scenario_context().unwrap();
let request = app.behavior.scenario.scroll_restore(context, area).unwrap();
view = observer(returned_height, context);
view.area = area;
view.restore = Some(request);
view.diff(&mut tree);
let expected = Vector::new(0., (returned_height - 200.).min(180.));
let messages = update(&mut view, &mut tree, redraw()).0;
assert!(
matches!(messages.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Restored(_, _, _, actual))] if *actual == expected)
);
assert_eq!(actual_offset(&mut view, &mut tree), expected);
for message in messages {
let _ = app.update(message);
}
assert!(
app.behavior
.scenario
.scroll_restore(app.scenario_context().unwrap(), area)
.is_none()
);
// A delayed receipt leaves the same request in the view; later input must not snap back.
for message in update(
&mut view,
&mut tree,
iced::Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Pixels { x: 0., y: 20. },
}),
)
.0
{
let _ = app.update(message);
}
let moved = expected - Vector::new(0., 20.);
assert_eq!(actual_offset(&mut view, &mut tree), moved);
view.context = app.scenario_context().unwrap();
view.diff(&mut tree);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
assert_eq!(actual_offset(&mut view, &mut tree), moved);
navigation_event(&mut app, |ctx| {
CanvasEvent::Restored(ctx, request.token, area, Vector::new(0., 999.))
});
let late_context = app.scenario_context().unwrap();
let _ = app.update(Message::Page(crate::app::Page::Diagram));
let epoch = app.canvas_epoch;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Restored(
late_context,
request.token,
area,
Vector::ZERO,
)));
assert_eq!(app.canvas_epoch, epoch);
assert_eq!(app.project(), &project);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
}
#[test]
@@ -562,9 +565,9 @@ mod scenario_scroll {
other.scenario = Id::new_v4();
let mut state = topology::State::default();
state.enter(context.document, context.scenario);
state.scrolled(scenario::ScrollArea::Page, Vector::new(0., 180.));
state.scrolled(scenario::ScrollArea::Canvas, Vector::new(0., 180.));
state.enter(other.document, other.scenario);
assert_eq!(state.details_offset(), Vector::ZERO);
assert_eq!(state.scroll_offset(), Vector::ZERO);
view = observer(600., other);
view.process = true;
view.diff(&mut tree);
@@ -581,91 +584,92 @@ mod scenario_scroll {
{
use widget::operation::scrollable::{AbsoluteOffset, scroll_to};
for process in [false, true] {
let area = scenario::ScrollArea::Page;
for change in 0..4 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut view = observer(600., context);
view.process = process;
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
let node = view.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
// Model a programmatic reveal before the observer has reported.
// Mounted identity must not depend on its last scroll notification.
let mut operation = scroll_to::<()>(
widget::Id::new("observer-test"),
AbsoluteOffset {
x: None,
y: Some(180.),
},
);
view.operate(&mut tree, Layout::new(&node), &(), &mut operation);
let mut next = context;
match change {
0 => next.scenario = Id::new_v4(),
1 => next.document = Id::new_v4(),
2 => {
next.revision += 1;
next.epoch += 5;
for area in [scenario::ScrollArea::Canvas, scenario::ScrollArea::Page] {
for change in 0..4 {
let context = scenario::direct_tests::context(Id::new_v4());
let mut view = observer(600., context);
view.process = process;
view.area = area;
let mut tree = widget::Tree::new(&view as &dyn Widget<Message, Theme, ()>);
let node = view.layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(800., 700.)),
);
// Model a programmatic reveal before the observer has reported.
// Mounted identity must not depend on its last scroll notification.
let mut operation = scroll_to::<()>(
widget::Id::new("observer-test"),
AbsoluteOffset {
x: None,
y: Some(180.),
},
);
view.operate(&mut tree, Layout::new(&node), &(), &mut operation);
let mut next = context;
match change {
0 => next.scenario = Id::new_v4(),
1 => next.document = Id::new_v4(),
2 => {
next.revision += 1;
next.epoch += 5;
}
_ => {}
}
_ => {}
view = observer(if change == 3 { 800. } else { 600. }, next);
view.process = process;
view.area = area;
view.diff(&mut tree);
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let (messages, _) = update(&mut view, &mut tree, redraw());
let actual = messages
.into_iter()
.filter_map(|message| match message {
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(
(c.document, c.owner, c.revision, c.epoch),
(next.document, next.scenario, next.revision, next.epoch)
);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(c, next);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if change < 2 {
Vector::ZERO
} else {
Vector::new(0., 180.)
}],
"process={process} area={area:?} change={change}"
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
}
view = observer(if change == 3 { 800. } else { 600. }, next);
view.process = process;
view.area = area;
view.diff(&mut tree);
let redraw = || {
iced::Event::Window(iced::window::Event::RedrawRequested(
std::time::Instant::now(),
))
};
let (messages, _) = update(&mut view, &mut tree, redraw());
let actual = messages
.into_iter()
.filter_map(|message| match message {
Message::TopologyCanvas(topology::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(
(c.document, c.owner, c.revision, c.epoch),
(next.document, next.scenario, next.revision, next.epoch)
);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
Message::ScenarioCanvas(scenario::CanvasEvent::Scrolled(
c,
reported_area,
offset,
)) => {
assert_eq!(c, next);
assert_eq!(
std::mem::discriminant(&reported_area),
std::mem::discriminant(&area)
);
Some(offset)
}
_ => None,
})
.collect::<Vec<_>>();
assert_eq!(
actual,
vec![if change < 2 {
Vector::ZERO
} else {
Vector::new(0., 180.)
}],
"process={process} area={area:?} change={change}"
);
assert!(update(&mut view, &mut tree, redraw()).0.is_empty());
}
}
}
@@ -1124,31 +1128,6 @@ struct BoxItem {
detail: String,
marker: Option<Kind>,
}
fn box_text_clip(
pan: iced::Vector,
rect: Rectangle,
scale: f32,
viewport: Size,
) -> Option<Rectangle> {
if !pan.x.is_finite()
|| !pan.y.is_finite()
|| !scale.is_finite()
|| scale <= 0.
|| rect.width <= 20.
|| rect.height <= 16.
{
return None;
}
Rectangle::new(
Point::new(
pan.x + (rect.x + 10.) * scale,
pan.y + (rect.y + 8.) * scale,
),
Size::new((rect.width - 20.) * scale, (rect.height - 16.) * scale),
)
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
}
struct Line {
id: Id,
points: Vec<Point>,
@@ -1156,23 +1135,13 @@ struct Line {
label_at: Point,
dashed: bool,
}
impl Line {
fn label_bounds(&self) -> Rectangle {
Rectangle::new(
self.label_at,
Size::new(
(self.label.chars().count() as f32 * 6.8 + 8.).min(225.),
25.,
),
)
}
}
struct Scene {
boxes: Vec<BoxItem>,
lines: Vec<Line>,
size: Size,
selected: Id,
scale: f32,
scroll_offset: iced::Vector,
camera_pan: iced::Vector,
interaction: Option<(scenario::Context, scenario::CanvasTool, bool)>,
pending_placement: Option<(scenario::Slot, Option<Id>)>,
@@ -1181,7 +1150,6 @@ struct Scene {
rows: BTreeMap<Id, (Id, f32)>,
open_calls: Vec<Point>,
sequence_bounds: BTreeMap<Id, (f32, f32)>,
lifecycle: scenario_lifecycle::render::Rendering,
}
struct SequenceSlot {
slot: scenario::Slot,
@@ -1228,6 +1196,7 @@ impl Scene {
fn sequence(p: &Project, owner: Id, selected: Id) -> Self {
let mut scene = Self {
scale: 1.,
scroll_offset: iced::Vector::ZERO,
camera_pan: iced::Vector::ZERO,
boxes: vec![],
lines: vec![],
@@ -1240,7 +1209,6 @@ impl Scene {
rows: BTreeMap::new(),
open_calls: vec![],
sequence_bounds: BTreeMap::new(),
lifecycle: scenario_lifecycle::render::Rendering::default(),
};
let mut lanes = p
.children(owner)
@@ -1327,17 +1295,7 @@ impl Scene {
}
let mut y = if scenario { 148. } else { 120. };
scene.occurrences(p, owner, scenario, &lane_x, &mut y, 0);
scene.size.height = (y + 45.).max(210.);
if scenario {
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
p,
owner,
&lane_x,
&scene.rows,
&scene.sequence_bounds,
scene.size.height,
);
scene.lifecycle.align_messages(&mut scene.lines);
let calls: Vec<_> = scene
.rows
.keys()
@@ -1362,28 +1320,23 @@ impl Scene {
scene.activation_sequence(p, p.elements[&call].parent.unwrap(), call, x, None);
}
}
scene.size.height = (y + 45.).max(210.);
for (id, name, detail) in lanes {
let x = lane_x[&id];
if !scenario {
scene.lines.insert(
0,
Line {
id,
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
label: String::new(),
label_at: Point::ORIGIN,
dashed: false,
},
);
}
scene.lines.insert(
0,
Line {
id,
points: vec![Point::new(x, 85.), Point::new(x, scene.size.height - 20.)],
label: String::new(),
label_at: Point::ORIGIN,
dashed: false,
},
);
scene.boxes.push(BoxItem {
id,
rect: Rectangle::new(Point::new(x - 102., 15.), Size::new(204., 70.)),
title: if scenario {
format!("Lane · {name}")
} else {
name
},
title: name,
detail,
marker: None,
});
@@ -1425,16 +1378,6 @@ impl Scene {
let Some(SharedBody::Occurrence { body, .. }) = e.shared() else {
continue;
};
if scenario
&& matches!(body, OccurrenceBody::Exchange {
sender: Some(sender), receiver: Some(receiver),
message_kind: MessageKind::Create | MessageKind::Delete, ..
} if sender == receiver)
{
// Self lifecycle elbows and their caption need clearance from
// the preceding action/state/reference box, before recording rows.
*y += 16.;
}
if ordered {
let slot = scenario::Slot {
parent: owner,
@@ -1654,27 +1597,17 @@ impl Scene {
});
}
}
fn activation_segment(
&mut self,
call: Id,
scope: Id,
receiver: Id,
x: f32,
top: f32,
bottom: f32,
) {
for span in self.lifecycle.alive_spans(receiver, scope, top, bottom) {
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(
Point::new(x - 4., span.start),
Size::new(8., span.end - span.start),
),
title: String::new(),
detail: String::new(),
marker: None,
});
fn activation_segment(&mut self, call: Id, x: f32, top: f32, bottom: f32) {
if bottom <= top {
return;
}
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(Point::new(x - 4., top), Size::new(8., bottom - top)),
title: String::new(),
detail: String::new(),
marker: None,
});
}
/// Follow a single correlation through the ordered tree. Branches are drawn
/// as independent segments, never as one chronological stretch through ALT
@@ -1687,18 +1620,6 @@ impl Scene {
x: f32,
mut start: Option<f32>,
) -> bool {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
receiver: Some(receiver),
..
},
..
}) = p.elements[&call].shared()
else {
return false;
};
let receiver = *receiver;
let entered_open = start.is_some();
let mut split = false;
for id in p.elements[&owner].steps().into_iter().flatten() {
@@ -1712,21 +1633,6 @@ impl Scene {
continue;
};
match p.elements[id].shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
message_kind: MessageKind::Delete,
receiver: Some(deleted),
..
},
..
}) if *deleted == receiver => {
// This path cannot show activation beyond its local death.
// Keep the unresolved-call cue; a Delete is not a Reply.
self.activation_segment(call, owner, receiver, x, top, y);
self.open_calls.push(Point::new(x + 12., y - 16.));
start = None;
}
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
@@ -1736,19 +1642,14 @@ impl Scene {
},
..
}) if *correlation == call => {
self.activation_segment(call, owner, receiver, x, top, y);
for span in self.lifecycle.alive_spans(receiver, owner, y - 1., y + 1.) {
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(
Point::new(x - 7., span.start),
Size::new(14., span.end - span.start),
),
title: String::new(),
detail: String::new(),
marker: None,
});
}
self.activation_segment(call, x, top, y);
self.boxes.push(BoxItem {
id: call,
rect: Rectangle::new(Point::new(x - 7., y - 1.), Size::new(14., 2.)),
title: String::new(),
detail: String::new(),
marker: None,
});
start = None;
}
Some(SharedBody::Occurrence {
@@ -1756,7 +1657,7 @@ impl Scene {
..
}) => {
split = true;
self.activation_segment(call, owner, receiver, x, top, y);
self.activation_segment(call, x, top, y);
let bottom = self
.boxes
.iter()
@@ -1791,7 +1692,7 @@ impl Scene {
} else {
top + 26.
};
self.activation_segment(call, owner, receiver, x, top, bottom);
self.activation_segment(call, x, top, bottom);
self.open_calls.push(Point::new(x + 8., bottom - 7.));
}
start.is_some()
@@ -1824,12 +1725,6 @@ impl Scene {
))
}
fn hit(&self, point: Point) -> Option<Id> {
if !point.x.is_finite() || !point.y.is_finite() {
return None;
}
if let Some(id) = self.lifecycle.marker_at(point) {
return Some(id);
}
self.boxes
.iter()
.rev()
@@ -1842,10 +1737,23 @@ impl Scene {
.or_else(|| {
self.lines
.iter()
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
.find(|line| {
!line.label.is_empty()
&& (Rectangle::new(
line.label_at,
Size::new(
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
25.,
),
)
.contains(point)
|| line.points.windows(2).any(|p| {
crate::diagram::distance_to_segment(point, p[0], p[1])
< 8. / self.scale
}))
})
.map(|line| line.id)
})
.or_else(|| self.near_sequence_item(point))
.or_else(|| {
self.boxes
.iter()
@@ -1853,7 +1761,6 @@ impl Scene {
.find(|b| b.rect.contains(point) && b.title.is_empty())
.map(|b| b.id)
})
.or_else(|| self.lifecycle.lane_at(point, self.scale))
.or_else(|| self.lane_at(point))
.or_else(|| {
self.lines
@@ -1868,34 +1775,6 @@ impl Scene {
.map(|line| line.id)
})
}
fn near_sequence_item(&self, point: Point) -> Option<Id> {
if !self.scale.is_finite() || self.scale <= 0. {
return None;
}
let messages = self
.lines
.iter()
.filter(|line| !line.label.is_empty())
.map(|line| {
let distance = line
.points
.windows(2)
.map(|p| crate::diagram::distance_to_segment(point, p[0], p[1]))
.fold(f32::INFINITY, f32::min);
(line.id, distance)
});
// One distance comparison for both kinds of screen-sized hit tolerance.
// Dense minimum-Fit rows must not give the first message or last marker
// a larger hit priority than the visibly nearer item. UUID breaks ties.
self.lifecycle
.nearest_marker(point)
.into_iter()
.chain(messages)
.filter(|(_, distance)| *distance <= 8. / self.scale)
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
.map(|(id, _)| id)
}
}
impl canvas::Program<Message> for Scene {
type State = SequenceInput;
@@ -1929,7 +1808,8 @@ impl canvas::Program<Message> for Scene {
if !editable {
state.cancel();
}
// The viewport masks input outside its visible canvas bounds.
// Scrollable marks a pointer outside its visible viewport unavailable,
// even when it lies inside the full (scrolled) canvas rectangle.
let visible = cursor.position().is_some();
if !visible
&& matches!(
@@ -1952,7 +1832,7 @@ impl canvas::Program<Message> for Scene {
SequencePress::Occurrence { .. } => {}
}
}
mouse::Cursor::Available(*position)
mouse::Cursor::Available(*position + self.scroll_offset)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. })
| canvas::Event::Window(iced::window::Event::Resized(_))
@@ -1964,9 +1844,11 @@ impl canvas::Program<Message> for Scene {
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
) => state
.pointer
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position();
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
cursor
}
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
@@ -2003,7 +1885,7 @@ impl canvas::Program<Message> for Scene {
state.press = Some(SequencePress::Message {
source,
slot,
start: cursor.position()?,
start: cursor.position()? - self.scroll_offset,
first,
moved: false,
});
@@ -2032,7 +1914,7 @@ impl canvas::Program<Message> for Scene {
state.press = Some(SequencePress::Reorder {
occurrence,
parent: self.rows[&occurrence].0,
start: cursor.position()?,
start: cursor.position()? - self.scroll_offset,
moved: false,
});
return Some(Action::request_redraw().and_capture());
@@ -2157,7 +2039,6 @@ impl canvas::Program<Message> for Scene {
f.fill_rectangle(Point::ORIGIN, bounds.size(), Color::WHITE);
f.translate(self.camera_pan);
f.scale(self.scale);
self.lifecycle.draw_lanes(&mut f, self.selected);
for b in &self.boxes {
let outline = matches!(
b.marker,
@@ -2236,32 +2117,24 @@ impl canvas::Program<Message> for Scene {
}
}
for b in &self.boxes {
if let Some(clip) = box_text_clip(self.camera_pan, b.rect, self.scale, f.size()) {
// Clipped child frames start with an identity transform. Match
// the card's pan and scale, as for lifecycle Create labels.
f.with_clip(clip, |label| {
label.translate(self.camera_pan);
label.scale(self.scale);
label.fill_text(Text {
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
size: 13.into(),
color: if b.id == self.selected {
style::TEAL
} else {
style::INK
},
..Default::default()
});
if !b.detail.is_empty() {
label.fill_text(Text {
content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 39.),
size: 11.into(),
color: style::MUTED,
..Default::default()
});
}
f.fill_text(Text {
content: short(&b.title, ((b.rect.width - 25.) / 7.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 12.),
size: 13.into(),
color: if b.id == self.selected {
style::TEAL
} else {
style::INK
},
..Default::default()
});
if !b.detail.is_empty() {
f.fill_text(Text {
content: short(&b.detail, ((b.rect.width - 25.) / 6.) as usize),
position: Point::new(b.rect.x + 12., b.rect.y + 39.),
size: 11.into(),
color: style::MUTED,
..Default::default()
});
}
}
@@ -2274,8 +2147,6 @@ impl canvas::Program<Message> for Scene {
..Default::default()
});
}
self.lifecycle
.draw_markers(&mut f, self.selected, self.scale, self.camera_pan);
if let Some((context, tool, editable)) = self.interaction {
let active = editable && state.context == Some(context) && state.tool == tool;
let point = cursor.position_in(bounds).map(|p| {
@@ -2545,26 +2416,25 @@ mod direct_canvas_tests {
}
}
#[test]
fn direct_panned_canvas_maps_ordered_pointer_events_to_the_visible_slot() {
fn direct_scrolled_canvas_maps_raw_pointer_events_to_the_visible_slot() {
let (f, mut scene) = scene();
scene.camera_pan = iced::Vector::new(-35., -140.);
scene.scroll_offset = iced::Vector::new(35., 140.);
let bounds = Rectangle::new(Point::new(20., 230.), scene.size);
let slot = scene.slots.last().unwrap().slot;
let y = scene.slots.last().unwrap().y;
let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) + scene.camera_pan;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) + scene.camera_pan;
assert!(bounds.contains(a) && bounds.contains(b));
let a = Point::new(bounds.x + scene.lanes[&f.sender], bounds.y + y) - scene.scroll_offset;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], bounds.y + y) - scene.scroll_offset;
let mut state = SequenceInput::default();
// Batched pointer events use their own positions with the final cursor.
// Iced Scrollable passes raw event coordinates and a translated cursor.
for event in [
mouse::Event::CursorMoved { position: a },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::CursorMoved { position: b },
] {
send(&scene, &mut state, event, b, bounds);
send(&scene, &mut state, event, b + scene.scroll_offset, bounds);
}
assert!(
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot)
matches!(send(&scene, &mut state, mouse::Event::ButtonReleased(mouse::Button::Left), b + scene.scroll_offset, bounds), Some(CanvasEvent::Begin { sender, receiver, slot: actual, .. }) if sender == f.sender && receiver == f.receiver && actual == slot)
);
}
#[test]
@@ -17,7 +17,6 @@ pub fn keyboard_enabled(app: &crate::app::App) -> bool {
&& !app.delete_pending
&& app.pending.is_none()
&& app.model_menu.is_none()
&& app.diagram_usages.is_none()
&& !app.reference_input_active()
}
@@ -64,7 +63,7 @@ pub struct Scope {
pub epoch: u64,
}
impl Scope {
pub(crate) fn current(app: &App) -> Option<Self> {
pub fn current(app: &App) -> Option<Self> {
if let Some(context) = app.scenario_context() {
Some(Self {
document: context.document,
@@ -82,7 +81,7 @@ impl Scope {
})
}
}
pub(crate) fn input_owner(app: &App) -> Option<Self> {
pub fn input_owner(app: &App) -> Option<Self> {
if keyboard_enabled(app)
&& !app.behavior.has_pending()
&& !app.draft_dirty()
@@ -158,12 +158,14 @@ impl<Message: Clone, R: renderer::Renderer> Widget<Message, Theme, R> for Contro
if matches!(
event,
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left))
) && !layout
.bounds()
.intersection(viewport)
.is_some_and(|bounds| cursor.is_over(bounds))
{
state.focused = false;
) {
if !layout
.bounds()
.intersection(viewport)
.is_some_and(|bounds| cursor.is_over(bounds))
{
state.focused = false;
}
}
}
if matches!(event, Event::Window(iced::window::Event::Unfocused)) {
@@ -93,19 +93,16 @@ pub(crate) struct Mounted {
pub operations: usize,
}
impl Mounted {
pub async fn unshown() -> Self {
pub async fn new(app: &mut App) -> Self {
use iced::advanced::renderer::Headless;
let renderer = <iced::Renderer as Headless>::new(iced::Font::DEFAULT, 16.into(), None)
.await
.expect("production mounted tests require the supported WGPU headless renderer");
Self {
let mut mounted = Self {
renderer,
cache: Cache::new(),
operations: 0,
}
}
pub async fn new(app: &mut App) -> Self {
let mut mounted = Self::unshown().await;
};
mounted.settle(app).await;
mounted
}
@@ -265,8 +262,7 @@ impl Mounted {
mouse::Cursor::Unavailable,
);
self.cache = ui.into_cache();
Headless::screenshot(
&mut self.renderer,
self.renderer.screenshot(
Size::new(app.window_size.width as u32, app.window_size.height as u32),
1.,
crate::style::BG,
@@ -886,7 +886,7 @@ fn contextual_source_incomplete_recovered_kind_reports_error_without_panicking()
}
#[test]
fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_layer() {
fn contextual_source_old_empty_action_cannot_open_a_previous_owner_or_layer() {
use crate::app::{App, Message, Page};
let (mut p, a) = scenario_project(Layer::Oa, ScenarioViewpoint::OperationalActivity);
let b = p
@@ -898,32 +898,30 @@ fn contextual_source_empty_palette_lifeline_cannot_open_a_previous_owner_or_laye
app.behavior.active_owner = Some(a);
app.selected = a;
let context = app.scenario_context().unwrap();
let source = app.document.bundle().clone();
let begin = |context| scenario::CanvasEvent::BeginOccurrence {
context,
tool: scenario::Tool::Lifeline,
slot: scenario::Slot {
parent: a,
before: None,
},
lifeline: None,
};
app.behavior.active_owner = Some(b);
app.selected = b;
let _ = app.update(Message::ScenarioCanvas(begin(context)));
let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First(
context,
scenario::Tool::Lifeline,
)));
assert!(!app.behavior.has_pending());
assert_eq!(app.project(), &p);
app.behavior.active_owner = Some(a);
app.selected = a;
app.layer = Layer::Sa;
let _ = app.update(Message::ScenarioCanvas(begin(context)));
let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First(
context,
scenario::Tool::Lifeline,
)));
assert!(!app.behavior.has_pending());
app.layer = Layer::Oa;
let current = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(begin(current)));
let _ = app.update(Message::ScenarioCanvas(scenario::CanvasEvent::First(
current,
scenario::Tool::Lifeline,
)));
assert_eq!(app.behavior.pending_owner(), Some(a));
assert_eq!(app.project(), &p);
assert_eq!(app.document.bundle(), &source);
assert!(!app.document.can_undo());
}
+1 -6
View File
@@ -694,9 +694,7 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
Picker::Owner => d.owner,
Picker::Context => d.context,
};
let staged_owner = picker == Picker::Owner && d.source.label(selected).is_some();
let title = match picker {
Picker::Owner if staged_owner => "Or choose an existing participant",
Picker::Owner => "Owned by participant",
Picker::Context => "Scenario context · optional",
};
@@ -707,16 +705,13 @@ fn picker_view<'a, R: iced::advanced::text::Renderer + 'a>(
.padding(6)
]
.spacing(5);
if let Some(id) = selected
&& !staged_owner
{
if let Some(id) = selected {
out = out.push(text(format!("Selected: {}", reference_label(p, id))).size(12));
} else if picker == Picker::Context {
out = out.push(text("No context selected").size(12));
}
if options.is_empty() {
out = out.push(text(match picker {
Picker::Owner if staged_owner => "No existing participants match this search. The new owner above remains selected.",
Picker::Owner => "No matching participant. Refine the search or choose Create new owner.",
Picker::Context => "No matching context. Refine the search, or leave this optional reference empty.",
}).size(12));
+91 -120
View File
@@ -4,8 +4,6 @@ use super::topology::{occurrence, unique_batch};
use super::*;
use iced::widget::{Column, column, text_input};
use std::{cell::RefCell, collections::BTreeSet};
pub(crate) mod focus;
mod usage;
mod workspace;
pub(crate) use workspace::NAME_INPUT;
pub use workspace::{Shelf, view};
@@ -97,6 +95,7 @@ impl std::fmt::Display for CanvasTool {
}
#[derive(Debug, Clone)]
pub enum CanvasEvent {
First(Context, Tool),
Scrolled(Context, ScrollArea, iced::Vector),
Restored(Context, Id, ScrollArea, iced::Vector),
Viewport(Context, Size),
@@ -130,13 +129,15 @@ pub enum CanvasEvent {
}
#[derive(Debug, Clone, Copy)]
pub enum ScrollArea {
// The independent Details/outline scroller; viewport motion uses Camera.
Canvas,
// Retained navigation channel, now the independent Selection/outline shelf.
Page,
}
impl CanvasEvent {
pub fn context(&self) -> Context {
match self {
Self::Scrolled(context, _, _)
Self::First(context, _)
| Self::Scrolled(context, _, _)
| Self::Restored(context, ..)
| Self::Viewport(context, _)
| Self::FormShown(context, _)
@@ -205,13 +206,12 @@ const VALUE_PAGE_SIZE: usize = 24;
pub struct State {
draft: Option<Draft>,
form_session: Id,
form_mount: Id,
form_focus_pending: bool,
canvas_tool: Option<(Id, CanvasTool)>,
call_choices: RefCell<Option<(CallChoiceContext, Vec<Choice>)>>,
details_offset: iced::Vector,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<Caller>,
restore: Option<DetailsRestore>,
restore: Option<ViewportRestore>,
presentation: workspace::Presentation,
}
const CALLER_LIMIT: usize = 32;
@@ -221,14 +221,15 @@ pub(crate) struct Caller {
pub occurrence: Id,
target: Id,
tool: CanvasTool,
details_offset: iced::Vector,
offsets: [iced::Vector; 2],
presentation: workspace::Presentation,
}
struct DetailsRestore {
struct ViewportRestore {
document: Id,
owner: Id,
token: Id,
offset: iced::Vector,
offsets: [iced::Vector; 2],
pending: [bool; 2],
}
#[derive(Clone, Copy)]
pub(super) struct ScrollRestore {
@@ -319,11 +320,7 @@ impl State {
self.draft.as_ref().is_some_and(|d| d.source.is_open())
}
pub fn source_back(&mut self) -> bool {
let returned = self.draft.as_mut().is_some_and(|d| d.source.back());
if returned {
self.remount_form();
}
returned
self.draft.as_mut().is_some_and(|d| d.source.back())
}
pub(crate) fn enter(&mut self, document: Id, owner: Id) {
@@ -333,7 +330,7 @@ impl State {
focused: self.presentation.focused,
..Default::default()
};
self.details_offset = iced::Vector::ZERO;
self.scroll_offsets = [iced::Vector::ZERO; 2];
self.restore = None;
}
}
@@ -393,11 +390,11 @@ impl State {
occurrence,
target,
tool: self.canvas_tool(context.scenario),
details_offset: self.details_offset,
offsets: self.scroll_offsets,
presentation: self.presentation.clone(),
});
self.canvas_tool = Some((target, CanvasTool::Select));
self.details_offset = iced::Vector::ZERO;
self.scroll_offsets = [iced::Vector::ZERO; 2];
self.restore = None;
Ok(target)
}
@@ -424,30 +421,34 @@ impl State {
return;
}
self.canvas_tool = Some((frame.owner, frame.tool));
self.details_offset = frame.details_offset;
self.scroll_offsets = frame.offsets;
self.presentation = frame.presentation;
self.presentation.identity = Some((context.document, context.scenario));
self.restore = Some(DetailsRestore {
self.restore = Some(ViewportRestore {
document: context.document,
owner: frame.owner,
token: Id::new_v4(),
offset: frame.details_offset,
offsets: frame.offsets,
pending: [false, true],
});
}
pub(super) fn scroll_restore(
&self,
context: Context,
ScrollArea::Page: ScrollArea,
area: ScrollArea,
) -> Option<ScrollRestore> {
let restore = self
.restore
.as_ref()
.filter(|r| r.document == context.document && r.owner == context.scenario)?;
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
};
let restore = self.restore.as_ref().filter(|r| {
r.document == context.document && r.owner == context.scenario && r.pending[index]
})?;
Some(ScrollRestore {
document: restore.document,
owner: restore.owner,
token: restore.token,
offset: restore.offset,
offset: restore.offsets[index],
})
}
pub(crate) fn restored(
@@ -457,12 +458,22 @@ impl State {
area: ScrollArea,
offset: iced::Vector,
) -> bool {
if !self.restore.as_ref().is_some_and(|r| {
r.document == context.document && r.owner == context.scenario && r.token == token
}) {
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
};
let Some(restore) = self.restore.as_mut().filter(|r| {
r.document == context.document
&& r.owner == context.scenario
&& r.token == token
&& r.pending[index]
}) else {
return false;
};
restore.pending[index] = false;
if !restore.pending.contains(&true) {
self.restore = None;
}
self.restore = None;
self.scrolled(area, offset)
}
pub(super) fn recovery_snapshot(&self) -> Result<Option<serde_json::Value>, String> {
@@ -475,11 +486,9 @@ impl State {
pub(super) fn from_recovery(value: serde_json::Value) -> Result<Self, String> {
let draft = serde_json::from_value(value).map_err(|e| e.to_string())?;
let session = Id::new_v4();
Ok(Self {
draft: Some(draft),
form_session: session,
form_mount: session,
form_session: Id::new_v4(),
form_focus_pending: true,
..Default::default()
})
@@ -489,28 +498,8 @@ impl State {
self.draft.as_ref().map(|_| self.form_session)
}
pub(crate) fn form_mount(&self) -> Option<Id> {
self.draft
.as_ref()
.filter(|d| !d.source.is_open())
.map(|_| self.form_mount)
}
pub(crate) fn focus_stamp(&self, context: Context) -> Option<focus::Stamp> {
(self.draft.as_ref()?.owner == context.scenario).then_some(focus::Stamp::new(
context,
self.form_session,
self.form_mount()?,
))
}
fn remount_form(&mut self) {
self.form_mount = Id::new_v4();
self.form_focus_pending = true;
}
pub(crate) fn take_form_focus(&mut self, mount: Id) -> bool {
if self.form_mount() == Some(mount) && self.form_focus_pending {
pub(crate) fn take_form_focus(&mut self, session: Id) -> bool {
if self.form_session() == Some(session) && self.form_focus_pending {
self.form_focus_pending = false;
true
} else {
@@ -518,14 +507,23 @@ impl State {
}
}
pub(crate) fn scrolled(&mut self, ScrollArea::Page: ScrollArea, offset: iced::Vector) -> bool {
pub(crate) fn scrolled(&mut self, area: ScrollArea, offset: iced::Vector) -> bool {
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
return false;
}
let changed = self.details_offset != offset;
self.details_offset = offset;
let index = match area {
ScrollArea::Canvas => 0,
ScrollArea::Page => 1,
};
let changed = self.scroll_offsets[index] != offset;
self.scroll_offsets[index] = offset;
changed
}
#[cfg(test)]
fn scroll_offset(&self) -> iced::Vector {
// The former page scroll now belongs to a sibling outline shelf.
self.scroll_offsets[0]
}
fn call_choices(&self, p: &Project, context: Context, draft: &Draft) -> Vec<Choice> {
let key = CallChoiceContext {
document: context.document,
@@ -675,7 +673,6 @@ impl State {
.source
.dismiss(&event)?
{
self.remount_form();
return Ok(None);
}
let d = self
@@ -716,11 +713,7 @@ impl State {
next.target = Some(id);
next.command(p)?;
}
let returned = d.source.is_open() && !next.source.is_open();
self.draft = Some(next);
if returned {
self.remount_form();
}
return Ok(None);
}
if self.source_open() && !matches!(&event, Event::Cancel) {
@@ -736,7 +729,6 @@ impl State {
return Err("Choose a scenario.".into());
}
self.form_session = Id::new_v4();
self.form_mount = self.form_session;
self.form_focus_pending = true;
self.draft = Some(Draft {
source: Default::default(),
@@ -2053,31 +2045,20 @@ pub(crate) mod direct_tests {
navigation_event(&mut app, |ctx| {
CanvasEvent::Choose(ctx, CanvasTool::Add(Tool::Reuse))
});
navigation_event(&mut app, |ctx| CanvasEvent::Zoom(ctx, None));
let camera_epoch = app.camera_epoch;
navigation_event(&mut app, |ctx| {
CanvasEvent::Camera(
ctx,
camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(25., 170.)),
)
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(25., 170.))
});
navigation_event(&mut app, |ctx| {
CanvasEvent::Scrolled(ctx, ScrollArea::Page, iced::Vector::new(0., 80.))
});
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, caller));
assert_eq!(app.selected, b);
assert_eq!(app.behavior.scenario.presentation.pan, iced::Vector::ZERO);
assert_eq!(app.behavior.scenario.scroll_offset(), iced::Vector::ZERO);
navigation_event(&mut app, |ctx| {
CanvasEvent::Choose(ctx, CanvasTool::Message(MessageStyle::Timer))
});
let camera_epoch = app.camera_epoch;
navigation_event(&mut app, |ctx| {
CanvasEvent::Camera(
ctx,
camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 115.)),
)
CanvasEvent::Scrolled(ctx, ScrollArea::Canvas, iced::Vector::new(0., 115.))
});
navigation_event(&mut app, |ctx| CanvasEvent::OpenUse(ctx, nested));
assert_eq!(app.selected, c);
@@ -2088,7 +2069,7 @@ pub(crate) mod direct_tests {
CanvasTool::Message(MessageStyle::Timer)
);
assert_eq!(
app.behavior.scenario.presentation.pan,
app.behavior.scenario.scroll_offset(),
iced::Vector::new(0., 115.)
);
navigation_event(&mut app, CanvasEvent::Back);
@@ -2099,11 +2080,11 @@ pub(crate) mod direct_tests {
CanvasTool::Add(Tool::Reuse)
);
assert_eq!(
app.behavior.scenario.presentation.pan,
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 170.)
);
assert_eq!(
app.behavior.scenario.details_offset,
app.behavior.scenario.scroll_offsets[1],
iced::Vector::new(0., 80.),
"The caller's independent outline scroll is restored separately"
);
@@ -2281,24 +2262,22 @@ pub(crate) mod direct_tests {
let before = app.document.bundle().clone();
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
scene.interaction = Some((context, CanvasTool::Add(tool), true));
scene.camera_pan = iced::Vector::new(23., 80.);
let bounds = Rectangle::new(
Point::new(20., 40.),
Size::new(scene.size.width + 46., scene.size.height + 160.),
);
scene.scroll_offset = iced::Vector::new(23., 80.);
let bounds = Rectangle::new(Point::new(20., 40.), scene.size);
let y = scene.slots.iter().find(|s| s.slot == slot).unwrap().y;
let x = if on_lifeline {
scene.lanes[&f.sender]
} else {
scene.size.width - 35.
};
let point = Point::new(bounds.x + x, bounds.y + y) + scene.camera_pan;
assert!(bounds.contains(point));
let point = Point::new(bounds.x + x, bounds.y + y);
let mut input = SequenceInput::default();
canvas_mouse(
&scene,
&mut input,
mouse::Event::CursorMoved { position: point },
mouse::Event::CursorMoved {
position: point - scene.scroll_offset,
},
point,
bounds,
);
@@ -2474,10 +2453,10 @@ pub(crate) mod direct_tests {
assert!(!app.behavior.has_pending());
}
let old_context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
old_context,
app.camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(0., 100.)),
ScrollArea::Canvas,
iced::Vector::new(0., 100.),
)));
let _ = app.update(Message::ScenarioCanvas(event(old_context)));
assert!(!app.behavior.has_pending());
@@ -2668,29 +2647,26 @@ pub(crate) mod direct_tests {
);
}
#[test]
fn direct_busy_save_keeps_details_current_and_camera_for_the_next_pointer_gesture() {
fn direct_busy_save_keeps_observed_scroll_current_for_the_next_pointer_gesture() {
let f = fixture();
let mut app = App::with_project(f.project.clone());
let _ = app.update(Message::Select(f.owner));
let _ = app.update(Message::Page(crate::app::Page::Behavior));
let context = app.scenario_context().unwrap();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Camera(
context,
app.camera_epoch,
super::super::canvas_workspace::Event::Pan(iced::Vector::new(-25., -100.)),
)));
let context = app.scenario_context().unwrap();
let original = app.document.snapshot();
app.busy = true;
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context,
ScrollArea::Page,
iced::Vector::new(0., 40.),
)));
for (area, offset) in [
(ScrollArea::Canvas, iced::Vector::new(25., 100.)),
(ScrollArea::Page, iced::Vector::new(0., 40.)),
] {
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
context, area, offset,
)));
}
assert!(app.busy);
assert_eq!(
app.behavior.scenario.details_offset,
iced::Vector::new(0., 40.)
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 100.)
);
assert_ne!(
app.scenario_context(),
@@ -2701,14 +2677,14 @@ pub(crate) mod direct_tests {
stale.document = Id::new_v4();
let _ = app.update(Message::ScenarioCanvas(CanvasEvent::Scrolled(
stale,
ScrollArea::Page,
ScrollArea::Canvas,
iced::Vector::new(0., 900.),
)));
assert_eq!(
app.behavior.scenario.details_offset,
iced::Vector::new(0., 40.)
app.behavior.scenario.scroll_offset(),
iced::Vector::new(25., 100.)
);
let root = std::path::PathBuf::from("scenario-details-save-fixture");
let root = std::path::PathBuf::from("scenario-scroll-save-fixture");
let receipt = crate::app::FileSnapshot {
document: context.document,
revision: context.revision,
@@ -2729,17 +2705,12 @@ pub(crate) mod direct_tests {
let context = app.scenario_context().unwrap();
let mut scene = Scene::sequence(app.project(), f.owner, f.call);
scene.interaction = Some((context, CanvasTool::Message(MessageStyle::CallReply), true));
assert_eq!(
app.behavior.scenario.presentation.pan,
iced::Vector::new(-25., -100.)
);
scene.camera_pan = app.behavior.scenario.presentation.pan;
scene.scroll_offset = app.behavior.scenario.scroll_offset();
let slot = scene.slots.last().unwrap().slot;
let bounds = Rectangle::new(Point::new(20., 250.), scene.size);
let y = bounds.y + scene.slots.last().unwrap().y;
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) + scene.camera_pan;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) + scene.camera_pan;
assert!(bounds.contains(a) && bounds.contains(b));
let a = Point::new(bounds.x + scene.lanes[&f.sender], y) - scene.scroll_offset;
let b = Point::new(bounds.x + scene.lanes[&f.receiver], y) - scene.scroll_offset;
let mut input = super::super::SequenceInput::default();
let mut published = vec![];
for event in [
@@ -2753,7 +2724,7 @@ pub(crate) mod direct_tests {
&mut input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(b),
mouse::Cursor::Available(b + scene.scroll_offset),
)
.and_then(|action| action.into_inner().0)
{
@@ -1,334 +0,0 @@
//! A delayed focus request must still own the live parent tree when executed.
//! Collect first: a missing target must not unfocus unrelated fields.
use super::Context;
use iced::advanced::widget::{Id, Operation, Tree, operation};
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer};
use iced::{Element, Event, Length, Rectangle, Size, Task, Theme, Vector, mouse};
use oa_core::Id as ModelId;
use std::collections::BTreeMap;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Stamp {
document: ModelId,
owner: ModelId,
revision: u64,
session: ModelId,
mount: ModelId,
}
impl Stamp {
pub(crate) fn new(context: Context, session: ModelId, mount: ModelId) -> Self {
Self {
document: context.document,
owner: context.scenario,
revision: context.revision,
session,
mount,
}
}
}
pub(crate) fn name_id(stamp: Stamp) -> Id {
Id::from(format!(
"{}:{}:{}:{}:{}:{}",
super::NAME_INPUT,
stamp.document,
stamp.owner,
stamp.revision,
stamp.session,
stamp.mount
))
}
struct Owner(Option<Stamp>);
struct Root<'a, Message, R: renderer::Renderer> {
content: Element<'a, Message, Theme, R>,
owner: Option<Stamp>,
}
pub(crate) fn root<'a, Message: 'a, R: renderer::Renderer + 'a>(
content: impl Into<Element<'a, Message, Theme, R>>,
owner: Option<Stamp>,
) -> Element<'a, Message, Theme, R> {
Element::new(Root {
content: content.into(),
owner,
})
}
impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Root<'_, Message, R> {
fn children(&self) -> Vec<Tree> {
vec![Tree::new(self.content.as_widget())]
}
fn diff(&self, tree: &mut Tree) {
tree.diff_children(std::slice::from_ref(&self.content));
}
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn layout(&mut self, tree: &mut Tree, renderer: &R, limits: &layout::Limits) -> layout::Node {
self.content
.as_widget_mut()
.layout(&mut tree.children[0], renderer, limits)
}
fn operate(
&mut self,
tree: &mut Tree,
layout: Layout<'_>,
renderer: &R,
op: &mut dyn Operation,
) {
// This is computed from the current App view, never retained widget state.
op.custom(None, layout.bounds(), &mut Owner(self.owner));
self.content
.as_widget_mut()
.operate(&mut tree.children[0], layout, renderer, op);
}
fn update(
&mut self,
tree: &mut Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &R,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
self.content.as_widget_mut().update(
&mut tree.children[0],
event,
layout,
cursor,
renderer,
clipboard,
shell,
viewport,
);
}
fn draw(
&self,
tree: &Tree,
renderer: &mut R,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content.as_widget().draw(
&tree.children[0],
renderer,
theme,
style,
layout,
cursor,
viewport,
);
}
fn mouse_interaction(
&self,
tree: &Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &R,
) -> mouse::Interaction {
self.content.as_widget().mouse_interaction(
&tree.children[0],
layout,
cursor,
viewport,
renderer,
)
}
fn overlay<'b>(
&'b mut self,
tree: &'b mut Tree,
layout: Layout<'b>,
renderer: &R,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'b, Message, Theme, R>> {
self.content.as_widget_mut().overlay(
&mut tree.children[0],
layout,
renderer,
viewport,
translation,
)
}
}
#[derive(Clone)]
struct Scroll {
index: usize,
bounds: Rectangle,
capacity: Vector,
offset: Vector,
}
struct Collect {
stamp: Stamp,
valid: bool,
translation: Vector,
scrolls: Vec<Scroll>,
pending: Option<Scroll>,
index: usize,
target: Option<(Rectangle, Vec<Scroll>)>,
}
pub(crate) fn task<Message: Send + 'static>(stamp: Stamp) -> Task<Message> {
iced::advanced::widget::operate(Collect {
stamp,
valid: false,
translation: Vector::ZERO,
scrolls: vec![],
pending: None,
index: 0,
target: None,
})
.discard()
}
impl Operation for Collect {
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
if let Some(owner) = state.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.stamp);
}
}
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
let previous = (self.translation, self.scrolls.len());
if let Some(scroll) = self.pending.take() {
self.translation += scroll.offset;
self.scrolls.push(scroll);
}
visit(self);
self.translation = previous.0;
self.scrolls.truncate(previous.1);
}
fn scrollable(
&mut self,
_: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn operation::Scrollable,
) {
self.pending = Some(Scroll {
index: self.index,
bounds: translated(bounds, self.translation),
capacity: Vector::new(
(content.width - bounds.width).max(0.),
(content.height - bounds.height).max(0.),
),
offset,
});
self.index += 1;
}
fn focusable(&mut self, id: Option<&Id>, bounds: Rectangle, _: &mut dyn operation::Focusable) {
if id == Some(&name_id(self.stamp)) {
self.target = Some((translated(bounds, self.translation), self.scrolls.clone()));
}
}
fn finish(&self) -> operation::Outcome<()> {
if !self.valid {
return operation::Outcome::None;
}
let Some((mut bounds, ancestors)) = self.target.clone() else {
return operation::Outcome::None;
};
let mut shifts = BTreeMap::new();
for ancestor in ancestors.iter().rev() {
let dx = reveal(
bounds.x,
bounds.width,
ancestor.bounds.x,
ancestor.bounds.width,
);
let dy = reveal(
bounds.y,
bounds.height,
ancestor.bounds.y,
ancestor.bounds.height,
);
// Drawing offsets are rounded; an already-visible axis must not
// acquire a scroll delta from a fractional capacity alone.
let delta = Vector::new(
if dx == 0. {
0.
} else {
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
},
if dy == 0. {
0.
} else {
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
},
);
if delta != Vector::ZERO {
shifts.insert(ancestor.index, delta);
}
bounds = translated(bounds, delta);
}
operation::Outcome::Chain(Box::new(Apply {
stamp: self.stamp,
valid: false,
shifts,
index: 0,
}))
}
}
fn translated(bounds: Rectangle, delta: Vector) -> Rectangle {
Rectangle {
x: bounds.x - delta.x,
y: bounds.y - delta.y,
..bounds
}
}
fn reveal(start: f32, length: f32, viewport: f32, size: f32) -> f32 {
if start < viewport || length > size {
start - viewport
} else {
(start + length - viewport - size).max(0.)
}
}
struct Apply {
stamp: Stamp,
valid: bool,
shifts: BTreeMap<usize, Vector>,
index: usize,
}
impl Operation for Apply {
fn custom(&mut self, _: Option<&Id>, _: Rectangle, state: &mut dyn std::any::Any) {
if let Some(owner) = state.downcast_ref::<Owner>() {
self.valid = owner.0 == Some(self.stamp);
}
}
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn Operation)) {
visit(self);
}
fn focusable(&mut self, id: Option<&Id>, _: Rectangle, state: &mut dyn operation::Focusable) {
if self.valid {
if id == Some(&name_id(self.stamp)) {
state.focus();
} else {
state.unfocus();
}
}
}
fn scrollable(
&mut self,
_: Option<&Id>,
bounds: Rectangle,
content: Rectangle,
_: Vector,
state: &mut dyn operation::Scrollable,
) {
if self.valid
&& let Some(delta) = self.shifts.get(&self.index)
{
state.scroll_by(
operation::scrollable::AbsoluteOffset {
x: delta.x,
y: delta.y,
},
bounds,
content,
);
}
self.index += 1;
}
}
@@ -1,207 +0,0 @@
//! Navigation is derived from the same opaque-reference scene that is painted.
use super::*;
use crate::diagram_usage::{Surface, Target, Usage};
/// The renderer collapses whitespace before shortening. Require at least one
/// actual character of this field, rather than an ellipsis at its start.
fn label_field_visible(full: &str, prefix: &str, value: &str) -> bool {
if value.trim().is_empty() {
return false;
}
let normalized = full.split_whitespace().collect::<Vec<_>>().join(" ");
let normalized_prefix = prefix.split_whitespace().collect::<Vec<_>>().join(" ");
let start = normalized_prefix.len()
+ usize::from(prefix.ends_with(char::is_whitespace) && !normalized_prefix.is_empty());
let painted = crate::views::short(full, 32);
let retained = normalized
.chars()
.zip(painted.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>();
start < retained
}
fn usages(p: &Project, owner: Id, subject: Id, scene: &Scene) -> Vec<Usage> {
let mut result = Vec::new();
let mut add = |id, bounds, role: &str| {
// Activation bars share the occurrence identity with a labelled message;
// they must not inflate its target count.
if !result
.iter()
.any(|u: &Usage| u.target == Target::ScenarioObject(id))
{
result.push(Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::ScenarioObject(id),
role: role.into(),
bounds,
});
}
};
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
let Some(element) = p.elements.get(&item.id) else {
continue;
};
if subject == item.id {
// A fragment's selectable header is useful even when its frame is
// much larger than the viewport.
let mut bounds = item.rect;
if item.marker == Some(Kind::Occurrence) {
bounds.height = bounds.height.min(38.);
}
add(item.id, bounds, "scenario object");
}
let represented = match element.shared() {
Some(SharedBody::Lifeline { represented }) => represented.map(|id| (id, "lifeline")),
Some(SharedBody::Occurrence { body, .. }) => match body {
OccurrenceBody::Action { action, .. } => action.map(|id| (id, "action occurrence")),
OccurrenceBody::State { state, .. } => state.map(|id| (id, "state observation")),
OccurrenceBody::SequenceReference { sequence } => {
sequence.map(|id| (id, "process reference"))
}
OccurrenceBody::ScenarioUse { scenario, .. } => {
scenario.map(|id| (id, "scenario reference"))
}
// An incomplete message fallback box only paints the occurrence
// name and a repair hint, not its exchange or operation.
_ => None,
},
_ => None,
};
if let Some((id, role)) = represented.filter(|(id, _)| *id == subject) {
let _ = id;
add(item.id, item.rect, role);
}
}
for line in scene.lines.iter().filter(|line| !line.label.is_empty()) {
let Some(element) = p.elements.get(&line.id) else {
continue;
};
let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = element.shared()
else {
continue;
};
let bounds = line.label_bounds();
if subject == line.id || *exchange == Some(subject) {
add(line.id, bounds, "message occurrence");
}
// Replies derive their operation from the exact call, matching the
// operation label formatter rather than scanning raw reference fields.
if let Ok(operations::OperationResolution::Selected(operation)) =
operations::resolve(p, line.id)
&& let Some(label) = crate::data_values::operation_label(p, element)
&& let Some(prefix) = line.label.strip_suffix(&label)
{
let allocation = &p.relations[&operation.allocation];
let operation_prefix = if matches!(
element.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
..
},
..
})
) {
"Reply operation from Call: "
} else {
"Operation: "
};
let fields = [
(allocation.source(), format!("{prefix}{operation_prefix}")),
(
allocation.target(),
format!(
"{prefix}{operation_prefix}{} / ",
p.named(allocation.source())
),
),
(
allocation.id,
format!(
"{prefix}{operation_prefix}{} / {} · ",
p.named(allocation.source()),
p.named(allocation.target())
),
),
];
for (id, prefix) in fields {
if id == subject && label_field_visible(&line.label, &prefix, p.named(id)) {
add(line.id, bounds, "operation label");
}
}
}
}
if subject == owner {
result.insert(
0,
Usage {
subject,
surface: Surface::Scenario(owner),
target: Target::Whole,
role: "scenario".into(),
bounds: Rectangle::new(Point::ORIGIN, scene.size),
},
);
}
result
}
impl State {
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
if !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return vec![];
}
usages(p, owner, subject, &Scene::sequence(p, owner, subject))
}
pub(crate) fn reveal_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
target: &Target,
) -> bool {
if self.draft.is_some()
|| !p
.elements
.get(&owner)
.is_some_and(|e| e.kind() == Kind::Scenario)
{
return false;
}
let id = match target {
Target::Whole => owner,
Target::ScenarioObject(id) => *id,
_ => return false,
};
let Some(usage) = self
.diagram_usages(p, owner, id)
.into_iter()
.find(|u| &u.target == target)
else {
return false;
};
// Preserve an existing mounted viewport across enter's owner reset.
let viewport = self.presentation.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
self.presentation.viewport = Some(viewport);
self.canvas_tool = Some((owner, CanvasTool::Select));
let (zoom, pan) = crate::diagram_usage::camera_for(target, usage.bounds, viewport);
self.presentation.zoom(Some(zoom));
self.presentation.pan = pan;
true
}
}
#[cfg(test)]
mod tests;
@@ -1,276 +0,0 @@
use super::*;
fn add_occurrence(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let layer = p.elements[&owner].layer();
let id = p
.add_element(Kind::Occurrence, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
if !steps.contains(&id) {
steps.push(id);
}
}
id
}
fn targets(state: &State, p: &Project, owner: Id, subject: Id) -> BTreeSet<Id> {
state
.diagram_usages(p, owner, subject)
.into_iter()
.filter_map(|u| match u.target {
Target::ScenarioObject(id) => Some(id),
_ => None,
})
.collect()
}
#[test]
fn scenario_usage_counts_nested_occurrences_and_lifelines_but_keeps_reuse_opaque() {
for (layer, work_kind, participant_kind) in [
(Layer::Oa, Kind::Activity, Kind::Entity),
(Layer::Sa, Kind::Function, Kind::SystemActor),
] {
let mut p = Project::empty();
let owner = p
.add_element(Kind::Scenario, layer, "Caller", None)
.unwrap();
let other = p
.add_element(Kind::Scenario, layer, "Definition", None)
.unwrap();
let work = p.add_element(work_kind, layer, "Respond", None).unwrap();
let unused = p.add_element(work_kind, layer, "Respond", None).unwrap();
let participant = p
.add_element(participant_kind, layer, "Operator", None)
.unwrap();
let lanes = ["First role", "Second role"].map(|name| {
let id = p
.add_element(Kind::Lifeline, layer, name, Some(owner))
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(participant),
};
id
});
let first = add_occurrence(
&mut p,
owner,
"First action",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[0]),
},
);
let fragment = add_occurrence(
&mut p,
owner,
"Decision",
OccurrenceBody::Fragment {
operator: FragmentOperator::Alternative,
branches: vec![],
},
);
let branch = add_occurrence(
&mut p,
fragment,
"When ready",
OccurrenceBody::Branch {
guard: "ready".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
let nested = add_occurrence(
&mut p,
branch,
"Again",
OccurrenceBody::Action {
action: Some(work),
lifeline: Some(lanes[1]),
},
);
let inside = add_occurrence(
&mut p,
other,
"Hidden inside definition",
OccurrenceBody::Action {
action: Some(unused),
lifeline: None,
},
);
let use_a = add_occurrence(
&mut p,
owner,
"Use A",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let use_b = add_occurrence(
&mut p,
owner,
"Use B",
OccurrenceBody::ScenarioUse {
scenario: Some(other),
bindings: vec![],
},
);
let before = p.clone();
let state = State::default();
assert_eq!(
targets(&state, &p, owner, work),
BTreeSet::from([first, nested])
);
assert_eq!(
targets(&state, &p, owner, participant),
BTreeSet::from(lanes)
);
assert_eq!(
targets(&state, &p, owner, other),
BTreeSet::from([use_a, use_b])
);
assert!(targets(&state, &p, owner, unused).is_empty());
assert!(targets(&state, &p, owner, inside).is_empty());
assert_eq!(targets(&state, &p, owner, branch), BTreeSet::from([branch]));
assert_eq!(
state.diagram_usages(&p, owner, owner)[0].target,
Target::Whole
);
assert_eq!(p, before);
}
}
#[test]
fn scenario_usage_keeps_repeated_messages_and_reveal_is_read_only_and_revalidated() {
let f = super::super::direct_tests::fixture();
let mut p = f.project;
let mut state = State::default();
assert_eq!(
targets(&state, &p, f.owner, f.exchange),
BTreeSet::from([f.call, f.reply])
);
let before = p.clone();
let target = Target::ScenarioObject(f.reply);
state.viewport(Size::new(900., 500.));
assert!(state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
let usage = state
.diagram_usages(&p, f.owner, f.reply)
.into_iter()
.find(|u| u.target == target)
.unwrap();
let zoom = state
.presentation
.scale(Scene::sequence(&p, f.owner, f.reply).size);
let centered = Point::new(
usage.bounds.center_x() * zoom,
usage.bounds.center_y() * zoom,
) + state.presentation.pan;
assert!((centered.x - 450.).abs() < 0.01 && (centered.y - 250.).abs() < 0.01);
assert!(zoom >= 0.5);
assert_eq!(p, before);
// Keeping an orphan semantic occurrence does not keep its painted target.
p.elements
.get_mut(&f.owner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != f.reply);
let camera = state.camera_boundary();
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &target));
assert!(camera == state.camera_boundary());
assert!(!state.reveal_usage(&p, Id::new_v4(), f.owner, &Target::Edge(f.exchange)));
// A message whose endpoints are incomplete paints only its repair box.
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { receiver, .. },
..
}) = p.elements.get_mut(&f.call).unwrap().shared_mut()
{
*receiver = None;
}
assert!(targets(&state, &p, f.owner, f.exchange).is_empty());
assert_eq!(
targets(&state, &p, f.owner, f.call),
BTreeSet::from([f.call])
);
}
#[test]
fn scenario_operation_label_membership_excludes_ellipsis_only_and_normalizes_whitespace() {
assert!(label_field_visible(
"1. call · X · Operation: I / O",
"1. call · X · Operation: ",
"I"
));
assert!(!label_field_visible(
"1234567890123456789012345678901Ixyz",
"1234567890123456789012345678901",
"Ixyz"
));
assert!(!label_field_visible(
"A very long occurrence label · Operation: I",
"A very long occurrence label · Operation: ",
"I"
));
assert!(label_field_visible(
"call\n\t· Operation: 界",
"call\n\t· Operation: ",
"界"
));
assert!(!label_field_visible("Operation: ", "Operation: ", ""));
}
#[test]
fn scenario_usage_only_reports_the_resolved_operation_fields_actually_in_the_label() {
let mut p = persistence::decode(include_bytes!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../harness/fixtures/interface-operation-probe/output/boundary-only.json"
)))
.unwrap();
let id = |n| Id::parse_str(&format!("52060906-0000-4000-8000-{n:012x}")).unwrap();
let (owner, call, exchange, allocation) = (id(40), id(43), id(34), id(30));
let (interface, item) = (
p.relations[&allocation].source(),
p.relations[&allocation].target(),
);
for (id, name) in [(call, "x"), (interface, "I"), (item, "O")] {
p.elements.get_mut(&id).unwrap().name = name.into();
}
p.relations.get_mut(&exchange).unwrap().name = "x".into();
p.relations.get_mut(&allocation).unwrap().name = "A".into();
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { operation, .. },
..
}) = p.elements.get_mut(&call).unwrap().shared_mut()
{
*operation = Some(OperationUse {
allocation: Some(allocation),
});
}
assert!(matches!(
operations::resolve(&p, call),
Ok(operations::OperationResolution::Selected(_))
));
let state = State::default();
let before = p.clone();
assert_eq!(
targets(&state, &p, owner, interface),
BTreeSet::from([call])
);
assert_eq!(targets(&state, &p, owner, item), BTreeSet::from([call]));
assert!(targets(&state, &p, owner, allocation).is_empty());
assert_eq!(p, before);
p.elements.get_mut(&call).unwrap().name = "This occurrence has a deliberately long name".into();
assert!(targets(&state, &p, owner, interface).is_empty());
assert!(targets(&state, &p, owner, item).is_empty());
}
@@ -1,5 +1,5 @@
//! Scenario presentation. Layout/camera controls never construct model commands.
use super::super::{form_session, scenario_scroll};
use super::super::scenario_scroll;
use super::*;
use iced::widget::{Space, column, sensor};
@@ -7,6 +7,7 @@ pub(crate) const NAME_INPUT: &str = "scenario-form-name";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
Tools,
Selection,
}
@@ -279,9 +280,7 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
let mut form = column![
text(d.tool.label()).size(16),
text_input("Name (defaults to selected source)", &d.name)
.id(focus::name_id(
state.focus_stamp(context).expect("visible parent form")
))
.id(NAME_INPUT)
.on_input(|s| event(Event::Name(s)))
.padding(9)
.size(12)
@@ -560,10 +559,9 @@ pub fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Element<'a
]
.spacing(7);
let session = state.form_session().expect("visible scenario draft");
let mount = state.form_mount().expect("visible parent form");
form_panel = sensor(editor_panel(session, footer.into(), form.into()))
.key(mount)
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, mount)))
.key(session)
.on_show(move |_| Message::ScenarioCanvas(CanvasEvent::FormShown(context, session)))
.into();
}
}
@@ -594,7 +592,6 @@ fn details_pane<'a, R: iced::advanced::text::Renderer + 'a>(
scenario_scroll::observe(
context,
ScrollArea::Page,
false,
restore,
scrollable(details)
.id("scenario-details")
@@ -611,7 +608,7 @@ fn editor_panel<'a, R: iced::advanced::text::Renderer + 'a>(
container(
column![
container(actions).id("scenario-actions").width(Fill),
form_session::reset_on_change(
super::super::form_session::reset_on_change(
session,
scrollable(fields)
.id("scenario-form")
File diff suppressed because it is too large Load Diff
@@ -2,8 +2,6 @@
use oa_core::{FragmentOperator, Id, Kind, MessageKind, OccurrenceBody, Project, SharedBody};
use std::collections::{BTreeMap, BTreeSet};
pub(super) mod render;
/// Maps already recorded by the sequence renderer. Rows carry their owning sequence.
pub(crate) struct Geometry<'a> {
pub rows: &'a BTreeMap<Id, (Id, f32)>,
@@ -1,392 +0,0 @@
//! Scene bridge for local lifetime geometry; no authoring or model mutations.
use super::{Geometry, LaneLifetime, Life, Marker, MarkerKind, Span, project_lifetimes};
use crate::{behavior::Line, style, views::short};
use iced::{
Color, Point, Rectangle, Size,
widget::canvas::{self, Frame, Path, Stroke, Text},
};
use oa_core::{Id, OccurrenceBody, Project, SharedBody};
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq)]
struct EventMarker {
event: Marker,
lifeline: Id,
center: Point,
capsule: Rectangle,
name: String,
sender_x: Option<f32>,
}
#[derive(Debug, Default, Clone, PartialEq)]
pub(in crate::behavior) struct Rendering {
// Complete rendering positions, including warning-admitted unassigned lanes.
// Scene.lanes independently retains the existing authoring eligibility rule.
lanes: BTreeMap<Id, f32>,
span: Option<Span>,
lifetimes: Vec<LaneLifetime>,
markers: Vec<EventMarker>,
}
impl Rendering {
pub(in crate::behavior) fn new(
project: &Project,
owner: Id,
lanes: &BTreeMap<Id, f32>,
rows: &BTreeMap<Id, (Id, f32)>,
insertion_scopes: &BTreeMap<Id, (f32, f32)>,
height: f32,
) -> Self {
let span = Span {
start: 85.,
end: height - 20.,
};
if !span.valid() {
return Self::default();
}
// Insertion scopes deliberately start later and end earlier. Empty and
// Create-only scenarios still have the full visible lifetime extent.
let mut scopes = insertion_scopes.clone();
for (scope, (start, end)) in &mut scopes {
if *scope != owner && *start == *end && start.is_finite() {
// An empty operand has 42 px between its insertion axis and
// frame end. Match the existing 26 px open-activation allowance.
*end = *start + 26.;
}
}
scopes.insert(owner, (span.start, span.end));
let lifetimes = project_lifetimes(
project,
owner,
&Geometry {
rows,
scopes: &scopes,
},
);
let lanes: BTreeMap<_, _> = lanes
.iter()
.filter(|(_, x)| x.is_finite())
.map(|(id, x)| (*id, *x))
.collect();
let mut markers = Vec::new();
for lane in &lifetimes {
let Some(&x) = lanes.get(&lane.lifeline) else {
continue;
};
for event in &lane.markers {
let sender_x = project
.elements
.get(&event.occurrence)
.and_then(|e| e.shared())
.and_then(|body| match body {
SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
sender: Some(sender),
..
},
..
} => lanes.get(sender).copied(),
_ => None,
});
markers.push(EventMarker {
event: *event,
lifeline: lane.lifeline,
center: Point::new(x, event.y),
capsule: Rectangle::new(
Point::new(x - 70., event.y - 14.),
Size::new(140., 28.),
),
name: project.named(lane.lifeline).to_owned(),
sender_x,
});
}
}
Self {
lanes,
span: Some(span),
lifetimes,
markers,
}
}
pub(in crate::behavior) fn align_messages(&self, lines: &mut [Line]) {
for marker in &self.markers {
let Some(sender) = marker.sender_x else {
continue;
};
let Some(line) = lines
.iter_mut()
.find(|line| line.id == marker.event.occurrence)
else {
continue;
};
let x = marker.center.x;
let y = marker.center.y;
let self_message = sender == x;
let end_x = match marker.event.kind {
MarkerKind::Birth if sender < x => marker.capsule.x,
MarkerKind::Birth => marker.capsule.x + marker.capsule.width,
MarkerKind::Death => x,
};
line.points = if self_message {
// The receiver is on the recorded occurrence row, including self
// lifecycle events. The elbow stays outside the birth capsule.
let elbow = x + 112.;
line.label_at = Point::new(x + 12., y - 52.);
vec![
Point::new(x, y - 24.),
Point::new(elbow, y - 24.),
Point::new(elbow, y),
Point::new(end_x, y),
]
} else {
if marker.event.kind == MarkerKind::Birth {
// Keep the label above the capsule, including right-to-left
// messages whose existing label starts beside the receiver.
line.label_at.y = y - 40.;
}
vec![Point::new(sender, y), Point::new(end_x, y)]
};
}
}
pub(in crate::behavior) fn alive_spans(
&self,
lifeline: Id,
scope: Id,
top: f32,
bottom: f32,
) -> Vec<Span> {
if !top.is_finite() || !bottom.is_finite() || bottom <= top {
return vec![];
}
self.lifetimes
.iter()
.find(|lane| lane.lifeline == lifeline)
.into_iter()
.flat_map(|lane| &lane.intervals)
.filter(|interval| interval.scope == scope && interval.life == Life::Alive)
.filter_map(|interval| {
let start = top.max(interval.span.start);
let end = bottom.min(interval.span.end);
(start < end).then_some(Span { start, end })
})
.collect()
}
pub(in crate::behavior) fn marker_at(&self, point: Point) -> Option<Id> {
self.markers
.iter()
.rev()
.find(|marker| match marker.event.kind {
MarkerKind::Birth => marker.capsule.contains(point),
MarkerKind::Death => marker_distance(marker, point) <= 1.25,
})
.map(|marker| marker.event.occurrence)
}
pub(in crate::behavior) fn nearest_marker(&self, point: Point) -> Option<(Id, f32)> {
if !point.x.is_finite() || !point.y.is_finite() {
return None;
}
// Exact visible shapes are tested first by Scene. A low-zoom tolerance
// must not outrank another marker's actual capsule, a header, or a label.
self.markers
.iter()
.map(|marker| (marker.event.occurrence, marker_distance(marker, point)))
.min_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)))
}
pub(in crate::behavior) fn lane_at(&self, point: Point, scale: f32) -> Option<Id> {
let span = self.span?;
if !scale.is_finite() || scale <= 0. || point.y < span.start || point.y > span.end {
return None;
}
self.lanes
.iter()
.filter(|(_, x)| (**x - point.x).abs() < 12. / scale)
.min_by(|a, b| (a.1 - point.x).abs().total_cmp(&(b.1 - point.x).abs()))
.map(|(id, _)| *id)
}
pub(in crate::behavior) fn draw_lanes(&self, frame: &mut Frame, selected: Id) {
let Some(span) = self.span else {
return;
};
for (&id, &x) in &self.lanes {
frame.stroke(
&Path::line(Point::new(x, span.start), Point::new(x, span.end)),
Stroke {
line_dash: canvas::LineDash {
segments: &[2., 5.],
offset: 0,
},
..Stroke::default()
.with_width(1.)
.with_color(if id == selected {
Color::from_rgb8(148, 190, 187)
} else {
Color::from_rgb8(214, 222, 222)
})
},
);
}
for lifetime in &self.lifetimes {
let Some(&x) = self.lanes.get(&lifetime.lifeline) else {
continue;
};
for interval in &lifetime.intervals {
match interval.life {
Life::Alive => frame.stroke(
&Path::line(
Point::new(x, interval.span.start),
Point::new(x, interval.span.end),
),
Stroke::default().with_width(2.).with_color(
if lifetime.lifeline == selected {
style::TEAL
} else {
Color::from_rgb8(91, 113, 113)
},
),
),
Life::Unresolved if interval.span.end - interval.span.start >= 16. => {
frame.fill_text(Text {
content: "?".into(),
position: Point::new(x + 9., interval.span.start + 2.),
size: 11.into(),
color: style::MUTED,
..Default::default()
});
}
_ => {}
}
}
}
if !self.lanes.is_empty() {
frame.fill_text(Text {
content:
"Dotted: editing guide · Solid: local lifetime · ?: lifetime depends on path"
.into(),
position: Point::new(28., span.end + 4.),
size: 10.into(),
color: style::MUTED,
..Default::default()
});
}
}
pub(in crate::behavior) fn draw_markers(
&self,
frame: &mut Frame,
selected: Id,
scale: f32,
pan: iced::Vector,
) {
for marker in &self.markers {
let color = if selected == marker.event.occurrence || selected == marker.lifeline {
style::TEAL
} else {
style::INK
};
match marker.event.kind {
MarkerKind::Birth => {
let path = Path::rounded_rectangle(
marker.capsule.position(),
marker.capsule.size(),
6.into(),
);
frame.fill(&path, Color::from_rgb8(243, 248, 248));
frame.stroke(
&path,
Stroke::default().with_color(color).with_width(
if selected == marker.event.occurrence {
2.5
} else {
1.5
},
),
);
if let Some(clip) = birth_label_clip(pan, marker.capsule, scale, frame.size()) {
// WGPU clipped frames start with an identity transform;
// neither the parent's pan/scale nor its clip is inherited.
frame.with_clip(clip, |label| {
label.translate(pan);
label.scale(scale);
label.fill_text(Text {
content: short(&marker.name, 18),
position: marker.capsule.position() + iced::Vector::new(8., 6.),
size: 12.into(),
color,
..Default::default()
});
});
}
}
MarkerKind::Death => {
for (a, b) in death_strokes(marker.center) {
frame.stroke(
&Path::line(a, b),
Stroke::default().with_color(color).with_width(2.5),
);
}
}
}
}
}
}
fn birth_label_clip(
pan: iced::Vector,
capsule: Rectangle,
scale: f32,
viewport: Size,
) -> Option<Rectangle> {
if !pan.x.is_finite() || !pan.y.is_finite() || !scale.is_finite() || scale <= 0. {
return None;
}
Rectangle::new(
Point::new(
pan.x + (capsule.x + 8.) * scale,
pan.y + (capsule.y + 4.) * scale,
),
Size::new((capsule.width - 16.) * scale, (capsule.height - 8.) * scale),
)
.intersection(&Rectangle::new(Point::ORIGIN, viewport))
}
fn marker_distance(marker: &EventMarker, point: Point) -> f32 {
match marker.event.kind {
MarkerKind::Birth => {
let dx = (marker.capsule.x - point.x)
.max(point.x - marker.capsule.x - marker.capsule.width)
.max(0.);
let dy = (marker.capsule.y - point.y)
.max(point.y - marker.capsule.y - marker.capsule.height)
.max(0.);
dx.hypot(dy)
}
MarkerKind::Death => death_strokes(marker.center)
.iter()
.map(|(a, b)| crate::diagram::distance_to_segment(point, *a, *b))
.fold(f32::INFINITY, f32::min),
}
}
fn death_strokes(center: Point) -> [(Point, Point); 2] {
[
(
center + iced::Vector::new(-7., -7.),
center + iced::Vector::new(7., 7.),
),
(
center + iced::Vector::new(-7., 7.),
center + iced::Vector::new(7., -7.),
),
]
}
#[cfg(test)]
#[path = "render_tests.rs"]
mod tests;
@@ -1,973 +0,0 @@
use super::*;
use crate::{behavior::Scene, document::Document};
use iced::Size;
use oa_core::*;
struct Fixture {
project: Project,
owner: Id,
sender: Id,
receiver: Id,
work: Id,
exchange: Id,
}
impl Fixture {
fn new(layer: Layer) -> Self {
let mut project = Project::empty();
let kind = if layer == Layer::Oa {
Kind::Activity
} else {
Kind::Function
};
let a = project.add_element(kind, layer, "Request", None).unwrap();
let work = project.add_element(kind, layer, "Provide", None).unwrap();
let exchange = project.relate(
if layer == Layer::Oa {
RelationKind::Interaction
} else {
RelationKind::FunctionalExchange
},
"Service",
a,
work,
);
let owner = project
.add_element(Kind::Scenario, layer, "Lifecycle", None)
.unwrap();
let mut lane = |name, represented| {
let id = project
.add_element(Kind::Lifeline, layer, name, Some(owner))
.unwrap();
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
represented: Some(represented),
};
id
};
let sender = lane("Requester", a);
let receiver = lane("Provider", work);
Self {
project,
owner,
sender,
receiver,
work,
exchange,
}
}
fn occurrence(&mut self, parent: Id, name: &str, body: OccurrenceBody) -> Id {
let mut element = self
.project
.make_element(
Kind::Occurrence,
self.project.elements[&parent].layer(),
name,
Some(parent),
)
.unwrap();
let id = element.id;
*element.shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
self.project.elements.insert(id, element);
let parent = self.project.elements.get_mut(&parent).unwrap();
if let Some(steps) = parent.steps_mut() {
steps.push(id);
} else if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = parent.shared_mut()
{
branches.push(id);
} else {
panic!("fixture parent must own ordered occurrences or operands");
}
id
}
fn message(&mut self, parent: Id, kind: MessageKind) -> Id {
self.occurrence(
parent,
"Message",
OccurrenceBody::Exchange {
exchange: Some(self.exchange),
sender: Some(self.sender),
receiver: Some(self.receiver),
message_kind: kind,
operation: None,
reply_to: None,
values: vec![],
},
)
}
fn fragment(&mut self, parent: Id, operator: FragmentOperator) -> Id {
self.occurrence(
parent,
"Paths",
OccurrenceBody::Fragment {
operator,
branches: vec![],
},
)
}
fn branch(&mut self, fragment: Id) -> Id {
self.occurrence(
fragment,
"Operand",
OccurrenceBody::Branch {
guard: "condition".into(),
occurrences: vec![],
},
)
}
fn document(&self) -> Document {
validate_candidate(&self.project).expect("ordinary core admission must succeed");
let mut bundle = source_project::format_bundle(&self.project);
for text in bundle.files.values_mut() {
*text = format!(
"// Retain exact authored CRLF and Unicode Ω\r\n{}",
text.replace('\n', "\r\n")
);
}
let document =
Document::from_bundle(bundle.clone(), source_project::ProjectLimits::default())
.unwrap();
assert_eq!(document.project(), &self.project);
assert_eq!(document.bundle(), &bundle);
document
}
fn scene(&self) -> Scene {
let document = self.document();
let before = document.snapshot();
let scene = Scene::sequence(document.project(), self.owner, self.owner);
assert_eq!(document.project(), &before.decoded.project);
assert_eq!(document.bundle(), &before.bundle);
assert_eq!(document.revision(), 0);
assert!(!document.can_undo() && !document.can_redo() && !document.dirty());
assert_eq!(
scene.lifecycle,
Scene::sequence(document.project(), self.owner, self.owner).lifecycle
);
assert!(
scene
.lines
.iter()
.flat_map(|line| &line.points)
.all(|p| p.x.is_finite() && p.y.is_finite())
);
scene
}
}
fn intervals(scene: &Scene, lifeline: Id, scope: Id) -> Vec<(f32, f32, Life)> {
scene
.lifecycle
.lifetimes
.iter()
.find(|lane| lane.lifeline == lifeline)
.unwrap()
.intervals
.iter()
.filter(|i| i.scope == scope)
.map(|i| (i.span.start, i.span.end, i.life))
.collect()
}
#[test]
fn real_scene_long_header_title_and_detail_keep_names_and_have_bounded_clips() {
for layer in [Layer::Oa, Layer::Sa] {
for name in [
"W".repeat(18),
"界".repeat(24),
"e\u{301}".repeat(24),
"👩\u{200d}🔧".repeat(12),
] {
let mut f = Fixture::new(layer);
f.project.elements.get_mut(&f.receiver).unwrap().name = name.clone();
f.project.elements.get_mut(&f.work).unwrap().name = name.clone();
let scene = f.scene();
let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
assert_eq!(header.title, format!("Lane · {name}"));
assert_eq!(header.detail, name);
assert_eq!(header.rect.size(), Size::new(204., 70.));
assert_eq!(scene.hit(header.rect.center()), Some(f.receiver));
// Scroll offset is handled by the existing outer canvas/scrollable;
// these are the local drawing frame's coordinates, not screen input.
for scale in [0.05, 0.5, 0.512, 0.64, 0.8, 1., 1.953125, 2.] {
let clip = crate::behavior::box_text_clip(
iced::Vector::ZERO,
header.rect,
scale,
Size::new(2000., 2000.),
)
.unwrap();
let world = Rectangle::new(
Point::new(clip.x / scale, clip.y / scale),
Size::new(clip.width / scale, clip.height / scale),
);
assert!((world.x - header.rect.x - 10.).abs() < 0.001);
assert!((world.y - header.rect.y - 8.).abs() < 0.001);
assert!((world.width - 184.).abs() < 0.001);
assert!((world.height - 54.).abs() < 0.001);
assert!(header.rect.contains(world.position()));
assert!(
header
.rect
.contains(Point::new(world.x + world.width, world.y + world.height,))
);
}
}
}
}
#[test]
fn box_text_clip_intersects_parent_viewport_and_rejects_empty_interiors() {
use crate::behavior::box_text_clip;
let card = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.));
assert_eq!(
box_text_clip(iced::Vector::ZERO, card, 2., Size::new(300., 500.)),
Some(Rectangle::new(Point::new(220., 416.), Size::new(80., 84.)))
);
assert_eq!(
box_text_clip(
iced::Vector::ZERO,
Rectangle::new(Point::new(-20., -10.), card.size()),
2.,
Size::new(300., 500.),
),
Some(Rectangle::new(Point::ORIGIN, Size::new(300., 104.)))
);
for viewport in [Size::new(200., 500.), Size::new(300., 400.), Size::ZERO] {
assert_eq!(box_text_clip(iced::Vector::ZERO, card, 2., viewport), None);
}
for scale in [0., -1., f32::NAN, f32::INFINITY] {
assert_eq!(
box_text_clip(iced::Vector::ZERO, card, scale, Size::new(1000., 1000.)),
None
);
}
for size in [Size::new(20., 70.), Size::new(204., 16.), Size::ZERO] {
assert_eq!(
box_text_clip(
iced::Vector::ZERO,
Rectangle::new(Point::ORIGIN, size),
1.,
card.size()
),
None
);
}
}
#[test]
fn real_scene_wide_latin_header_exceeds_old_character_budget() {
use iced::advanced::text::{self, Paragraph as _};
let mut f = Fixture::new(Layer::Oa);
f.project.elements.get_mut(&f.receiver).unwrap().name = "W".repeat(18);
f.project.elements.get_mut(&f.work).unwrap().name = "W".repeat(24);
let scene = f.scene();
let header = scene.boxes.iter().find(|b| b.id == f.receiver).unwrap();
for (content, size, divisor) in [(&header.title, 13., 7.), (&header.detail, 11., 6.)] {
let label = iced::widget::canvas::Text {
content: crate::views::short(content, ((header.rect.width - 25.) / divisor) as usize),
size: size.into(),
..Default::default()
};
// Use the same real paragraph/font/shaping defaults as canvas Text.
// The native defect survives short(): glyph width, not scalar count,
// is what exceeds the card. This is a font witness, not a pixel gate.
let paragraph = <iced::Renderer as text::Renderer>::Paragraph::with_text(text::Text {
content: label.content.as_str(),
bounds: Size::INFINITE,
size: label.size,
line_height: label.line_height,
font: label.font,
align_x: label.align_x,
align_y: label.align_y,
shaping: label.shaping,
wrapping: text::Wrapping::default(),
});
let old_available = header.rect.width - 12.;
assert!(
paragraph.min_width() > old_available,
"wide glyph fixture must overflow the old unbounded card: {} <= {old_available}",
paragraph.min_width()
);
}
}
#[test]
fn birth_label_clip_keeps_padded_world_bounds_at_each_scene_scale() {
let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.));
for (scale, expected) in [
(
0.05,
Rectangle::new(Point::new(5.4, 10.2), Size::new(6.2, 1.)),
),
(
0.5,
Rectangle::new(Point::new(54., 102.), Size::new(62., 10.)),
),
(
1.,
Rectangle::new(Point::new(108., 204.), Size::new(124., 20.)),
),
(
2.,
Rectangle::new(Point::new(216., 408.), Size::new(248., 40.)),
),
] {
let clip =
birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)).unwrap();
for (actual, expected) in [
(clip.x, expected.x),
(clip.y, expected.y),
(clip.width, expected.width),
(clip.height, expected.height),
] {
assert!((actual - expected).abs() < 0.0001);
}
assert!(capsule.contains(Point::new(clip.x / scale, clip.y / scale)));
assert!(capsule.contains(Point::new(
(clip.x + clip.width) / scale,
(clip.y + clip.height) / scale,
)));
}
}
#[test]
fn birth_label_clip_preserves_parent_viewport_bounds() {
let capsule = Rectangle::new(Point::new(100., 200.), Size::new(140., 28.));
assert_eq!(
birth_label_clip(iced::Vector::ZERO, capsule, 2., Size::new(300., 420.)),
Some(Rectangle::new(Point::new(216., 408.), Size::new(84., 12.)))
);
let crossing_origin = Rectangle::new(Point::new(-20., -10.), Size::new(140., 28.));
assert_eq!(
birth_label_clip(
iced::Vector::ZERO,
crossing_origin,
2.,
Size::new(300., 420.)
),
Some(Rectangle::new(Point::ORIGIN, Size::new(224., 28.)))
);
for viewport in [Size::new(200., 420.), Size::new(300., 400.), Size::ZERO] {
assert_eq!(
birth_label_clip(iced::Vector::ZERO, capsule, 2., viewport),
None
);
}
for scale in [0., -1., f32::NAN, f32::INFINITY] {
assert_eq!(
birth_label_clip(iced::Vector::ZERO, capsule, scale, Size::new(1000., 1000.)),
None
);
}
}
#[test]
fn real_scene_empty_and_create_only_use_visible_root_extents_not_insertion_bounds() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let empty = f.scene();
assert_eq!(empty.sequence_bounds[&f.owner], (106., 106.));
assert_eq!(
intervals(&empty, f.receiver, f.owner),
vec![(85., empty.size.height - 20., Life::Alive)]
);
assert!(empty.lines.is_empty(), "lane guides are not message lines");
let create = f.message(f.owner, MessageKind::Create);
let scene = f.scene();
let birth = scene.rows[&create].1;
assert_eq!(birth, 148.);
assert_eq!(scene.sequence_bounds[&f.owner], (106., 198.));
assert_eq!(
intervals(&scene, f.receiver, f.owner),
vec![
(85., birth, Life::Absent),
(birth, scene.size.height - 20., Life::Alive),
]
);
assert_eq!(
intervals(&scene, f.sender, f.owner),
vec![(85., scene.size.height - 20., Life::Alive)]
);
assert_eq!(scene.slots[0].y, 106.);
assert_eq!(
scene.lane_at(Point::new(scene.lanes[&f.receiver], 90.)),
Some(f.receiver)
);
}
}
#[test]
fn real_scene_birth_death_markers_align_endpoints_and_win_selection_without_mutating_source() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let create = f.message(f.owner, MessageKind::Create);
f.occurrence(
f.owner,
"Work",
OccurrenceBody::Action {
action: Some(f.work),
lifeline: Some(f.receiver),
},
);
let delete = f.message(f.owner, MessageKind::Delete);
let mut scene = f.scene();
let birth = scene.rows[&create].1;
let death = scene.rows[&delete].1;
assert_eq!(
intervals(&scene, f.receiver, f.owner),
vec![
(85., birth, Life::Absent),
(birth, death, Life::Alive),
(death, scene.size.height - 20., Life::Absent),
]
);
let capsule = scene
.lifecycle
.markers
.iter()
.find(|m| m.event.occurrence == create)
.unwrap()
.capsule;
let line = scene.lines.iter().find(|l| l.id == create).unwrap();
let expected_x = if scene.lanes[&f.sender] < scene.lanes[&f.receiver] {
capsule.x
} else {
capsule.x + capsule.width
};
assert_eq!(line.points.last(), Some(&Point::new(expected_x, birth)));
assert!(
line.label_at.y + 22. < capsule.y,
"message caption clears birth capsule"
);
let line = scene.lines.iter().find(|l| l.id == delete).unwrap();
assert_eq!(
line.points.last(),
Some(&Point::new(scene.lanes[&f.receiver], death))
);
for scale in [0.5, 1., 2.] {
scene.scale = scale;
assert_eq!(
scene.hit(Point::new(scene.lanes[&f.receiver], birth)),
Some(create)
);
assert_eq!(
scene.hit(Point::new(scene.lanes[&f.receiver], death)),
Some(delete)
);
assert_eq!(
scene.hit(Point::new(
scene.lanes[&f.receiver],
scene.size.height - 25.
)),
Some(f.receiver)
);
assert_eq!(
scene.hit(Point::new(scene.lanes[&f.receiver], 40.)),
Some(f.receiver)
);
}
}
}
#[test]
fn real_scene_branch_delete_clips_only_its_activation_and_keeps_unanswered_call_visible() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let call = f.message(f.owner, MessageKind::Call);
let fragment = f.fragment(f.owner, FragmentOperator::Alternative);
let left = f.branch(fragment);
let delete = f.message(left, MessageKind::Delete);
let right = f.branch(fragment);
let scene = f.scene();
let death = scene.rows[&delete].1;
let (left_start, left_end) = scene.sequence_bounds[&left];
let (right_start, _) = scene.sequence_bounds[&right];
let bars: Vec<_> = scene
.boxes
.iter()
.filter(|b| b.id == call && b.rect.width == 8.)
.map(|b| b.rect)
.collect();
assert!(
bars.iter()
.any(|r| r.y == left_start && r.y + r.height == death)
);
assert!(
!bars
.iter()
.any(|r| r.y < left_end && r.y + r.height > death && r.y >= left_start)
);
assert!(
bars.iter().any(|r| r.y == right_start && r.height == 26.),
"empty exclusive sibling retains its own open activation"
);
assert_eq!(
intervals(&scene, f.receiver, right),
vec![(right_start, right_start + 26., Life::Alive)]
);
let frame_end = scene.boxes.iter().find(|b| b.id == fragment).unwrap().rect;
assert!(
!bars.iter().any(|r| r.y >= frame_end.y + frame_end.height),
"uncertain post-fragment lifetime cannot carry a definite activation bar"
);
assert!(
scene
.open_calls
.iter()
.any(|p| p.y < death && p.y > left_start)
);
assert!(scene.open_calls.iter().any(|p| p.y > right_start));
assert_eq!(
intervals(&scene, f.receiver, left),
vec![
(left_start, death, Life::Alive),
(death, left_end, Life::Absent)
]
);
assert_eq!(
intervals(&scene, f.receiver, f.owner).last().unwrap().2,
Life::Unresolved
);
assert!(!f.project.elements.values().any(|e| matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Reply,
..
},
..
})
)));
}
}
#[test]
fn real_scene_unassigned_lanes_and_explicit_incomplete_receiver_keep_real_marker_identity() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
*f.project
.elements
.get_mut(&f.receiver)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline { represented: None };
let create = f.message(f.owner, MessageKind::Create);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = f.project.elements.get_mut(&create).unwrap().shared_mut()
{
*exchange = None;
}
let scene = f.scene();
assert!(
!scene.lanes.contains_key(&f.receiver),
"authoring eligibility remains separate"
);
let x = scene.lifecycle.lanes[&f.receiver];
assert_eq!(scene.lifecycle.markers[0].lifeline, f.receiver);
assert_eq!(
scene.hit(Point::new(x, scene.rows[&create].1)),
Some(create)
);
assert_eq!(scene.hit(Point::new(x, 40.)), Some(f.receiver));
assert_eq!(
scene.hit(Point::new(x, scene.size.height - 25.)),
Some(f.receiver)
);
assert_eq!(scene.lane_at(Point::new(x, 90.)), None);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
receiver, exchange, ..
},
..
}) = f.project.elements.get_mut(&create).unwrap().shared_mut()
{
*receiver = None;
*exchange = Some(f.exchange);
}
let missing = f.scene();
assert!(
missing.lifecycle.markers.is_empty(),
"do not infer receiver from global exchange"
);
}
}
#[test]
fn real_scene_minimum_zoom_marker_halos_do_not_steal_adjacent_capsule_or_header_hits() {
let mut f = Fixture::new(Layer::Sa);
*f.project
.elements
.get_mut(&f.receiver)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline { represented: None };
let create = f.message(f.owner, MessageKind::Create);
let delete = f.message(f.owner, MessageKind::Delete);
for event in [create, delete] {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { exchange, .. },
..
}) = f.project.elements.get_mut(&event).unwrap().shared_mut()
{
*exchange = None;
}
}
let mut scene = f.scene();
let x = scene.lifecycle.lanes[&f.receiver];
assert_eq!(scene.rows[&delete].1 - scene.rows[&create].1, 92.);
for scale in [0.05, 0.1, 0.5, 1., 2.] {
scene.scale = scale;
assert_eq!(
scene.hit(Point::new(x, scene.rows[&create].1)),
Some(create)
);
assert_eq!(
scene.hit(Point::new(x, scene.rows[&delete].1)),
Some(delete)
);
assert_eq!(scene.hit(Point::new(x, 40.)), Some(f.receiver));
if scale <= 0.1 {
for offset in [-25., -15., 15., 25.] {
assert_eq!(
scene.hit(Point::new(x, scene.rows[&delete].1 + offset)),
Some(delete),
"a nearby death halo must beat an earlier message's wider tolerance"
);
}
}
}
}
#[test]
fn real_scene_minimum_zoom_near_ordinary_message_wins_over_lifecycle_halos() {
let mut f = Fixture::new(Layer::Sa);
f.message(f.owner, MessageKind::Create);
let ordinary = f.message(f.owner, MessageKind::Asynchronous);
f.message(f.owner, MessageKind::Delete);
let mut scene = f.scene();
let x = (scene.lanes[&f.sender] + scene.lanes[&f.receiver]) / 2.;
for scale in [0.05, 0.1] {
scene.scale = scale;
assert_eq!(
scene.hit(Point::new(x, scene.rows[&ordinary].1 + 2.)),
Some(ordinary)
);
}
}
#[test]
fn real_scene_self_lifecycle_elbows_end_on_recorded_marker_rows() {
for layer in [Layer::Oa, Layer::Sa] {
for kind in [MessageKind::Create, MessageKind::Delete] {
let mut f = Fixture::new(layer);
let event = f.message(f.owner, kind);
if let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange, sender, ..
},
..
}) = f.project.elements.get_mut(&event).unwrap().shared_mut()
{
*exchange = None;
*sender = Some(f.receiver);
}
let scene = f.scene();
let marker = &scene.lifecycle.markers[0];
let line = scene.lines.iter().find(|l| l.id == event).unwrap();
assert_eq!(line.points.len(), 4);
assert_eq!(line.points[0].y, marker.center.y - 24.);
assert_eq!(line.points[3].y, marker.center.y);
assert_eq!(
line.points[3].x,
if marker.event.kind == MarkerKind::Birth {
marker.capsule.x + marker.capsule.width
} else {
marker.center.x
}
);
assert!(line.points[1].x > marker.capsule.x + marker.capsule.width);
assert_eq!(scene.hit(marker.center), Some(event));
}
}
}
#[test]
fn real_scene_self_delete_caption_clears_the_preceding_action_box() {
let mut f = Fixture::new(Layer::Sa);
let action = f.occurrence(
f.owner,
"Finish work",
OccurrenceBody::Action {
action: Some(f.work),
lifeline: Some(f.receiver),
},
);
let delete = f.message(f.owner, MessageKind::Delete);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange, sender, ..
},
..
}) = f.project.elements.get_mut(&delete).unwrap().shared_mut()
{
*exchange = None;
*sender = Some(f.receiver);
}
let scene = f.scene();
let action = scene.boxes.iter().find(|b| b.id == action).unwrap();
let line = scene.lines.iter().find(|l| l.id == delete).unwrap();
assert!(line.label_at.y > action.rect.y + action.rect.height);
assert!(line.label_at.y + 22. < line.points[0].y);
assert_eq!(line.points[3].y, scene.rows[&delete].1);
assert_eq!(
scene
.slots
.iter()
.find(|s| s.slot.before == Some(delete))
.unwrap()
.y,
scene.rows[&delete].1 - 42.
);
}
#[test]
fn real_scene_conditional_repeated_and_parallel_scopes_do_not_claim_definite_continuation() {
for operator in [
FragmentOperator::Alternative,
FragmentOperator::Optional,
FragmentOperator::Loop,
FragmentOperator::Parallel,
] {
let mut f = Fixture::new(Layer::Sa);
let fragment = f.fragment(f.owner, operator);
let first = f.branch(fragment);
let create = f.message(first, MessageKind::Create);
let scene = f.scene();
assert_eq!(scene.lifecycle.markers[0].event.occurrence, create);
assert_eq!(
intervals(&scene, f.receiver, f.owner).first().unwrap().2,
Life::Unresolved
);
assert_eq!(
intervals(&scene, f.receiver, f.owner).last().unwrap().2,
Life::Unresolved
);
assert!(
scene
.lifecycle
.lifetimes
.iter()
.find(|l| l.lifeline == f.sender)
.unwrap()
.intervals
.iter()
.all(|i| i.life == Life::Alive)
);
}
}
#[test]
fn real_scene_lifecycle_edit_undo_redo_restores_exact_source_and_changed_geometry() {
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
let create = f.message(f.owner, MessageKind::Create);
let mut document = f.document();
let before = document.snapshot();
let geometry = Scene::sequence(document.project(), f.owner, create).lifecycle;
let mut edited = document.project().elements[&create].clone();
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { message_kind, .. },
..
}) = edited.shared_mut()
{
*message_kind = MessageKind::Asynchronous;
}
document
.execute(Command::EditElement(create, edited))
.unwrap();
let after = document.snapshot();
let changed = Scene::sequence(document.project(), f.owner, create).lifecycle;
assert_ne!(geometry, changed);
assert!(changed.markers.is_empty());
assert_ne!(before.bundle, after.bundle);
assert!(document.undo());
assert_eq!(document.project(), &before.decoded.project);
assert_eq!(document.bundle(), &before.bundle);
assert_eq!(
Scene::sequence(document.project(), f.owner, create).lifecycle,
geometry
);
assert!(!document.can_undo() && document.can_redo());
assert!(document.redo());
assert_eq!(document.project(), &after.decoded.project);
assert_eq!(document.bundle(), &after.bundle);
assert_eq!(
Scene::sequence(document.project(), f.owner, create).lifecycle,
changed
);
assert!(!document.can_redo());
}
}
#[test]
fn panned_header_and_create_clips_use_screen_translation_and_actual_viewport() {
use crate::behavior::box_text_clip;
use iced::Vector;
let header = Rectangle::new(Point::new(100., 200.), Size::new(204., 70.));
let capsule = Rectangle::new(header.position(), Size::new(140., 28.));
let rect = |x, y, w, h| Rectangle::new(Point::new(x, y), Size::new(w, h));
for (pan, scale, viewport, header_expected, birth_expected) in [
(
Vector::new(37., -41.),
0.5,
Size::new(300., 400.),
rect(92., 63., 92., 27.),
rect(91., 61., 62., 10.),
),
(
Vector::new(37., -41.),
1.,
Size::new(300., 400.),
rect(147., 167., 153., 54.),
rect(145., 163., 124., 20.),
),
(
Vector::new(37., -41.),
2.,
Size::new(300., 400.),
rect(257., 375., 43., 25.),
rect(253., 367., 47., 33.),
),
(
Vector::new(-200., -400.),
2.,
Size::new(300., 100.),
rect(20., 16., 280., 84.),
rect(16., 8., 248., 40.),
),
(
Vector::new(-237., -411.),
2.,
Size::new(300., 100.),
rect(0., 5., 300., 95.),
rect(0., 0., 227., 37.),
),
] {
assert_eq!(
box_text_clip(pan, header, scale, viewport),
Some(header_expected)
);
assert_eq!(
birth_label_clip(pan, capsule, scale, viewport),
Some(birth_expected)
);
}
for pan in [
Vector::new(1000., 1000.),
Vector::new(-1000., -1000.),
Vector::new(f32::NAN, 0.),
Vector::new(0., f32::INFINITY),
] {
assert_eq!(box_text_clip(pan, header, 2., Size::new(300., 100.)), None);
assert_eq!(
birth_label_clip(pan, capsule, 2., Size::new(300., 100.)),
None
);
}
}
#[test]
fn real_scene_panned_zoomed_program_keeps_header_birth_and_death_hit_identity() {
use crate::{
app::Message,
behavior::{
SequenceInput,
scenario::{CanvasEvent, CanvasTool},
},
};
use iced::{Vector, mouse, widget::canvas};
for layer in [Layer::Oa, Layer::Sa] {
let mut f = Fixture::new(layer);
f.project.elements.get_mut(&f.receiver).unwrap().name = "界".repeat(24);
let create = f.message(f.owner, MessageKind::Create);
let delete = f.message(f.owner, MessageKind::Delete);
let document = f.document();
let before = document.snapshot();
for scale in [0.5, 1., 2.] {
for pan in [Vector::new(35., 40.), Vector::new(-35., -5.)] {
let mut scene = f.scene();
scene.scale = scale;
scene.camera_pan = pan;
let context = crate::behavior::scenario::direct_tests::context(f.owner);
scene.interaction = Some((context, CanvasTool::Select, true));
let header = scene.boxes.iter().find(|b| b.id == f.sender).unwrap();
let targets = [
(f.sender, header.rect.center()),
(
create,
scene
.lifecycle
.markers
.iter()
.find(|m| m.event.occurrence == create)
.unwrap()
.center,
),
(
delete,
scene
.lifecycle
.markers
.iter()
.find(|m| m.event.occurrence == delete)
.unwrap()
.center,
),
];
// Absolute widget origin models the outer layout/scroll translation;
// camera pan remains local screen units and must be subtracted once.
let bounds = Rectangle::new(Point::new(73., 119.), Size::new(2000., 1200.));
for (expected, world) in targets {
let screen =
bounds.position() + pan + Vector::new(world.x * scale, world.y * scale);
assert!(bounds.contains(screen));
let mut state = SequenceInput::default();
let mut published = Vec::new();
for event in [
mouse::Event::CursorMoved { position: screen },
mouse::Event::ButtonPressed(mouse::Button::Left),
mouse::Event::ButtonReleased(mouse::Button::Left),
] {
if let Some(message) = canvas::Program::update(
&scene,
&mut state,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(screen),
)
.and_then(|a| a.into_inner().0)
{
published.push(message);
}
}
assert!(
matches!(published.as_slice(), [Message::ScenarioCanvas(CanvasEvent::Select(actual_context, actual))] if *actual_context == context && *actual == expected),
"Pan/zoom must retain the typed hit identity"
);
}
assert_eq!(scene.lifecycle, f.scene().lifecycle);
}
}
assert_eq!(document.project(), &before.decoded.project);
assert_eq!(document.bundle(), &before.bundle);
assert!(!document.can_undo() && !document.dirty());
}
}
+37 -583
View File
@@ -325,73 +325,50 @@ impl State {
) -> Option<UiElement<'a, Event, iced::Theme, R>> {
let d = self.editing.as_ref()?;
let event = move |edit| Event::Edit(d.token, edit);
let kinds = d.scope.kinds(d.layer);
let determined = kinds.len() == 1 && d.kind == kinds.first().copied();
let mut fields = column![
text(if determined {
format!("New {} · {}", kinds[0], d.layer)
} else {
format!("New source · {}", d.layer)
text(format!("New source · {}", d.layer)).size(18),
text(match d.scope {
Scope::Lifeline { owner, viewpoint } =>
format!("For a lifeline in {} · {:?}", p.named(owner), viewpoint),
Scope::Work { owner } => format!("For a contribution in {}", p.named(owner)),
Scope::Machine { .. } => "Owner for the pending state machine".into(),
})
.size(14),
.size(12),
text("The parent form is retained. This source is created only with its consumer.")
.size(12)
]
.spacing(8);
for kind in d.scope.kinds(d.layer) {
fields = fields.push(
button(text(kind.to_string()))
.on_press(event(Edit::Kind(kind)))
.style(if d.kind == Some(kind) {
button::primary
} else {
button::secondary
}),
);
}
fields = fields.push(
text_input("Source name (independent of the consumer name)", &d.name)
.id(NAME_INPUT)
.on_input(move |v| event(Edit::Name(v)))
.size(14)
.padding(6),
]
.spacing(4);
if !determined {
let mut choices = row![].spacing(6);
for kind in kinds {
choices = choices.push(
container(
button(text(kind.to_string()).size(14))
.padding(4)
.on_press(event(Edit::Kind(kind)))
.style(if d.kind == Some(kind) {
button::primary
} else {
button::secondary
}),
)
.id(format!("behavior-source-kind-{kind:?}")),
);
}
fields = fields.push(choices.wrap());
}
.padding(8),
);
fields = fields.push(
container(
text(format!(
"Semantic parent: {}",
d.parent
.as_ref()
.map_or(format!("{} layer root", d.layer), |e| format!(
"{} · {} · @{}",
e.name,
e.kind(),
e.alias
))
))
.size(12),
)
.id("behavior-source-parent"),
text(format!(
"Semantic parent: {}",
d.parent
.as_ref()
.map_or(format!("{} layer root", d.layer), |e| format!(
"{} · {} · @{}",
e.name,
e.kind(),
e.alias
))
))
.size(12),
);
fields = fields
.push(
text(match d.scope {
Scope::Lifeline { owner, viewpoint } => {
format!("For a lifeline in {} · {:?}", p.named(owner), viewpoint)
}
Scope::Work { owner } => format!("For a contribution in {}", p.named(owner)),
Scope::Machine { .. } => "Owner for the pending state machine".into(),
})
.size(12),
)
.push(
text("The parent form is retained. This source is created only with its consumer.")
.size(12),
);
if let Some(kind) = d.kind {
fields = fields.push(button("Use layer root").on_press_maybe(
validation::can_parent(p, kind, d.layer, None).then(|| event(Edit::Parent(None))),
@@ -509,526 +486,3 @@ impl Draft {
Ok(command)
}
}
#[cfg(all(test, debug_assertions))]
mod layout_tests {
use super::*;
use iced::advanced::{Layout, Shell, graphics, layout, renderer, text as shaped, widget};
use iced::{Background, Color, Font, Pixels, Point, Rectangle, Size, Transformation, Vector};
// Use Iced's real paragraph shaping/measurement without a window or GPU.
// The unit renderer's zero-size paragraphs cannot detect this regression.
struct FontLayout;
impl renderer::Renderer for FontLayout {
fn start_layer(&mut self, _: Rectangle) {}
fn end_layer(&mut self) {}
fn start_transformation(&mut self, _: Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _: Rectangle) {}
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(
Result<iced::advanced::image::Allocation, iced::advanced::image::Error>,
) + Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut (), handle, callback);
}
}
impl shaped::Renderer for FontLayout {
type Font = Font;
type Paragraph = graphics::text::Paragraph;
type Editor = ();
const ICON_FONT: Font = Font::DEFAULT;
const CHECKMARK_ICON: char = '0';
const ARROW_DOWN_ICON: char = '0';
const SCROLL_UP_ICON: char = '0';
const SCROLL_DOWN_ICON: char = '0';
const SCROLL_LEFT_ICON: char = '0';
const SCROLL_RIGHT_ICON: char = '0';
const ICED_LOGO: char = '0';
fn default_font(&self) -> Font {
Font::DEFAULT
}
fn default_size(&self) -> Pixels {
Pixels(16.)
}
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
#[derive(Default)]
struct Geometry {
fields: Option<Rectangle>,
content: Option<Rectangle>,
name: Option<Rectangle>,
parent: Option<Rectangle>,
actions: Option<Rectangle>,
kinds: BTreeMap<String, Rectangle>,
text: Vec<(String, Rectangle)>,
focus_name: bool,
}
impl widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&widget::Id>, bounds: Rectangle) {
if id == Some(&widget::Id::new("behavior-source-actions")) {
self.actions = Some(bounds);
}
if id == Some(&widget::Id::new("behavior-source-parent")) {
self.parent = Some(bounds);
}
for kind in [
Kind::Entity,
Kind::Actor,
Kind::Role,
Kind::System,
Kind::SystemActor,
Kind::Activity,
Kind::Function,
] {
if id == Some(&widget::Id::from(format!("behavior-source-kind-{kind:?}"))) {
self.kinds.insert(kind.to_string(), bounds);
}
}
}
fn scrollable(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn widget::operation::Scrollable,
) {
if id == Some(&widget::Id::new("behavior-source-fields")) {
assert_eq!(
offset,
Vector::ZERO,
"Initial fields must be visible without scrolling"
);
self.fields = Some(bounds);
self.content = Some(content);
}
}
fn text_input(
&mut self,
id: Option<&widget::Id>,
bounds: Rectangle,
_: &mut dyn widget::operation::TextInput,
) {
if id == Some(&widget::Id::new(NAME_INPUT)) {
self.name = Some(bounds);
}
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
_: Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
if self.focus_name && id == Some(&widget::Id::new(NAME_INPUT)) {
state.focus();
}
}
fn text(&mut self, _: Option<&widget::Id>, bounds: Rectangle, text: &str) {
self.text.push((text.into(), bounds));
}
}
fn contains(outer: Rectangle, inner: Rectangle) -> bool {
inner.width > 0.
&& inner.height > 0.
&& inner.x >= outer.x - 0.1
&& inner.y >= outer.y - 0.1
&& inner.x + inner.width <= outer.x + outer.width + 0.1
&& inner.y + inner.height <= outer.y + outer.height + 0.1
}
fn host<'a>(
state: &'a State,
p: &'a Project,
caption: &str,
) -> UiElement<'a, Event, iced::Theme, FontLayout> {
iced::widget::column![text(caption.to_owned()).size(12), state.view(p).unwrap()]
.spacing(6)
.height(Fill)
.width(Fill)
.into()
}
fn geometry(
view: &mut UiElement<'_, Event, iced::Theme, FontLayout>,
tree: &mut widget::Tree,
size: Size,
) -> (layout::Node, Geometry) {
let node =
view.as_widget_mut()
.layout(tree, &FontLayout, &layout::Limits::new(Size::ZERO, size));
let mut geometry = Geometry::default();
view.as_widget_mut()
.operate(tree, Layout::new(&node), &FontLayout, &mut geometry);
(node, geometry)
}
fn fixture(layer: Layer, viewpoint: Option<ScenarioViewpoint>, work: bool) -> (Project, State) {
let mut p = Project::empty();
let scope = if work {
let owner = p
.add_element(
if layer == Layer::Oa {
Kind::Process
} else {
Kind::FunctionalChain
},
layer,
"Consumer",
None,
)
.unwrap();
Scope::Work { owner }
} else if let Some(viewpoint) = viewpoint {
let owner = p
.add_element(
Kind::Scenario,
layer,
"Incident coordination with a long contextual heading 雨 Ω",
None,
)
.unwrap();
if let Some(SharedBody::Scenario { viewpoint: v, .. }) =
p.elements.get_mut(&owner).unwrap().shared_mut()
{
*v = viewpoint;
}
Scope::Lifeline { owner, viewpoint }
} else {
Scope::Machine { layer }
};
let mut state = State::default();
let invocation = Id::new_v4();
state
.update(&p, scope, invocation, Event::Open(invocation))
.unwrap();
(p, state)
}
#[test]
fn contextual_source_layout_initial_essentials_fit_short_composed_host_with_real_paragraphs() {
for (layer, viewpoint, work) in [
(
Layer::Oa,
Some(ScenarioViewpoint::OperationalActivity),
false,
),
(Layer::Sa, Some(ScenarioViewpoint::Functional), false),
(
Layer::Oa,
Some(ScenarioViewpoint::ParticipantInteraction),
false,
),
(Layer::Sa, Some(ScenarioViewpoint::BoundaryInterface), false),
(Layer::Oa, None, false),
(Layer::Sa, None, false),
(Layer::Oa, None, true),
(Layer::Sa, None, true),
] {
let (p, state) = fixture(layer, viewpoint, work);
let raw = serde_json::to_value(&state).unwrap();
for width in [340., 500.] {
// The retained caption + 6px gap leaves approximately the observed 194px card.
let size = Size::new(width, 215.);
let mut view = host(&state, &p, "Consumer name: Assessment lifeline");
let mut tree = widget::Tree::new(view.as_widget());
let (_, g) = geometry(&mut view, &mut tree, size);
let fields = g.fields.unwrap();
assert!(
(125. ..=132.).contains(&fields.height),
"Measured field viewport: {fields:?}"
);
assert!(
contains(fields, g.name.unwrap()),
"Name clipped: {layer:?}/{viewpoint:?}/{work}/{width}"
);
assert!(
contains(fields, g.parent.unwrap()),
"Parent clipped: {layer:?}/{viewpoint:?}/{work}/{width}"
);
assert!(contains(
Rectangle::new(Point::ORIGIN, size),
g.actions.unwrap()
));
assert!(fields.y + fields.height <= g.actions.unwrap().y);
let expected = state.editing.as_ref().unwrap().scope.kinds(layer);
assert_eq!(
g.kinds.len(),
if expected.len() == 1 {
0
} else {
expected.len()
}
);
for bounds in g.kinds.values() {
assert!(
contains(fields, *bounds),
"Kind clipped: {bounds:?} in {fields:?}"
);
}
let parent_text = g
.text
.iter()
.find(|(s, _)| s.starts_with("Semantic parent:"))
.unwrap();
assert_eq!(
parent_text.0,
format!("Semantic parent: {layer} layer root")
);
assert!(
parent_text.1.height > 0.,
"Paragraph measurement must not be null"
);
let explanation = g
.text
.iter()
.find(|(s, _)| s.starts_with("The parent form"))
.unwrap();
assert!(explanation.1.y >= g.parent.unwrap().y + g.parent.unwrap().height);
}
assert_eq!(
serde_json::to_value(&state).unwrap(),
raw,
"Layout must not choose a kind or mutate the draft"
);
if state.editing.as_ref().unwrap().scope.kinds(layer).len() == 1 {
let mut incomplete = state.clone();
incomplete.editing.as_mut().unwrap().kind = None;
let mut view = host(&incomplete, &p, "Consumer name: Assessment lifeline");
let mut tree = widget::Tree::new(view.as_widget());
let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.));
assert_eq!(
g.kinds.len(),
1,
"An incomplete recovery still needs an explicit repair choice"
);
assert!(contains(
g.fields.unwrap(),
*g.kinds.values().next().unwrap()
));
assert!(contains(g.fields.unwrap(), g.name.unwrap()));
assert_eq!(incomplete.editing.as_ref().unwrap().kind, None);
}
}
}
#[test]
fn contextual_source_layout_long_parent_wraps_after_visible_name_without_losing_raw_data() {
let (mut p, mut state) = fixture(Layer::Oa, None, true);
let parent = p
.add_element(
Kind::Activity,
Layer::Oa,
"Northern operations coordination — exact parent 雨",
None,
)
.unwrap();
let d = state.editing.as_ref().unwrap();
let token = d.token;
let scope = d.scope;
let invocation = Id::new_v4();
state
.update(
&p,
scope,
invocation,
Event::Edit(token, Edit::Parent(Some(parent))),
)
.unwrap();
state
.update(
&p,
scope,
invocation,
Event::Edit(token, Edit::Name(" Distinct source 雨 ".into())),
)
.unwrap();
let raw = serde_json::to_value(&state).unwrap();
for width in [340., 500.] {
let size = Size::new(width, 235.);
let mut view = host(
&state,
&p,
"Consumer name: Coordination with a long independent label 雨 Ω",
);
let mut tree = widget::Tree::new(view.as_widget());
let (_, g) = geometry(&mut view, &mut tree, size);
assert!(contains(g.fields.unwrap(), g.name.unwrap()));
assert!(contains(g.fields.unwrap(), g.parent.unwrap()));
assert!(contains(
Rectangle::new(Point::ORIGIN, size),
g.actions.unwrap()
));
let summary = g
.text
.iter()
.find(|(s, _)| s.starts_with("Semantic parent:"))
.unwrap();
assert!(summary.0.contains(&p.elements[&parent].name));
assert!(summary.0.contains(&p.elements[&parent].alias));
assert!(
summary.1.height > 15.,
"Long parent should exercise real wrapping"
);
}
assert_eq!(serde_json::to_value(&state).unwrap(), raw);
assert_eq!(
state.editing.as_ref().unwrap().name,
" Distinct source 雨 "
);
let (mut p, mut state) = fixture(
Layer::Oa,
Some(ScenarioViewpoint::ParticipantInteraction),
false,
);
let parent = p
.add_element(
Kind::Entity,
Layer::Oa,
// Eight repetitions need not exceed a whole viewport with the
// platform's measured font. Exercise substantial real overflow.
&"Northern operational responsibility 雨 ".repeat(32),
None,
)
.unwrap();
let d = state.editing.as_ref().unwrap();
let token = d.token;
let scope = d.scope;
state
.update(
&p,
scope,
Id::new_v4(),
Event::Edit(token, Edit::Kind(Kind::Actor)),
)
.unwrap();
state
.update(
&p,
scope,
Id::new_v4(),
Event::Edit(token, Edit::Parent(Some(parent))),
)
.unwrap();
let raw = serde_json::to_value(&state).unwrap();
let recovered: State = serde_json::from_value(raw.clone()).unwrap();
let mut view = host(&recovered, &p, "Consumer name: Assessment lifeline");
let mut tree = widget::Tree::new(view.as_widget());
let (_, g) = geometry(&mut view, &mut tree, Size::new(340., 215.));
let fields = g.fields.unwrap();
assert!(contains(fields, g.name.unwrap()));
assert_eq!(g.kinds.len(), 3);
for bounds in g.kinds.values() {
assert!(
contains(fields, *bounds),
"Long parent must not hide a semantic choice"
);
assert!(bounds.y + bounds.height <= g.parent.unwrap().y);
}
// Arbitrary authored detail may scroll, but its label and first line are
// visible after the required kind controls, never in front of them.
let parent_bounds = g.parent.unwrap();
assert!(parent_bounds.y + 16. <= fields.y + fields.height);
assert!(
parent_bounds.height > fields.height,
"Exercise genuinely overflowing parent text: {parent_bounds:?} in {fields:?}"
);
let content = g.content.unwrap();
assert!(
content.height > fields.height && contains(content, parent_bounds),
"Full parent must remain in scrollable content: {parent_bounds:?}, {content:?}, {fields:?}"
);
let summary = g
.text
.iter()
.find(|(s, _)| s.starts_with("Semantic parent:"))
.unwrap();
assert!(summary.0.contains(&p.elements[&parent].name));
assert!(summary.0.contains(&p.elements[&parent].alias));
assert!(contains(
Rectangle::new(Point::ORIGIN, Size::new(340., 215.)),
g.actions.unwrap()
));
assert_eq!(serde_json::to_value(&recovered).unwrap(), raw);
}
#[test]
fn contextual_source_layout_visible_kind_click_keeps_choice_explicit_and_name_input_works() {
let (p, mut state) = fixture(
Layer::Oa,
Some(ScenarioViewpoint::ParticipantInteraction),
false,
);
let original = p.clone();
let token = state.editing.as_ref().unwrap().token;
let scope = state.editing.as_ref().unwrap().scope;
assert_eq!(state.editing.as_ref().unwrap().kind, None);
let size = Size::new(340., 215.);
let mut view = host(&state, &p, "Consumer name: Assessment lifeline");
let mut tree = widget::Tree::new(view.as_widget());
let (node, g) = geometry(&mut view, &mut tree, size);
let mut messages = Vec::new();
for kind in [Kind::Entity, Kind::Actor, Kind::Role] {
let bounds = g.kinds[&kind.to_string()];
assert!(contains(g.fields.unwrap(), bounds));
for action in [
iced::mouse::Event::ButtonPressed(iced::mouse::Button::Left),
iced::mouse::Event::ButtonReleased(iced::mouse::Button::Left),
] {
view.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(action),
Layout::new(&node),
iced::mouse::Cursor::Available(bounds.center()),
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::new(Point::ORIGIN, size),
);
}
assert!(
matches!(messages.pop(), Some(Event::Edit(id, Edit::Kind(k))) if id == token && k == kind)
);
}
let mut focus = Geometry {
focus_name: true,
..Geometry::default()
};
view.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut focus);
let event = iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key: iced::keyboard::Key::Character("Ω".into()),
modified_key: iced::keyboard::Key::Character("Ω".into()),
physical_key: iced::keyboard::key::Physical::Code(iced::keyboard::key::Code::KeyA),
location: iced::keyboard::Location::Standard,
modifiers: iced::keyboard::Modifiers::empty(),
text: Some("Ω".into()),
repeat: false,
});
view.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut Shell::new(&mut messages),
&Rectangle::new(Point::ORIGIN, size),
);
let edit = messages.pop().unwrap();
assert!(matches!(&edit, Event::Edit(id, Edit::Name(name)) if *id == token && name == "Ω"));
drop(view);
state.update(&p, scope, Id::new_v4(), edit).unwrap();
assert_eq!(
state.editing.as_ref().unwrap().kind,
None,
"Rendering/click event production must not silently adopt a kind"
);
assert_eq!(state.editing.as_ref().unwrap().name, "Ω");
assert_eq!(p, original);
}
}
@@ -27,8 +27,6 @@ pub struct StateCanvas<'a> {
pub diagram: Id,
pub context: Context,
pub selected: Id,
/// Presentation-only navigation marker; never an authoring placement.
pub navigation_focus: Option<Rectangle>,
pub tool: Tool,
pub zoom: f32,
pub pan: Vector,
@@ -417,171 +415,6 @@ fn state_action_label(project: &Project, relation: &Relation) -> String {
format!("{phase} / {}", project.named(target))
}
/// Match a typed label field by its structural offset, never by a display-name
/// substring (names may duplicate or also occur in free-form guards).
fn retained_label_field(full: &str, painted: &str, start: usize) -> bool {
start
< full
.chars()
.zip(painted.chars())
.take_while(|(a, b)| a == b)
.map(|(c, _)| c.len_utf8())
.sum::<usize>()
}
fn transition_label_role(
project: &Project,
relation: &Relation,
subject: Id,
width: f32,
) -> Option<&'static str> {
let full = routing::state_transition_summary(project, relation);
transition_label_role_in_text(project, relation, subject, &short(&full, width, 10.))
}
fn transition_label_role_in_text(
project: &Project,
relation: &Relation,
subject: Id,
painted: &str,
) -> Option<&'static str> {
let RelationBody::StateTransition {
transition_kind,
triggers,
effects,
effect,
..
} = &relation.body
else {
return None;
};
if project.named(subject).trim().is_empty() {
return None;
}
let full = routing::state_transition_summary(project, relation);
let mut offset = format!("{transition_kind:?} · ").len();
for id in triggers {
if *id == subject && retained_label_field(&full, painted, offset) {
return Some("transition trigger");
}
offset += project.named(*id).len() + " OR ".len();
}
if !effects.is_empty() {
// The canonical routing summary appends typed effects before optional
// authored effect prose. Derive its offset from that exact suffix.
let mut suffix = format!(
" / {}",
effects
.iter()
.map(|id| project.named(*id))
.collect::<Vec<_>>()
.join(", ")
);
if !effect.is_empty() {
suffix.push_str(&format!(" / {effect}"));
}
if let Some(prefix) = full.strip_suffix(&suffix) {
offset = prefix.len() + " / ".len();
for id in effects {
if *id == subject && retained_label_field(&full, painted, offset) {
return Some("transition effect");
}
offset += project.named(*id).len() + ", ".len();
}
}
}
None
}
/// Actual state compartments and painted transition-reference labels. Generic
/// projected node/edge identities are handled by the shared saved-view query.
pub(crate) fn diagram_usages(
project: &Project,
diagram: &Diagram,
view: &ProjectedView,
subject: Id,
) -> Vec<crate::diagram_usage::Usage> {
use crate::diagram_usage::{Surface, Target, Usage};
if diagram.projection.state_machine().is_none() {
return vec![];
}
let mut result = Vec::new();
for node in &view.nodes {
for (relation, bounds) in state_action_rows(project, node) {
let label = state_action_label(project, relation);
let painted = short(&label, bounds.width - 12., 11.);
let role = if relation.id == subject {
Some("state action")
} else if relation.target() == subject
&& retained_label_field(
&label,
&painted,
label.len() - project.named(subject).len(),
)
{
Some("state action work")
} else {
None
};
if let Some(role) = role {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
target: Target::StateRow {
placement: node.id,
relation: relation.id,
},
role: role.into(),
bounds,
});
}
}
for (relation, bounds) in internal_rows(project, node) {
let role = if relation.id == subject {
Some("internal transition")
} else {
transition_label_role(project, relation, subject, bounds.width - 12.)
};
if let Some(role) = role {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
target: Target::StateRow {
placement: node.id,
relation: relation.id,
},
role: role.into(),
bounds,
});
}
}
}
for edge in routing::route_diagram(project, diagram, view)
.edges
.values()
{
let Some(relation) = project.relations.get(&edge.id) else {
continue;
};
if edge.id == subject || edge.label.text.is_empty() {
continue;
}
// Routing already shortens external summaries before the canvas applies
// its width limit. Compare against precisely that doubly shortened text.
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
if let Some(role) = transition_label_role_in_text(project, relation, subject, &painted) {
result.push(Usage {
subject,
surface: Surface::Saved(diagram.id),
target: Target::Edge(edge.id),
role: role.into(),
bounds: crate::diagram_usage::edge_focus_bounds(edge),
});
}
}
result
}
impl StateCanvas<'_> {
fn scale(&self) -> f32 {
if self.zoom.is_finite() {
@@ -1315,14 +1148,6 @@ impl canvas::Program<Event> for StateCanvas<'_> {
overlay.with_clip(content, |frame| {
frame.translate(self.offset());
frame.scale(self.scale());
if let Some(bounds) = self.navigation_focus {
frame.stroke(
&Path::rectangle(bounds.position(), bounds.size()),
Stroke::default()
.with_color(style::TEAL)
.with_width(2.5 / self.scale()),
);
}
if let Some(at) = world
&& self.editable
{
@@ -1750,189 +1575,6 @@ mod tests {
}
}
#[test]
fn diagram_usage_state_rows_and_label_references_follow_actual_projection() {
use crate::diagram_usage::Target;
let mut f = fixture();
let work = f
.project
.add_element(Kind::Function, Layer::Sa, "Act", None)
.unwrap();
let event = f
.project
.add_element(Kind::Event, Layer::Sa, "E", None)
.unwrap();
let unused = f
.project
.add_element(Kind::Event, Layer::Sa, "E", None)
.unwrap();
let action = f
.project
.relate(RelationKind::StateAction, "Entry", f.a, work);
let internal = f
.project
.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
let external = f
.project
.relate(RelationKind::StateTransition, "External", f.a, f.b);
for (id, kind) in [
(internal, TransitionKind::Internal),
(external, TransitionKind::External),
] {
if let RelationBody::StateTransition {
transition_kind,
triggers,
effects,
..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = kind;
*triggers = vec![event];
*effects = vec![work];
}
}
let before = f.project.clone();
let diagram = &f.project.diagrams[&f.diagram];
let view = projection::project_diagram(&f.project, diagram);
let placement = view.nodes.iter().find(|n| n.element == f.a).unwrap().id;
let action_target = Target::StateRow {
placement,
relation: action,
};
let internal_target = Target::StateRow {
placement,
relation: internal,
};
assert_eq!(
diagram_usages(&f.project, diagram, &view, action)[0].target,
action_target
);
assert_eq!(
diagram_usages(&f.project, diagram, &view, internal)[0].target,
internal_target
);
let events = diagram_usages(&f.project, diagram, &view, event);
assert_eq!(events.len(), 2);
assert!(events.iter().any(|u| u.target == internal_target));
assert!(events.iter().any(|u| u.target == Target::Edge(external)));
assert!(diagram_usages(&f.project, diagram, &view, unused).is_empty());
// External edge identity belongs to the shared saved-view adapter.
assert!(diagram_usages(&f.project, diagram, &view, external).is_empty());
let work_usages = diagram_usages(&f.project, diagram, &view, work);
assert!(work_usages.iter().any(|u| u.target == action_target));
assert!(work_usages.iter().any(|u| u.target == internal_target));
let row = work_usages
.iter()
.find(|u| u.target == action_target)
.unwrap();
let canvas = canvas(&f, Tool::Select, 1.);
let scene = canvas.scene(&State::default()).unwrap();
assert_eq!(
canvas.hit(&scene, row.bounds.center()),
Some(Hit::StateAction(action))
);
assert_eq!(f.project, before);
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&placement)
.unwrap()
.collapsed = true;
let diagram = &f.project.diagrams[&f.diagram];
let folded = projection::project_diagram(&f.project, diagram);
assert!(diagram_usages(&f.project, diagram, &folded, action).is_empty());
assert!(diagram_usages(&f.project, diagram, &folded, internal).is_empty());
}
#[test]
fn diagram_usage_transition_labels_do_not_match_prose_or_fields_beyond_cutoff() {
let mut f = fixture();
let early = f
.project
.add_element(Kind::Event, Layer::Sa, "A", None)
.unwrap();
let late = f
.project
.add_element(Kind::Event, Layer::Sa, "B", None)
.unwrap();
let relation = f
.project
.relate(RelationKind::StateTransition, "Transition", f.a, f.b);
if let RelationBody::StateTransition {
triggers, guard, ..
} = &mut f.project.relations.get_mut(&relation).unwrap().body
{
*triggers = vec![early, late];
*guard = "B".into();
}
let relation = &f.project.relations[&relation];
assert_eq!(
transition_label_role(&f.project, relation, early, 84.),
Some("transition trigger")
);
assert_eq!(transition_label_role(&f.project, relation, late, 84.), None);
assert_eq!(
transition_label_role(&f.project, relation, late, 800.),
Some("transition trigger")
);
assert!(!retained_label_field("1234A678", "1234…", 4));
}
#[test]
fn diagram_usage_external_labels_obey_routing_cutoff_while_internal_rows_retain_full_summary() {
use crate::diagram_usage::Target;
let mut f = fixture();
let first = f
.project
.add_element(Kind::Event, Layer::Sa, &"A".repeat(35), None)
.unwrap();
let second = f
.project
.add_element(Kind::Event, Layer::Sa, "Second", None)
.unwrap();
let external = f
.project
.relate(RelationKind::StateTransition, "External", f.a, f.b);
let internal = f
.project
.relate(RelationKind::StateTransition, "Internal", f.a, f.a);
for (id, kind) in [
(external, TransitionKind::External),
(internal, TransitionKind::Internal),
] {
if let RelationBody::StateTransition {
transition_kind,
triggers,
..
} = &mut f.project.relations.get_mut(&id).unwrap().body
{
*transition_kind = kind;
*triggers = vec![first, second];
}
}
let diagram = &f.project.diagrams[&f.diagram];
let view = projection::project_diagram(&f.project, diagram);
let routed = routing::route_diagram(&f.project, diagram, &view);
let edge = &routed.edges[&external];
let painted = short(&edge.label.text, edge.label.bounds.size.width - 10., 10.);
// Independent painted-text oracle: 11 prefix characters + 35 As, then
// only the space before OR survives the routing limit.
assert_eq!(edge.label.text, format!("External · {} …", "A".repeat(35)));
assert!(!painted.contains("Second"));
let usages = diagram_usages(&f.project, diagram, &view, second);
assert!(!usages.iter().any(|u| u.target == Target::Edge(external)));
assert!(usages.iter().any(
|u| matches!(u.target, Target::StateRow { relation, .. } if relation == internal)
));
assert!(
diagram_usages(&f.project, diagram, &view, first)
.iter()
.any(|u| u.target == Target::Edge(external))
);
}
fn canvas(f: &Fixture, tool: Tool, zoom: f32) -> StateCanvas<'_> {
StateCanvas {
project: &f.project,
@@ -1944,7 +1586,6 @@ mod tests {
epoch: 3,
},
selected: f.machine,
navigation_focus: None,
tool,
zoom,
pan: Vector::new(20., 30.),
@@ -165,9 +165,6 @@ pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Elem
diagram: id,
context,
selected: app.selected,
navigation_focus: app
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
.map(|usage| usage.bounds),
tool: state.tool,
zoom: state.zoom,
pan: state.pan,
+62 -141
View File
@@ -5,7 +5,6 @@ use iced::widget::{Column, text_input};
use std::collections::BTreeSet;
pub(crate) mod contextual;
mod direct;
mod usages;
mod view;
use direct::Input;
pub(crate) use direct::{
@@ -60,6 +59,7 @@ pub enum Event {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Shelf {
Tools,
Selection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -80,7 +80,7 @@ pub struct State {
identity: Option<(Id, Id)>,
tool: CanvasTool,
expanded: BTreeSet<Id>,
details_offset: iced::Vector,
scroll_offsets: [iced::Vector; 2],
return_to: Vec<direct::ReturnFrame>,
draft: Option<Draft>,
viewport: Option<Size>,
@@ -351,8 +351,8 @@ impl State {
}
fn fit_area(&self, fallback_width: f32) -> Size {
// The sensor measures the viewport, independent of graph content.
// Leave a small content gutter; before first layout use a compact
// The sensor measures the inner scroller, independent of graph content.
// Leave a small gutter for scrollbars; before first layout use a compact
// provisional viewport and recompute when the sensor reports actual size.
let viewport = self.viewport.unwrap_or(Size::new(fallback_width, 180.));
Size::new(
@@ -361,6 +361,11 @@ impl State {
)
}
#[cfg(test)]
fn canvas_offset(&self) -> iced::Vector {
self.scroll_offsets[0]
}
#[cfg(test)]
fn fit_limited(&self, graph: Size, fallback_width: f32) -> bool {
let available = self.fit_area(fallback_width);
@@ -911,13 +916,6 @@ fn sequence_links(p: &Project, owner: Id) -> Vec<&Relation> {
})
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PreviewLink {
relation: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
}
struct GraphNode {
id: Id,
rect: Rectangle,
@@ -926,98 +924,26 @@ struct GraphNode {
control: Option<ControlKind>,
source: Option<Id>,
preview: Vec<(OccurrenceEndpoint, String)>,
preview_links: Vec<PreviewLink>,
preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>,
preview_total: usize,
}
impl GraphNode {
fn preview_link_points(&self, link: &PreviewLink) -> Option<[Point; 2]> {
if ![&link.source, &link.target]
.into_iter()
.all(|endpoint| self.preview.iter().any(|(shown, _)| shown == endpoint))
{
return None;
}
let a = self.endpoint_rect(&link.source);
let b = self.endpoint_rect(&link.target);
Some([
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
])
}
fn preview_link_visible_segment(&self, link: &PreviewLink) -> Option<[Point; 2]> {
visible_preview_segment(
self.preview_link_points(link)?,
(0..self.preview.len()).map(|index| self.preview_rect(index)),
)
}
fn preview_rect(&self, index: usize) -> Rectangle {
Rectangle::new(
Point::new(
self.rect.x + 12. + (index % 2) as f32 * 125.,
self.rect.y + 118. + (index / 2) as f32 * 44.,
),
Size::new(113., 32.),
)
}
fn endpoint_rect(&self, endpoint: &OccurrenceEndpoint) -> Rectangle {
self.preview
.iter()
.position(|(candidate, _)| candidate == endpoint)
.map(|index| self.preview_rect(index))
.map(|i| {
Rectangle::new(
Point::new(
self.rect.x + 12. + (i % 2) as f32 * 125.,
self.rect.y + 118. + (i / 2) as f32 * 44.,
),
Size::new(113., 32.),
)
})
.unwrap_or(self.rect)
}
}
/// Preview tiles are painted after their arrows. Focus only a remaining line
/// segment, excluding opaque tile interiors and their one-pixel borders.
fn visible_preview_segment(
[a, b]: [Point; 2],
tiles: impl Iterator<Item = Rectangle>,
) -> Option<[Point; 2]> {
let delta = b - a;
let length = (delta.x * delta.x + delta.y * delta.y).sqrt();
if !length.is_finite() || length <= 0.5 {
return None;
}
let mut hidden = Vec::new();
for tile in tiles {
let mut low = 0_f32;
let mut high = 1_f32;
for (origin, step, min, max) in [
(a.x, delta.x, tile.x - 1., tile.x + tile.width + 1.),
(a.y, delta.y, tile.y - 1., tile.y + tile.height + 1.),
] {
if step.abs() <= f32::EPSILON {
if origin < min || origin > max {
high = -1.;
break;
}
} else {
let first = (min - origin) / step;
let last = (max - origin) / step;
low = low.max(first.min(last));
high = high.min(first.max(last));
}
}
if high > low {
hidden.push((low, high));
}
}
hidden.sort_by(|a, b| a.0.total_cmp(&b.0));
let mut cursor = 0_f32;
let mut best = (0_f32, 0_f32);
for (low, high) in hidden.into_iter().chain([(1., 1.)]) {
if low - cursor > best.1 - best.0 {
best = (cursor, low);
}
cursor = cursor.max(high);
}
((best.1 - best.0) * length > 0.5).then(|| [a + delta * best.0, a + delta * best.1])
}
struct GraphEdge {
id: Id,
source: Id,
@@ -1058,6 +984,7 @@ mod contextual_routing;
struct Graph<'a> {
project: &'a Project,
interaction: Option<(Context, CanvasTool, bool)>,
scroll_offset: iced::Vector,
camera_pan: iced::Vector,
pending_insertion: Option<Insertion>,
nodes: Vec<GraphNode>,
@@ -1066,8 +993,6 @@ struct Graph<'a> {
ordered: bool,
size: Size,
selected: Id,
selected_preview: Option<OccurrenceEndpoint>,
selected_preview_link: Option<PreviewLink>,
scale: f32,
}
impl<'a> Graph<'a> {
@@ -1216,16 +1141,17 @@ impl<'a> Graph<'a> {
occurrence: r.target(),
})
})
.map(|r| PreviewLink {
relation: r.id,
source: OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.source(),
},
target: OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.target(),
},
.map(|r| {
(
OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.source(),
},
OccurrenceEndpoint {
references: vec![e.id],
occurrence: r.target(),
},
)
})
.collect();
}
@@ -1392,6 +1318,7 @@ impl<'a> Graph<'a> {
Self {
project: p,
interaction: None,
scroll_offset: iced::Vector::ZERO,
camera_pan: iced::Vector::ZERO,
pending_insertion: None,
nodes,
@@ -1400,8 +1327,6 @@ impl<'a> Graph<'a> {
ordered: !order.is_empty(),
size,
selected,
selected_preview: None,
selected_preview_link: None,
scale: 1.,
}
}
@@ -1631,41 +1556,39 @@ impl canvas::Program<Message> for Graph<'_> {
let mini: BTreeMap<_, _> = n
.preview
.iter()
.map(|(id, _)| (id.clone(), n.endpoint_rect(id)))
.enumerate()
.map(|(i, (id, _))| {
(
id.clone(),
Rectangle::new(
Point::new(
n.rect.x + 12. + (i % 2) as f32 * 125.,
n.rect.y + 118. + (i / 2) as f32 * 44.,
),
Size::new(113., 32.),
),
)
})
.collect();
for link in &n.preview_links {
let Some(points) = n.preview_link_points(link) else {
continue;
};
let selected = self.selected_preview_link.as_ref() == Some(link);
for (a, b) in &n.preview_links {
let a = mini[a];
let b = mini[b];
draw_arrow(
&mut f,
&points,
if selected { style::TEAL } else { style::MUTED },
if selected { 3. } else { 1. },
&[
Point::new(a.x + a.width, a.center_y()),
Point::new(b.x, b.center_y()),
],
style::MUTED,
1.,
);
}
for (id, name) in &n.preview {
let r = mini[id];
let selected = self.selected_preview.as_ref() == Some(id);
f.fill_rectangle(
r.position(),
r.size(),
if selected {
Color::from_rgb8(224, 247, 242)
} else {
Color::WHITE
},
);
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
f.stroke(
&Path::rectangle(r.position(), r.size()),
Stroke::default()
.with_color(if selected {
style::TEAL
} else {
style::LINE
})
.with_width(if selected { 2.5 } else { 1. }),
Stroke::default().with_color(style::LINE),
);
draw_text(
&mut f,
@@ -2249,6 +2172,7 @@ mod tests {
let accepted = app.document.snapshot();
assert!(app.behavior.prepare_pending(app.project()).is_err());
for (shelf, expected) in [
(Shelf::Tools, Some(Shelf::Tools)),
(Shelf::Selection, Some(Shelf::Selection)),
(Shelf::Selection, None),
] {
@@ -2265,12 +2189,10 @@ mod tests {
}
#[test]
fn process_camera_selection_ignores_independent_details_scroll() {
fn process_fit_canvas_selection_ignores_retained_ordered_page_scroll() {
let (p, owner, _, _, _, _) = fixture();
let mut state = State::default();
state.camera(crate::behavior::canvas_workspace::Event::Pan(
iced::Vector::new(11., 19.),
));
state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(11., 19.));
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 370.));
let context = Context {
document: p.id,
@@ -2281,17 +2203,16 @@ mod tests {
for scale in [0.45, 1.] {
let mut graph = Graph::new(&p, owner, owner);
graph.scale = scale;
graph.camera_pan = state.pan;
graph.scroll_offset = state.canvas_offset();
graph.interaction = Some((context, CanvasTool::Select, true));
let node = &graph.nodes[0];
let id = node.id;
let point = node.rect.center();
let bounds = Rectangle::new(Point::new(40., 100.), Size::new(800., 300.));
let screen = Point::new(
bounds.x + point.x * scale + 11.,
bounds.y + point.y * scale + 19.,
bounds.x + point.x * scale - 11.,
bounds.y + point.y * scale - 19.,
);
assert!(bounds.contains(screen));
let message = graph
.input(
&mut Input::default(),
@@ -158,23 +158,20 @@ impl State {
self.fit = false;
self.viewport = None;
self.expanded.clear();
self.details_offset = iced::Vector::ZERO;
self.scroll_offsets = [iced::Vector::ZERO; 2];
}
}
#[cfg(test)]
pub(crate) fn details_offset(&self) -> iced::Vector {
self.details_offset
pub(crate) fn scroll_offset(&self) -> iced::Vector {
self.scroll_offsets[0] + self.scroll_offsets[1]
}
pub(crate) fn scrolled(
&mut self,
scenario::ScrollArea::Page: scenario::ScrollArea,
offset: iced::Vector,
) -> bool {
if !offset.x.is_finite() || !offset.y.is_finite() || offset.x < 0. || offset.y < 0. {
return false;
}
let changed = self.details_offset != offset;
self.details_offset = offset;
pub(crate) fn scrolled(&mut self, area: scenario::ScrollArea, offset: iced::Vector) -> bool {
let index = match area {
scenario::ScrollArea::Canvas => 0,
scenario::ScrollArea::Page => 1,
};
let changed = self.scroll_offsets[index] != offset;
self.scroll_offsets[index] = offset;
changed
}
pub(crate) fn choose(&mut self, tool: CanvasTool) {
@@ -625,7 +622,7 @@ impl Graph<'_> {
{
*moved |= position.distance(*start) >= 3.;
}
mouse::Cursor::Available(*position)
mouse::Cursor::Available(*position + self.scroll_offset)
}
canvas::Event::Mouse(mouse::Event::WheelScrolled { .. } | mouse::Event::CursorLeft)
| canvas::Event::Window(
@@ -644,9 +641,11 @@ impl Graph<'_> {
}
canvas::Event::Mouse(
mouse::Event::ButtonPressed(_) | mouse::Event::ButtonReleased(_),
) => state.pointer.map_or(cursor, mouse::Cursor::Available),
) => state
.pointer
.map_or(cursor, |p| mouse::Cursor::Available(p + self.scroll_offset)),
canvas::Event::Window(iced::window::Event::RedrawRequested(_)) => {
state.pointer = cursor.position();
state.pointer = cursor.position().map(|p| p - self.scroll_offset);
cursor
}
_ => cursor,
@@ -670,7 +669,7 @@ impl Graph<'_> {
if !editable {
return None;
}
let start = cursor.position()?;
let start = cursor.position()? - self.scroll_offset;
match tool {
CanvasTool::Add(..) => {
state.press = Some(Press::Add {
@@ -830,6 +829,7 @@ impl Graph<'_> {
}
let point = state
.pointer
.map(|p| p + self.scroll_offset)
.or_else(|| cursor.position())
.filter(|p| bounds.contains(*p))
.map(|p| {
@@ -540,7 +540,7 @@ fn send(
input,
&canvas::Event::Mouse(event),
bounds,
mouse::Cursor::Available(point),
mouse::Cursor::Available(point + graph.scroll_offset),
)
.and_then(|a| a.into_inner().0)
}
@@ -554,20 +554,16 @@ fn position(graph: &Graph<'_>, id: Id) -> Point {
.center()
}
#[test]
fn direct_process_production_click_click_drag_insert_and_camera_coordinates() {
fn direct_process_production_click_click_drag_insert_and_scroll_coordinates() {
let f = fixture(Layer::Oa);
for drag in [false, true] {
let mut graph = graph(&f, CanvasTool::Connect);
graph.camera_pan = iced::Vector::new(35., 90.);
let bounds = Rectangle::new(
Point::new(10., 20.),
Size::new(graph.size.width + 70., graph.size.height + 180.),
);
graph.scroll_offset = iced::Vector::new(35., 90.);
let bounds = Rectangle::new(Point::new(10., 20.), graph.size);
let screen =
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) + graph.camera_pan;
|id| position(&graph, id) + iced::Vector::new(bounds.x, bounds.y) - graph.scroll_offset;
let a = screen(f.nodes[0]);
let b = screen(f.nodes[2]);
assert!(bounds.contains(a) && bounds.contains(b));
let mut input = Input::default();
send(
&graph,
@@ -1335,9 +1331,7 @@ fn direct_process_palette_preserves_fit_and_manual_camera() {
let document = Id::new_v4();
state.enter(document, f.owner);
state.viewport(Size::new(500., 300.));
state.camera(super::super::canvas_workspace::Event::Pan(
iced::Vector::new(35., 90.),
));
state.scrolled(scenario::ScrollArea::Canvas, iced::Vector::new(35., 90.));
state.scrolled(scenario::ScrollArea::Page, iced::Vector::new(0., 120.));
for camera in [None, Some(0.75), Some(1.5)] {
state.zoom(camera);
@@ -1359,16 +1353,14 @@ fn direct_process_palette_preserves_fit_and_manual_camera() {
"{layer:?} {camera:?} {tool:?}"
);
assert_eq!(state.fit, camera.is_none());
assert_eq!(state.pan, iced::Vector::new(35., 90.));
assert_eq!(state.details_offset(), iced::Vector::new(0., 120.));
assert_eq!(state.scroll_offset(), iced::Vector::new(35., 210.));
state.enter(document, f.owner);
assert_eq!(state.scale(size, 500.), before);
}
}
state.enter(Id::new_v4(), f.owner);
assert_eq!(state.scale(Size::new(690., 250.), 500.), 1.);
assert_eq!(state.pan, iced::Vector::ZERO);
assert_eq!(state.details_offset(), iced::Vector::ZERO);
assert_eq!(state.scroll_offset(), iced::Vector::ZERO);
}
}
@@ -1379,18 +1371,15 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
for scale in [0.15, 0.6898551, 1.5] {
let mut graph = graph(&f, CanvasTool::Add(Tool::Control, ControlKind::Choice));
graph.scale = scale;
graph.camera_pan = iced::Vector::new(11., 19.);
graph.scroll_offset = iced::Vector::new(11., 19.);
let bounds = Rectangle::new(
Point::new(40., 70.),
Size::new(
graph.size.width * scale + 22.,
graph.size.height * scale + 38.,
),
Size::new(graph.size.width * scale, graph.size.height * scale),
);
let screen = |world: Point| {
Point::new(
world.x * scale + bounds.x + 11.,
world.y * scale + bounds.y + 19.,
world.x * scale + bounds.x - 11.,
world.y * scale + bounds.y - 19.,
)
};
let edge = &graph.edges[0];
@@ -1398,7 +1387,6 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
(edge.points[0].x + edge.points[1].x) / 2.,
edge.points[0].y,
));
assert!(bounds.contains(point));
let mut input = Input::default();
send(
&graph,
@@ -1429,7 +1417,6 @@ fn direct_process_scaled_control_and_link_gestures_use_visible_positions() {
let mut input = Input::default();
let a = screen(position(&graph, f.nodes[0]));
let b = screen(position(&graph, f.nodes[2]));
assert!(bounds.contains(a) && bounds.contains(b));
send(
&graph,
&mut input,
@@ -86,7 +86,7 @@ async fn production_process_canvas_scaled_path_creation_preserves_source_and_edi
assert_eq!(app.behavior.topology.pan, pan);
mounted.scroll(&app, "process-form", 500.);
assert_eq!(mounted.geometry(&app).bounds("process-actions"), actions);
for shelf in [Shelf::Selection, Shelf::Selection] {
for shelf in [Shelf::Tools, Shelf::Selection, Shelf::Selection] {
let _ = app.update(event(Event::Shelf(shelf)));
assert_eq!(mounted.geometry(&app).bounds("process-viewport"), bounds);
assert_eq!(
@@ -723,7 +723,7 @@ async fn production_canvas_keyboard_and_delayed_tasks_respect_real_chooser_and_r
panic!("actual Review dialog opens");
};
let message = state.address_event(row);
let task = app.update(Message::Workflow(crate::workflow::Event::semantic(message)));
let task = app.update(Message::Workflow(crate::workflow::Event::Semantic(message)));
mounted.perform(&mut app, task).await;
}
let Some(crate::workflow::Workflow::Semantic(state)) = app.workflow.as_ref() else {
@@ -1,274 +0,0 @@
//! Read-only usages of the actual process graph, including bounded reuse tiles.
use super::*;
use crate::diagram_usage::{Surface, Target, Usage};
/// The only presentation inputs needed by a background usage query. Drafts,
/// focus, camera, history and widget state never cross the worker boundary.
#[derive(Clone)]
pub(crate) struct UsageSnapshot {
identity: Option<(Id, Id)>,
expanded: BTreeSet<Id>,
contextual_expanded: BTreeSet<OccurrenceEndpoint>,
}
impl UsageSnapshot {
pub(crate) fn into_query_state(self) -> State {
State {
identity: self.identity,
expanded: self.expanded,
contextual_expanded: self.contextual_expanded,
..State::default()
}
}
}
impl State {
pub(crate) fn usage_snapshot(&self) -> UsageSnapshot {
UsageSnapshot {
identity: self.identity,
expanded: self.expanded.clone(),
contextual_expanded: self.contextual_expanded.clone(),
}
}
}
fn owner_exists(p: &Project, owner: Id) -> bool {
p.elements
.get(&owner)
.is_some_and(|e| matches!(e.kind(), Kind::Process | Kind::FunctionalChain))
}
fn occurrence_role(p: &Project, occurrence: Id, subject: Id) -> Option<&'static str> {
let e = p.elements.get(&occurrence)?;
if occurrence == subject {
return Some("contribution");
}
match e.shared()? {
SharedBody::Occurrence {
body: OccurrenceBody::Action {
action: Some(id), ..
},
..
} if *id == subject => Some("action occurrence"),
SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(id), ..
},
..
} if *id == subject => Some("exchange occurrence"),
SharedBody::Occurrence {
body: OccurrenceBody::SequenceReference { sequence: Some(id) },
..
} if *id == subject => Some("reused process / chain"),
_ => None,
}
}
impl Graph<'_> {
fn usage_bounds(&self, target: &Target) -> Option<Rectangle> {
match target {
Target::Whole => Some(Rectangle::new(Point::ORIGIN, self.size)),
Target::ProcessOccurrence(endpoint) => {
let root = endpoint
.references
.first()
.copied()
.unwrap_or(endpoint.occurrence);
let node = self.nodes.iter().find(|node| node.id == root)?;
if endpoint.references.is_empty() {
Some(node.rect)
} else {
// endpoint_rect intentionally has a proxy fallback for other callers;
// a usage promises an actual tile and must never take that fallback.
node.preview
.iter()
.any(|(shown, _)| shown == endpoint)
.then(|| node.endpoint_rect(endpoint))
}
}
Target::ProcessLink(id) => {
let edge = self.edges.iter().find(|edge| edge.id == *id)?;
if !edge.guard.is_empty() {
Some(edge.label)
} else {
let segment = edge
.points
.windows(2)
.nth(edge.points.len().saturating_sub(1) / 2)?;
let center = Point::new(
(segment[0].x + segment[1].x) / 2.,
(segment[0].y + segment[1].y) / 2.,
);
Some(Rectangle::new(
center - iced::Vector::new(16., 16.),
Size::new(32., 32.),
))
}
}
Target::ProcessPreviewLink {
relation,
source,
target,
} => {
let node = self
.nodes
.iter()
.find(|node| source.references.first() == Some(&node.id))?;
let link = node.preview_links.iter().find(|link| {
link.relation == *relation && link.source == *source && link.target == *target
})?;
let [a, b] = node.preview_link_visible_segment(link)?;
Some(Rectangle::new(
Point::new((a.x + b.x) / 2. - 16., (a.y + b.y) / 2. - 16.),
Size::new(32., 32.),
))
}
_ => None,
}
}
}
impl State {
/// Inactive owners use the same default preview policy as entering their canvas.
fn usage_graph<'a>(&self, p: &'a Project, owner: Id) -> Graph<'a> {
if self.identity.is_some_and(|(_, current)| current == owner) {
Graph::expanded_qualified(p, owner, owner, &self.expanded, &self.contextual_expanded)
} else {
Graph::new(p, owner, owner)
}
}
pub(crate) fn diagram_usages(&self, p: &Project, owner: Id, subject: Id) -> Vec<Usage> {
if !owner_exists(p, owner)
|| !(p.elements.contains_key(&subject) || p.relations.contains_key(&subject))
{
return vec![];
}
let graph = self.usage_graph(p, owner);
let mut usages = vec![];
let mut add = |target: Target, role: &str| {
if let Some(bounds) = graph.usage_bounds(&target) {
usages.push(Usage {
subject,
surface: Surface::Process(owner),
target,
role: role.into(),
bounds,
});
}
};
if subject == owner {
add(Target::Whole, "process / chain canvas");
}
for node in &graph.nodes {
if let Some(role) = occurrence_role(p, node.id, subject) {
add(
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: node.id,
}),
role,
);
}
for (endpoint, _) in &node.preview {
if let Some(role) = occurrence_role(p, endpoint.occurrence, subject) {
add(
Target::ProcessOccurrence(endpoint.clone()),
&format!("reused {role}"),
);
}
}
for link in &node.preview_links {
if link.relation == subject {
add(
Target::ProcessPreviewLink {
relation: link.relation,
source: link.source.clone(),
target: link.target.clone(),
},
"reused sequence link",
);
}
}
}
for edge in &graph.edges {
let role = if edge.id == subject {
Some("process link")
} else {
match p.relations.get(&edge.id).map(|r| &r.body) {
Some(RelationBody::ContextualLink {
purpose: ContextualLinkPurpose::Exchange { exchange },
..
}) if *exchange == subject => Some("assembly exchange"),
Some(RelationBody::ContextualLink {
purpose: ContextualLinkPurpose::CommonWork { work },
..
}) if *work == subject => Some("common work"),
_ => None,
}
};
if let Some(role) = role {
add(Target::ProcessLink(edge.id), role);
}
}
usages
}
/// Returns success, not whether the camera changed. App must separately guard
/// its other drafts and revalidate the queried subject's current usage.
pub(crate) fn reveal_usage(
&mut self,
p: &Project,
document: Id,
owner: Id,
target: &Target,
) -> bool {
if !owner_exists(p, owner) || self.draft.is_some() {
return false;
}
let same = self.identity == Some((document, owner));
let graph = if same {
self.usage_graph(p, owner)
} else {
Graph::new(p, owner, owner)
};
let Some(bounds) = graph.usage_bounds(target) else {
return false;
};
let address = if let Target::ProcessOccurrence(endpoint) = target {
let Ok(resolved) =
oa_core::topology_context::TopologyIndex::new(p).resolve(owner, endpoint)
else {
return false;
};
(!endpoint.references.is_empty()).then_some(resolved.address)
} else {
None
};
let viewport = self.viewport.unwrap_or(Size::new(500., 300.));
self.enter(document, owner);
if !same {
self.contextual_expanded.clear();
}
self.selected_context = address;
self.tool = CanvasTool::Select;
self.viewport = Some(viewport);
let fit = ((viewport.width - 48.).max(1.) / bounds.width.max(1.))
.min((viewport.height - 48.).max(1.) / bounds.height.max(1.));
let scale = if matches!(target, Target::Whole) {
fit.clamp(0.0001, 1.)
} else {
fit.clamp(0.6, 1.25)
};
self.zoom = Some(scale);
self.fit = false;
self.pan = iced::Vector::new(
viewport.width / 2. - bounds.center_x() * scale,
viewport.height / 2. - bounds.center_y() * scale,
);
true
}
}
#[cfg(test)]
mod tests;
@@ -1,439 +0,0 @@
use super::*;
fn add(p: &mut Project, owner: Id, name: &str, body: OccurrenceBody) -> Id {
let e = occurrence(p, owner, name, body);
let id = e.id;
*p = execute_batch(p, &[Command::CreateElement(e)]).unwrap();
id
}
fn reused_fixture() -> (Project, Id, Id, Id, [Id; 2], Vec<Id>) {
let f = super::super::direct_tests::fixture(Layer::Oa);
let mut p = f.p;
let sequence = p
.make_element(Kind::Process, Layer::Oa, "Reviewed checks", None)
.unwrap();
let sequence_id = sequence.id;
p = execute_batch(&p, &[Command::CreateElement(sequence)]).unwrap();
let mut leaves = vec![];
for name in [
"Check one",
"Check two",
"Check three",
"Check four",
"Check five",
] {
leaves.push(add(
&mut p,
sequence_id,
name,
OccurrenceBody::Action {
action: Some(f.work[0]),
lifeline: None,
},
));
}
let first = add(
&mut p,
f.owner,
"First invocation",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
let second = add(
&mut p,
f.owner,
"Second invocation",
OccurrenceBody::SequenceReference {
sequence: Some(sequence_id),
},
);
(p, f.owner, sequence_id, f.work[0], [first, second], leaves)
}
#[test]
fn process_usages_exclude_endpoint_coincidence_and_keep_actual_occurrences_and_links() {
for layer in [Layer::Oa, Layer::Sa] {
let f = super::super::direct_tests::fixture(layer);
let mut p = f.p;
let state = State::default();
// Both exchange endpoints have Action contributions, but there is no
// Exchange occurrence and no exchange arrow in this process.
assert!(state.diagram_usages(&p, f.owner, f.exchange).is_empty());
let direct = state.diagram_usages(&p, f.owner, f.work[0]);
assert_eq!(direct.len(), 1);
assert_eq!(
direct[0].target,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: f.nodes[0]
})
);
let link = state.diagram_usages(&p, f.owner, f.link);
assert_eq!(link.len(), 1);
assert_eq!(link[0].target, Target::ProcessLink(f.link));
let first = add(
&mut p,
f.owner,
"First exchange",
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
let second = add(
&mut p,
f.owner,
"Second exchange",
OccurrenceBody::Exchange {
exchange: Some(f.exchange),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
operation: None,
},
);
let usages = state.diagram_usages(&p, f.owner, f.exchange);
assert_eq!(usages.len(), 2);
assert_eq!(
usages
.iter()
.map(|usage| usage.target.clone())
.collect::<Vec<_>>(),
vec![
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: first
}),
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![],
occurrence: second
}),
]
);
}
}
#[test]
fn process_usages_keep_caller_paths_and_exclude_unshown_fifth_preview() {
let (p, owner, sequence, work, calls, leaves) = reused_fixture();
let before = p.clone();
let mut state = State::default();
let leaf = state.diagram_usages(&p, owner, leaves[0]);
assert_eq!(leaf.len(), 2);
for (usage, call) in leaf.iter().zip(calls) {
assert_eq!(
usage.target,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![call],
occurrence: leaves[0]
})
);
}
assert_ne!(leaf[0].bounds, leaf[1].bounds);
assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty());
assert_eq!(state.diagram_usages(&p, owner, sequence).len(), 2);
// One local action and four tiles in each of two caller boxes.
assert_eq!(state.diagram_usages(&p, owner, work).len(), 9);
state.enter(Id::new_v4(), owner);
assert!(state.expand(&p, owner, calls[0]));
let fifth = state.diagram_usages(&p, owner, leaves[4]);
assert_eq!(fifth.len(), 1);
assert_eq!(
fifth[0].target,
Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: leaves[4]
})
);
// Another active owner must not leak its expansion to this queried canvas.
state.enter(Id::new_v4(), sequence);
assert!(state.diagram_usages(&p, owner, leaves[4]).is_empty());
assert_eq!(p, before);
}
#[test]
fn process_usage_reveal_validates_before_state_changes_and_centers_the_exact_tile() {
let (p, owner, _, _, calls, leaves) = reused_fixture();
let before = p.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter(document, owner);
state.viewport(Size::new(880., 460.));
state.pan = iced::Vector::new(-3000., -900.);
let hidden = Target::ProcessOccurrence(OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: leaves[4],
});
let prior = (
state.identity,
state.pan,
state.zoom,
state.fit,
state.selected_context.clone(),
);
assert!(!state.reveal_usage(&p, document, owner, &hidden));
assert_eq!(
(
state.identity,
state.pan,
state.zoom,
state.fit,
state.selected_context.clone()
),
prior
);
assert!(!state.reveal_usage(&p, document, Id::new_v4(), &Target::Whole));
assert_eq!(
(
state.identity,
state.pan,
state.zoom,
state.fit,
state.selected_context.clone()
),
prior
);
let endpoint = OccurrenceEndpoint {
references: vec![calls[1]],
occurrence: leaves[0],
};
let target = Target::ProcessOccurrence(endpoint.clone());
assert!(state.reveal_usage(&p, document, owner, &target));
assert_eq!(state.selected_context.as_ref().unwrap().endpoint, endpoint);
let bounds = state
.diagram_usages(&p, owner, leaves[0])
.into_iter()
.find(|usage| usage.target == target)
.unwrap()
.bounds;
let scale = state.zoom.unwrap();
assert!(scale >= 0.6);
assert!((bounds.center_x() * scale + state.pan.x - 440.).abs() < 0.01);
assert!((bounds.center_y() * scale + state.pan.y - 230.).abs() < 0.01);
assert!(state.reveal_usage(&p, document, owner, &Target::Whole));
assert!(state.selected_context.is_none());
state
.update(&p, Event::Start(owner, Tool::Work, None))
.unwrap();
let draft = state.recovery_snapshot().unwrap();
let camera = (state.pan, state.zoom, state.fit);
assert!(!state.reveal_usage(&p, document, owner, &target));
assert_eq!(state.recovery_snapshot().unwrap(), draft);
assert_eq!((state.pan, state.zoom, state.fit), camera);
assert_eq!(p, before);
}
#[test]
fn process_contextual_purpose_and_painted_preview_links_keep_distinct_identities() {
let (mut p, owner, sequence, work, calls, leaves) = reused_fixture();
let source = OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: leaves[0],
};
let target = OccurrenceEndpoint {
references: vec![calls[1]],
occurrence: leaves[0],
};
let relation = Relation {
id: Id::new_v4(),
alias: "same_check".into(),
name: "One common check".into(),
description: String::new(),
body: RelationBody::ContextualLink {
owner,
source,
target,
purpose: ContextualLinkPurpose::CommonWork { work },
},
};
let contextual = relation.id;
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else {
unreachable!()
};
let local_link = relation.id;
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
let state = State::default();
assert!(
state
.diagram_usages(&p, owner, work)
.iter()
.any(|usage| usage.target == Target::ProcessLink(contextual)
&& usage.role == "common work")
);
assert_eq!(state.diagram_usages(&p, owner, contextual).len(), 1);
let previews = state.diagram_usages(&p, owner, local_link);
assert_eq!(previews.len(), 2);
for (usage, call) in previews.iter().zip(calls) {
assert_eq!(
usage.target,
Target::ProcessPreviewLink {
relation: local_link,
source: OccurrenceEndpoint {
references: vec![call],
occurrence: leaves[0]
},
target: OccurrenceEndpoint {
references: vec![call],
occurrence: leaves[1]
},
}
);
}
assert_eq!(state.diagram_usages(&p, sequence, local_link).len(), 1);
}
#[test]
fn preview_arrow_focus_is_in_the_painted_gap_and_hidden_endpoints_never_fallback() {
let (mut p, owner, _, _, calls, leaves) = reused_fixture();
let Command::CreateRelation(shown) = link(&p, leaves[0], leaves[1], "next") else {
unreachable!()
};
let shown_id = shown.id;
p = execute_batch(&p, &[Command::CreateRelation(shown)]).unwrap();
let Command::CreateRelation(hidden) = link(&p, leaves[0], leaves[4], "later") else {
unreachable!()
};
let hidden_id = hidden.id;
p = execute_batch(&p, &[Command::CreateRelation(hidden)]).unwrap();
let before = p.clone();
let document = Id::new_v4();
let mut state = State::default();
state.enter(document, owner);
state.viewport(Size::new(800., 460.));
let rows = state.diagram_usages(&p, owner, shown_id);
assert_eq!(rows.len(), 2);
let graph = Graph::new(&p, owner, owner);
for (row, call) in rows.iter().zip(calls) {
let node = graph.nodes.iter().find(|n| n.id == call).unwrap();
// The painter places 113px tiles 125px apart, beginning 12px in.
// The 12px gap is centered 131px into the caller, 134px below its top.
assert_eq!(
row.bounds.center(),
Point::new(node.rect.x + 131., node.rect.y + 134.)
);
assert!(
node.preview
.iter()
.all(|(e, _)| !node.endpoint_rect(e).contains(row.bounds.center()))
);
}
assert_ne!(rows[0].bounds, rows[1].bounds);
assert!(state.diagram_usages(&p, owner, hidden_id).is_empty());
assert!(state.reveal_usage(&p, document, owner, &rows[1].target));
assert!(
state.selected_context.is_none(),
"an arrow is not an occurrence authoring context"
);
let zoom = state.zoom.unwrap();
assert!((rows[1].bounds.center_x() * zoom + state.pan.x - 400.).abs() < 0.01);
assert!((rows[1].bounds.center_y() * zoom + state.pan.y - 230.).abs() < 0.01);
assert!(state.expand(&p, owner, calls[0]));
let expanded = state.diagram_usages(&p, owner, hidden_id);
assert_eq!(expanded.len(), 1);
assert!(
matches!(&expanded[0].target, Target::ProcessPreviewLink { source, .. }
if source.references == vec![calls[0]])
);
let mut stale = rows[1].target.clone();
if let Target::ProcessPreviewLink { source, .. } = &mut stale {
source.references = vec![Id::new_v4()];
}
let prior = (state.pan, state.zoom, state.fit);
assert!(!state.reveal_usage(&p, document, owner, &stale));
assert_eq!((state.pan, state.zoom, state.fit), prior);
assert_eq!(p, before);
}
#[test]
fn visible_preview_segment_excludes_all_opaque_tiles_for_reverse_and_cross_row_arrows() {
let tiles = [
Rectangle::new(Point::new(12., 118.), Size::new(113., 32.)),
Rectangle::new(Point::new(137., 118.), Size::new(113., 32.)),
Rectangle::new(Point::new(12., 162.), Size::new(113., 32.)),
Rectangle::new(Point::new(137., 162.), Size::new(113., 32.)),
];
for points in [
[Point::new(250., 134.), Point::new(12., 134.)],
[Point::new(125., 134.), Point::new(137., 178.)],
[Point::new(250., 178.), Point::new(12., 134.)],
] {
let [a, b] = visible_preview_segment(points, tiles.into_iter()).unwrap();
for fraction in [0.1, 0.5, 0.9] {
let point = a + (b - a) * fraction;
assert!(tiles.iter().all(|tile| !tile.contains(point)));
}
}
assert!(
visible_preview_segment(
[Point::new(30., 134.), Point::new(100., 134.)],
tiles.into_iter()
)
.is_none()
);
assert!(
visible_preview_segment([Point::new(1., 1.), Point::new(1., 1.)], tiles.into_iter())
.is_none()
);
}
#[test]
fn expanded_nested_tiles_do_not_invent_links_the_preview_painter_does_not_emit() {
let (mut p, owner, sequence, work, calls, _) = reused_fixture();
let nested = p
.make_element(Kind::Process, Layer::Oa, "Nested checks", None)
.unwrap();
let nested_id = nested.id;
p = execute_batch(&p, &[Command::CreateElement(nested)]).unwrap();
let leaves: Vec<_> = ["Nested one", "Nested two"]
.into_iter()
.map(|name| {
add(
&mut p,
nested_id,
name,
OccurrenceBody::Action {
action: Some(work),
lifeline: None,
},
)
})
.collect();
let Command::CreateRelation(relation) = link(&p, leaves[0], leaves[1], "next") else {
unreachable!()
};
let relation_id = relation.id;
p = execute_batch(&p, &[Command::CreateRelation(relation)]).unwrap();
let reference = add(
&mut p,
sequence,
"Nested invocation",
OccurrenceBody::SequenceReference {
sequence: Some(nested_id),
},
);
let mut state = State::default();
state.enter(Id::new_v4(), owner);
state.expanded.insert(calls[0]);
state.contextual_expanded.insert(OccurrenceEndpoint {
references: vec![calls[0]],
occurrence: reference,
});
let shown = state.diagram_usages(&p, owner, leaves[0]);
assert_eq!(shown.len(), 1);
assert!(
matches!(&shown[0].target, Target::ProcessOccurrence(endpoint)
if endpoint.references == vec![calls[0], reference])
);
assert!(state.diagram_usages(&p, owner, relation_id).is_empty());
assert_eq!(state.diagram_usages(&p, nested_id, relation_id).len(), 1);
}
@@ -183,32 +183,6 @@ pub(super) fn view<'a>(app: &'a App, owner: Id, out: Column<'a, Message>) -> Ele
&state.expanded,
&state.contextual_expanded,
);
graph.selected_preview = state
.qualified_selection(p, owner)
.filter(|address| {
address
.endpoint
.references
.first()
.copied()
.unwrap_or(address.endpoint.occurrence)
== app.selected
})
.map(|address| address.endpoint.clone());
graph.selected_preview_link = app
.diagram_focus(crate::diagram_usage::Surface::Process(owner))
.and_then(|usage| match &usage.target {
crate::diagram_usage::Target::ProcessPreviewLink {
relation,
source,
target,
} => Some(PreviewLink {
relation: *relation,
source: source.clone(),
target: target.clone(),
}),
_ => None,
});
graph.scale = state.scale(graph.size, (app.window_size.width - 600.).max(200.));
graph.camera_pan = state.pan;
graph.pending_insertion = state
-20
View File
@@ -41,8 +41,6 @@ pub struct DiagramCanvas<'a> {
pub view_index: Option<&'a oa_core::view_options::ViewIndex>,
pub diagram: Id,
pub selected: Id,
/// Presentation-only navigation marker; never used by hit tests or edits.
pub navigation_focus: Option<Rectangle>,
pub zoom: f32,
pub pan: Vector,
pub context: Context,
@@ -1195,14 +1193,6 @@ impl canvas::Program<Message> for DiagramCanvas<'_> {
f.fill(&Path::circle(center, 1.5), color);
}
}
if let Some(bounds) = self.navigation_focus {
f.stroke(
&Path::rectangle(bounds.position(), bounds.size()),
Stroke::default()
.with_color(style::TEAL)
.with_width(2.5 / self.zoom),
);
}
if let Some(edge) = scene.routed.edges.get(&self.selected)
&& !edge.custom_suspended
{
@@ -1912,7 +1902,6 @@ mod tests {
connection: None,
diagram,
selected: p.id,
navigation_focus: None,
zoom: 1.,
pan: Vector::ZERO,
};
@@ -1987,7 +1976,6 @@ mod tests {
connection: None,
diagram,
selected: p.id,
navigation_focus: None,
zoom: 0.6,
pan: Vector::new(10., 20.),
};
@@ -2104,7 +2092,6 @@ mod tests {
diagram,
context,
selected: p.id,
navigation_focus: None,
selected_bend: None,
zoom,
pan: Vector::new(20., 30.),
@@ -2272,7 +2259,6 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom,
pan: Vector::new(150., 300.),
context: Context {
@@ -2348,7 +2334,6 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom: 0.5,
pan: Vector::new(150., 300.),
context: Context {
@@ -2445,7 +2430,6 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom: 1.,
pan: Vector::new(150., 300.),
context: Context {
@@ -2573,7 +2557,6 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom: 0.7,
pan: Vector::new(350., 350.),
context: Context {
@@ -2627,7 +2610,6 @@ mod tests {
view_index: None,
diagram,
selected: p.id,
navigation_focus: None,
zoom: 1.,
pan: Vector::new(100., 400.),
context: Context {
@@ -2701,7 +2683,6 @@ mod tests {
view_index: None,
diagram,
selected: edge,
navigation_focus: None,
zoom: 1.,
pan: Vector::new(100., 400.),
context: Context {
@@ -2734,7 +2715,6 @@ mod tests {
view_index: None,
diagram,
selected,
navigation_focus: None,
zoom: 1.,
pan: Vector::ZERO,
context: Context {
@@ -57,7 +57,6 @@ fn canvas(app: &App) -> DiagramCanvas<'_> {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: 1.,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -65,7 +65,6 @@ fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -6,7 +6,6 @@ fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
view_index: None,
diagram,
selected: Id::nil(),
navigation_focus: None,
zoom,
pan: Vector::new(17., 23.),
context: Context {
@@ -178,7 +178,6 @@ mod tests {
view_index: None,
diagram: app.active_diagram.unwrap(),
selected: app.selected,
navigation_focus: None,
zoom: 1.,
pan: Vector::ZERO,
context: app.route_context().unwrap(),
-472
View File
@@ -1,472 +0,0 @@
//! Read-only identities for objects actually represented by a diagram renderer.
use iced::Rectangle;
use oa_core::{Id, OccurrenceEndpoint, Project, projection, routing, view_options::ViewIndex};
pub(crate) mod view;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Context {
pub document: Id,
pub revision: u64,
pub subject: Id,
}
#[derive(Debug, Clone)]
pub(crate) enum Event {
Open(Context),
Close(Id),
Search(Id, String),
Page(Id, usize),
Destination(Id, Option<Surface>),
Choose(Id, Usage),
Move(Id, i8),
Activate(Id),
}
/// One completed immutable accepted-model lookup. The token owns one worker slot.
#[derive(Debug, Clone)]
pub(crate) struct Completion {
pub token: Id,
pub context: Context,
pub result: Result<Vec<Usage>, String>,
}
pub(crate) struct Panel {
pub token: Id,
pub context: Context,
pub usages: Vec<Usage>,
pub loading: bool,
pub search: String,
pub page: usize,
pub cursor: usize,
pub destination: Option<Surface>,
pub notice: Option<String>,
}
impl Panel {
pub fn new(context: Context, usages: Vec<Usage>) -> Self {
Self {
token: Id::new_v4(),
context,
usages,
loading: false,
search: String::new(),
page: 0,
cursor: 0,
destination: None,
notice: None,
}
}
pub fn matches<'a>(&'a self, p: &Project) -> Vec<&'a Usage> {
let query = self.search.to_lowercase();
if query.is_empty() {
return self.usages.iter().collect();
}
self.usages
.iter()
.filter(|usage| {
let surface = usage.surface;
let searchable = format!(
"{} {} {} {} {}",
surface.category(p),
p.named(surface.id()),
alias(p, surface.id()),
target_name(p, usage),
usage.role
);
searchable.to_lowercase().contains(&query)
})
.collect()
}
pub fn choices(&self, p: &Project) -> Vec<Event> {
if self.loading {
return Vec::new();
}
let matches = self.matches(p);
if let Some(surface) = self.destination {
matches
.into_iter()
.filter(|u| u.surface == surface)
.map(|u| Event::Choose(self.token, u.clone()))
.collect()
} else {
let mut seen = std::collections::BTreeSet::new();
matches
.into_iter()
.filter(|u| seen.insert(u.surface))
.map(|u| Event::Destination(self.token, Some(u.surface)))
.collect()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) enum Surface {
Saved(Id),
Process(Id),
Scenario(Id),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Target {
Whole,
Placement(Id),
Port {
port: Id,
placement: Id,
},
Edge(Id),
StateRow {
placement: Id,
relation: Id,
},
AllocationGrouping {
relation: Id,
work_placement: Id,
performer_placement: Id,
},
ProcessOccurrence(OccurrenceEndpoint),
ProcessLink(Id),
ProcessPreviewLink {
relation: Id,
source: OccurrenceEndpoint,
target: OccurrenceEndpoint,
},
ScenarioObject(Id),
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Usage {
pub subject: Id,
pub surface: Surface,
pub target: Target,
/// Concise, truthful explanation, e.g. "action occurrence" or "entry action".
pub role: String,
/// Actual current renderer coordinates. Recompute before activation.
pub bounds: Rectangle,
}
/// A navigation highlight is presentation state, never an edit/Delete target.
pub(crate) struct Focus {
pub context: Context,
pub usage: Usage,
pub selected: Id,
}
impl Surface {
pub fn id(self) -> Id {
match self {
Self::Saved(id) | Self::Process(id) | Self::Scenario(id) => id,
}
}
pub fn category(self, p: &Project) -> &'static str {
match self {
Self::Saved(id)
if p.diagrams
.get(&id)
.is_some_and(|d| d.projection.state_machine().is_some()) =>
{
"State views"
}
Self::Saved(_) => "Architecture",
Self::Process(_) => "Processes and chains",
Self::Scenario(_) => "Scenarios",
}
}
}
pub(crate) fn collect(
p: &Project,
subject: Id,
process: &crate::behavior::topology::State,
scenario: &crate::behavior::scenario::State,
) -> Vec<Usage> {
let mut usages = saved_usages(p, subject);
for element in p.elements.values() {
match element.kind() {
oa_core::Kind::Process | oa_core::Kind::FunctionalChain => {
usages.extend(process.diagram_usages(p, element.id, subject));
}
oa_core::Kind::Scenario => {
usages.extend(scenario.diagram_usages(p, element.id, subject));
}
_ => {}
}
}
usages.sort_by_cached_key(|u| {
(
u.surface.category(p),
p.named(u.surface.id()).to_lowercase(),
u.surface,
target_name(p, u).to_lowercase(),
)
});
usages
.dedup_by(|a, b| a.subject == b.subject && a.surface == b.surface && a.target == b.target);
usages
}
pub(crate) fn alias(p: &Project, id: Id) -> &str {
p.elements
.get(&id)
.map(|e| e.alias.as_str())
.or_else(|| p.relations.get(&id).map(|r| r.alias.as_str()))
.or_else(|| p.diagrams.get(&id).map(|d| d.alias.as_str()))
.or_else(|| p.traces.get(&id).map(|t| t.alias.as_str()))
.or_else(|| {
p.diagrams
.values()
.find_map(|d| d.nodes.get(&id).map(|n| n.alias.as_str()))
})
.unwrap_or("")
}
pub(crate) fn target_name(p: &Project, usage: &Usage) -> String {
let id = match &usage.target {
Target::Whole => usage.surface.id(),
Target::Placement(id) => p
.diagrams
.get(&usage.surface.id())
.and_then(|d| d.nodes.get(id))
.map_or(*id, |n| n.element),
Target::Port { port, .. } => *port,
Target::Edge(id)
| Target::StateRow { relation: id, .. }
| Target::AllocationGrouping { relation: id, .. }
| Target::ProcessLink(id)
| Target::ScenarioObject(id) => *id,
Target::ProcessOccurrence(endpoint) => {
return endpoint
.references
.iter()
.copied()
.chain([endpoint.occurrence])
.map(|id| format!("{} (@{})", p.named(id), alias(p, id)))
.collect::<Vec<_>>()
.join(" / ");
}
Target::ProcessPreviewLink {
relation, source, ..
} => {
return source
.references
.iter()
.copied()
.chain([*relation])
.map(|id| format!("{} (@{})", p.named(id), alias(p, id)))
.collect::<Vec<_>>()
.join(" / ");
}
};
format!("{} (@{})", p.named(id), alias(p, id))
}
/// Saved view membership is the renderer's final projection, including folds,
/// projection-kind exclusions and explicit view filters. Stored route metadata
/// and merely having both relation endpoints placed do not imply visibility.
pub(crate) fn saved_usages(p: &Project, subject: Id) -> Vec<Usage> {
saved_usages_for(p, subject, None)
}
/// Revalidate only the requested saved surface before activation. The optional
/// filter is applied before projection and routing, never after a whole lookup.
pub(crate) fn saved_usages_for(p: &Project, subject: Id, only_diagram: Option<Id>) -> Vec<Usage> {
if only_diagram.is_some_and(|id| !p.diagrams.contains_key(&id)) {
return Vec::new();
}
let index = ViewIndex::new(p);
let mut usages = Vec::new();
for diagram in p
.diagrams
.values()
.filter(|diagram| only_diagram.is_none_or(|id| diagram.id == id))
{
let view = projection::project_diagram_with_index(p, diagram, &index);
let surface = Surface::Saved(diagram.id);
if subject == diagram.id || diagram.projection.state_machine() == Some(subject) {
usages.push(Usage {
subject,
surface,
target: Target::Whole,
role: if subject == diagram.id {
"diagram"
} else {
"state machine view"
}
.into(),
bounds: routed_bounds(routing::route_diagram(p, diagram, &view).bounds),
});
}
for node in &view.nodes {
if subject == node.element || subject == node.id {
usages.push(Usage {
subject,
surface,
target: Target::Placement(node.id),
role: "node".into(),
bounds: Rectangle {
x: node.position.x,
y: node.position.y,
// Reveal the labelled header, not the empty interior of
// a large expanded container at the minimum zoom.
width: node.size.width.min(420.),
height: node.size.height.min(64.),
},
});
}
}
for port in &view.ports {
if subject == port.element {
usages.push(Usage {
subject,
surface,
target: Target::Port {
port: port.element,
placement: port.owner,
},
role: "port".into(),
bounds: Rectangle {
x: port.position.x - 12.,
y: port.position.y - 12.,
width: 24.,
height: 24.,
},
});
}
}
// Allocation arrows are suppressed in this projection. A direct
// relation is represented only by its actual visible containment chain;
// aggregate summaries never manufacture inherited relations.
if diagram.projection == oa_core::ProjectionKind::Allocation
&& let Some(relation) = p.relations.get(&subject)
&& relation.kind() == oa_core::RelationKind::Allocation
{
let placements: std::collections::BTreeMap<_, _> =
view.nodes.iter().map(|node| (node.id, node)).collect();
for work in view.nodes.iter().filter(|n| n.element == relation.target()) {
let mut parent = work.visual_parent;
let mut seen = std::collections::BTreeSet::new();
while let Some(id) = parent.filter(|id| seen.insert(*id)) {
let Some(performer) = placements.get(&id) else {
break;
};
if performer.element == relation.source() {
usages.push(Usage {
subject,
surface,
target: Target::AllocationGrouping {
relation: subject,
work_placement: work.id,
performer_placement: performer.id,
},
role: "allocation grouping".into(),
bounds: Rectangle {
x: work.position.x,
y: work.position.y,
width: work.size.width.min(420.),
height: work.size.height.min(64.),
},
});
break;
}
parent = performer.visual_parent;
}
}
}
if view.edges.iter().any(|edge| edge.id == subject)
&& let Some(edge) = routing::route_diagram(p, diagram, &view)
.edges
.get(&subject)
{
usages.push(Usage {
subject,
surface,
target: Target::Edge(subject),
role: "relationship".into(),
bounds: edge_focus_bounds(edge),
});
}
if diagram.projection.state_machine().is_some() {
usages.extend(crate::behavior::state_canvas::diagram_usages(
p, diagram, &view, subject,
));
}
}
usages
}
pub(crate) fn routed_bounds(bounds: routing::RoutingBounds) -> Rectangle {
Rectangle {
x: bounds.position.x,
y: bounds.position.y,
width: bounds.size.width,
height: bounds.size.height,
}
}
/// Focus a named edge at its label. For an unlabelled line, focus its middle
/// segment instead of shrinking a long connector to fit the whole diagram.
pub(crate) fn edge_focus_bounds(edge: &routing::RoutedEdge) -> Rectangle {
if !edge.label.text.is_empty() {
return routed_bounds(edge.label.bounds);
}
let point = edge
.points
.get(edge.points.len() / 2)
.copied()
.unwrap_or(edge.label.center);
Rectangle {
x: point.x - 40.,
y: point.y - 24.,
width: 80.,
height: 48.,
}
}
/// Whole-canvas navigation fits its entire extent; an individual target keeps
/// the readable minimum zoom of `camera` rather than shrinking unrelated space.
pub(crate) fn camera_for(
target: &Target,
bounds: Rectangle,
viewport: iced::Size,
) -> (f32, iced::Vector) {
if !matches!(target, Target::Whole) {
return camera(bounds, viewport);
}
let available = iced::Size::new(
(viewport.width - 64.).max(1.),
(viewport.height - 64.).max(1.),
);
let zoom = (available.width / bounds.width.max(1.))
.min(available.height / bounds.height.max(1.))
.clamp(0.0001, 1.25);
let pan = iced::Vector::new(
viewport.width / 2. - bounds.center_x() * zoom,
viewport.height / 2. - bounds.center_y() * zoom,
);
(zoom, pan)
}
/// Return a readable, centered camera without modifying authored geometry.
pub(crate) fn camera(bounds: Rectangle, viewport: iced::Size) -> (f32, iced::Vector) {
let available = iced::Size::new(
(viewport.width - 64.).max(1.),
(viewport.height - 64.).max(1.),
);
let zoom = (available.width / bounds.width.max(1.))
.min(available.height / bounds.height.max(1.))
.clamp(0.5, 1.25);
let pan = iced::Vector::new(
viewport.width / 2. - bounds.center_x() * zoom,
viewport.height / 2. - bounds.center_y() * zoom,
);
(zoom, pan)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
mod coverage_tests;
@@ -1,386 +0,0 @@
//! Independent allocation membership oracles. Expected targets and coordinates
//! come from handwritten fixtures, never from the usage predicate under test.
use super::{Surface, Target, Usage, saved_usages, saved_usages_for};
use iced::Rectangle;
use oa_core::{
Id, Kind, Layer, Position, Project, ProjectionKind, RelationKind, Size, projection, validation,
};
struct AllocationFixture {
project: Project,
diagram: Id,
performer: Id,
work: Id,
relation: Id,
performer_node: Id,
work_node: Id,
}
fn assert_valid(project: &Project) {
let errors: Vec<_> = validation::validate(project)
.into_iter()
.filter(|issue| issue.severity == validation::Severity::Error)
.collect();
assert!(
errors.is_empty(),
"fixture must be structurally valid: {errors:?}"
);
}
fn fixture() -> AllocationFixture {
let mut project = Project::empty();
let performer = project
.add_element(Kind::Entity, Layer::Oa, "Rescue team", None)
.unwrap();
let work = project
.add_element(Kind::Activity, Layer::Oa, "Dispatch", None)
.unwrap();
let relation = project.relate(RelationKind::Allocation, "Performs", performer, work);
let diagram = project.ensure_diagram(Layer::Oa);
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
let performer_node = project.place(diagram, performer, Position { x: 20., y: 40. });
let work_node = project.place(diagram, work, Position { x: 240., y: 320. });
project
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&work_node)
.unwrap()
.size = Size {
width: 320.,
height: 110.,
};
assert_valid(&project);
AllocationFixture {
project,
diagram,
performer,
work,
relation,
performer_node,
work_node,
}
}
fn expected(f: &AllocationFixture) -> Usage {
Usage {
subject: f.relation,
surface: Surface::Saved(f.diagram),
target: Target::AllocationGrouping {
relation: f.relation,
work_placement: f.work_node,
performer_placement: f.performer_node,
},
role: "allocation grouping".into(),
bounds: Rectangle {
x: 240.,
y: 320.,
width: 320.,
height: 64.,
},
}
}
/// Two semantic decomposition levels, both summaries of the one allocated leaf.
/// No Allocation relation targets either summary.
fn nest_under_summaries(f: &mut AllocationFixture) -> (Id, Id, Id, Id) {
let top = f
.project
.add_element(Kind::Activity, Layer::Oa, "Respond", None)
.unwrap();
let middle = f
.project
.add_element(Kind::Activity, Layer::Oa, "Coordinate", Some(top))
.unwrap();
f.project.elements.get_mut(&f.work).unwrap().parent = Some(middle);
let top_node = f
.project
.place(f.diagram, top, Position { x: 70., y: 120. });
let middle_node = f
.project
.place(f.diagram, middle, Position { x: 130., y: 210. });
assert_valid(&f.project);
(top, middle, top_node, middle_node)
}
#[test]
fn allocation_grouping_has_exact_relation_placements_and_readable_work_geometry() {
let f = fixture();
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
let work = view
.nodes
.iter()
.find(|node| node.id == f.work_node)
.unwrap();
assert_eq!(work.visual_parent, Some(f.performer_node));
assert_eq!(work.position, Position { x: 240., y: 320. });
assert_eq!(
work.size,
Size {
width: 320.,
height: 110.
}
);
assert!(!view.edges.iter().any(|edge| edge.id == f.relation));
assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]);
assert_eq!(
saved_usages_for(&f.project, f.relation, Some(f.diagram)),
vec![expected(&f)]
);
assert!(saved_usages_for(&f.project, f.relation, Some(Id::new_v4())).is_empty());
// The semantic work and its placement remain ordinary node selections.
for subject in [f.work, f.work_node] {
let usages = saved_usages(&f.project, subject);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].target, Target::Placement(f.work_node));
}
assert_eq!(f.project, before);
}
#[test]
fn allocation_grouping_walks_two_visible_summary_levels_without_inventing_allocations() {
let mut f = fixture();
let (top, middle, top_node, middle_node) = nest_under_summaries(&mut f);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
for (node_id, parent, summary) in [
(f.work_node, Some(middle_node), false),
(middle_node, Some(top_node), true),
(top_node, Some(f.performer_node), true),
] {
let node = view.nodes.iter().find(|node| node.id == node_id).unwrap();
assert_eq!(node.visual_parent, parent);
assert_eq!(node.allocation_summary, summary);
}
assert_eq!(f.project.relations.len(), 1);
assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]);
for (element, node) in [(top, top_node), (middle, middle_node)] {
let usages = saved_usages(&f.project, element);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].target, Target::Placement(node));
}
assert_eq!(f.project, before);
}
#[test]
fn folded_summary_or_performer_does_not_substitute_a_visible_ancestor_for_the_leaf() {
let mut f = fixture();
let (_, _, top_node, middle_node) = nest_under_summaries(&mut f);
for folded in [f.performer_node, top_node, middle_node] {
let mut project = f.project.clone();
project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&folded)
.unwrap()
.collapsed = true;
assert_valid(&project);
let before = project.clone();
let view = projection::project_view(&project, f.diagram);
assert!(view.nodes.iter().any(|node| node.id == folded));
assert!(!view.nodes.iter().any(|node| node.id == f.work_node));
assert!(saved_usages(&project, f.relation).is_empty());
assert_eq!(project, before);
}
}
#[test]
fn absent_endpoint_placements_and_hidden_performer_do_not_create_grouping_targets() {
let mut f = fixture();
let (_, _, top_node, _) = nest_under_summaries(&mut f);
for absent in [f.performer_node, f.work_node] {
let mut project = f.project.clone();
project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.remove(&absent);
assert_valid(&project);
let before = project.clone();
let view = projection::project_view(&project, f.diagram);
// The aggregate card remains visible even when the actual leaf or its
// actual performer has no placement. It cannot stand in for that end.
assert!(
view.nodes
.iter()
.any(|node| node.id == top_node && node.allocation_summary)
);
assert!(saved_usages(&project, f.relation).is_empty());
assert_eq!(project, before);
}
let outer = f
.project
.add_element(Kind::Entity, Layer::Oa, "Organization", None)
.unwrap();
f.project.elements.get_mut(&f.performer).unwrap().parent = Some(outer);
let outer_node = f
.project
.place(f.diagram, outer, Position { x: -40., y: -60. });
f.project
.diagrams
.get_mut(&f.diagram)
.unwrap()
.nodes
.get_mut(&outer_node)
.unwrap()
.collapsed = true;
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
assert!(view.nodes.iter().any(|node| node.id == outer_node));
assert!(!view.nodes.iter().any(|node| node.id == f.performer_node));
assert!(saved_usages(&f.project, f.relation).is_empty());
assert_eq!(f.project, before);
}
#[test]
fn collapsed_leaf_and_edge_filter_still_leave_the_actual_nesting_visible() {
let mut f = fixture();
let diagram = f.project.diagrams.get_mut(&f.diagram).unwrap();
diagram.nodes.get_mut(&f.work_node).unwrap().collapsed = true;
// hidden_edges controls arrows. Allocation is drawn as nesting, which is
// still visible; do not mistake the filter for a hidden node/grouping.
diagram.view_options.hidden_edges.push(f.relation);
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
let work = view
.nodes
.iter()
.find(|node| node.id == f.work_node)
.unwrap();
assert!(work.collapsed);
assert_eq!(work.visual_parent, Some(f.performer_node));
assert_eq!(saved_usages(&f.project, f.relation), vec![expected(&f)]);
assert_eq!(f.project, before);
}
#[test]
fn other_projections_do_not_report_allocation_nesting_when_only_an_arrow_or_nothing_is_drawn() {
let mut f = fixture();
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::Overview;
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
assert!(view.edges.iter().any(|edge| edge.id == f.relation));
assert!(view.nodes.iter().all(|node| node.visual_parent.is_none()));
let usages = saved_usages(&f.project, f.relation);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].subject, f.relation);
assert_eq!(usages[0].surface, Surface::Saved(f.diagram));
assert_eq!(usages[0].target, Target::Edge(f.relation));
assert_eq!(f.project, before);
f.project.diagrams.get_mut(&f.diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
assert!(view.nodes.iter().any(|node| node.id == f.work_node));
assert!(!view.nodes.iter().any(|node| node.id == f.performer_node));
assert!(saved_usages(&f.project, f.relation).is_empty());
assert_eq!(f.project, before);
}
#[test]
fn repeated_saved_views_keep_allocation_placement_identity_and_geometry_independent() {
let mut f = fixture();
// The first diagram is Allocation, so this creates a different Overview.
let second = f.project.ensure_diagram(Layer::Oa);
assert_ne!(second, f.diagram);
f.project.diagrams.get_mut(&second).unwrap().projection = ProjectionKind::Allocation;
let second_performer = f
.project
.place(second, f.performer, Position { x: 900., y: 600. });
let second_work = f
.project
.place(second, f.work, Position { x: 1000., y: 700. });
f.project
.diagrams
.get_mut(&second)
.unwrap()
.nodes
.get_mut(&second_work)
.unwrap()
.size = Size {
width: 520.,
height: 130.,
};
assert_ne!(second_performer, f.performer_node);
assert_ne!(second_work, f.work_node);
assert_valid(&f.project);
let before = f.project.clone();
let other = Usage {
subject: f.relation,
surface: Surface::Saved(second),
target: Target::AllocationGrouping {
relation: f.relation,
work_placement: second_work,
performer_placement: second_performer,
},
role: "allocation grouping".into(),
bounds: Rectangle {
x: 1000.,
y: 700.,
width: 420.,
height: 64.,
},
};
let usages = saved_usages(&f.project, f.relation);
assert_eq!(usages.len(), 2);
assert!(usages.contains(&expected(&f)));
assert!(usages.contains(&other));
assert_eq!(
saved_usages_for(&f.project, f.relation, Some(second)),
vec![other]
);
assert_eq!(f.project, before);
}
#[test]
fn role_derived_nesting_without_a_direct_allocation_does_not_invent_one() {
let mut f = fixture();
f.project.relations.remove(&f.relation);
let role = f
.project
.add_element(Kind::Role, Layer::Oa, "Dispatcher", None)
.unwrap();
let realization = f.project.relate(
RelationKind::RoleRealization,
"Realizes dispatcher",
f.performer,
role,
);
let assignment = f
.project
.relate(RelationKind::RoleAssignment, "Dispatches", role, f.work);
assert_valid(&f.project);
let before = f.project.clone();
let view = projection::project_view(&f.project, f.diagram);
let work = view
.nodes
.iter()
.find(|node| node.id == f.work_node)
.unwrap();
assert_eq!(work.visual_parent, Some(f.performer_node));
assert!(work.allocation_summary);
assert!(
f.project
.relations
.values()
.all(|relation| relation.kind() != RelationKind::Allocation)
);
for subject in [f.relation, realization, assignment] {
assert!(saved_usages(&f.project, subject).is_empty());
}
let usages = saved_usages(&f.project, f.work);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].target, Target::Placement(f.work_node));
assert_eq!(f.project, before);
}
@@ -1,196 +0,0 @@
use super::*;
use oa_core::{Kind, Layer, Position, RelationKind};
#[test]
fn saved_membership_respects_filters_and_does_not_change_project() {
let mut p = Project::empty();
let first = p.add(Kind::Activity, "Same name", 20., 20.);
let second = p.add(Kind::Activity, "Same name", 340., 20.);
let relation = p.relate(RelationKind::Interaction, "Handoff", first, second);
let shown = p.ensure_diagram(Layer::Oa);
let mut hidden = p.diagrams[&shown].clone();
hidden.id = Id::new_v4();
hidden.alias = "filtered_copy".into();
hidden.nodes = hidden
.nodes
.into_values()
.map(|mut node| {
node.id = Id::new_v4();
node.alias = format!("filtered_{}", node.alias);
(node.id, node)
})
.collect();
hidden.view_options.hidden_edges.push(relation);
let hidden_id = hidden.id;
p.diagrams.insert(hidden_id, hidden);
let before = p.clone();
let hits = saved_usages(&p, relation);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].surface, Surface::Saved(shown));
assert_eq!(hits[0].target, Target::Edge(relation));
let hits = saved_usages(&p, first);
assert_eq!(hits.len(), 2);
assert!(hits.iter().all(|hit| match hit.target {
Target::Placement(node) => p.diagrams[&hit.surface.id()].nodes[&node].element == first,
_ => false,
}));
assert_eq!(p, before);
}
#[test]
fn folded_child_is_not_a_visible_node_and_placement_identity_stays_exact() {
let mut p = Project::empty();
let parent = p.add(Kind::Activity, "Container", 20., 20.);
let child = p
.add_element(Kind::Activity, Layer::Oa, "Nested", Some(parent))
.unwrap();
let diagram = p.ensure_diagram(Layer::Oa);
let child_node = p.place(diagram, child, Position { x: 50., y: 100. });
let parent_node = p.diagrams[&diagram]
.nodes
.values()
.find(|n| n.element == parent)
.unwrap()
.id;
let hits = saved_usages(&p, child_node);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].target, Target::Placement(child_node));
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&parent_node)
.unwrap()
.collapsed = true;
let before = p.clone();
assert!(saved_usages(&p, child).is_empty());
assert!(saved_usages(&p, child_node).is_empty());
assert_eq!(saved_usages(&p, parent).len(), 1);
assert_eq!(p, before);
}
#[test]
fn ports_are_found_at_the_visible_owner_and_unplaced_elements_do_not_match_names() {
let mut p = Project::empty();
let owner = p.add(Kind::Function, "Compute", 20., 20.);
let port = Id::new_v4();
let element = oa_core::ports::make_port(
&p,
port,
owner,
"Input",
oa_core::PortContract::FunctionInput {
incoming_items: vec![],
},
)
.unwrap();
p = oa_core::execute_batch(&p, &[oa_core::Command::CreateElement(element)]).unwrap();
let unrelated = p
.add_element(Kind::Function, Layer::Sa, "Compute", None)
.unwrap();
let hits = saved_usages(&p, port);
assert_eq!(hits.len(), 1);
let view = projection::project_view(&p, hits[0].surface.id());
let painted = view
.ports
.iter()
.find(|shown| shown.element == port)
.unwrap();
assert_eq!(
hits[0].target,
Target::Port {
port,
placement: painted.owner
}
);
assert_eq!(
hits[0].bounds.center(),
iced::Point::new(painted.position.x, painted.position.y)
);
assert!(saved_usages(&p, unrelated).is_empty());
assert!(saved_usages(&p, Id::new_v4()).is_empty());
}
#[test]
fn reveal_camera_centers_target_without_whole_view_shrink() {
let bounds = Rectangle {
x: 100_000.,
y: -50_000.,
width: 224.,
height: 94.,
};
let viewport = iced::Size::new(700., 500.);
let (zoom, pan) = camera(bounds, viewport);
assert_eq!(zoom, 1.25);
assert!((bounds.center_x() * zoom + pan.x - 350.).abs() < 0.01);
assert!((bounds.center_y() * zoom + pan.y - 250.).abs() < 0.01);
}
#[test]
fn oversized_container_reveal_targets_its_labelled_header() {
let mut p = Project::empty();
let container = p.add(Kind::Activity, "Large enclosing activity", 40., 70.);
let diagram = p.ensure_diagram(Layer::Oa);
let node = p
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.values_mut()
.next()
.unwrap();
node.size = oa_core::Size {
width: 4000.,
height: 4000.,
};
let before = p.clone();
let hits = saved_usages(&p, container);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].bounds.x, 40.);
assert_eq!(hits[0].bounds.y, 70.);
assert!(hits[0].bounds.width <= 420.);
assert!(hits[0].bounds.height <= 64.);
let (zoom, pan) = camera(hits[0].bounds, iced::Size::new(500., 300.));
assert!((hits[0].bounds.center_x() * zoom + pan.x - 250.).abs() < 0.01);
assert!(70. * zoom + pan.y >= 0.);
assert_eq!(p, before);
}
#[test]
fn state_machine_owner_finds_its_view_without_a_machine_placement() {
let mut p = Project::empty();
let machine = p
.add_element(Kind::StateMachine, Layer::Sa, "Monitor", None)
.unwrap();
let unrelated = p
.add_element(Kind::StateMachine, Layer::Sa, "Other", None)
.unwrap();
let diagram = p.ensure_diagram(Layer::Sa);
p.diagrams.get_mut(&diagram).unwrap().projection =
oa_core::ProjectionKind::StateMachine { machine };
let before = p.clone();
let hits = saved_usages(&p, machine);
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].surface, Surface::Saved(diagram));
assert_eq!(hits[0].target, Target::Whole);
assert_eq!(hits[0].role, "state machine view");
assert!(saved_usages(&p, unrelated).is_empty());
assert_eq!(p, before);
}
#[test]
fn whole_diagram_camera_fits_large_bounds_instead_of_individual_object_scale() {
let bounds = Rectangle {
x: 2000.,
y: -4000.,
width: 100_000.,
height: 80_000.,
};
let (zoom, pan) = camera_for(&Target::Whole, bounds, iced::Size::new(500., 300.));
assert!(zoom < 0.01);
assert!(bounds.x * zoom + pan.x >= 0.);
assert!(bounds.y * zoom + pan.y >= 0.);
assert!((bounds.x + bounds.width) * zoom + pan.x <= 500.);
assert!((bounds.y + bounds.height) * zoom + pan.y <= 300.);
}
-220
View File
@@ -1,220 +0,0 @@
use super::*;
use crate::{
app::{App, Message},
style,
views::{action, short},
};
use iced::{
Element, Fill,
widget::{self, column, container, row, text, text_input, tooltip},
};
use std::collections::BTreeSet;
const PAGE_SIZE: usize = 6;
pub(crate) fn overlay<'a>(app: &'a App, base: Element<'a, Message>) -> Element<'a, Message> {
let Some(panel) = &app.diagram_usages else {
return base;
};
let p = app.project();
let token = panel.token;
let message = |event| Message::DiagramUsage(event);
let matched = panel.matches(p);
let mut surfaces = Vec::new();
let mut seen = BTreeSet::new();
for usage in &matched {
if seen.insert(usage.surface) {
surfaces.push(usage.surface);
}
}
let targets: Vec<_> = matched
.iter()
.copied()
.filter(|usage| Some(usage.surface) == panel.destination)
.collect();
let count = if panel.destination.is_some() {
targets.len()
} else {
surfaces.len()
};
let last = count.saturating_sub(1) / PAGE_SIZE;
let page = panel.page.min(last);
let total_surfaces = panel
.usages
.iter()
.map(|u| u.surface)
.collect::<BTreeSet<_>>()
.len();
let mut content = column![
row![
text("Show in diagrams").size(22).width(Fill),
action("Close", message(Event::Close(token)))
]
.spacing(12),
tooltip(
text(format!(
"{} · @{}",
short(p.named(panel.context.subject), 48),
short(alias(p, panel.context.subject), 48)
))
.size(14),
container(
widget::scrollable(
text(format!(
"{}\n@{}",
p.named(panel.context.subject),
alias(p, panel.context.subject)
))
.size(13)
)
.height(220)
)
.padding(10)
.width(620)
.style(style::card),
tooltip::Position::Bottom
),
text(if panel.loading {
"Searching rendered objects…".into()
} else {
format!(
"{} diagram destinations · {} representations",
total_surfaces,
panel.usages.len()
)
})
.size(12)
.color(style::MUTED),
text_input("Filter diagram, occurrence, or alias", &panel.search)
.id(format!("diagram-usage-filter-{}", panel.token))
.padding(9)
.on_input(move |value| Message::DiagramUsage(Event::Search(token, value)))
.on_submit(message(Event::Activate(token))),
]
.spacing(10);
if let Some(notice) = &panel.notice {
content = content.push(text(notice).size(13));
}
if let Some(surface) = panel.destination {
content = content.push(
row![
action("All diagrams", message(Event::Destination(token, None))),
text(short(p.named(surface.id()), 55)).size(15),
]
.spacing(10),
);
for (index, usage) in targets
.into_iter()
.enumerate()
.skip(page * PAGE_SIZE)
.take(PAGE_SIZE)
{
let name = target_name(p, usage);
content = content.push(tooltip(
action(
&format!("{} · {}", usage.role, short(&name, 70)),
message(Event::Choose(token, usage.clone())),
)
.width(Fill)
.style(if panel.cursor == index {
style::selected
} else {
style::nav
}),
container(text(name).size(13))
.padding(10)
.max_width(680)
.style(style::card),
tooltip::Position::Top,
));
}
} else {
for (index, surface) in surfaces
.into_iter()
.enumerate()
.skip(page * PAGE_SIZE)
.take(PAGE_SIZE)
{
let targets = matched.iter().filter(|u| u.surface == surface).count();
let name = format!(
"{} · {} (@{}) · {} representations",
surface.category(p),
p.named(surface.id()),
alias(p, surface.id()),
targets
);
let layer = p
.diagrams
.get(&surface.id())
.map(|d| d.layer)
.or_else(|| p.layer_of(surface.id()))
.map(|l| l.to_string())
.unwrap_or_default();
content = content.push(tooltip(
action(
&format!(
"{} · {} · {} · {}",
surface.category(p),
layer,
short(p.named(surface.id()), 34),
targets
),
message(Event::Destination(token, Some(surface))),
)
.width(Fill)
.style(if panel.cursor == index {
style::selected
} else {
style::nav
}),
container(text(name).size(13))
.padding(10)
.max_width(680)
.style(style::card),
tooltip::Position::Top,
));
}
}
if panel.loading {
content = content.push(text("Finding diagram representations…").size(13));
} else if count == 0 {
content = content.push(text(if panel.usages.is_empty() {
"No currently rendered representation found. Folded or filtered objects and unexpanded reuse contents are excluded."
} else { "No results match this filter." }).size(13));
}
let mut paging = row![
text(format!(
"Page {} of {} · {} matches",
page + 1,
last + 1,
count
))
.size(12)
.width(Fill)
]
.spacing(8);
if page > 0 {
paging = paging.push(action("Previous", message(Event::Page(token, page - 1))));
}
if page < last {
paging = paging.push(action("Next", message(Event::Page(token, page + 1))));
}
content = content.push(paging).push(text("↑ ↓ choose · Enter opens · Escape closes. Source and saved view settings are preserved.").size(12).color(style::MUTED));
widget::stack![
base,
widget::opaque(
container(
container(content)
.padding(22)
.width((app.window_size.width - 80.).clamp(620., 820.))
.style(style::card)
)
.center(Fill)
.style(|_| container::Style {
background: Some(iced::Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
..Default::default()
})
)
]
.into()
}
+4 -18
View File
@@ -95,7 +95,7 @@ pub struct Document {
past: Vec<HistoryEntry>,
future: Vec<HistoryEntry>,
current_bytes: usize,
saved: Option<Arc<SourceBundle>>,
saved: Arc<SourceBundle>,
revision: u64,
view_index: std::cell::OnceCell<oa_core::view_options::ViewIndex>,
limits: ProjectLimits,
@@ -125,7 +125,7 @@ impl Document {
current_bytes,
past: Vec::new(),
future: Vec::new(),
saved: Some(Arc::new(bundle)),
saved: Arc::new(bundle),
revision: 0,
view_index: Default::default(),
limits,
@@ -156,7 +156,7 @@ impl Document {
}
pub fn dirty(&self) -> bool {
self.recovered_unsaved || self.saved.as_deref() != Some(self.bundle())
self.recovered_unsaved || self.bundle() != self.saved.as_ref()
}
/// Recovered work is a detached copy until explicitly saved by its user.
@@ -181,24 +181,10 @@ impl Document {
/// Records the bytes actually saved, even when later edits remain current.
/// The I/O adapter owns document identity and save completion ordering.
pub fn mark_saved(&mut self, snapshot: Arc<CompiledSourceProject>) {
self.saved = Some(Arc::new(snapshot.bundle.clone()));
self.saved = Arc::new(snapshot.bundle.clone());
self.recovered_unsaved = false;
}
/// Bind an installed or recovered repair destination to source read there.
/// These bytes may be invalid or differ from the retained accepted model,
/// but must replace the previous disk baseline. Accepted history is retained;
/// applying or undoing to old saved content does not save this destination.
pub(crate) fn bind_repair_baseline(&mut self, source: SourceBundle) {
self.saved = Some(Arc::new(source));
}
/// Recovery changed the current destination, but its source could not be
/// reloaded. No accepted or historical snapshot is known to be saved there.
pub(crate) fn invalidate_saved_baseline(&mut self) {
self.saved = None;
}
/// Check a source Save as before writing any files. A retained included file
/// must never be displaced by the new entry, including on a later Undo.
pub fn check_save_entry(&self, entry: &str) -> Result<(), DocumentError> {
+1 -1
View File
@@ -670,7 +670,7 @@ mod tests {
}
fn semantic_send(app: &mut crate::app::App, event: crate::semantic_review::RowEvent) {
let event = semantic_state(app).address_event(event);
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(event)));
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(event)));
}
fn semantic_decide(app: &mut crate::app::App, facet: FieldFacet, decision: SemanticDecision) {
use crate::semantic_review::RowEvent as Event;
@@ -35,10 +35,10 @@ pub fn modal<'a, Message: 'a, R: renderer::Renderer + 'a>(
}
pub fn field_id(generation: ModelId, path: &str) -> Id {
Id::from(format!("reference:{generation}:{path}"))
Id::new(format!("reference:{generation}:{path}"))
}
pub fn scope_id(session: ModelId) -> Id {
Id::from(format!("reference-form:{session}"))
Id::new(format!("reference-form:{session}"))
}
#[derive(Default)]
@@ -226,7 +226,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Field<'_, Mes
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
if let Some(on_open) = &self.on_open {
if self.on_open.is_some() {
let state = tree.state.downcast_mut::<Interaction>();
let visible = layout.bounds().intersection(viewport);
let hovered = visible.is_some_and(|bounds| cursor.is_over(bounds));
@@ -260,7 +260,7 @@ impl<Message, R: renderer::Renderer> Widget<Message, Theme, R> for Field<'_, Mes
_ => false,
};
if activate && let Some(bounds) = state.anchor {
shell.publish(on_open(bounds));
shell.publish(self.on_open.as_ref().unwrap()(bounds));
shell.capture_event();
return;
}
@@ -437,7 +437,7 @@ pub fn focus_operation(
) -> impl Operation + 'static {
focus::exact(session, generation, path, step)
}
pub fn focus<Message: Send + 'static>(
pub fn focus<Message>(
session: ModelId,
generation: ModelId,
path: &str,
@@ -183,19 +183,9 @@ impl Operation for Collect {
ancestor.bounds.y,
ancestor.bounds.height,
);
// Iced reports rounded drawing offsets, while capacity can be
// fractional. Clamping an already-visible axis can move it a pixel.
let delta = Vector::new(
if dx == 0. {
0.
} else {
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x
},
if dy == 0. {
0.
} else {
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y
},
(ancestor.offset.x + dx).clamp(0., ancestor.capacity.x) - ancestor.offset.x,
(ancestor.offset.y + dy).clamp(0., ancestor.capacity.y) - ancestor.offset.y,
);
if delta != Vector::ZERO {
shifts.insert(ancestor.index, delta);
+17 -45
View File
@@ -445,32 +445,28 @@ fn article(topic: Topic) -> Article {
walkthrough(
"Describe a path through exchanges",
&[
"On an empty path, choose Work (W) from the right palette and click the canvas. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
"A blank path offers Add first activity/function contribution. You can use existing work or stage a new source in that form; exchanges still need explicit compatible source and target work.",
"Open Behavior & scenarios → New. Choose Operational process in OA or Functional chain in SA, enter its exact name, then Create and open. The new behavior belongs at the layer root and opens immediately; Undo removes the creation as one edit.",
"Choose Work (W) or Exchange (E) from the permanent palette on the right, then a highlighted position. Hover a palette button to read its tool name. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
"Choose Sequence link (->) and click or drag from the preceding contribution to the following contribution. Choose Select contributions or paths (S) to drag a selected path endpoint and reconnect it. A contribution's ↕ handle reorders contributions within the same column; links determine the columns. These are local contributions and sequence links, not new architecture exchanges.",
"Select a contribution, then add another after it. Details → Two paths... creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions.",
"Open Tools and choose Work or Exchange, then a highlighted position. In Work, Create new source keeps the contribution name and position while you name its Activity/Function and choose its semantic parent. Use this new source stages it; Create commits source and contribution in one Undo. An adjacent exchange may require a particular existing work identity.",
"In Tools, choose Sequence link and click or drag from the preceding contribution to the following contribution. Choose Tools → Select / reorder to drag a selected path endpoint and reconnect it; a contribution's ↕ handle changes its position within a column. These are local contributions and sequence links, not new architecture exchanges.",
"Select a contribution, then add another after it. Tools → Two paths… creates a split, two paths and their merge in one undoable edit. Enter explicit alternative conditions. Fit overview uses the visible canvas area; 100% keeps working scale with scrollbars. If the minimum zoom is reached, scroll to inspect the remainder.",
"Use Chain involvement from the Process/Functional chain to the capability it supports. Read Model checks for unfinished or disconnected paths.",
],
),
explain(
"Navigate the canvas",
"The header and palette stay available while Details and creation forms overlay the canvas. Fit frames the contents; −/+ changes zoom and 100% restores working scale. Focus the canvas to zoom with the wheel or pan with middle-button drag or Space + left drag. F6 enters or leaves canvas controls; Tab moves to the next control, Enter/Space activates it, and Escape leaves canvas controls or cancels a gesture.",
),
explain(
"Choose the right behavioral description",
"A chain concentrates on a route through existing exchanges. A scenario can also carry actions, state observations, lifelines and nested fragments. The architecture diagram remains a view of the possible relationships rather than an execution trace.",
),
explain(
"Review the path as an engineer",
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Reuse process / chain (ref) in the palette to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Details → Append exchange... continues that path; Edit path links and guards exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
"Check that exchanges express meaningful handoffs and that the links and branch guards support the selected outcome. Use Tools → Reuse to involve an existing process or chain as a nested reference. For an imported ordered exchange path, Append exchange… continues that path; Convert ordered path to explicit links exposes its implied links for topology editing. Conversion preserves the original order in one undoable change.",
),
walkthrough(
"Connect a particular use of a procedure",
&[
"Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Details to read its complete caller and reference identity.",
"In Details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.",
"Open Details → Qualified link / assembly... and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.",
"Choose Focus canvas for more working space; Restore panels brings the explorer and properties back. Select a contribution inside a reusable preview, then open Selection details to read its complete caller and reference identity.",
"In Selection details, use Expand nested reference or Fold nested reference to inspect that instance. Open source or Open contribution definition visits the shared definition; Back to caller returns to the original caller context.",
"Open Tools → Qualified link / assembly… and choose its source and target. Use Enter reference to open an instance's contributions, Up one reference for its caller scope, or Root for the link owner. Search and page through all contributions in that scope, then select the exact endpoint. The first and second uses of a procedure remain different choices even when their work has the same name.",
"Choose Precedence to describe order and its guard, Exchange assembly to identify a work exchange between the contributions, or Common work incidence to state that both use the same work. Select the engineering identity when required, then Apply link.",
],
),
@@ -488,40 +484,28 @@ fn article(topic: Topic) -> Article {
const {
Article {
summary: "Use a scenario to tell a concrete behavioral story, including repeated messages, actions, state observations and guarded alternatives.",
keywords: "scenario sequence lifeline lifetime lifecycle birth death capsule sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state interface operation allocation call reply unresolved",
keywords: "scenario sequence lifeline sender receiver fragment branch alternative loop guard parallel strict optional occurrence exchange action state interface operation allocation call reply unresolved",
sections: &[
walkthrough(
"Create a scenario",
&[
"Open Behavior & scenarios → New → Scenario and enter its name. Explicitly choose Activities or Participants in OA, or Functions or System boundary in SA. Add optional context only when needed: this is a reference, while the scenario itself belongs at the layer root. Create and open starts the empty scenario.",
"Choose Lifeline (L) in the permanent palette on the right, then click the canvas. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.",
"Choose a message style from the palette; hover a button to read its tool name. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.",
"Call and correlated reply (C/R) creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
"Choose Action (A), State observation (St), Reuse scenario (ref) or Fragment (alt) and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.",
"Choose Select / reorder occurrences (S) and drag an occurrence itself to another position within the same sequence. A drag into another fragment operand is refused. Open Details for the ordered list's up/down controls or Properties for the selected occurrence, including its message values.",
"Use Add first lifeline on an empty scenario, or + Lifeline. Choose existing work/participant or Create new source. The explicit viewpoint limits its kinds; choose participant kinds deliberately. Source name and lifeline name are separate, and the source semantic parent is independent of the scenario. No allocation or capability involvement is inferred.",
"Choose a message style in the tool selector. Start at a highlighted sequence position on a lifeline, then click or drag to the receiving lifeline. Complete the exchange and details in the form, which retains that sequence position. Escape cancels the gesture.",
"Call + correlated reply creates both occurrences in one edit. Reply to existing call offers valid unanswered calls. A local timer has an expression and a lifeline, with no global exchange. Create and delete messages describe participant lifetime; Model checks also flag risks inside repeated loops.",
"Choose Add action, Observe state, Reuse scenario or Add fragment and click the intended sequence position. Clicking an action or observation on a lifeline also selects that lifeline. Complete any remaining choices before Create.",
"In Select / reorder, click an occurrence and drag its ↕ handle to another position within the same sequence. The ordered list's up/down controls provide another way to reorder. Select an occurrence to inspect its particular use of the exchange, including its message values.",
],
),
explain(
"Navigate the canvas",
"The header and palette stay available while Details and creation forms overlay the canvas. Fit frames the contents; −/+ changes zoom and 100% restores working scale. Focus the canvas to zoom with the wheel or pan with middle-button drag or Space + left drag. F6 enters or leaves canvas controls; Tab moves to the next control, Enter/Space activates it, and Escape leaves canvas controls or cancels a gesture.",
),
walkthrough(
"Describe alternatives",
&[
"Choose Fragment (alt), click its sequence position and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
"Choose Add fragment, click its sequence position and select ALT, PAR, LOOP or OPT. Enter the branch conditions; the tool creates the fragment and its operands together.",
"Click the highlighted position inside an operand to place the next message, action or reference there. The form shows its destination and insertion position. Append to this sequence instead explicitly moves the pending insertion to that sequence's end.",
"Choose Reuse scenario and click its position, then bind every referenced lifeline to a compatible local lifeline explicitly. Select the resulting use and choose Details → Open definition. Back returns to that exact use and its viewport after you apply or cancel pending edits.",
"Choose Reuse scenario and click its position, then bind every referenced lifeline to a compatible local lifeline explicitly. Select the resulting use and choose Open definition. Back to caller returns to that exact use and its viewport after you apply or cancel pending edits.",
"Inspect the scenario projection and Model checks. State exactly what each guard means, including units and assumptions where relevant.",
],
),
explain(
"Read lifeline creation and deletion",
"The Lane header and dotted guide identify a lifeline; the guide alone does not establish its lifetime. The stronger solid line shows where the lifeline is known to exist on this scenario path. Create starts that line at a named capsule; Delete ends it at an X. Select the capsule or X to edit that message occurrence. Select the header or guide to inspect the lifeline.",
),
explain(
"Distinguish uncertain lifetime from an unanswered call",
"A ? means lifetime depends on an alternative, optional, repeated or parallel path. Operands have independent lifetimes: Delete in one alternative does not delete a lifeline in another. Beyond such a fragment, the guide can continue without a definite solid line. This uncertainty is not by itself a model error. The … beside an activation marks a Call that remains unanswered on that path. A reused scenario's internal Create/Delete events appear when you choose Open definition; they do not create or delete the caller's lifelines.",
),
walkthrough(
"Invoke an interface operation",
&[
@@ -805,7 +789,7 @@ fn article(topic: Topic) -> Article {
const {
Article {
summary: "The text editor and graphical forms edit the same model. Source projects preserve file organization and comments for human review, automation and version control.",
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity definition references lexical navigation F12 back",
keywords: "text textual source language syntax highlighting lint parser git aliases uuid identity rename diff merge restructuredtext spec canonical parity",
sections: &[
walkthrough(
"Edit and review source",
@@ -818,18 +802,6 @@ fn article(topic: Topic) -> Article {
"Save all writes the entry and its included UTF-8 files. Review the Git diff before committing, including relationships, traces and diagram changes that belong to the same engineering edit.",
],
),
explain(
"Follow definitions and lexical references",
"Place the caret on a declaration alias or an @reference. Choose Definition (F12) to locate its declaration, or References (Shift+F12) to list lexical uses. Choose a row when a definition is ambiguous, draft or detached. Lexical uses exclude comments and strings; they do not establish typed relationships or resolve an ambiguous alias to one declaration. Back (Alt+Left) returns to the previous source location; if that draft changed, it restores the current cursor instead of an obsolete selection. These shortcuts work while the source editor has focus; the Text toolbar also offers the actions.",
),
explain(
"Read the navigation scope",
"Queries inspect bounded snapshots of currently loaded drafts, without applying them or loading missing included files. The panel identifies partial scope, draft evidence, detached or unverified files, and alias or ID collisions. Counts show total, displayed and omitted rows. A missing result in partial scope does not establish that the whole project has no match. If sources change, stale rows are disabled; run Definition or References again. Row locations use one-based lines and UTF-8 byte columns. Navigation preserves your source drafts and the accepted model.",
),
explain(
"Find the record in diagrams",
"Choose Show in diagrams from the Text toolbar on a unique verified alias in clean sources, or from the chosen model-tree record's context menu. Choose a diagram destination, then an exact representation. Results follow visible projection: ports, state rows, repeated caller tiles and their drawn sequence links remain distinct. Hidden tiles and nested links that are not drawn are excluded. A direct allocation can be represented by visible grouping; aggregate summaries do not invent allocation relations. A visual marker locates the port, row or grouped work while keeping the queried semantic record selected for model actions. Moving the camera preserves that marker. Finish pending edits first; navigation preserves accepted geometry, sources and history.",
),
explain(
"Names, aliases and IDs serve different purposes",
"The displayed name is engineering prose. A stable @alias is the readable source reference and need not change when the name changes. UUID identity distinguishes the persistent record. Preserve IDs when revising the same engineering object; changing identity is a deliberate operation that affects references.",
-1
View File
@@ -7,7 +7,6 @@ mod context_menu;
mod data_values;
mod data_view;
mod diagram;
mod diagram_usage;
mod document;
mod explorer;
mod explorer_view;
-5
View File
@@ -281,11 +281,6 @@ pub fn popup(app: &App) -> Option<Menu<Message>> {
}
entries.push(item("Properties", Message::EditProperties(id), true));
entries.push(item("Open source", Message::SourceForSelection, true));
entries.push(item(
"Show in diagrams…",
app.diagram_usage_message(id),
true,
));
entries.push(item(
"Connections & references",
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
+41 -4
View File
@@ -126,8 +126,28 @@ fn groups(p: &Project, selected: Id) -> Vec<Group> {
]
}
pub fn view(app: &crate::app::App, selected: Id) -> Element<'_, Message> {
let p = app.project();
pub fn diagrams_containing(p: &Project, selected: Id) -> Vec<Id> {
let endpoints = p.relations.get(&selected).map(|r| [r.source(), r.target()]);
p.diagrams
.values()
.filter(|d| {
let placed = |id| {
let e = p.elements.get(&id);
let owner = e
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(id);
let kind = p.elements.get(&owner).map(|e| e.kind());
kind.is_none_or(|kind| d.projection.includes(kind))
&& d.nodes.values().any(|n| n.element == owner || n.id == id)
};
placed(selected) || endpoints.is_some_and(|ends| ends.into_iter().all(placed))
})
.map(|d| d.id)
.collect()
}
pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
let mut content = column![
text(short(p.named(selected), 50)).size(18),
text("Follow ownership, usage and engineering relationships.")
@@ -183,8 +203,25 @@ pub fn view(app: &crate::app::App, selected: Id) -> Element<'_, Message> {
content = content.push(container(entry).padding(8).style(style::subtle));
}
}
content =
content.push(action("Show in diagrams…", app.diagram_usage_message(selected)).width(Fill));
let diagrams = diagrams_containing(p, selected);
if !diagrams.is_empty() {
content = content.push(label("SHOWN IN DIAGRAMS"));
for id in diagrams {
content = content.push(
action(
&short(p.named(id), 42),
Message::RevealInDiagram(id, selected),
)
.width(Fill),
);
}
} else if p.elements.contains_key(&selected) {
content = content.push(
text("This element has no diagram placement yet.")
.size(12)
.color(style::MUTED),
);
}
if count == 0 {
content = content.push(text("No semantic connections yet. Use Actions or right-click an element to add relationships.").size(12).color(style::MUTED));
}
@@ -694,7 +694,7 @@ impl State {
Fill,
widget::{button, checkbox, column, pick_list, row, text},
};
let message = |event| Message::Workflow(crate::workflow::Event::semantic(event));
let message = |event| Message::Workflow(crate::workflow::Event::Semantic(event));
let mut available: Vec<_> = self
.base
.traces
@@ -821,12 +821,8 @@ impl State {
.active
.view(&self.base, header.into(), self.can_apply())
.map(move |message| match message {
Message::Workflow(crate::workflow::Event::Semantic(event)) => {
let event = match *event {
Event::Editor(event) => self.address_event(event),
other => other,
};
Message::Workflow(crate::workflow::Event::semantic(event))
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event))) => {
Message::Workflow(crate::workflow::Event::Semantic(self.address_event(event)))
}
other => other,
});
@@ -522,10 +522,10 @@ fn batch_apply_is_one_exact_source_and_history_undo_redo_roundtrip() {
&mut app,
RowEvent::Input(path(f.direction, "minimum"), "120".into()),
);
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
Event::ChooseTrace(view::choice(&f.project, f.traces[1])),
)));
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
Event::OpenTrace,
)));
tests::decide(
+1 -1
View File
@@ -926,5 +926,5 @@ pub fn form_event(message: Message) -> Message {
Message::FieldReferenceEvent(event) => RowEvent::ReferenceEvent(event),
_ => RowEvent::Preview,
};
Message::Workflow(crate::workflow::Event::semantic(Event::Editor(event)))
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event)))
}
@@ -3,7 +3,7 @@ use crate::{app::App, document::Document, workflow::Workflow};
use serde_json::json;
pub(super) fn send(app: &mut App, event: RowEvent) {
let _ = app.update(Message::Workflow(crate::workflow::Event::semantic(
let _ = app.update(Message::Workflow(crate::workflow::Event::Semantic(
state(app).address_event(event),
)));
}
@@ -13,7 +13,7 @@ use iced::{
mod reserved_artifact_tests;
fn event(event: RowEvent) -> Message {
Message::Workflow(crate::workflow::Event::semantic(Event::Editor(event)))
Message::Workflow(crate::workflow::Event::Semantic(Event::Editor(event)))
}
fn action<'a>(
label: impl Into<String>,
@@ -1,9 +1,4 @@
//! Literal source search and line navigation, independent of accepted model state.
mod controller;
pub mod interaction;
pub mod query;
pub mod symbols;
pub use controller::*;
use iced::widget::text_editor::{Cursor, Position};
#[derive(Debug, Clone)]
@@ -1,126 +0,0 @@
//! Ephemeral editor navigation; no model commands or filesystem operations.
use super::query;
use iced::widget::text_editor::{Cursor, Position};
use oa_core::Id;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
#[derive(Debug, Clone)]
pub enum SymbolEvent {
DiagramUsages,
Query(query::Action),
Back,
Close,
Choose {
stamp: query::QueryStamp,
reference: bool,
index: usize,
},
}
/// Identity of the mounted source view that emitted an action. Message queues
/// may outlive that view; request-time stamping alone cannot detect replay.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SymbolContext {
pub session: Id,
pub path: String,
}
#[derive(Debug, Clone)]
pub struct HistoryEntry {
pub session: Id,
pub path: String,
pub generation: u64,
pub cursor: Cursor,
}
#[derive(Debug, Clone)]
pub struct Request {
pub job: Id,
pub interaction: Id,
pub raw_interaction: u64,
pub stamp: query::QueryStamp,
pub path: String,
pub offset: usize,
pub cursor: Cursor,
pub action: query::Action,
}
#[derive(Debug, Clone)]
pub struct Completion {
pub request: Request,
pub result: Result<(Arc<query::Snapshot>, Arc<query::Results>), String>,
}
pub struct Reveal {
pub ticket: Id,
pub interaction: Id,
pub raw_interaction: u64,
pub snapshot: query::SnapshotKey,
pub path: String,
}
pub struct SymbolState {
pub open: bool,
pub interaction: Id,
pub raw_interaction: Arc<AtomicU64>,
pub serial: u64,
pub in_flight: Option<Id>,
pub request: Option<Request>,
pub cache: Option<Arc<query::Snapshot>>,
pub results: Option<Arc<query::Results>>,
pub notice: Option<String>,
pub back: Vec<HistoryEntry>,
pub reveal: Option<Reveal>,
}
impl Default for SymbolState {
fn default() -> Self {
Self {
open: false,
interaction: Id::new_v4(),
raw_interaction: Arc::new(AtomicU64::new(0)),
serial: 0,
in_flight: None,
request: None,
cache: None,
results: None,
notice: None,
back: Vec::new(),
reveal: None,
}
}
}
/// Iced uses zero-based UTF-8 byte columns. Reject stale or split-codepoint
/// coordinates; do not normalize CRLF or silently clamp query destinations.
pub fn byte_offset(text: &str, position: Position) -> Option<usize> {
let start = if position.line == 0 {
0
} else {
text.match_indices('\n').nth(position.line - 1)?.0 + 1
};
let end = text[start..]
.find('\n')
.map_or(text.len(), |end| start + end);
let offset = start.checked_add(position.column)?;
(offset <= end && text.is_char_boundary(offset)).then_some(offset)
}
pub fn cursor_offset(text: &str, cursor: Cursor) -> Option<usize> {
let position = cursor.selection.map_or(cursor.position, |selection| {
if (selection.line, selection.column) < (cursor.position.line, cursor.position.column) {
selection
} else {
cursor.position
}
});
byte_offset(text, position)
}
pub fn exact_cursor(text: &str, cursor: Cursor) -> bool {
byte_offset(text, cursor.position).is_some()
&& cursor
.selection
.is_none_or(|position| byte_offset(text, position).is_some())
}
@@ -1,284 +0,0 @@
//! Raw input invalidation and focus checks at widget-operation execution time.
use iced::advanced::{Clipboard, Layout, Shell, Widget, layout, overlay, renderer, widget};
use iced::{Element, Event, Length, Rectangle, Size, Task, Vector, keyboard, mouse, touch};
use std::sync::{
Arc,
atomic::{AtomicU64, Ordering},
};
/// Exhaustion fails closed: a saturated clock can never authorize navigation.
/// Replace the clock with a new workspace/controller, rather than wrapping it.
pub fn is_current(clock: &AtomicU64, expected: u64) -> bool {
expected != u64::MAX && clock.load(Ordering::Acquire) == expected
}
fn invalidate(clock: &AtomicU64, event: &Event) {
if matches!(
event,
Event::Keyboard(keyboard::Event::KeyPressed { .. })
| Event::Mouse(mouse::Event::ButtonPressed(_) | mouse::Event::WheelScrolled { .. })
| Event::Touch(touch::Event::FingerPressed { .. })
| Event::Window(iced::window::Event::Unfocused)
) {
let _ = clock.fetch_update(Ordering::AcqRel, Ordering::Acquire, |value| {
Some(value.saturating_add(1))
});
}
}
/// Transparent, including overlays: invalidate before the child can capture input.
pub fn observe<'a, Message: 'a, Theme: 'a, Renderer: renderer::Renderer + 'a>(
content: Element<'a, Message, Theme, Renderer>,
clock: Arc<AtomicU64>,
) -> Element<'a, Message, Theme, Renderer> {
Element::new(Observed { content, clock })
}
struct Observed<'a, Message, Theme, Renderer> {
content: Element<'a, Message, Theme, Renderer>,
clock: Arc<AtomicU64>,
}
impl<Message, Theme, Renderer: renderer::Renderer> Widget<Message, Theme, Renderer>
for Observed<'_, Message, Theme, Renderer>
{
fn size(&self) -> Size<Length> {
self.content.as_widget().size()
}
fn size_hint(&self) -> Size<Length> {
self.content.as_widget().size_hint()
}
fn tag(&self) -> widget::tree::Tag {
self.content.as_widget().tag()
}
fn state(&self) -> widget::tree::State {
self.content.as_widget().state()
}
fn children(&self) -> Vec<widget::Tree> {
self.content.as_widget().children()
}
fn diff(&self, tree: &mut widget::Tree) {
self.content.as_widget().diff(tree);
}
fn layout(
&mut self,
tree: &mut widget::Tree,
renderer: &Renderer,
limits: &layout::Limits,
) -> layout::Node {
self.content.as_widget_mut().layout(tree, renderer, limits)
}
fn draw(
&self,
tree: &widget::Tree,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
) {
self.content
.as_widget()
.draw(tree, renderer, theme, style, layout, cursor, viewport);
}
fn operate(
&mut self,
tree: &mut widget::Tree,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_widget_mut()
.operate(tree, layout, renderer, operation);
}
fn update(
&mut self,
tree: &mut widget::Tree,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
viewport: &Rectangle,
) {
invalidate(&self.clock, event);
self.content.as_widget_mut().update(
tree, event, layout, cursor, renderer, clipboard, shell, viewport,
);
}
fn mouse_interaction(
&self,
tree: &widget::Tree,
layout: Layout<'_>,
cursor: mouse::Cursor,
viewport: &Rectangle,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_widget()
.mouse_interaction(tree, layout, cursor, viewport, renderer)
}
fn overlay<'a>(
&'a mut self,
tree: &'a mut widget::Tree,
layout: Layout<'a>,
renderer: &Renderer,
viewport: &Rectangle,
translation: Vector,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
let clock = self.clock.clone();
self.content
.as_widget_mut()
.overlay(tree, layout, renderer, viewport, translation)
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
}
}
struct ObservedOverlay<'a, Message, Theme, Renderer> {
content: overlay::Element<'a, Message, Theme, Renderer>,
clock: Arc<AtomicU64>,
}
impl<Message, Theme, Renderer: renderer::Renderer> overlay::Overlay<Message, Theme, Renderer>
for ObservedOverlay<'_, Message, Theme, Renderer>
{
fn layout(&mut self, renderer: &Renderer, bounds: Size) -> layout::Node {
self.content.as_overlay_mut().layout(renderer, bounds)
}
fn draw(
&self,
renderer: &mut Renderer,
theme: &Theme,
style: &renderer::Style,
layout: Layout<'_>,
cursor: mouse::Cursor,
) {
self.content
.as_overlay()
.draw(renderer, theme, style, layout, cursor);
}
fn operate(
&mut self,
layout: Layout<'_>,
renderer: &Renderer,
operation: &mut dyn widget::Operation,
) {
self.content
.as_overlay_mut()
.operate(layout, renderer, operation);
}
fn update(
&mut self,
event: &Event,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
clipboard: &mut dyn Clipboard,
shell: &mut Shell<'_, Message>,
) {
invalidate(&self.clock, event);
self.content
.as_overlay_mut()
.update(event, layout, cursor, renderer, clipboard, shell);
}
fn mouse_interaction(
&self,
layout: Layout<'_>,
cursor: mouse::Cursor,
renderer: &Renderer,
) -> mouse::Interaction {
self.content
.as_overlay()
.mouse_interaction(layout, cursor, renderer)
}
fn overlay<'a>(
&'a mut self,
layout: Layout<'a>,
renderer: &Renderer,
) -> Option<overlay::Element<'a, Message, Theme, Renderer>> {
let clock = self.clock.clone();
self.content
.as_overlay_mut()
.overlay(layout, renderer)
.map(|content| overlay::Element::new(Box::new(ObservedOverlay { content, clock })))
}
fn index(&self) -> f32 {
self.content.as_overlay().index()
}
}
/// Inspect all focus owners, then focus only the mounted target. Iced executes
/// Operation::Chain synchronously, without an intervening input event. Both
/// traversals recheck the raw clock; no result message authorizes later focus.
pub fn guarded_focus<Message: Send + 'static>(
target: widget::Id,
clock: Arc<AtomicU64>,
expected: u64,
) -> Task<Message> {
widget::operate(GuardedFocus::new(target, clock, expected))
}
struct GuardedFocus {
target: widget::Id,
clock: Arc<AtomicU64>,
expected: u64,
target_count: usize,
foreign_focus: bool,
apply: bool,
}
impl GuardedFocus {
fn new(target: widget::Id, clock: Arc<AtomicU64>, expected: u64) -> Self {
Self {
target,
clock,
expected,
target_count: 0,
foreign_focus: false,
apply: false,
}
}
}
impl<Message: 'static> widget::Operation<Message> for GuardedFocus {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation<Message>)) {
if is_current(&self.clock, self.expected) {
visit(self);
}
}
fn focusable(
&mut self,
id: Option<&widget::Id>,
_: Rectangle,
state: &mut dyn widget::operation::Focusable,
) {
if !is_current(&self.clock, self.expected) {
return;
}
if id == Some(&self.target) {
self.target_count += 1;
if self.apply {
state.focus();
}
} else if state.is_focused() {
self.foreign_focus = true;
}
}
fn finish(&self) -> widget::operation::Outcome<Message> {
if !self.apply
&& self.target_count == 1
&& !self.foreign_focus
&& is_current(&self.clock, self.expected)
{
widget::operation::Outcome::Chain(Box::new(Self {
apply: true,
..Self::new(self.target.clone(), self.clock.clone(), self.expected)
}))
} else {
widget::operation::Outcome::None
}
}
}
#[cfg(all(test, debug_assertions))]
mod tests;
@@ -1,190 +0,0 @@
use super::*;
use iced::advanced::widget::operation::{Focusable, Outcome};
use iced::widget::{column, text_input};
use iced::{Point, Theme};
#[derive(Default)]
struct FocusMap(Vec<(Option<widget::Id>, Rectangle, bool)>);
impl widget::Operation for FocusMap {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn widget::Operation)) {
visit(self);
}
fn focusable(&mut self, id: Option<&widget::Id>, bounds: Rectangle, state: &mut dyn Focusable) {
self.0.push((id.cloned(), bounds, state.is_focused()));
}
}
struct Mounted {
element: Element<'static, (), Theme, ()>,
tree: widget::Tree,
node: layout::Node,
}
impl Mounted {
fn new(clock: Arc<AtomicU64>) -> Self {
let content = column![
text_input("Editor", "source")
.id("editor")
.on_input(|_| ())
.padding(12),
text_input("Find", "")
.id("find")
.on_input(|_| ())
.padding(12),
]
.spacing(10)
.into();
let mut element = observe(content, clock);
let mut tree = widget::Tree::new(element.as_widget());
let node = element.as_widget_mut().layout(
&mut tree,
&(),
&layout::Limits::new(Size::ZERO, Size::new(400., 200.)),
);
Self {
element,
tree,
node,
}
}
fn pass(&mut self, operation: &mut dyn widget::Operation) {
self.element.as_widget_mut().operate(
&mut self.tree,
Layout::new(&self.node),
&(),
operation,
);
}
fn run(&mut self, operation: impl widget::Operation + 'static) {
let mut next: Box<dyn widget::Operation> = Box::new(operation);
loop {
self.pass(next.as_mut());
match next.finish() {
Outcome::Chain(chained) => next = chained,
Outcome::None => break,
Outcome::Some(()) => panic!("focus must not publish an application message"),
}
}
}
fn focus(&mut self) -> FocusMap {
let mut focus = FocusMap::default();
self.pass(&mut focus);
focus
}
fn click(&mut self, id: &str) {
let bounds = self
.focus()
.0
.into_iter()
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
.unwrap()
.1;
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
self.element.as_widget_mut().update(
&mut self.tree,
&Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Layout::new(&self.node),
mouse::Cursor::Available(bounds.center()),
&(),
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(Size::new(400., 200.)),
);
assert!(shell.is_event_captured());
assert!(
messages.is_empty(),
"focus-only click must not rely on App messages"
);
}
fn focused(&mut self, id: &str) -> bool {
self.focus()
.0
.into_iter()
.find(|(found, _, _)| found.as_ref() == Some(&widget::Id::from(id.to_owned())))
.unwrap()
.2
}
}
#[test]
fn captured_find_click_invalidates_without_app_message_and_preserves_focus() {
let clock = Arc::new(AtomicU64::new(0));
let mut ui = Mounted::new(clock.clone());
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
assert!(ui.focused("editor"));
ui.click("find");
assert!(!is_current(&clock, 0));
assert!(ui.focused("find"));
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 0));
assert!(ui.focused("find"));
// Even a matching clock cannot authorize stealing another widget's focus.
ui.run(GuardedFocus::new(
"editor".into(),
clock.clone(),
clock.load(Ordering::Acquire),
));
assert!(ui.focused("find"));
assert!(!ui.focused("editor"));
}
#[test]
fn focus_rechecks_clock_between_inspection_and_application_and_missing_target_is_inert() {
let clock = Arc::new(AtomicU64::new(0));
let mut ui = Mounted::new(clock.clone());
let mut operation = GuardedFocus::new("editor".into(), clock.clone(), 0);
ui.pass(&mut operation);
let Outcome::Chain(mut apply) = <GuardedFocus as widget::Operation<()>>::finish(&operation)
else {
panic!("mounted, unopposed target must be eligible");
};
clock.store(1, Ordering::Release);
ui.pass(apply.as_mut());
assert!(!ui.focused("editor"));
ui.run(GuardedFocus::new("editor".into(), clock.clone(), 1));
assert!(ui.focused("editor"));
ui.run(GuardedFocus::new("removed-editor".into(), clock, 1));
assert!(ui.focused("editor"));
}
#[test]
fn raw_navigation_inputs_advance_but_passive_geometry_does_not_and_exhaustion_refuses() {
let clock = AtomicU64::new(0);
let events = [
Event::Keyboard(keyboard::Event::KeyPressed {
key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowLeft),
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowLeft),
location: keyboard::Location::Standard,
modifiers: keyboard::Modifiers::empty(),
text: None,
repeat: false,
}),
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
Event::Mouse(mouse::Event::WheelScrolled {
delta: mouse::ScrollDelta::Lines { x: 0., y: 1. },
}),
Event::Touch(touch::Event::FingerPressed {
id: touch::Finger(1),
position: Point::ORIGIN,
}),
Event::Window(iced::window::Event::Unfocused),
];
for (index, event) in events.iter().enumerate() {
invalidate(&clock, event);
assert_eq!(clock.load(Ordering::Acquire), index as u64 + 1);
}
invalidate(
&clock,
&Event::Mouse(mouse::Event::CursorMoved {
position: Point::ORIGIN,
}),
);
invalidate(&clock, &Event::Window(iced::window::Event::Focused));
assert!(is_current(&clock, 5));
clock.store(u64::MAX - 1, Ordering::Release);
invalidate(&clock, &events[0]);
assert!(!is_current(&clock, u64::MAX));
invalidate(&clock, &events[0]);
assert_eq!(clock.load(Ordering::Acquire), u64::MAX);
assert!(!is_current(&clock, 0));
}
File diff suppressed because it is too large Load Diff
@@ -1,989 +0,0 @@
use super::*;
const PROJECT: &str = "00000000-0000-0000-0000-000000000001";
const PUMP: &str = "00000000-0000-0000-0000-000000000002";
const LINK: &str = "00000000-0000-0000-0000-000000000003";
#[derive(Clone)]
struct Fixture {
entry: String,
files: Vec<(String, String)>,
}
impl Fixture {
fn standard() -> Self {
Self {
entry: "project.arcadia".into(),
files: vec![
(
"project.arcadia".into(),
format!(
"arcadia 7;\r\nproject model \"Harbour\" id \"{PROJECT}\" {{\r\n include \"模块/活動.arcadia\";\r\n include \"links.arcadia\";\r\n}}\r\n"
),
),
(
"模块/活動.arcadia".into(),
format!(
"// 原文 remains exact\r\noa_activity pump \"泵 🚀\" id \"{PUMP}\" {{}}\r\n"
),
),
(
"links.arcadia".into(),
format!(
"// 航海\r\ninteraction connection \"流れ\" id \"{LINK}\" {{\r\n source @ // separator @pump\r\n pump;\r\n description \"船 @pump\"; target @pump;\r\n}}\r\n"
),
),
],
}
}
fn borrowed(&self) -> Vec<DraftFile<'_>> {
self.files
.iter()
.map(|(path, text)| DraftFile { path, text })
.collect()
}
fn text(&self, path: &str) -> &str {
&self
.files
.iter()
.find(|(candidate, _)| candidate == path)
.unwrap()
.1
}
fn text_mut(&mut self, path: &str) -> &mut String {
&mut self
.files
.iter_mut()
.find(|(candidate, _)| candidate == path)
.unwrap()
.1
}
fn snapshot(&self, validated: Option<&Self>, applied: bool, limits: Limits) -> Snapshot {
let current = self.borrowed();
let old = validated.map(Self::borrowed);
capture(
Input {
key: key(1),
entry: &self.entry,
files: &current,
applied,
validation: old.as_ref().map(|files| ValidatedSources {
entry: &validated.unwrap().entry,
files,
}),
},
limits,
)
.unwrap()
}
}
fn key(revision: u64) -> SnapshotKey {
SnapshotKey {
workspace: Id::from_u128(100),
revision,
}
}
fn query(snapshot: &Snapshot, path: &str, byte_offset: usize, action: Action) -> Results {
snapshot
.query(Query {
stamp: QueryStamp {
snapshot: snapshot.key(),
serial: 37,
},
path,
byte_offset,
action,
})
.unwrap()
}
fn reference_offset(fixture: &Fixture) -> usize {
fixture.text("links.arcadia").find("source @").unwrap() + "source ".len()
}
#[test]
fn exact_multifile_unicode_crlf_definition_and_repeated_lexical_references() {
let fixture = Fixture::standard();
let original = fixture.files.clone();
let snapshot = fixture.snapshot(Some(&fixture), true, Limits::default());
assert!(snapshot.scope().closure_complete && snapshot.scope().validated);
assert_eq!(
(
snapshot.scope().included_files,
snapshot.scope().detached_files
),
(3, 0)
);
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&fixture),
Action::Definition,
);
assert_eq!(result.disposition, Disposition::Direct);
assert_eq!(
(result.total_definitions, result.omitted_definitions),
(1, 0)
);
let definition = &result.definitions[0];
assert_eq!(definition.path, "模块/活動.arcadia");
assert_eq!(definition.declaration.kind, "oa_activity");
assert_eq!(definition.declaration.name.as_deref(), Some("泵 🚀"));
assert_eq!(
definition.declaration.authored_id,
Some(Id::parse_str(PUMP).unwrap())
);
assert_eq!(
definition.position,
Position {
line: 1,
byte_column: "oa_activity ".len()
}
);
assert_eq!(
&fixture.text(&definition.path)[definition.span.start..definition.span.end],
"pump"
);
let uses = query(
&snapshot,
&definition.path,
definition.span.start,
Action::References,
);
assert_eq!(uses.disposition, Disposition::LexicalReferences);
assert_eq!((uses.total_references, uses.omitted_references), (2, 0));
assert_eq!(
uses.references[0].position,
Position {
line: 3,
byte_column: 3
}
);
let target_prefix = " description \"船 @pump\"; target @";
assert_ne!(target_prefix.len(), target_prefix.chars().count());
assert_eq!(
uses.references[1].position,
Position {
line: 4,
byte_column: target_prefix.len()
}
);
for row in &uses.references {
let text = fixture.text(&row.path);
assert_eq!(&text[row.span.start..row.span.end], "pump");
assert_eq!(&text[row.marker.start..row.marker.end], "@");
let context = row.context.as_ref().unwrap();
assert_eq!(context.alias, "connection");
assert_eq!(context.kind, "interaction");
assert_eq!(context.name.as_deref(), Some("流れ"));
assert_eq!(row.evidence, Evidence::Validated);
}
assert_eq!(
uses.stamp,
QueryStamp {
snapshot: key(1),
serial: 37
}
);
assert_eq!(
fixture.files, original,
"no stabilization, newline rewrite or accepted adoption"
);
for (index, (path, text)) in original.iter().enumerate() {
let owned = snapshot.file(index).unwrap();
assert_eq!((owned.path, owned.text), (path.as_str(), text.as_str()));
}
}
#[test]
fn comments_strings_and_comment_gap_are_not_navigation_hits() {
let fixture = Fixture::standard();
let snapshot = fixture.snapshot(None, true, Limits::default());
let text = fixture.text("links.arcadia");
for offset in [
text.find("separator @pump").unwrap() + "separator ".len(),
text.find("船 @pump").unwrap() + "船 ".len(),
text.find("@ //").unwrap() + 1,
] {
let result = query(&snapshot, "links.arcadia", offset, Action::Definition);
assert_eq!(result.disposition, Disposition::NoSymbol);
assert_eq!(result.total_definitions, 0);
}
assert_eq!(
query(
&snapshot,
"links.arcadia",
text.find(" pump;").unwrap() + 3,
Action::Definition
)
.total_definitions,
1
);
let inside_utf8 = text.find('船').unwrap() + 1;
assert_eq!(
snapshot.query(Query {
stamp: QueryStamp {
snapshot: key(1),
serial: 1
},
path: "links.arcadia",
byte_offset: inside_utf8,
action: Action::Definition
}),
Err(QueryError::Query(symbols::QueryError::InvalidBytePosition))
);
}
#[test]
fn dirty_but_parseable_text_has_current_names_and_no_validated_fallback() {
let original = Fixture::standard();
let mut dirty = original.clone();
*dirty.text_mut("模块/活動.arcadia") = dirty
.text("模块/活動.arcadia")
.replace("泵 🚀", "Edited 泵\\nwith detail");
let snapshot = dirty.snapshot(Some(&original), true, Limits::default());
assert!(snapshot.scope().closure_complete);
assert!(!snapshot.scope().validated);
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&dirty),
Action::Definition,
);
assert_eq!(result.disposition, Disposition::Choices);
assert_eq!(
result.definitions[0].declaration.name.as_deref(),
Some("Edited 泵\nwith detail")
);
assert_eq!(result.definitions[0].evidence, Evidence::Draft);
assert_eq!(
snapshot.file(1).unwrap().text,
dirty.text("模块/活動.arcadia")
);
assert_ne!(
snapshot.file(1).unwrap().text,
original.text("模块/活動.arcadia")
);
}
#[test]
fn invalid_include_syntax_never_borrows_the_old_reachable_file_set() {
let original = Fixture::standard();
let mut invalid = original.clone();
*invalid.text_mut("project.arcadia") = invalid
.text("project.arcadia")
.replace("include \"links.arcadia\";", "include \"links.arcadia\"");
let snapshot = invalid.snapshot(Some(&original), true, Limits::default());
assert!(snapshot.scope().partial());
assert!(!snapshot.scope().validated);
assert!(
snapshot
.scope()
.issues
.iter()
.any(|issue| issue.kind == ScopeIssueKind::InvalidIncludes)
);
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&invalid),
Action::Definition,
);
assert_eq!(result.total_definitions, 1);
assert_eq!(result.disposition, Disposition::Choices);
assert_eq!(result.definitions[0].membership, Membership::Unverified);
}
#[test]
fn missing_include_partial_scope_does_not_resurrect_witness_text() {
let original = Fixture::standard();
let mut missing = original.clone();
missing
.files
.retain(|(path, _)| path != "模块/活動.arcadia");
let snapshot = missing.snapshot(Some(&original), true, Limits::default());
assert!(snapshot.scope().partial());
assert!(
snapshot
.scope()
.issues
.iter()
.any(|issue| issue.kind == ScopeIssueKind::MissingInclude)
);
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&missing),
Action::Definition,
);
assert_eq!(result.disposition, Disposition::NoDefinition);
assert!(result.definitions.is_empty());
assert!(
result.scope.partial(),
"zero means no loaded definition, not no project definition"
);
assert!(snapshot.file(2).is_none());
}
#[test]
fn detached_alias_and_identity_collisions_never_auto_select() {
let accepted = Fixture::standard();
for (alias, id) in [
("pump", "00000000-0000-0000-0000-000000000099"),
("other", PUMP),
] {
let mut loaded = accepted.clone();
loaded.files.push((
"detached.arcadia".into(),
format!("oa_activity {alias} \"Detached\" id \"{id}\" {{}}"),
));
let snapshot = loaded.snapshot(Some(&accepted), true, Limits::default());
assert!(snapshot.scope().validated);
assert_eq!(snapshot.scope().detached_files, 1);
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&loaded),
Action::Definition,
);
assert_eq!(result.disposition, Disposition::Choices);
if alias == "pump" {
assert_eq!(result.total_definitions, 2);
assert!(result.definitions.iter().any(
|row| row.membership == Membership::Detached && row.evidence == Evidence::Draft
));
} else {
assert_eq!(result.total_definitions, 1);
assert_eq!(
result.definitions[0].declaration.identity,
Identity::Collision { count: 2 }
);
}
}
}
#[test]
fn sole_detached_candidate_and_unapplied_equal_bytes_require_choice() {
let accepted = Fixture::standard();
let mut detached = accepted.clone();
*detached.text_mut("project.arcadia") = detached
.text("project.arcadia")
.replace(" include \"模块/活動.arcadia\";\r\n", "");
let snapshot = detached.snapshot(None, true, Limits::default());
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&detached),
Action::Definition,
);
assert_eq!(result.disposition, Disposition::Choices);
assert_eq!(result.definitions[0].membership, Membership::Detached);
let unapplied = accepted.snapshot(Some(&accepted), false, Limits::default());
assert!(unapplied.scope().closure_complete && unapplied.scope().partial());
assert!(!unapplied.scope().validated);
assert_eq!(
query(
&unapplied,
"links.arcadia",
reference_offset(&accepted),
Action::Definition
)
.disposition,
Disposition::Choices
);
}
#[test]
fn include_cycle_depth_and_aggregate_edges_are_explicit_partial_outcomes() {
let mut fixture = Fixture::standard();
fixture
.text_mut("模块/活動.arcadia")
.push_str("include \"../project.arcadia\";\r\n");
let cycle = fixture.snapshot(None, true, Limits::default());
assert!(
cycle
.scope()
.issues
.iter()
.any(|issue| issue.kind == ScopeIssueKind::IncludeCycle)
);
let fixture = Fixture::standard();
for (depth, edges, expected) in [
(0, 1024, ScopeIssueKind::IncludeDepth),
(32, 1, ScopeIssueKind::IncludeBudget),
] {
let snapshot = fixture.snapshot(
None,
true,
Limits {
include_depth: depth,
include_edges: edges,
..Limits::default()
},
);
assert!(snapshot.scope().partial());
assert!(
snapshot
.scope()
.issues
.iter()
.any(|issue| issue.kind == expected)
);
assert_eq!(
query(
&snapshot,
"links.arcadia",
reference_offset(&fixture),
Action::Definition
)
.disposition,
Disposition::Choices
);
}
}
#[test]
fn exact_snapshot_readback_rejects_same_key_byte_membership_and_evidence_changes() {
let fixture = Fixture::standard();
let files = fixture.borrowed();
let validation = Some(ValidatedSources {
entry: &fixture.entry,
files: &files,
});
let input = Input {
key: key(1),
entry: &fixture.entry,
files: &files,
applied: true,
validation,
};
let snapshot = capture(input, Limits::default()).unwrap();
assert!(snapshot.matches(input));
assert!(!snapshot.matches(Input {
key: key(2),
..input
}));
assert!(!snapshot.matches(Input {
validation: None,
..input
}));
assert!(!snapshot.matches(Input {
applied: false,
..input
}));
let mut reversed = files.clone();
reversed.reverse();
assert!(snapshot.matches(Input {
files: &reversed,
..input
}));
let mut changed = fixture.clone();
changed.text_mut("links.arcadia").push(' ');
let altered = changed.borrowed();
assert!(!snapshot.matches(Input {
files: &altered,
..input
}));
let missing = &files[..files.len() - 1];
assert!(!snapshot.matches(Input {
files: missing,
..input
}));
let duplicated = [files[0], files[0], files[2]];
assert!(!snapshot.matches(Input {
files: &duplicated,
..input
}));
assert_eq!(
snapshot.query(Query {
stamp: QueryStamp {
snapshot: key(2),
serial: 37
},
path: "links.arcadia",
byte_offset: 0,
action: Action::Definition
}),
Err(QueryError::Query(symbols::QueryError::StaleSnapshot))
);
}
#[test]
fn omitted_rows_are_counted_before_result_allocation_and_never_confer_uniqueness() {
let fixture = Fixture::standard();
let snapshot = fixture.snapshot(
Some(&fixture),
true,
Limits {
result_rows: 2,
..Limits::default()
},
);
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&fixture),
Action::References,
);
assert_eq!((result.total_definitions, result.total_references), (1, 2));
assert_eq!(
(
result.definitions.len(),
result.references.len(),
result.omitted_references
),
(1, 1, 1)
);
let zero = fixture.snapshot(
Some(&fixture),
true,
Limits {
result_rows: 0,
..Limits::default()
},
);
let result = query(
&zero,
"links.arcadia",
reference_offset(&fixture),
Action::Definition,
);
assert_eq!(
(
result.definitions.len(),
result.total_definitions,
result.omitted_definitions
),
(0, 1, 1)
);
assert_eq!(result.disposition, Disposition::Choices);
let envelope = fixture.snapshot(
Some(&fixture),
true,
Limits {
result_bytes: 0,
..Limits::default()
},
);
assert_eq!(
envelope.query(Query {
stamp: QueryStamp {
snapshot: key(1),
serial: 1
},
path: "links.arcadia",
byte_offset: reference_offset(&fixture),
action: Action::Definition
}),
Err(QueryError::ResultEnvelopeLimit)
);
}
#[test]
fn large_display_name_cannot_escape_the_owned_row_string_budget() {
let mut fixture = Fixture::standard();
*fixture.text_mut("模块/活動.arcadia") = fixture
.text("模块/活動.arcadia")
.replace("泵 🚀", &"長".repeat(8192));
let original = fixture.files.clone();
let snapshot = fixture.snapshot(
None,
true,
Limits {
result_bytes: 64,
..Limits::default()
},
);
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&fixture),
Action::Definition,
);
assert_eq!(
(result.total_definitions, result.omitted_definitions),
(1, 1)
);
assert!(result.definitions.is_empty());
assert_eq!(result.disposition, Disposition::Choices);
assert_eq!(fixture.files, original);
}
#[test]
fn validation_witness_must_match_entry_and_complete_included_file_set() {
let fixture = Fixture::standard();
for mutation in 0..3 {
let mut witness = fixture.clone();
match mutation {
0 => witness.entry = "other.arcadia".into(),
1 => {
witness.files.pop();
}
_ => witness.files.push(("unused.arcadia".into(), String::new())),
}
let snapshot = fixture.snapshot(Some(&witness), true, Limits::default());
assert!(snapshot.scope().closure_complete);
assert!(!snapshot.scope().validated);
assert_eq!(
query(
&snapshot,
"links.arcadia",
reference_offset(&fixture),
Action::Definition
)
.disposition,
Disposition::Choices
);
}
}
#[test]
fn raw_aggregate_limits_refuse_before_outline_or_copied_snapshot() {
// Token budget zero and malformed paths/text would fail later. Raw byte/path
// limits must win, including a large second file; no partial Snapshot exists.
let large = "not a model \0".repeat(16_384);
let files = [
DraftFile {
path: "../bad.arcadia",
text: "?",
},
DraftFile {
path: "large.arcadia",
text: &large,
},
];
let input = Input {
key: key(1),
entry: "project.arcadia",
files: &files,
applied: true,
validation: None,
};
for (resource, files_limit, bytes_limit, paths_limit) in [
(symbols::Resource::Files, 1, usize::MAX, usize::MAX),
(symbols::Resource::SourceBytes, 8, 1, usize::MAX),
(symbols::Resource::PathBytes, 8, usize::MAX, 1),
] {
let limits = Limits {
index: symbols::Limits {
files: files_limit,
source_bytes: bytes_limit,
path_bytes: paths_limit,
tokens: 0,
records: 0,
},
..Limits::default()
};
assert!(
matches!(capture(input, limits), Err(AdmissionError::Index(symbols::BuildError::Limit { resource: actual, .. })) if actual == resource)
);
}
assert_eq!(files[1].text, large);
}
#[test]
fn token_declaration_and_live_reference_limits_are_aggregate_not_per_file() {
let files = [
DraftFile {
path: "a.arcadia",
text: "oa_activity a \"A\" {}",
},
DraftFile {
path: "b.arcadia",
text: "oa_activity b \"B\" {}",
},
];
let input = Input {
key: key(1),
entry: "a.arcadia",
files: &files,
applied: false,
validation: None,
};
let limits = Limits {
index: symbols::Limits {
tokens: 10,
..Limits::default().index
},
..Limits::default()
};
assert!(matches!(
capture(input, limits),
Err(AdmissionError::Outline {
file: 1,
error: language::OutlineLimit {
resource: language::OutlineResource::Tokens,
..
}
})
));
let limits = Limits {
index: symbols::Limits {
records: 1,
..Limits::default().index
},
..Limits::default()
};
assert!(matches!(
capture(input, limits),
Err(AdmissionError::Outline {
file: 1,
error: language::OutlineLimit {
resource: language::OutlineResource::Declarations,
..
}
})
));
let use_files = [
files[0],
DraftFile {
path: "uses.arcadia",
text: "source @a; target @a; // @a ignored\n",
},
];
let limits = Limits {
index: symbols::Limits {
records: 2,
..Limits::default().index
},
..Limits::default()
};
assert!(matches!(
capture(
Input {
files: &use_files,
..input
},
limits
),
Err(AdmissionError::Index(symbols::BuildError::Limit {
resource: symbols::Resource::Records,
observed_at_least: 3,
..
}))
));
}
#[test]
fn missing_and_noncanonical_authored_ids_remain_draft_spans_without_generated_anchors() {
let fixture = Fixture::standard();
for replacement in [
String::new(),
" id \"00000000000000000000000000000002\"".into(),
] {
let mut draft = fixture.clone();
let original_id = format!(" id \"{PUMP}\"");
*draft.text_mut("模块/活動.arcadia") = draft
.text("模块/活動.arcadia")
.replace(&original_id, &replacement);
draft
.text_mut("模块/活動.arcadia")
.push_str(&format!("// Historical UUID {PUMP} and @pump\r\n"));
let before = draft.files.clone();
let snapshot = draft.snapshot(None, false, Limits::default());
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&draft),
Action::Definition,
);
assert_eq!(result.definitions[0].declaration.authored_id, None);
assert_eq!(
result.definitions[0].declaration.identity,
Identity::Missing
);
assert_eq!(result.disposition, Disposition::Choices);
assert_eq!(draft.files, before);
assert_eq!(
snapshot.file(1).unwrap().text,
draft.text("模块/活動.arcadia")
);
}
}
#[test]
fn keyword_alias_and_loaded_path_case_collisions_use_reviewed_index_rules() {
let mut fixture = Fixture::standard();
for (_, text) in &mut fixture.files {
*text = text.replace("pump", "source");
}
let snapshot = fixture.snapshot(None, true, Limits::default());
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&fixture),
Action::Definition,
);
assert_eq!(result.alias.as_deref(), Some("source"));
assert_eq!(result.total_definitions, 1);
fixture.files.push(("LINKS.arcadia".into(), String::new()));
let files = fixture.borrowed();
assert!(matches!(
capture(
Input {
key: key(1),
entry: &fixture.entry,
files: &files,
applied: true,
validation: None
},
Limits::default()
),
Err(AdmissionError::Index(symbols::BuildError::Input {
problem: symbols::InputProblem::DuplicateOrCaseCollidingPath,
..
}))
));
}
#[test]
fn owned_request_moves_exact_text_into_worker_snapshot_and_matches_sync_results() {
let fixture = Fixture::standard();
let files = fixture.borrowed();
let input = Input {
key: key(1),
entry: &fixture.entry,
files: &files,
applied: true,
validation: Some(ValidatedSources {
entry: &fixture.entry,
files: &files,
}),
};
let owned = admit_owned(input, Limits::default()).unwrap();
let allocations: Vec<_> = owned.files.iter().map(|file| file.text.as_ptr()).collect();
let snapshot = owned.capture().unwrap();
for (file, allocation) in allocations.iter().enumerate() {
assert_eq!(
snapshot.file(file).unwrap().text.as_ptr(),
*allocation,
"worker moves the one current-text copy"
);
}
assert!(snapshot.matches(input));
let synchronous = capture(input, Limits::default()).unwrap();
assert_eq!(
query(
&snapshot,
"links.arcadia",
reference_offset(&fixture),
Action::References
),
query(
&synchronous,
"links.arcadia",
reference_offset(&fixture),
Action::References
)
);
let mut edited = fixture.clone();
edited
.text_mut("links.arcadia")
.push_str("// edited after request\r\n");
let changed = edited.borrowed();
assert!(!snapshot.matches(Input {
files: &changed,
..input
}));
assert!(!snapshot.matches(Input {
validation: None,
..input
}));
}
#[test]
fn owned_request_checks_raw_limits_immediately_and_extracts_only_on_worker() {
let fixture = Fixture::standard();
let files = fixture.borrowed();
let input = Input {
key: key(1),
entry: &fixture.entry,
files: &files,
applied: true,
validation: None,
};
let no_tokens = Limits {
index: symbols::Limits {
tokens: 0,
..Limits::default().index
},
..Limits::default()
};
let owned = admit_owned(input, no_tokens).expect("request admission does not lex or parse");
assert!(matches!(
owned.capture(),
Err(AdmissionError::Outline {
file: 0,
error: language::OutlineLimit {
resource: language::OutlineResource::Tokens,
..
}
})
));
let no_bytes = Limits {
index: symbols::Limits {
source_bytes: 1,
..Limits::default().index
},
..Limits::default()
};
assert!(matches!(
admit_owned(input, no_bytes),
Err(AdmissionError::Index(symbols::BuildError::Limit {
resource: symbols::Resource::SourceBytes,
..
}))
));
assert_eq!(files[1].text, fixture.text("模块/活動.arcadia"));
}
#[test]
fn owned_validation_proof_preserves_full_witness_count_and_partial_scope() {
let accepted = Fixture::standard();
for missing_include in [false, true] {
let mut current = accepted.clone();
current
.files
.retain(|(path, _)| path != "模块/活動.arcadia");
if !missing_include {
*current.text_mut("project.arcadia") = current
.text("project.arcadia")
.replace(" include \"模块/活動.arcadia\";\r\n", "");
}
let files = current.borrowed();
let old = accepted.borrowed();
let input = Input {
key: key(1),
entry: &current.entry,
files: &files,
applied: true,
validation: Some(ValidatedSources {
entry: &accepted.entry,
files: &old,
}),
};
let snapshot = admit_owned(input, Limits::default())
.unwrap()
.capture()
.unwrap();
assert_eq!(snapshot.scope().closure_complete, !missing_include);
assert!(
!snapshot.scope().validated,
"the old witness is not silently reduced to matching loaded files"
);
let result = query(
&snapshot,
"links.arcadia",
reference_offset(&current),
Action::Definition,
);
assert_eq!(result.disposition, Disposition::NoDefinition);
assert!(snapshot.matches(input));
}
}
File diff suppressed because it is too large Load Diff
+4 -217
View File
@@ -1,7 +1,7 @@
//! Per-file drafts and navigation. Accepted model/source history lives in Document.
use iced::widget::text_editor::Cursor;
use oa_core::{
Id, language,
language,
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
};
use std::collections::{BTreeMap, BTreeSet};
@@ -86,7 +86,6 @@ pub struct Buffer {
pub cursor: Cursor,
past: Vec<String>,
future: Vec<String>,
generation: u64,
}
impl Buffer {
@@ -100,20 +99,14 @@ impl Buffer {
},
past: Vec::new(),
future: Vec::new(),
generation: 0,
}
}
pub fn dirty(&self) -> bool {
self.text != self.baseline
}
pub fn generation(&self) -> u64 {
self.generation
}
}
pub struct Workspace {
session: Id,
revision: u64,
pub entry: String,
pub active: String,
pub files: BTreeMap<String, Buffer>,
@@ -124,35 +117,6 @@ pub struct Workspace {
}
impl Workspace {
/// Ephemeral editor identity, never a model identity or recovery payload.
pub fn session(&self) -> Id {
self.session
}
pub fn revision(&self) -> u64 {
self.revision
}
fn advance(&mut self) -> u64 {
if let Some(revision) = self.revision.checked_add(1) {
self.revision = revision;
} else {
self.reset_navigation_session();
}
self.revision
}
/// A changed filesystem root must not reuse queued navigation from Save As.
pub fn reset_navigation_session(&mut self) {
self.session = Id::new_v4();
self.revision = 0;
for buffer in self.files.values_mut() {
buffer.generation = 0;
}
}
pub fn set_reachable(&mut self, reachable: BTreeSet<String>) {
if self.reachable != reachable {
self.advance();
self.reachable = reachable;
}
}
pub fn recovery_snapshot(&self) -> RecoveryWorkspace {
RecoveryWorkspace {
entry: self.entry.clone(),
@@ -172,8 +136,6 @@ impl Workspace {
pub fn from_recovery(saved: RecoveryWorkspace) -> Result<Self, String> {
saved.validate()?;
Ok(Self {
session: Id::new_v4(),
revision: 0,
entry: saved.entry,
active: saved.active,
files: saved
@@ -192,8 +154,6 @@ impl Workspace {
}
pub fn new(bundle: &SourceBundle, applied: bool) -> Self {
Self {
session: Id::new_v4(),
revision: 0,
entry: bundle.entry.clone(),
active: bundle.entry.clone(),
files: bundle
@@ -231,20 +191,11 @@ impl Workspace {
}
}
pub fn edit(&mut self, text: String, cursor: Cursor) {
let changed = self.files.get(&self.active).is_none_or(|b| b.text != text);
let generation = if changed {
self.advance()
} else {
self.revision
};
let buffer = self
.files
.entry(self.active.clone())
.or_insert_with(|| Buffer::new(String::new()));
buffer.cursor = cursor;
if changed {
buffer.generation = generation;
}
if text != buffer.text {
buffer.past.push(std::mem::replace(&mut buffer.text, text));
if buffer.past.len() > 100 {
@@ -317,8 +268,6 @@ impl Workspace {
} else {
buffer.future.push(previous);
}
let generation = self.advance();
self.files.get_mut(&self.active).unwrap().generation = generation;
self.trim_history(DRAFT_HISTORY_BYTES);
true
}
@@ -332,7 +281,6 @@ impl Workspace {
}
}
pub fn accept(&mut self, bundle: &SourceBundle) {
let generation = self.advance();
if self.entry != bundle.entry
&& !bundle.files.contains_key(&self.entry)
&& self.files.get(&self.entry).is_some_and(|b| !b.dirty())
@@ -343,13 +291,7 @@ impl Workspace {
self.reachable = bundle.files.keys().cloned().collect();
for (path, text) in &bundle.files {
let cursor = self.files.get(path).map(|b| b.cursor);
let previous_generation = self
.files
.get(path)
.filter(|b| b.text == *text)
.map(|b| b.generation);
let mut buffer = Buffer::new(text.clone());
buffer.generation = previous_generation.unwrap_or(generation);
if let Some(cursor) = cursor {
buffer.cursor = cursor;
}
@@ -361,22 +303,10 @@ impl Workspace {
self.applied = true;
}
pub fn discovered(&mut self, bundle: &SourceBundle, reachable: BTreeSet<String>) {
let changed = self.reachable != reachable
|| bundle
.files
.keys()
.any(|path| !self.files.contains_key(path));
let generation = if changed {
self.advance()
} else {
self.revision
};
for (path, text) in &bundle.files {
self.files.entry(path.clone()).or_insert_with(|| {
let mut buffer = Buffer::new(text.clone());
buffer.generation = generation;
buffer
});
self.files
.entry(path.clone())
.or_insert_with(|| Buffer::new(text.clone()));
}
self.reachable = reachable;
}
@@ -384,7 +314,6 @@ impl Workspace {
if self.reachable.contains(path) || self.files.get(path).is_none_or(Buffer::dirty) {
return false;
}
self.advance();
self.files.remove(path);
if self.active == path {
self.active = self.entry.clone();
@@ -447,148 +376,6 @@ pub fn lint(bundle: &SourceBundle) -> Vec<SourceDiagnostic> {
mod tests {
use super::*;
use oa_core::Project;
#[test]
fn fresh_session_cannot_reuse_a_retained_buffer_generation() {
let bundle = SourceBundle {
entry: "main.arcadia".into(),
files: BTreeMap::from([
("main.arcadia".into(), "first".into()),
("other.arcadia".into(), "other".into()),
]),
};
let mut workspace = Workspace::new(&bundle, true);
let cursor = workspace.files["main.arcadia"].cursor;
workspace.edit("second".into(), cursor);
workspace.reset_navigation_session();
let before = workspace.files["main.arcadia"].generation();
workspace.edit("third".into(), cursor);
assert_ne!(workspace.files["main.arcadia"].generation(), before);
workspace.switch("other.arcadia", cursor);
workspace.revision = u64::MAX;
workspace.edit("changed other".into(), cursor);
let after_wrap = workspace.files["main.arcadia"].generation();
workspace.switch("main.arcadia", cursor);
workspace.edit("fourth".into(), cursor);
assert_ne!(workspace.files["main.arcadia"].generation(), after_wrap);
assert!(workspace.undo(false));
assert_eq!(workspace.text(), "third");
}
#[test]
fn navigation_versions_distinguish_reopened_recovered_and_changed_drafts() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "entry\r\n".into()),
("part.arcadia".into(), "雨 draft\r\n".into()),
]),
};
let mut workspace = Workspace::new(&bundle, true);
let reopened = Workspace::new(&bundle, true);
let recovered = Workspace::from_recovery(workspace.recovery_snapshot()).unwrap();
assert_ne!(workspace.session(), reopened.session());
assert_ne!(workspace.session(), recovered.session());
assert_eq!(workspace.bundle(), recovered.bundle());
let saved = serde_json::to_value(workspace.recovery_snapshot()).unwrap();
assert!(saved.get("session").is_none() && saved.get("revision").is_none());
let cursor = Cursor {
position: iced::widget::text_editor::Position { line: 0, column: 3 },
selection: None,
};
let revision = workspace.revision();
workspace.edit(workspace.text().into(), cursor);
assert_eq!(
workspace.revision(),
revision,
"selection is not a text change"
);
workspace.switch("part.arcadia", cursor);
assert_eq!(
workspace.revision(),
revision,
"switching retains the snapshot"
);
let untouched = workspace.files["project.arcadia"].generation();
workspace.edit("雨 unfinished !\r\n".into(), cursor);
let edited = workspace.files["part.arcadia"].generation();
assert!(workspace.revision() > revision);
assert_eq!(workspace.files["project.arcadia"].generation(), untouched);
assert!(workspace.undo(false));
assert_eq!(workspace.bundle(), bundle);
assert!(workspace.files["part.arcadia"].generation() > edited);
assert!(workspace.undo(true));
assert_eq!(workspace.text(), "雨 unfinished !\r\n");
let previous_session = workspace.session();
let previous_text = workspace.bundle();
workspace.reset_navigation_session();
assert_ne!(workspace.session(), previous_session);
assert_eq!(workspace.bundle(), previous_text);
assert!(
workspace.undo(false),
"session reset preserves text history"
);
}
#[test]
fn navigation_scope_changes_invalidate_without_changing_unchanged_positions() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([
("project.arcadia".into(), "entry".into()),
("part.arcadia".into(), "part".into()),
]),
};
let mut workspace = Workspace::new(&bundle, true);
let entry_generation = workspace.files[&bundle.entry].generation();
let initial_revision = workspace.revision();
workspace.set_reachable(BTreeSet::from([bundle.entry.clone()]));
assert!(workspace.revision() > initial_revision);
let membership_revision = workspace.revision();
workspace.set_reachable(workspace.reachable.clone());
assert_eq!(workspace.revision(), membership_revision);
assert!(workspace.discard_detached("part.arcadia"));
assert!(workspace.revision() > membership_revision);
let closed_revision = workspace.revision();
workspace.discovered(&bundle, BTreeSet::from([bundle.entry.clone()]));
assert!(workspace.revision() > closed_revision);
assert_eq!(
workspace.files[&bundle.entry].generation(),
entry_generation
);
assert!(workspace.files["part.arcadia"].generation() > closed_revision);
let discovered_revision = workspace.revision();
workspace.discovered(&bundle, workspace.reachable.clone());
assert_eq!(workspace.revision(), discovered_revision);
workspace.accept(&bundle);
assert!(
workspace.revision() > discovered_revision,
"validation evidence changed"
);
assert_eq!(
workspace.files[&bundle.entry].generation(),
entry_generation
);
assert_eq!(workspace.bundle(), bundle);
}
#[test]
fn navigation_revision_wrap_cannot_reuse_an_old_workspace_key() {
let bundle = SourceBundle {
entry: "project.arcadia".into(),
files: BTreeMap::from([("project.arcadia".into(), "original".into())]),
};
let mut workspace = Workspace::new(&bundle, true);
workspace.revision = u64::MAX;
let before = workspace.session();
let cursor = workspace.files[&bundle.entry].cursor;
workspace.edit("changed".into(), cursor);
assert_ne!(workspace.session(), before);
assert_eq!(workspace.revision(), 0);
assert!(workspace.undo(false));
assert_eq!(workspace.bundle(), bundle);
}
#[test]
fn draft_history_budget_covers_inactive_files_without_discarding_drafts() {
let bundle = SourceBundle {
+3 -415
View File
@@ -377,8 +377,7 @@ fn source_navigation_waits_for_editor_layout_and_cancels_on_edit() {
send(&mut app, Message::SourceForSelection);
assert_eq!(app.page, Page::Text);
assert!(app.source_jump.is_some());
let ticket = app.source_reveal_ticket();
send(&mut app, Message::SourceViewport(ticket));
send(&mut app, Message::SourceViewport);
assert!(app.source_jump.is_none());
send(&mut app, Message::SourceForSelection);
assert!(app.source_jump.is_some());
@@ -410,8 +409,7 @@ fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() {
cursor.selection,
Some(text_editor::Position { line: 0, column: 9 })
);
let ticket = app.source_reveal_ticket();
send(&mut app, Message::SourceViewport(ticket));
send(&mut app, Message::SourceViewport);
assert_eq!(app.source_editor.cursor(), cursor);
send(
&mut app,
@@ -1271,26 +1269,7 @@ fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner()
.get_mut(&ancestor)
.unwrap()
.collapsed = true;
// The usage query reports rendered objects; this port's owner is folded.
// The legacy authoring reveal below explicitly expands its saved ancestor.
assert!(
!oa_core::projection::project_view(&p, diagram)
.ports
.iter()
.any(|shown| shown.element == port)
);
assert!(crate::diagram_usage::saved_usages_for(&p, port, Some(diagram)).is_empty());
// Other saved views of this function may still paint the port.
let elsewhere = crate::diagram_usage::saved_usages(&p, port);
assert!(
!elsewhere.is_empty(),
"the demo still paints this port in another saved view"
);
assert!(
elsewhere
.iter()
.all(|usage| usage.surface != crate::diagram_usage::Surface::Saved(diagram))
);
assert!(crate::navigation::diagrams_containing(&p, port).contains(&diagram));
let mut app = App::with_project(p);
send(&mut app, Message::RevealInDiagram(diagram, port));
assert_eq!(app.selected, port);
@@ -1302,12 +1281,6 @@ fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner()
.iter()
.any(|p| p.element == port)
);
assert!(
crate::diagram_usage::saved_usages_for(app.project(), port, Some(diagram))
.iter()
.any(|usage| matches!(usage.target,
crate::diagram_usage::Target::Port { port: found, .. } if found == port))
);
}
#[test]
@@ -1690,388 +1663,3 @@ fn adding_bend_between_close_ports_stays_outside_owner_clearance() {
}]
);
}
#[test]
fn child_work_preserves_allocated_context_and_atomic_history() {
use oa_core::projection::project_view;
for (layer, performer_kind, work_kind) in [
(Layer::Oa, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::Function),
] {
for folded in [false, true] {
let mut p = Project::empty();
let performer = p
.add_element(performer_kind, layer, "Performer", None)
.unwrap();
let work = p
.add_element(work_kind, layer, "Parent work", None)
.unwrap();
let allocation = p.relate(
RelationKind::Allocation,
"Engineering assignment",
performer,
work,
);
p.relations.get_mut(&allocation).unwrap().description =
"Retain assignment metadata".into();
let original = p.relations[&allocation].clone();
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
let performer_node = p.place(diagram, performer, Position { x: 60., y: 60. });
let work_node = p.place(diagram, work, Position { x: 84., y: 124. });
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
for node in [performer_node, work_node] {
p.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.get_mut(&node)
.unwrap()
.collapsed = folded;
}
let mut app = App::with_project(p);
send(&mut app, Message::Layer(layer));
send(&mut app, Message::Page(Page::Diagram));
send(
&mut app,
Message::Diagram(crate::app::Choice {
id: diagram,
label: String::new(),
}),
);
let mut first = None;
for step in 0..3 {
let parent = if step == 2 { first.unwrap() } else { work };
send(&mut app, Message::Select(parent));
let before = app.project().clone();
let before_source = app.document.bundle().clone();
send(&mut app, Message::CreateContextElement(work_kind, true));
let created = app.selected;
assert!(
!before.elements.contains_key(&created),
"{layer:?} step {step}: {}",
app.status
);
assert_eq!(app.project().elements.len(), before.elements.len() + 1);
assert_eq!(app.project().elements[&created].parent, Some(parent));
for (id, element) in &before.elements {
assert_eq!(&app.project().elements[id], element);
}
assert!(
!oa_core::validation::validate(app.project())
.iter()
.any(|i| i.severity == oa_core::validation::Severity::Error)
);
assert_eq!(
oa_core::validation::allocation_summary(app.project(), work),
Some(performer)
);
assert_eq!(
oa_core::validation::effective_performer(app.project(), created),
Some(performer)
);
assert!(
!app.project()
.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation && r.target() == parent)
);
if step != 1 {
let mut expected = original.clone();
if let RelationBody::Allocation { target, .. } = &mut expected.body {
*target = created;
}
assert_eq!(app.project().relations[&allocation], expected);
} else {
assert_eq!(
app.project().relations[&allocation],
before.relations[&allocation]
);
}
assert_eq!(
app.project().relations.len(),
before.relations.len() + usize::from(step == 1)
);
let view = project_view(app.project(), diagram);
let parent_node = view.nodes.iter().find(|n| n.element == parent).unwrap();
let child_node = view.nodes.iter().find(|n| n.element == created).unwrap();
let root_node = view.nodes.iter().find(|n| n.element == work).unwrap();
assert_eq!(root_node.visual_parent, Some(performer_node));
assert_eq!(child_node.visual_parent, Some(parent_node.id));
assert!(!app.project().diagrams[&diagram].nodes[&performer_node].collapsed);
assert!(!app.project().diagrams[&diagram].nodes[&parent_node.id].collapsed);
assert!(child_node.position.x >= parent_node.position.x);
assert!(child_node.position.y >= parent_node.position.y + 64.);
assert!(
child_node.position.x + child_node.size.width
<= parent_node.position.x + parent_node.size.width
);
assert!(
child_node.position.y + child_node.size.height
<= parent_node.position.y + parent_node.size.height
);
for sibling in view
.nodes
.iter()
.filter(|n| n.visual_parent == Some(parent_node.id) && n.id != child_node.id)
{
assert!(sibling.position.y + sibling.size.height <= child_node.position.y);
}
let after = app.project().clone();
let after_source = app.document.bundle().clone();
send(&mut app, Message::Undo);
assert_eq!(
app.project(),
&before,
"creation, allocation and reveal/reflow undo together"
);
assert_eq!(app.document.bundle(), &before_source);
send(&mut app, Message::Redo);
assert_eq!(app.project(), &after);
assert_eq!(app.document.bundle(), &after_source);
if step == 0 {
first = Some(created);
}
}
}
}
}
#[test]
fn child_work_does_not_invent_unallocated_or_mixed_performers() {
for (layer, performer_kind, other_kind, work_kind) in [
(Layer::Oa, Kind::Entity, Kind::Entity, Kind::Activity),
(Layer::Sa, Kind::System, Kind::SystemActor, Kind::Function),
] {
// Root leaf, partly unallocated composite, and mixed-performer composite.
for context in 0..3 {
let mut p = Project::empty();
let performer = p
.add_element(performer_kind, layer, "Performer", None)
.unwrap();
let other = p
.add_element(other_kind, layer, "Other performer", None)
.unwrap();
let work = p
.add_element(work_kind, layer, "Parent work", None)
.unwrap();
let mut placed = vec![performer, other, work];
if context != 0 {
let first = p
.add_element(work_kind, layer, "First leaf", Some(work))
.unwrap();
let second = p
.add_element(work_kind, layer, "Second leaf", Some(work))
.unwrap();
p.relate(
RelationKind::Allocation,
"First assignment",
performer,
first,
);
if context == 2 {
p.relate(RelationKind::Allocation, "Other assignment", other, second);
}
placed.extend([first, second]);
}
assert_eq!(oa_core::validation::allocation_summary(&p, work), None);
let diagram = p.ensure_diagram(layer);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
for id in placed {
p.place(diagram, id, Position { x: 60., y: 60. });
}
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
let mut app = App::with_project(p);
send(&mut app, Message::Layer(layer));
send(&mut app, Message::Page(Page::Diagram));
send(
&mut app,
Message::Diagram(crate::app::Choice {
id: diagram,
label: String::new(),
}),
);
send(&mut app, Message::Select(work));
let before = app.project().clone();
let before_source = app.document.bundle().clone();
send(&mut app, Message::CreateContextElement(work_kind, true));
let child = app.selected;
assert!(!before.elements.contains_key(&child), "{}", app.status);
assert_eq!(app.project().elements[&child].parent, Some(work));
assert_eq!(app.project().relations, before.relations);
assert_eq!(
oa_core::validation::effective_performer(app.project(), child),
None
);
assert_eq!(
oa_core::validation::allocation_summary(app.project(), work),
None
);
let after = app.project().clone();
let after_source = app.document.bundle().clone();
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before);
assert_eq!(app.document.bundle(), &before_source);
send(&mut app, Message::Redo);
assert_eq!(app.project(), &after);
assert_eq!(app.document.bundle(), &after_source);
}
}
}
#[test]
fn child_work_transfers_leaf_roles_without_fabricating_responsibilities() {
// Direct allocation + matching roles, uniquely realized roles only,
// unresolved roles, and roles realized by different performers.
for context in 0..4 {
let mut p = Project::empty();
let performer = p
.add_element(Kind::Entity, Layer::Oa, "Performer", None)
.unwrap();
let other = p
.add_element(Kind::Entity, Layer::Oa, "Other performer", None)
.unwrap();
let work = p
.add_element(Kind::Activity, Layer::Oa, "Responsible work", None)
.unwrap();
let mut responsibilities = Vec::new();
if context == 0 {
responsibilities.push(p.relate(
RelationKind::Allocation,
"Direct responsibility",
performer,
work,
));
}
for index in 0..2 {
let role = p
.add_element(Kind::Role, Layer::Oa, &format!("Role {index}"), None)
.unwrap();
responsibilities.push(p.relate(
RelationKind::RoleAssignment,
&format!("Assigned role {index}"),
role,
work,
));
if context != 2 {
let source = if context == 3 && index == 1 {
other
} else {
performer
};
p.relate(RelationKind::RoleRealization, "Realizes role", source, role);
}
}
for (index, id) in responsibilities.iter().enumerate() {
let relation = p.relations.get_mut(id).unwrap();
relation.description =
format!("Responsibility {index}: preserve engineering rationale");
let link = Id::new_v4();
p.artifact_links.insert(
link,
ArtifactLink {
id: link,
alias: format!("responsibility_evidence_{index}"),
name: format!("Responsibility evidence {index}"),
description: "Preserve the reference to this exact relation".into(),
owner: *id,
relation: ArtifactRelation::References,
uri: format!("https://example.invalid/responsibility/{index}"),
revision: Some("review-7".into()),
fragment: Some("assignment".into()),
},
);
}
let original_relations = p.relations.clone();
let diagram = p.ensure_diagram(Layer::Oa);
p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation;
p.place(diagram, performer, Position { x: 60., y: 60. });
p.place(diagram, work, Position { x: 84., y: 124. });
p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
let expected_performer = (context < 2).then_some(performer);
assert_eq!(
oa_core::validation::allocation_summary(&p, work),
expected_performer
);
let mut app = App::with_project(p);
send(&mut app, Message::Page(Page::Diagram));
send(
&mut app,
Message::Diagram(crate::app::Choice {
id: diagram,
label: String::new(),
}),
);
let mut first = None;
for step in 0..2 {
send(&mut app, Message::Select(work));
let before = app.project().clone();
let before_source = app.document.bundle().clone();
send(
&mut app,
Message::CreateContextElement(Kind::Activity, true),
);
let child = app.selected;
assert!(
!before.elements.contains_key(&child),
"context {context}, step {step}: {}",
app.status
);
assert_eq!(app.project().elements[&child].parent, Some(work));
if step == 0 {
first = Some(child);
}
for (id, original) in &original_relations {
let mut expected = original.clone();
if responsibilities.contains(id) {
match &mut expected.body {
RelationBody::Allocation { target, .. }
| RelationBody::RoleAssignment { target, .. } => *target = first.unwrap(),
_ => unreachable!(),
}
}
assert_eq!(app.project().relations[id], expected);
}
assert_eq!(app.project().artifact_links, before.artifact_links);
assert!(!app.project().artifact_links.is_empty());
let added = usize::from(step == 1 && expected_performer.is_some());
assert_eq!(
app.project().relations.len(),
before.relations.len() + added
);
assert_eq!(
oa_core::validation::effective_performer(app.project(), child),
expected_performer
);
assert_eq!(
app.project()
.relations
.values()
.filter(|r| r.kind() == RelationKind::RoleAssignment && r.target() == child)
.count(),
if step == 0 { 2 } else { 0 }
);
if context != 0 && step == 0 {
assert!(
!app.project()
.relations
.values()
.any(|r| r.kind() == RelationKind::Allocation)
);
}
assert!(
!oa_core::validation::validate(app.project())
.iter()
.any(|i| i.severity == oa_core::validation::Severity::Error)
);
let after = app.project().clone();
let after_source = app.document.bundle().clone();
send(&mut app, Message::Undo);
assert_eq!(app.project(), &before);
assert_eq!(app.document.bundle(), &before_source);
send(&mut app, Message::Redo);
assert_eq!(app.project(), &after);
assert_eq!(app.document.bundle(), &after_source);
}
}
}
+12 -567
View File
@@ -1,4 +1,3 @@
mod source_symbols;
pub(crate) mod traceability;
use crate::{
app::{App, Choice, FileOperation, Message, Page, Pending},
@@ -13,7 +12,6 @@ use iced::{
},
};
use oa_core::*;
use unicode_segmentation::UnicodeSegmentation;
pub fn label<'a>(s: impl Into<String>) -> widget::Text<'a> {
text(s.into()).size(11).color(style::MUTED)
@@ -26,12 +24,10 @@ pub fn action<'a>(s: &str, m: Message) -> widget::Button<'a, Message> {
}
pub fn short(s: &str, limit: usize) -> String {
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
if s.graphemes(true).count() > limit {
if s.chars().count() > limit {
format!(
"{}…",
s.graphemes(true)
.take(limit.saturating_sub(1))
.collect::<String>()
s.chars().take(limit.saturating_sub(1)).collect::<String>()
)
} else {
s
@@ -450,9 +446,6 @@ fn diagram(app: &App) -> View<'_, Message> {
view_index: Some(app.document.view_index()),
diagram: id,
selected: app.selected,
navigation_focus: app
.diagram_focus(crate::diagram_usage::Surface::Saved(id))
.map(|usage| usage.bounds),
zoom: app.zoom,
pan: app.pan,
context: app.route_context().unwrap(),
@@ -473,17 +466,10 @@ fn diagram(app: &App) -> View<'_, Message> {
if let Some(editor) = crate::app::architecture_authoring::inline_editor(app) {
scene = scene.push(editor);
}
let reveal = app.architecture_reveal_stamp(id);
row![
widget::sensor(scene)
.on_show(move |size| reveal
.map_or(Message::CanvasResized(size), |(context, epoch)| {
Message::DiagramRevealViewport(context, epoch, size)
}))
.on_resize(move |size| reveal
.map_or(Message::CanvasResized(size), |(context, epoch)| {
Message::DiagramRevealViewport(context, epoch, size)
})),
.on_show(Message::CanvasResized)
.on_resize(Message::CanvasResized),
crate::app::architecture_authoring::palette(app)
]
.height(Fill)
@@ -599,7 +585,7 @@ fn inspector(app: &App) -> View<'_, Message> {
return container(
column![
tabs,
scrollable(crate::navigation::view(app, app.selected)).height(Fill)
scrollable(crate::navigation::view(p, app.selected)).height(Fill)
]
.spacing(14),
)
@@ -1033,7 +1019,7 @@ fn source(app: &App) -> View<'_, Message> {
),
action("Next", Message::SourceNavigation(SourceEvent::Next(false))),
label(app.source_navigation.label()),
action("Close", app.source_find_close_message()),
action("Close", Message::SourceNavigation(SourceEvent::Close)),
]
.spacing(6)
.align_y(iced::Center),
@@ -1127,12 +1113,8 @@ fn source(app: &App) -> View<'_, Message> {
}
}
let editor = widget::text_editor(&app.source_editor)
.id(app.source_editor_id())
.id("source-editor")
.on_action(Message::SourceEdit)
.key_binding({
let context = app.source_symbol_context();
move |press| source_binding(press, &context)
})
.font(Font::MONOSPACE)
.size(14)
.padding(16)
@@ -1141,7 +1123,8 @@ fn source(app: &App) -> View<'_, Message> {
app.source_snapshot.clone(),
crate::highlight::format,
);
let header = container(
column![
container(
column![
row![
container(heading(
@@ -1184,501 +1167,17 @@ fn source(app: &App) -> View<'_, Message> {
"Undo text affects this file's draft. Model Undo restores accepted files together (100 changes / about 32 MiB)."
}),
navigation,
row![
action("Definition · F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::Definition))),
action("References · Shift F12", app.source_symbol_message(crate::source_navigation::SymbolEvent::Query(crate::source_navigation::query::Action::References))),
action("Show in diagrams…", app.source_symbol_message(crate::source_navigation::SymbolEvent::DiagramUsages)),
button(text("Back · Alt ←").size(12)).padding([8, 10]).style(style::nav)
.on_press_maybe((app.source_symbols.back.iter().any(|entry| entry.session == app.source_workspace.session())).then(|| app.source_symbol_message(crate::source_navigation::SymbolEvent::Back))),
].spacing(7),
scrollable(diagnostics)
.height((visible_diagnostics as f32 * 32.).min(132.))
]
.spacing(12)
)
.padding(18);
let editor = widget::sensor(editor)
.key((
app.source_workspace.active.clone(),
app.source_reveal_ticket(),
))
.on_show({
let ticket = app.source_reveal_ticket();
move |_| Message::SourceViewport(ticket)
})
.on_resize({
let ticket = app.source_reveal_ticket();
move |_| Message::SourceViewport(ticket)
});
let workspace: View<'_, Message> = if app.source_symbols.open {
row![
container(editor).width(Fill).height(Fill),
container(scrollable(source_symbols::panel(app)))
.width(300)
.height(Fill),
]
.height(Fill)
.into()
} else {
container(editor).width(Fill).height(Fill).into()
};
source_layout(header.into(), workspace, app.window_size.height)
}
fn source_layout<'a, Renderer: iced::advanced::text::Renderer + 'a>(
header: View<'a, Message, iced::Theme, Renderer>,
workspace: View<'a, Message, iced::Theme, Renderer>,
window_height: f32,
) -> View<'a, Message, iced::Theme, Renderer> {
// Lint clears and repopulates diagnostics asynchronously. Reserve the same
// outer slot so a pointer aimed at the editor cannot land in the header.
column![
container(scrollable(header).id("source-header")).height((window_height * 0.42).max(180.)),
workspace,
.padding(18),
widget::sensor(editor).key((app.source_workspace.active.clone(),app.source_jump))
.on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport)
]
.height(Fill)
.into()
}
fn source_binding(
press: widget::text_editor::KeyPress,
context: &crate::source_navigation::SymbolContext,
) -> Option<widget::text_editor::Binding<Message>> {
use crate::source_navigation::{SymbolEvent, query::Action};
use iced::keyboard::{Key, key::Named};
use widget::text_editor::{Binding, Status};
// Windows owns Alt+Space for its window menu. Iced's default binding would
// otherwise insert a supplied space payload, even after that menu closes.
// Keep Ctrl+Alt (AltGr), shifted input, and ordinary Space unchanged.
#[cfg(target_os = "windows")]
if matches!(press.status, Status::Focused { .. })
&& press.modifiers == iced::keyboard::Modifiers::ALT
&& matches!(
press.key.as_ref(),
Key::Named(Named::Space) | Key::Character(" ")
)
{
return None;
}
if matches!(press.status, Status::Focused { .. })
&& !press.modifiers.control()
&& !press.modifiers.logo()
{
let event = match &press.key {
Key::Named(Named::F12) if !press.modifiers.alt() => {
Some(SymbolEvent::Query(if press.modifiers.shift() {
Action::References
} else {
Action::Definition
}))
}
Key::Named(Named::ArrowLeft) if press.modifiers.alt() && !press.modifiers.shift() => {
Some(SymbolEvent::Back)
}
_ => None,
};
if let Some(event) = event {
return Some(Binding::Custom(Message::SourceSymbols(
context.clone(),
event,
)));
}
}
Binding::from_key_press(press)
}
#[cfg(all(test, debug_assertions))]
mod source_tests {
use super::*;
use iced::advanced::{
Layout, Shell, graphics, layout, renderer, text as shaped, widget as core_widget,
};
use iced::widget::column;
use iced::{Background, Color, Pixels, Point, Rectangle, Size, Transformation, Vector};
// Measure real Iced paragraphs/editor text without constructing a window or GPU.
struct FontLayout;
impl renderer::Renderer for FontLayout {
fn start_layer(&mut self, _: Rectangle) {}
fn end_layer(&mut self) {}
fn start_transformation(&mut self, _: Transformation) {}
fn end_transformation(&mut self) {}
fn reset(&mut self, _: Rectangle) {}
fn fill_quad(&mut self, _: renderer::Quad, _: impl Into<Background>) {}
fn allocate_image(
&mut self,
handle: &iced::advanced::image::Handle,
callback: impl FnOnce(
Result<iced::advanced::image::Allocation, iced::advanced::image::Error>,
) + Send
+ 'static,
) {
renderer::Renderer::allocate_image(&mut (), handle, callback);
}
}
impl shaped::Renderer for FontLayout {
type Font = Font;
type Paragraph = graphics::text::Paragraph;
type Editor = graphics::text::Editor;
const ICON_FONT: Font = Font::DEFAULT;
const CHECKMARK_ICON: char = '0';
const ARROW_DOWN_ICON: char = '0';
const SCROLL_UP_ICON: char = '0';
const SCROLL_DOWN_ICON: char = '0';
const SCROLL_LEFT_ICON: char = '0';
const SCROLL_RIGHT_ICON: char = '0';
const ICED_LOGO: char = '0';
fn default_font(&self) -> Font {
Font::DEFAULT
}
fn default_size(&self) -> Pixels {
Pixels(16.)
}
fn fill_paragraph(&mut self, _: &Self::Paragraph, _: Point, _: Color, _: Rectangle) {}
fn fill_editor(&mut self, _: &Self::Editor, _: Point, _: Color, _: Rectangle) {}
fn fill_text(&mut self, _: shaped::Text, _: Point, _: Color, _: Rectangle) {}
}
#[derive(Default)]
struct Geometry {
header: Option<(Rectangle, Rectangle, Vector)>,
editor: Option<Rectangle>,
}
impl core_widget::Operation for Geometry {
fn traverse(&mut self, visit: &mut dyn FnMut(&mut dyn core_widget::Operation)) {
visit(self);
}
fn container(&mut self, id: Option<&core_widget::Id>, bounds: Rectangle) {
if id == Some(&core_widget::Id::new("test-source-editor")) {
self.editor = Some(bounds);
}
}
fn scrollable(
&mut self,
id: Option<&core_widget::Id>,
bounds: Rectangle,
content: Rectangle,
offset: Vector,
_: &mut dyn core_widget::operation::Scrollable,
) {
if id == Some(&core_widget::Id::new("source-header")) {
self.header = Some((bounds, content, offset));
}
}
}
fn mounted_source<'a>(
content: &'a widget::text_editor::Content<FontLayout>,
diagnostics: usize,
find: bool,
window_height: f32,
) -> View<'a, Message, iced::Theme, FontLayout> {
let mut notices = column![].spacing(5);
for index in 0..diagnostics {
notices = notices.push(button(text(format!(
"救援/assess.arcadia:{}:5 · Warning: verify this participant's engineering allocation", index + 1,
)).size(12)).padding([4, 6]).width(Fill));
}
let find: View<'_, Message, iced::Theme, FontLayout> = if find {
column![
text_input("Find literal text", "assess").on_input(|s| Message::SourceNavigation(
crate::source_navigation::Event::Query(s)
)),
text_input("Line", "1").on_input(|s| Message::SourceNavigation(
crate::source_navigation::Event::Line(s)
)),
]
.spacing(8)
.into()
} else {
Space::new().into()
};
let header = container(
column![
text("Textual model").size(24),
text("Edit the current file. Apply text validates the complete included model.")
.size(11),
row![
button(text("Apply text")),
button(text("Undo text")),
button(text("Model Undo"))
]
.spacing(8),
text("救援/assess.arcadia · All files applied to the accepted model").size(11),
text("Undo text affects this draft. Model Undo restores accepted files together.")
.size(11),
find,
row![
button(text("Definition · F12")),
button(text("References · Shift F12")),
button(text("Back"))
]
.spacing(7),
scrollable(notices).height((diagnostics as f32 * 32.).min(132.)),
]
.spacing(12),
)
.padding(18);
let editor = container(
widget::text_editor(content)
.on_action(Message::SourceEdit)
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Fill),
)
.id("test-source-editor")
.height(Fill)
.width(Fill);
source_layout(header.into(), editor.into(), window_height)
}
#[test]
fn source_header_keeps_editor_bounds_stable_through_lint_and_find_rebuilds() {
for (host, window_height) in [
(Size::new(720., 540.), 700.),
(Size::new(1180., 890.), 1080.),
] {
let content = widget::text_editor::Content::<FontLayout>::with_text(
"// is excluded\r\n// 雨\r\n",
);
let mut tree = core_widget::Tree::empty();
let mut baseline = None;
for (diagnostics, find) in [
(0, false),
(6, false),
(0, false),
(6, true),
(1, true),
(0, false),
] {
let mut ui = mounted_source(&content, diagnostics, find, window_height);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
let mut geometry = Geometry::default();
ui.as_widget_mut().operate(
&mut tree,
Layout::new(&node),
&FontLayout,
&mut geometry,
);
let editor = geometry.editor.unwrap();
let (header, _, _) = geometry.header.unwrap();
let slot = node.children()[0].bounds();
assert_eq!(slot.height, (window_height * 0.42).max(180.));
assert!(
header.height <= slot.height,
"short content may shrink inside the fixed slot"
);
assert!(
editor.height >= 240.,
"remaining editor must stay usable: {editor:?}"
);
assert_eq!(editor.y, slot.y + slot.height);
if let Some(expected) = baseline {
assert_eq!(editor, expected, "diagnostics={diagnostics}, find={find}");
} else {
baseline = Some(editor);
}
}
}
}
#[test]
fn source_header_remains_scrollable_and_preserves_offset_across_diagnostic_refresh() {
let content = widget::text_editor::Content::<FontLayout>::with_text("// 雨\r\n");
let host = Size::new(720., 540.);
let mut tree = core_widget::Tree::empty();
let mut saved = None;
for diagnostics in [6, 7] {
let mut ui = mounted_source(&content, diagnostics, true, 700.);
tree.diff(ui.as_widget());
let node = ui.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, host),
);
let mut geometry = Geometry::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
let (bounds, contents, _) = geometry.header.unwrap();
assert!(
contents.height > bounds.height,
"fixture must actually overflow"
);
if saved.is_none() {
let mut messages = Vec::new();
let mut shell = Shell::new(&mut messages);
ui.as_widget_mut().update(
&mut tree,
&iced::Event::Mouse(iced::mouse::Event::WheelScrolled {
delta: iced::mouse::ScrollDelta::Pixels { x: 0., y: -45. },
}),
Layout::new(&node),
iced::mouse::Cursor::Available(Point::new(bounds.x + 8., bounds.y + 8.)),
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(host),
);
assert!(shell.is_event_captured());
}
let mut geometry = Geometry::default();
ui.as_widget_mut()
.operate(&mut tree, Layout::new(&node), &FontLayout, &mut geometry);
let offset = geometry.header.unwrap().2;
assert!(offset.y > 0., "the actual mounted Scrollable must move");
if let Some(before) = saved {
assert_eq!(offset, before);
} else {
saved = Some(offset);
}
}
}
#[cfg(target_os = "windows")]
#[test]
fn source_editor_windows_menu_payload_is_inert_but_space_and_altgr_still_edit() {
use core_widget::operation::Focusable;
use iced::keyboard::{
self, Key, Modifiers,
key::{Code, Named, Physical},
};
use widget::text_editor::{Binding, Content, KeyPress, State, Status};
let baseline = "// is excluded\r\n// 雨\r\n";
let context = crate::source_navigation::SymbolContext {
session: Id::new_v4(),
path: "救援/assess.arcadia".into(),
};
for (key, physical_key, modifiers, payload, inserted) in [
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::ALT,
" ",
false,
),
(
Key::Character(" ".into()),
Physical::Code(Code::Space),
Modifiers::ALT,
" ",
false,
),
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::empty(),
" ",
true,
),
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::CTRL | Modifiers::ALT,
" ",
true,
),
(
Key::Character("e".into()),
Physical::Code(Code::KeyE),
Modifiers::CTRL | Modifiers::ALT,
"€",
true,
),
(
Key::Named(Named::Space),
Physical::Code(Code::Space),
Modifiers::ALT | Modifiers::SHIFT,
" ",
true,
),
] {
let mut content = Content::<FontLayout>::with_text(baseline);
content.move_to(widget::text_editor::Cursor {
position: widget::text_editor::Position { line: 0, column: 5 },
selection: None,
});
let press = KeyPress {
key: key.clone(),
modified_key: key.clone(),
physical_key,
modifiers,
text: Some(payload.into()),
status: Status::Focused { is_hovered: false },
};
assert!(
matches!(
Binding::<Message>::from_key_press(press),
Some(Binding::Insert(_))
),
"the supplied event must expose the default insertion path"
);
let mut messages = Vec::new();
{
let mut editor: View<'_, Message, iced::Theme, FontLayout> =
widget::text_editor(&content)
.on_action(Message::SourceEdit)
.font(Font::MONOSPACE)
.size(14)
.padding(16)
.height(Fill)
.key_binding(|press| source_binding(press, &context))
.into();
let mut tree = core_widget::Tree::new(editor.as_widget());
let size = Size::new(600., 240.);
let node = editor.as_widget_mut().layout(
&mut tree,
&FontLayout,
&layout::Limits::new(Size::ZERO, size),
);
tree.state
.downcast_mut::<State<shaped::highlighter::PlainText>>()
.focus();
let event = iced::Event::Keyboard(keyboard::Event::KeyPressed {
key: key.clone(),
modified_key: key,
physical_key,
location: keyboard::Location::Standard,
modifiers,
text: Some(payload.into()),
repeat: false,
});
let mut shell = Shell::new(&mut messages);
editor.as_widget_mut().update(
&mut tree,
&event,
Layout::new(&node),
iced::mouse::Cursor::Unavailable,
&FontLayout,
&mut iced::advanced::clipboard::Null,
&mut shell,
&Rectangle::with_size(size),
);
}
assert_eq!(
messages.len(),
usize::from(inserted),
"modifiers={modifiers:?}"
);
for message in messages {
let Message::SourceEdit(action) = message else {
panic!("unexpected navigation action");
};
content.perform(action);
}
let expected = if inserted {
format!("// is{payload} excluded\r\n// 雨\r\n")
} else {
baseline.into()
};
assert_eq!(content.text(), expected);
}
}
}
fn checks(app: &App) -> View<'_, Message> {
let issues = &app.model_issues;
let mut out = column![heading(
@@ -1791,57 +1290,3 @@ fn checks(app: &App) -> View<'_, Message> {
}
scrollable(out.padding(24)).into()
}
#[cfg(test)]
mod tests {
use super::short;
#[test]
fn short_preserves_native_lifeline_clusters_at_cutoff() {
let oa = "abcdefghijklmnope\u{0301}尾";
assert_eq!(short(oa, 17), "abcdefghijklmnop…");
assert_eq!(short(oa, 18), oa);
assert_eq!(short(oa, 19), oa);
let sa = "abcdefghijklmnop👩\u{200d}🔬 tail";
assert_eq!(short(sa, 17), "abcdefghijklmnop…");
assert_eq!(short(sa, 18), "abcdefghijklmnop👩\u{200d}🔬…");
assert_eq!(short(sa, 19), "abcdefghijklmnop👩\u{200d}🔬 …");
assert_eq!(short(sa, 22), sa);
}
#[test]
fn short_never_splits_retained_extended_graphemes() {
for cluster in ["e\u{0301}", "👩\u{200d}🔬", "👍🏽", "🇫🇷"] {
let value = format!("A{cluster}BC");
assert_eq!(short(&value, 2), "A…");
assert_eq!(short(&value, 3), format!("A{cluster}…"));
assert_eq!(short(&value, 4), value);
}
}
#[test]
fn short_preserves_ascii_cjk_and_whitespace_policy() {
assert_eq!(short("abcd", 3), "ab…");
assert_eq!(short("abcd", 4), "abcd");
assert_eq!(short("abcd", 5), "abcd");
assert_eq!(short("甲乙丙丁", 3), "甲乙…");
assert_eq!(short("甲乙丙丁", 4), "甲乙丙丁");
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 5), "A e\u{0301} B");
assert_eq!(short(" \tA\r\n e\u{0301}\u{2003}B ", 4), "A e\u{0301}…");
}
#[test]
fn short_preserves_empty_and_small_limit_policy() {
for limit in [0, 1, 2] {
assert_eq!(short("", limit), "");
assert_eq!(short(" \t\r\n", limit), "");
}
assert_eq!(short("A", 0), "…");
assert_eq!(short("A", 1), "A");
assert_eq!(short("AB", 1), "…");
assert_eq!(short("e\u{0301}", 0), "…");
assert_eq!(short("e\u{0301}", 1), "e\u{0301}");
assert_eq!(short("e\u{0301}B", 1), "…");
}
}
@@ -1,260 +0,0 @@
use crate::{
app::{App, Message},
source_navigation::{
SymbolEvent,
query::{self, Declaration, Disposition, Evidence, Identity, Membership, Position},
},
style,
};
use iced::{
Element, Fill,
widget::{Space, button, column, container, row, scrollable, text, tooltip},
};
pub(super) fn panel(app: &App) -> Element<'_, Message> {
let state = &app.source_symbols;
if !state.open
|| (state.results.is_none() && state.in_flight.is_none() && state.notice.is_none())
{
return column![].into();
}
let title = state.results.as_ref().map_or_else(
|| "Source symbols".to_owned(),
|results| {
let action = match results.action {
query::Action::Definition => "Definitions",
query::Action::References => "References",
};
results.alias.as_ref().map_or_else(
|| action.to_owned(),
|alias| format!("{action} · @{}", preview(alias, 40)),
)
},
);
let mut content = column![
row![
text(title).size(13),
Space::new().width(Fill),
button(text("Close").size(11))
.padding([3, 8])
.style(style::nav)
.on_press(app.source_symbol_message(SymbolEvent::Close)),
]
.align_y(iced::Center)
]
.spacing(5);
if let Some(notice) = &state.notice {
content = content.push(super::label(preview(notice, 240)));
}
if state.in_flight.is_some() {
content = content.push(super::label("Finding source symbols…"));
} else if let Some(results) = &state.results {
let stale = results.stamp.snapshot != app.source_key();
if stale {
content = content.push(super::label(
"Stale results — source changed. Run Definition or References again.",
));
}
let scope = &results.scope;
content = content.push(super::label(format!(
"{} · {} · {} included / {} detached / {} unverified files · {} scope issues",
if scope.partial() {
"Partial scope"
} else {
"Complete include scope"
},
if !scope.applied {
"Unapplied drafts"
} else if scope.validated {
"Validated sources"
} else {
"Draft sources"
},
scope.included_files,
scope.detached_files,
scope.unverified_files,
scope.issues.len(),
)));
let explanation = match results.disposition {
Disposition::NoSymbol => "Place the caret on a declaration alias or an @reference.",
Disposition::NoDefinition => "No matching declaration in the loaded sources.",
Disposition::Choices => {
"Choose a declaration; draft and detached candidates need an explicit choice."
}
Disposition::Direct => "Definition in the current sources.",
Disposition::LexicalReferences => {
"Lexical uses of this alias; these do not establish a semantic target."
}
};
content = content.push(super::label(explanation));
if results.total_definitions > 1 {
content = content.push(super::label(format!(
"Alias collision: {} declarations. Uses are not attributed to a single declaration.",
results.total_definitions,
)));
}
let enabled = !stale && !app.busy;
let mut rows = column![super::label(counts(
"Declarations",
results.definitions.len(),
results.total_definitions,
results.omitted_definitions,
))]
.spacing(4);
for (index, definition) in results.definitions.iter().enumerate() {
rows = rows.push(location_row(
declaration_title(&definition.declaration),
&definition.path,
definition.position,
provenance(
definition.membership,
definition.evidence,
definition.declaration.identity,
false,
),
enabled.then(|| {
app.source_symbol_message(SymbolEvent::Choose {
stamp: results.stamp,
reference: false,
index,
})
}),
));
}
rows = rows.push(super::label(counts(
"Lexical uses",
results.references.len(),
results.total_references,
results.omitted_references,
)));
for (index, reference) in results.references.iter().enumerate() {
let title = reference.context.as_ref().map_or_else(
|| format!("@{} · lexical use", preview(&reference.alias, 48)),
|context| {
format!(
"@{} · inside {}",
preview(&reference.alias, 48),
declaration_title(context)
)
},
);
rows = rows.push(location_row(
title,
&reference.path,
reference.position,
provenance(
reference.membership,
reference.evidence,
reference
.context
.as_ref()
.map_or(Identity::Missing, |context| context.identity),
true,
),
enabled.then(|| {
app.source_symbol_message(SymbolEvent::Choose {
stamp: results.stamp,
reference: true,
index,
})
}),
));
}
let row_count = results.definitions.len() + results.references.len();
content =
content.push(scrollable(rows).height((44.0 + row_count as f32 * 48.0).min(180.0)));
}
container(content)
.padding(8)
.width(Fill)
.style(style::subtle)
.into()
}
fn counts(label: &str, shown: usize, total: usize, omitted: usize) -> String {
format!("{label}: {total} total · {shown} shown · {omitted} omitted")
}
fn declaration_title(declaration: &Declaration) -> String {
let name = declaration
.name
.as_ref()
.map_or_else(String::new, |name| format!(" \"{}\"", preview(name, 48)));
format!(
"{}{} · @{}",
preview(&declaration.kind, 24),
name,
preview(&declaration.alias, 48)
)
}
fn provenance(
membership: Membership,
evidence: Evidence,
identity: Identity,
context: bool,
) -> String {
let membership = match membership {
Membership::Included => "Included",
Membership::Detached => "Detached",
Membership::Unverified => "Unverified",
};
let evidence = match evidence {
Evidence::Validated => "Validated",
Evidence::Draft => "Draft",
};
let collision = match identity {
Identity::Collision { count } => format!(
" · {}ID collision ({count})",
if context { "Context " } else { "" },
),
Identity::Missing | Identity::Unique(_) => String::new(),
};
format!("{membership} · {evidence}{collision}")
}
fn location_row(
title: String,
path: &str,
position: Position,
status: String,
event: Option<Message>,
) -> Element<'static, Message> {
let line = position.line.saturating_add(1);
let column = position.byte_column.saturating_add(1);
let location = format!("{}:{line}:{column}", preview(path, 72));
tooltip(
button(
column![
text(title).size(12),
super::label(format!("{location} · {status}"))
]
.spacing(2),
)
.padding([4, 6])
.width(Fill)
.style(style::nav)
.on_press_maybe(event),
text(format!(
"{path}:{line}:{column} (1-based line and UTF-8 byte column)"
))
.size(11),
tooltip::Position::Top,
)
.into()
}
/// Keep headings compact without copying the remainder of a long admitted value.
fn preview(value: &str, limit: usize) -> String {
let mut chars = value.chars();
let mut result: String = chars
.by_ref()
.take(limit)
.map(|ch| if ch.is_whitespace() { ' ' } else { ch })
.collect();
if chars.next().is_some() {
result.push('…');
}
result
}
+1 -6
View File
@@ -19,7 +19,7 @@ mod transition_tests;
#[derive(Debug, Clone)]
pub enum Event {
Semantic(Box<crate::semantic_review::Event>),
Semantic(crate::semantic_review::Event),
Port(crate::port_workflow::Event),
ExchangeItem(Id, bool),
Endpoint(bool, EndpointChoice),
@@ -41,11 +41,6 @@ pub enum Event {
Resolve(Id, TransitionField, FieldDecision),
Preview,
}
impl Event {
pub fn semantic(event: crate::semantic_review::Event) -> Self {
Self::Semantic(Box::new(event))
}
}
pub enum Workflow {
Semantic(Box<crate::semantic_review::State>),
Ports(Box<crate::port_workflow::State>),
-106
View File
@@ -1,106 +0,0 @@
Diagram representation navigation
=================================
This contract derives from user issues `14: Text symbol usages
<http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/14>`_
and `17: tree-to-diagram navigation
<http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/17>`_.
Implementation and acceptance status belong to those issues. The source language
and identity rules remain in ``language.rst``.
Identity and membership
-----------------------
A result identifies the queried subject, a diagram destination, and a specific
graphical representation. A destination is a saved diagram UUID, a process or
functional-chain UUID, or a scenario UUID. Saved state diagrams retain their own
diagram UUID; membership in the same state machine does not make their contents
interchangeable.
Representation identities are node placement, port plus owner placement, edge,
state row plus owner placement, process occurrence plus qualified reference path,
process link, or scenario lifeline/occurrence. Opening an owner itself is a
separate whole-diagram target. Names and aliases describe these identities; they
are never replacement identities. Repeated occurrences remain independently
selectable even when they represent the same activity or exchange.
The current renderer determines membership:
* Saved node, port, and edge results come from the final projection, including
folds, projection-kind rules, and view filters. A stored route or two placed
endpoints does not establish a visible edge.
* Process results use the displayed contribution graph. A contextual preview
retains its full caller path and is included only while its actual tile is
present. An inactive process uses the same initial preview policy as entering
its canvas. Preview adjacency has no invented relation UUID.
* Scenario results use the actual scene, preserving lifeline and message
occurrence identities, nested fragment operands, and repeated messages.
A scenario-reference box represents its called scenario. It does not also
represent the called scenario's messages or lifelines.
* State action and internal-transition rows retain their relation identity.
Visible named references, such as a state action's work or a transition's
trigger, can target the row or edge whose label displays that reference.
Label-only membership must account for the renderer's shortening policy.
An arbitrary semantic reference is insufficient. A description mentioning a
name, an exchange item hidden by the selected label mode, or an unexpanded callee
does not automatically add a diagram result. The result count describes
currently rendered representations, not all semantic dependencies. Folded,
filtered, and unexpanded representations are explicitly excluded by this version;
an empty result must disclose this scope and must not claim that an object is
unused everywhere. Semantic dependencies remain available through Connections.
Two additional representation forms remain gaps in this branch: allocation
relations expressed through nesting, and persisted sequence links drawn inside
qualified caller previews. These must either receive precise grouping/qualified
edge targets or remain explicitly disclosed limitations; a passing direct-edge
test does not establish their coverage.
Interaction and lifecycle
-------------------------
``Show in diagrams`` opens one searchable destination list from the Text caret,
an object's context/Actions menu, or Connections. It identifies the subject by
name and alias, groups targets by destination, reports counts, and presents a
bounded page. A destination then exposes its individual representations. Search
matches destination names, aliases, categories, representation names, and roles.
Long descriptions remain available without replacing stable identities.
The Text entry point uses the exact current caret and the source-symbol service's
immutable snapshot. It requires a unique, validated, included declaration whose
authored identity and alias still match the accepted model. It never substitutes
the previously selected tree object. Unapplied source is retained with an explicit
instruction to apply it before diagram lookup. Comment, prose, ambiguous, missing,
detached, and stale symbols cannot trigger a diagram jump.
Every open request captures the document instance, accepted revision, and subject.
Every chooser action captures that chooser's unique token. Before opening a
representation, re-resolve the full subject-to-target relationship and renderer
geometry. If the model or presentation changed, refresh the list and require a
fresh choice. Matching names, reused aliases, or surviving endpoints cannot make
a stale target current. An asynchronous source response must also match its job,
source session/revision, raw interaction, active file, and caret.
Lookup and reveal do not execute model commands, Apply, Revert, or Save. They do
not expand persisted folds, change filters, rewrite source, or alter undo/redo.
Pending edits that would be displaced prevent the jump and remain intact.
Successful reveal updates only the navigation owner/page, precise selection,
tool lifetime, and camera. The camera centers the target at a readable scale;
fitting a huge whole diagram is not sufficient evidence of locating one object.
Escape closes the chooser and restores Text focus/caret when invoked there.
Verification obligations
------------------------
Independent fixtures must cover actual versus filtered edges, folded children,
ports, same-name distinct identities, repeated work/message/lifeline occurrences,
qualified reuse paths, opaque scenario references, state rows and label references.
For each activation, compare the complete accepted Project, SourceBundle, raw
buffers, revision, dirty flags, and undo/redo reachability before and after.
Exercise stale document/revision/token/subject relationships and retained drafts.
Native acceptance additionally checks keyboard and pointer selection, exact
visible target highlight, camera behavior after the destination's first layout,
empty and multi-result states, readable long Unicode names, minimum-window layout,
return to editing, and representative large-model latency. Unit tests and a
successful build do not by themselves establish these interaction outcomes.
+5 -89
View File
@@ -1319,25 +1319,16 @@ references a Call occurrence in the same scenario. The call MUST precede the
reply on a guaranteed common sequence/enclosing branch path. Reply lifelines
reverse the call's sender/receiver; it may reuse the call's exchange in reverse
or use an explicit reverse-direction exchange. A second reply to the same call
that can follow another reply on a shared execution path, including conditional
or parallel operands, is an error. Missing reply correlation is a draft
on that guaranteed path is an error. Missing reply correlation is a draft
warning; non-Reply messages MUST NOT have <span class="docutils literal">reply_to</span>.</p>
<p>Create identifies the receiver lifeline's creation. Another use that can precede
its creation on a shared execution path is an error. Delete identifies its
destruction; another use that can follow it on a shared execution path is an
error. These checks include conditional and parallel operands but exclude
mutually exclusive alternatives. Ordering checks respect branch ownership; they
MUST NOT flatten alternative branches into a false unconditional order. A Timer is
<p>Create identifies the receiver lifeline's creation. An earlier guaranteed use
of that lifeline is an error. Delete identifies its destruction; a later
guaranteed use is an error. Ordering checks respect branch ownership; they MUST
NOT flatten alternative branches into a false unconditional order. A Timer is
local to its sender lifeline, has no global exchange or values, and has either
no receiver or the same receiver as sender. Its nonempty expression describes
the delay/deadline; an empty expression warns. Conditions and expressions are
stored engineering annotations, not an executable simulation language.</p>
<p>These ordering checks compare distinct stored occurrences along structural
paths; they do not simulate repeated loop iterations. Repetition risks use
<span class="docutils literal">reply_repetition</span> and <span class="docutils literal">lifeline_repetition</span> warnings separately. For example,
a loop containing one Reply to an outer Call can warn about repetition without
containing two distinct replies; a same-operand Create/use/Delete lifetime can
repeat without its previous iteration being treated as an earlier stored use.</p>
<p>Each MessageValue is <span class="docutils literal">{ path [&#64;parameter]; field &#64;data_field; expression &quot;value expression&quot;; }</span>.
Its identity is the pair (path, field), so two uses of one Record can have
different values for the same terminal field. Path contains enclosing DataField
@@ -2642,46 +2633,6 @@ trace links, occurrence identity, comments and file ownership. Each open file
retains its own unapplied draft across file navigation. Located lint diagnostics
refer to the raw draft bytes rather than an identity-stabilized intermediate.
Property editing MUST NOT silently discard a pending source draft.</p>
<p>In Textual model, place the caret on a declaration alias or an <span class="docutils literal">&#64;reference</span>
and choose <strong>Definition</strong> (<span class="docutils literal">F12</span>) or <strong>References</strong> (<span class="docutils literal">Shift+F12</span>).
<strong>Back</strong> (<span class="docutils literal">Alt+Left</span>) returns to the previous source location in this
workspace. These shortcuts act while the source editor has focus; the Text
toolbar also exposes the actions. If the previous draft changed, Back restores
its current cursor rather than reusing an obsolete selection.</p>
<p>Symbol navigation reads bounded snapshots of the currently loaded source
drafts. It does not apply drafts or load missing included files. The results
identify partial scope, draft evidence, detached or unverified files, alias
ambiguity and authored-ID collisions. Draft, detached and ambiguous definition
candidates require an explicit row choice. References are lexical uses of an
alias, not a list of resolved typed relationships; comments and strings are
excluded. Declaration and use counts include any rows omitted by the result
limits. If sources change, stale rows cannot navigate: run the query again.
Navigation MUST preserve the accepted model and every source draft, and select
locations in the original UTF-8 bytes. Displayed locations use one-based lines
and UTF-8 byte columns.</p>
<p><strong>Show in diagrams</strong> finds representations of a model record in saved diagrams,
process/chain canvases and scenarios. Use the Text toolbar with the caret on a
unique, verified alias in clean accepted sources, or the model tree's context
menu for the chosen record. Choose a destination, then its exact representation.
Lookup runs from an accepted-model snapshot; navigation preserves source drafts,
accepted geometry and history. Pending edits or modal operations must be
finished first. Changed or stale results cannot activate a different target.</p>
<p>Membership follows the actual projection, including visible ports, state action
and internal-transition rows, qualified reuse tiles, and SequenceLink arrows
painted inside caller previews. Repeated callers have distinct destinations
within the same canvas. Hidden preview endpoints and deeper nested links that
the renderer does not draw are excluded; projected adjacency without an actual
relation is not a SequenceLink usage. Preview-arrow focus lies on a visible
line segment outside opaque tiles.</p>
<p>An Allocation projection represents a direct Allocation relation by grouping
only when its visible source performer placement occurs on the visible target
work placement's actual visual-parent chain. Aggregate-only allocation summaries
do not create direct relation usages. The navigation marker can focus a work
header, port owner or state row independently of semantic selection: selecting
an Allocation or state-action relation retains that relation as the model action
target. Camera movement preserves a valid marker; changing the selected record,
document, accepted revision or represented context retires it. Navigation does
not alter allocation ownership or the meaning of Delete.</p>
<p>Changing a graphical UUID must atomically update map keys and every reference.
Alias changes preserve UUID identity. Deletion must account for owned elements,
incident relationships, ordered occurrences, and diagram placements without
@@ -2689,41 +2640,6 @@ leaving dangling references. Deleting a sequence does not delete the semantic
exchanges it references. Cross-layer counterparts are not owned descendants.
Text edits describe a complete candidate and must remove or repair dependencies
explicitly before Apply succeeds.</p>
<section id="scenario-lifetime-presentation">
<h3>8.1. Scenario lifetime presentation</h3>
<p>Scenario lifetime geometry is derived from the stored occurrences and their
sequence/operand ownership. It adds no model records, source annotations or
history entries. A lifeline's identity header and neutral editing guide remain
available before creation, after deletion and when its represented element is
unfinished. The guide MUST be visually distinguishable from a live interval;
it is an editing axis, not a statement that the participant exists.</p>
<p>A directly ordered Create starts the receiver's live interval at its occurrence;
Delete ends that receiver's interval at its occurrence. Create uses a named birth
marker and Delete an X, each selectable through the original occurrence identity.
The message's receiving endpoint MUST align with its marker, including self
messages. A pre-existing lifeline without a creation has a live interval from
below its header; without deletion it extends to the visible scenario bottom,
including in an empty scenario. A missing lifecycle receiver MUST NOT be inferred from
the exchange. An explicit local receiver remains meaningful when its exchange
reference is unfinished.</p>
<p>Operands MUST be projected independently from their enclosing entry context.
A lifetime change in an alternative, optional, loop or parallel operand MUST
NOT imply the same change in its displayed sibling or a definite lifetime
after the fragment. When local structure does not establish one lifetime across
the represented paths, the continuation is visually unresolved. A subsequent
explicit Create or Delete in the enclosing sequence can establish a definite interval.
Neither guard text, screen order nor a guessed loop count proves execution.
ScenarioUse remains opaque: opening its definition may show its lifecycle,
but a callee's geometry MUST NOT create or destroy a caller lifeline.</p>
<p>Call activation is distinct from lifetime. A branch-local Delete clips that
branch's visible activation at the death marker without inventing a Reply,
removing an unresolved-call warning or terminating sibling paths.
Solid activation bars are restricted to definite live intervals; an unresolved
continuation retains its uncertainty/call diagnostic rather than depicting
definite survival after a possible deletion. Selection,
insertion slots, source edits and Undo/Redo retain the same semantic identities
and validation boundary regardless of the rendered intervals.</p>
</section>
</section>
<section id="persistence-and-explicit-migration">
<h2>9. Persistence and explicit migration</h2>
+5 -99
View File
@@ -680,28 +680,18 @@ references a Call occurrence in the same scenario. The call MUST precede the
reply on a guaranteed common sequence/enclosing branch path. Reply lifelines
reverse the call's sender/receiver; it may reuse the call's exchange in reverse
or use an explicit reverse-direction exchange. A second reply to the same call
that can follow another reply on a shared execution path, including conditional
or parallel operands, is an error. Missing reply correlation is a draft
on that guaranteed path is an error. Missing reply correlation is a draft
warning; non-Reply messages MUST NOT have ``reply_to``.
Create identifies the receiver lifeline's creation. Another use that can precede
its creation on a shared execution path is an error. Delete identifies its
destruction; another use that can follow it on a shared execution path is an
error. These checks include conditional and parallel operands but exclude
mutually exclusive alternatives. Ordering checks respect branch ownership; they
MUST NOT flatten alternative branches into a false unconditional order. A Timer is
Create identifies the receiver lifeline's creation. An earlier guaranteed use
of that lifeline is an error. Delete identifies its destruction; a later
guaranteed use is an error. Ordering checks respect branch ownership; they MUST
NOT flatten alternative branches into a false unconditional order. A Timer is
local to its sender lifeline, has no global exchange or values, and has either
no receiver or the same receiver as sender. Its nonempty expression describes
the delay/deadline; an empty expression warns. Conditions and expressions are
stored engineering annotations, not an executable simulation language.
These ordering checks compare distinct stored occurrences along structural
paths; they do not simulate repeated loop iterations. Repetition risks use
``reply_repetition`` and ``lifeline_repetition`` warnings separately. For example,
a loop containing one Reply to an outer Call can warn about repetition without
containing two distinct replies; a same-operand Create/use/Delete lifetime can
repeat without its previous iteration being treated as an earlier stored use.
Each MessageValue is ``{ path [@parameter]; field @data_field; expression "value expression"; }``.
Its identity is the pair (path, field), so two uses of one Record can have
different values for the same terminal field. Path contains enclosing DataField
@@ -2064,51 +2054,6 @@ retains its own unapplied draft across file navigation. Located lint diagnostics
refer to the raw draft bytes rather than an identity-stabilized intermediate.
Property editing MUST NOT silently discard a pending source draft.
In Textual model, place the caret on a declaration alias or an ``@reference``
and choose **Definition** (``F12``) or **References** (``Shift+F12``).
**Back** (``Alt+Left``) returns to the previous source location in this
workspace. These shortcuts act while the source editor has focus; the Text
toolbar also exposes the actions. If the previous draft changed, Back restores
its current cursor rather than reusing an obsolete selection.
Symbol navigation reads bounded snapshots of the currently loaded source
drafts. It does not apply drafts or load missing included files. The results
identify partial scope, draft evidence, detached or unverified files, alias
ambiguity and authored-ID collisions. Draft, detached and ambiguous definition
candidates require an explicit row choice. References are lexical uses of an
alias, not a list of resolved typed relationships; comments and strings are
excluded. Declaration and use counts include any rows omitted by the result
limits. If sources change, stale rows cannot navigate: run the query again.
Navigation MUST preserve the accepted model and every source draft, and select
locations in the original UTF-8 bytes. Displayed locations use one-based lines
and UTF-8 byte columns.
**Show in diagrams** finds representations of a model record in saved diagrams,
process/chain canvases and scenarios. Use the Text toolbar with the caret on a
unique, verified alias in clean accepted sources, or the model tree's context
menu for the chosen record. Choose a destination, then its exact representation.
Lookup runs from an accepted-model snapshot; navigation preserves source drafts,
accepted geometry and history. Pending edits or modal operations must be
finished first. Changed or stale results cannot activate a different target.
Membership follows the actual projection, including visible ports, state action
and internal-transition rows, qualified reuse tiles, and SequenceLink arrows
painted inside caller previews. Repeated callers have distinct destinations
within the same canvas. Hidden preview endpoints and deeper nested links that
the renderer does not draw are excluded; projected adjacency without an actual
relation is not a SequenceLink usage. Preview-arrow focus lies on a visible
line segment outside opaque tiles.
An Allocation projection represents a direct Allocation relation by grouping
only when its visible source performer placement occurs on the visible target
work placement's actual visual-parent chain. Aggregate-only allocation summaries
do not create direct relation usages. The navigation marker can focus a work
header, port owner or state row independently of semantic selection: selecting
an Allocation or state-action relation retains that relation as the model action
target. Camera movement preserves a valid marker; changing the selected record,
document, accepted revision or represented context retires it. Navigation does
not alter allocation ownership or the meaning of Delete.
Changing a graphical UUID must atomically update map keys and every reference.
Alias changes preserve UUID identity. Deletion must account for owned elements,
incident relationships, ordered occurrences, and diagram placements without
@@ -2117,45 +2062,6 @@ exchanges it references. Cross-layer counterparts are not owned descendants.
Text edits describe a complete candidate and must remove or repair dependencies
explicitly before Apply succeeds.
8.1. Scenario lifetime presentation
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Scenario lifetime geometry is derived from the stored occurrences and their
sequence/operand ownership. It adds no model records, source annotations or
history entries. A lifeline's identity header and neutral editing guide remain
available before creation, after deletion and when its represented element is
unfinished. The guide MUST be visually distinguishable from a live interval;
it is an editing axis, not a statement that the participant exists.
A directly ordered Create starts the receiver's live interval at its occurrence;
Delete ends that receiver's interval at its occurrence. Create uses a named birth
marker and Delete an X, each selectable through the original occurrence identity.
The message's receiving endpoint MUST align with its marker, including self
messages. A pre-existing lifeline without a creation has a live interval from
below its header; without deletion it extends to the visible scenario bottom,
including in an empty scenario. A missing lifecycle receiver MUST NOT be inferred from
the exchange. An explicit local receiver remains meaningful when its exchange
reference is unfinished.
Operands MUST be projected independently from their enclosing entry context.
A lifetime change in an alternative, optional, loop or parallel operand MUST
NOT imply the same change in its displayed sibling or a definite lifetime
after the fragment. When local structure does not establish one lifetime across
the represented paths, the continuation is visually unresolved. A subsequent
explicit Create or Delete in the enclosing sequence can establish a definite interval.
Neither guard text, screen order nor a guessed loop count proves execution.
ScenarioUse remains opaque: opening its definition may show its lifecycle,
but a callee's geometry MUST NOT create or destroy a caller lifeline.
Call activation is distinct from lifetime. A branch-local Delete clips that
branch's visible activation at the death marker without inventing a Reply,
removing an unresolved-call warning or terminating sibling paths.
Solid activation bars are restricted to definite live intervals; an unresolved
continuation retains its uncertainty/call diagnostic rather than depicting
definite survival after a possible deletion. Selection,
insertion slots, source edits and Undo/Redo retain the same semantic identities
and validation boundary regardless of the rendered intervals.
9. Persistence and explicit migration
-------------------------------------
+8 -10
View File
@@ -1,6 +1,6 @@
# Linux packaging and deployment inputs
The packaging command stages **already-built** desktop, server and source CLI executables for `x86_64-unknown-linux-gnu`. It runs on Windows PowerShell 5.1 or PowerShell 7 (including Linux) with Python 3.11+ standard library, Git, and the matching Rust/Cargo dependency cache. It does not build, download, install runtime libraries or launch the application. Python creates and reads back the tar archive; end users do not need Python, PowerShell or Rust.
The packaging command stages **already-built** desktop/server executables for `x86_64-unknown-linux-gnu`. It runs on Windows PowerShell 5.1 or PowerShell 7 (including Linux) with Python 3.11+ standard library, Git, and the matching Rust/Cargo dependency cache. It does not build, download, install runtime libraries or launch the application. Python creates and reads back the tar archive; end users do not need Python, PowerShell or Rust.
Build and validate first in a source checkout. The current runtime validation baseline is Ubuntu 24.04 x86-64. A glibc symbol inventory is not a claim that every distribution with that glibc version supports the renderer and dialogs.
@@ -9,25 +9,23 @@ Build and validate first in a source checkout. The current runtime validation ba
-Version 0.2.0-rc1 -BinaryDirectory ./target/release -Python python3
```
For an explicit-target build, use `target/x86_64-unknown-linux-gnu/release`. A cross-build may supply another directory containing all three executables: `arcadia-studio`, `arcadia-server` and `arcadia`. Select the Rust compiler used for the intended build before packaging; its Linux target libraries, copyright reports and locked source/archive cache must be installed. Read-only `cargo metadata --locked --offline --filter-platform x86_64-unknown-linux-gnu` and Rust version/sysroot queries are the only Rust commands run by packaging. When packaging from Windows, `-Python C:/path/to/python.exe` selects Python explicitly.
For an explicit-target build, use `target/x86_64-unknown-linux-gnu/release`. A cross-build may supply another directory containing both `arcadia-studio` and `arcadia-server`. Select the Rust compiler used for the intended build before packaging; its Linux target libraries, copyright reports and locked source/archive cache must be installed. Read-only `cargo metadata --locked --offline --filter-platform x86_64-unknown-linux-gnu` and Rust version/sysroot queries are the only Rust commands run by packaging. When packaging from Windows, `-Python C:/path/to/python.exe` selects Python explicitly.
The script refuses existing output/claim names. It creates `dist/ArcadiaStudio-<version>-x86_64-unknown-linux-gnu/`, a `.tar.gz`, and an adjacent `.sha256`. A failed attempt leaves its claim and any partial outputs for diagnosis; choose a fresh version or output root rather than overwriting them. Archives use sorted paths, zero timestamps and uid/gid, 0755 for the three Linux executables and 0644 for other files. Every entry is read back and compared against staging, including permissions. The same unchanged inputs on the same Python/zlib implementation produce the same bytes; this does not promise reproducible compiler output.
The script refuses existing output/claim names. It creates `dist/ArcadiaStudio-<version>-x86_64-unknown-linux-gnu/`, a `.tar.gz`, and an adjacent `.sha256`. A failed attempt leaves its claim and any partial outputs for diagnosis; choose a fresh version or output root rather than overwriting them. Archives use sorted paths, zero timestamps and uid/gid, 0755 for the two Linux executables and 0644 for other files. Every entry is read back and compared against staging, including permissions. The same unchanged inputs on the same Python/zlib implementation produce the same bytes; this does not promise reproducible compiler output.
## Contents and provenance
All three executables, the MIT license, a package-specific README, complete tracked examples (including all water-pressure teaching/evidence files), deployment references and the reachable documentation closure are included. Relative Markdown/reST/HTML file links are checked before archiving; upstream license texts are preserved verbatim and their internal source-tree hyperlinks are outside that documentation check. Untracked private files, caches and unrelated harness history are not included merely because they exist on disk.
Both executables, the MIT license, a package-specific README, complete tracked examples (including all water-pressure teaching/evidence files), deployment references and the reachable documentation closure are included. Relative Markdown/reST/HTML file links are checked before archiving; upstream license texts are preserved verbatim and their internal source-tree hyperlinks are outside that documentation check. Untracked private files, caches and unrelated harness history are not included merely because they exist on disk.
`package.json` records input hashes and static ELF dependencies. `SHA256SUMS.txt` inventories every file except itself; the archive checksum covers that inventory too. `dependency-notices.json`, `THIRD-PARTY-NOTICES.txt` and `licenses/` use the same strict collector as Windows: exact target-filtered normal/build dependency graph rooted at `oa-desktop`, `oa-server` and `oa-cli`, locked crate archives, actual source hashes, checksum-bound supplements and embedded fonts, Rust distribution notices and target-library hashes. Unknown sources, absent notices or changed supplements stop packaging. No Linux system libraries are bundled by this pipeline; their runtime distribution supplies them and its notices.
`package.json` records input hashes and static ELF dependencies. `SHA256SUMS.txt` inventories every file except itself; the archive checksum covers that inventory too. `dependency-notices.json`, `THIRD-PARTY-NOTICES.txt` and `licenses/` use the same strict collector as Windows: exact target-filtered normal/build dependency graph, locked crate archives, actual source hashes, checksum-bound supplements and embedded fonts, Rust distribution notices and target-library hashes. Unknown sources, absent notices or changed supplements stop packaging. No Linux system libraries are bundled by this pipeline; their runtime distribution supplies them and its notices.
ELF preflight requires little-endian x86-64 executables/PIE, the glibc dynamic loader, no RPATH/RUNPATH and a reviewed set of direct libc/compiler-runtime imports. Unknown libraries require an explicit packaging design instead of being silently copied from a developer machine. Graphics/window/portal libraries loaded at runtime are outside that static import list.
Optionally pass `-BuildEvidence <json-file>`, containing nonempty `source_identity`, `profile`, `compiler`, exact `target`, and `binary_sha256` keyed by `arcadia-studio`, `arcadia-server` and `arcadia`. Hash strings must be lowercase SHA256. Target and binary hashes are checked; other fields remain explicit caller assertions. Omission records build provenance as unknown. Never label a development binary an optimized release because of its directory or package version.
Optionally pass `-BuildEvidence <json-file>`, containing nonempty `source_identity`, `profile`, `compiler`, exact `target`, and `binary_sha256` keyed by `arcadia-studio` and `arcadia-server`. Hash strings must be lowercase SHA256. Target and binary hashes are checked; other fields remain explicit caller assertions. Omission records build provenance as unknown. Never label a development binary an optimized release because of its directory or package version.
## Native extracted-bundle checks
The source authoring CLI is `./arcadia`. Run `./arcadia --help` for `check`, `lint`, `stabilize` and `rename-alias`; each takes an explicit `.arcadia` entry file. Mutation commands preview by default and require `--write` to apply their candidate; `--json` produces a machine-readable report. Test writes on a separate project copy. CLI use does not require a display, a server or Rust. Include CLI check, preview, write and unchanged-repeat checks in extracted-bundle acceptance, alongside desktop and server checks.
Extract into a fresh user-writable Linux directory and run from an unrelated working directory. Verify SHA256SUMS, file permissions, `file`/`ldd` and actual desktop/runtime libraries. A typical Ubuntu runtime needs X11/XKB and Wayland clients and Vulkan loader/Mesa Vulkan drivers. Linux Open/Save uses the application's path-entry dialog and does not require an xdg-desktop-portal backend. A successful `ldd` alone is insufficient because graphics/window libraries can be loaded dynamically.
Extract into a fresh user-writable Linux directory and run from an unrelated working directory. Verify SHA256SUMS, file permissions, `file`/`ldd` and actual desktop/runtime libraries. A typical Ubuntu runtime needs X11/XKB and Wayland clients, Vulkan loader/Mesa Vulkan drivers, and working session DBus plus an xdg-desktop-portal backend for file dialogs. A successful `ldd` alone is insufficient because these libraries can be loaded dynamically.
For a software Vulkan trial, first locate the installed lavapipe ICD; only if it exists, use:
@@ -35,7 +33,7 @@ For a software Vulkan trial, first locate the installed lavapipe ICD; only if it
WGPU_BACKEND=vulkan VK_DRIVER_FILES=/usr/share/vulkan/icd.d/lvp_icd.x86_64.json ./arcadia-studio
```
A compatible native GPU may run without these variables. WSLg uses real Linux user space with a particular display environment; record the actual X11/Wayland backend and do not infer support for all Linux sessions from one trial. Exercise Home walkthrough, Help/search, Linux Open/Save path entry, existing-file handling, SVG export overwrite confirmation, a multi-file project, diagram/source edits, Undo/Redo and reopening at normal/minimum window sizes. Compare complete saved model/source and inspect stderr, not merely whether a window appeared.
A compatible native GPU may run without these variables. WSLg uses real Linux user space with a particular display environment; record the actual X11/Wayland backend and do not infer support for all Linux sessions from one trial. Exercise Home walkthrough, Help/search, native Open/Save dialogs, a multi-file project, diagram/source edits, Undo/Redo and reopening at normal/minimum window sizes. Compare complete saved model/source and inspect stderr, not merely whether a window appeared.
Run the server against a separate local data directory and unused loopback port. Follow [server configuration](server.md) for health, authentication, ETags, restart and backup/restore. Test create/import, a changed conditional update, unchanged no-op, stale/invalid rejection, and persistence after a clean restart. Record exact binary/archive/source/compiler hashes and platform. Packaging verification is separate from current-tree release acceptance.
+84 -55
View File
@@ -320,68 +320,97 @@ wrong-context edits or inability to preserve reproducible independent studies.
The comparison establishes semantic/text maintenance evidence, not human task
timings or full quantity/library integration acceptance.
## Scenario lifetime rendering integration — 2026-09-11
## Analysis and library contract convergence — 2026-09-11
Codex-ytr83r /root. Adopt the [scoped lifecycle proposal](release-scenario-lifecycle-design-20260910.md)
as derived presentation over existing source-7 semantics. Always retain a neutral
identity header/editing guide; depict definite live intervals separately, with
original-occurrence Create capsules and Delete crosses. Alternative, optional,
loop and parallel operands inherit their enclosing entry independently and leave
an unresolved continuation after a local lifetime change. Reused scenarios remain
opaque. This does not execute guards or add model/source/history records.
Root Codex-956atf read the complete [baseline refinement](release-analysis-baseline-contract-20260911.md),
[library refinement](release-library-compatibility-contract-20260911.md), their
complete fixtures and [independent review](release-refined-contracts-review-20260911.md).
Select the following architecture for production implementation:
Full-height ordinary spines fail to communicate creation/deletion; one global
earliest-birth/latest-death range would falsely order exclusive operands. Both
alternatives lose to the scoped projection. The [independent review](release-scenario-lifecycle-independent-review-20260911.md)
requires true visible root bounds, a complete rendering lane map and local
activation clipping at Delete; insertion bounds/eligible lanes must keep their
separate roles. Receiver endpoints and selectable markers must agree, including
self messages. Those findings determine the integration contract in language
section 8.1 and the real-Scene/native acceptance fixtures.
- Required property-owned Binding values, explicit ModelBaseline/Case selection,
one level of complete-case override maps and shared root checks. Clear removes
an override; Unbound masks the base. Inherited formulas evaluate dependencies
in the requested context. Pure calculations see only explicit parameters.
- Same-layer property dependencies and case targets, with explicit analysis
selection for transitions and exports. Check/case subjects may be absent;
architectural containment must not infer their selection. Validate every
effective dependency graph, including statically referenced unused branches.
- The refined R6 closed algebra, exact rational R7 literal/AST preservation,
affine point/difference distinction and declared-output kind anchoring.
Strict expression operands gather actual missing/failure causes; Boolean
control flow evaluates only the selected branch. Supported domain failures
are distinct from violated predicates and missing values.
- Whole accepted PortableDocument freeze with exact SourceBundle, local
definitions, all imported package contents/pins and validated provenance.
Replay resolves only that immutable private capture. A result report, shallow
case copy or current cache is not a reproducible freeze.
- Exact archive/reference/history identity alongside verified same-origin
typed-contract equivalence. Different nominal origins remain distinct.
Compatibility supplies a scoped expected/actual field-path and operation
correspondence; it never advances a pin or rewrites outside record identities.
Keep qualified neutral definitions, immutable mapped layered definitions and
ordinary editable pattern instances for the first coherent implementation.
- Explicit source preparation/acceptance with complete version-controlled
package/base/map/lineage state, recomputed admission and stale-input rejection.
Exact three-way facets retain independent local edits. Mounted OA/SA transfer
requires a verified exact target-layer export or an explicitly independent
editable local derivative; no relabeling or silent mutation of a mount.
Core/source semantics are unchanged. In section 4.3, replacing the old
guaranteed-only lifetime/duplicate-reply wording with possible shared-path wording
corrects documentation to `validation.rs` and the existing conditional/parallel
regressions; it does not broaden admission or add a new rule. Compilation and
native acceptance of the renderer are still pending at this decision. Revisit the
visual encoding if actual minimum-size engineering workflows make marker identity,
uncertain continuation or the distinction between guide and existence unclear.
These are architecture decisions, not claims that the proposed schema8 examples
are admitted by current source7. Before real consumer integration, enumerate and
implement the class-specific canonical contract over all interpretation-bearing
fields and use sites, exact transitive archive closure and full source/history
boundaries. Retain the frozen120-assertion prototype with its documented C2
threshold-unit coverage gap; production typing must check every operand.
## Diagram representation navigation — 2026-09-12
Close independent C1 before evaluator expansion: a request-local, profile-versioned
logical work meter must bound dynamic expression visits, calls, list traversal
and exact-number work. Static acyclicity/depth/node limits do not bound expanded
execution. Cache warmth must not change outcomes; terminal exhaustion stops
further traversal and marks collected causes as incomplete. Numeric ceilings and
the precise charging table require measured foundation implementation before
publishing the profile. The333-node/31-level counterexample and a nearby successful
case are mandatory tests across direct/API/frozen replay.
Codex-ytr83r /root. User issues [14](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/14)
and [17](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/17)
share one typed query and destination chooser; the [contract](../docs/diagram-navigation.rst)
defines identity, visible membership, source freshness and nonmutation. A result
retains its representation identity separately from its queried semantic subject.
Use the actual saved projection, process Graph, scenario Scene and state row
geometry. Repeated occurrences and qualified reuse paths remain distinct.
The exact numeric foundation is the next bounded core implementation. Ordinary
engineering approximations, a complete low-noise text workflow, two-project
library maintenance and native/API/source parity remain required release work.
An exact-only slice cannot complete the user's first release.
Reject endpoint-only membership and generic semantic-reference enumeration:
they admit suppressed edges and opaque callee contents while missing behavioral
occurrences and state rows. Reject a destination submenu containing every use or
always-visible per-line lenses: both add noise and scale poorly for the user's
editing workflow. An explicitly invoked, searchable chooser groups destinations
and exposes their occurrences on demand. Independent design work identified
minimum-window, repeated-name, source-caret, stale-result and full-history oracles
before implementation; passing those evaluators remains necessary for acceptance.
## Analysis production staging — 2026-09-11
Ordinary navigation must not reuse the legacy reveal action's implicit Apply and
persisted fold expansion. Resolve the exact target before changing presentation;
center that target at readable scale. This first implementation lists currently
rendered representations and explicitly discloses folded/filtered/unexpanded
exclusions. It does not claim complete authored-membership discovery. Revisit
transient hidden-object reveal after measured user workflows; adding a result
must never imply an invisible object was actually shown or silently edit a view.
Root accepts the concrete [production integration sequence](release-analysis-production-integration-plan-20260911.md).
Allocate schema8 to the next coherent production analysis integration. Start with
the actual Project's neutral definition catalog, three analysis element kinds,
typed expressions and a closed Local definition-reference environment. Imported
references will be admitted only with the real package environment; no placeholder
resolver, guessed pin, implicit type compatibility or competing package format.
This stages implementation and does not defer the required maintained libraries.
The Text entry point builds on delivered source navigation from33c6d83, including
eeeaa12 native corrections, while preserving the later canvas/chooser/repair
integration. It requires verified applied-source identity and never substitutes
model selection for the caret. Dirty-source semantic lookup remains a disclosed
limitation. Implementation is branch-specific at this decision; Windows build,
combined tests and native acceptance are not established by this record.
The first useful gate is the complete persisted/source-authored response budget,
shared two-parameter calculation and check: baseline7/4 seconds versus degraded9/4,
with explicit request context and the same core result through CLI and REST.
Required property fields cannot come from generic factory guesses. Existing
descriptive expressions/unit labels retain their exact old meaning and bytes.
Freeze older top-level wire DTOs before making definitions required in schema8;
reject new fields/constructors in older versions instead of silently dropping them.
Global, scoped parameter, imported and historical identities remain distinct.
Implement pure static numeric type helpers once and reuse them for admission and
runtime. Validate every effective case graph and unused branch before accepting a
candidate; deleting dependencies requires explicit same-batch repair. Numeric
domain failure is not a static type failure. Full evaluator traversal and actual
C1 input, provenance, terminal incomplete causes and immutable replay are required
before claiming evaluated engineering behavior. Until explicit analysis review/
transition facets exist, those entry points must reject unsupported analysis with
a clear capability error, including manual-trace paths that could otherwise appear
to reconcile headers alone.
## 2026-09-12 — Cross-machine coordination evidence
User clarification: independent Gitea peer sessions run on different machines and cannot access this session’s `.local` directory. In-thread delegated agents share this workspace. Publish required source, reviews and runtime evidence through versioned Gitea commits or attachments before handing them to peers; verify access and link the exact version. Local paths are provenance only for remote peers. Local compiler, process and native-window observations do not establish another machine’s state or a shared hardware lease.
Logical expression depth128 must be reconciled with bounded actual JSON/source
decoding; do not simply disable recursion limits. Raw byte, decoded string and
aggregate literal/graph/case-admission limits are separate from evaluation fuel.
Concrete decoder/admission ceilings remain subject to bounded boundary and timing
experiments; the implementer must surface those choices before locking the profile.
The current local numeric gate runs against frozen root source. New core contract
work proceeds in an isolated checkout, preserving root's Cargo/native leases and
the other hosts' CLI, staged-source and B4 ownership.
+73 -66
View File
@@ -1,77 +1,84 @@
> Superseded on 2026-09-11. This document is a preserved historical checkpoint.
> Live plans, priorities, ownership and handoffs now reside in [Gitea issue 1](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-410) and [issue 2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2).
> Do not resume synchronizing this ledger. See [STATE.md](STATE.md) for the integrated checkpoint.
# First release execution plan
Updated 2026-09-11 for the published Codex-ytr83r B4 source checkpoint.
[PROJECT_GOAL.md](../PROJECT_GOAL.md) defines the full outcome;
[STATE.md](STATE.md) scopes evidence/ownership and
[TASK_LEDGER.md](TASK_LEDGER.md) separates facts from decisions.
Updated 2026-09-11. [PROJECT_GOAL.md](../PROJECT_GOAL.md) is the authoritative
current objective. [STATE.md](STATE.md) records exact execution/evidence and leases;
[TASK_LEDGER.md](TASK_LEDGER.md) separates facts, assumptions and decisions.
The complete previous plan is [preserved byte-for-byte](history/2026-09-10-ledgers-before-continuation-refresh/PLAN.md).
Its earlier goal paths, active-process claims and Escape pauses are historical.
## Immediate integration and verification
## Outcome and working principles
1. Preserve completed integration c81e285/e207b04/b853a1e and reviewed B4 source
52f6e17 on codex/release1-ytr83r. Do not replay equivalent3978/d114 changes.
CLI-only01b76a1 was reviewed and imported as023e3a8 without core/dependency
changes. Preserve the new AGENTS maintenance instruction and exact goal bytes.
2. Core433, independent lifecycle-oracle admission, focused lifecycle22 and full
desktop675 passed on this host. Production freeze2908405 is unchanged by
evidence checkpoint3ade397. GUI build, both-layer history/reopen and corrected
workspace strict Clippy passed. Finish the affected test and Unicode/native
workflows below. Do not repeat the passed suites without a
source change or new finding. Root serializes writers and records exact scope.
3. Use the imported evidence2ec creation-oracle v2 for native Undo comparisons.
Reject unknown/changed detached files while allowing exact files
retained from an explicit pre-Undo origin. Preserve full Project and reachable
SourceBundle equality and original role identities through Redo/reopen.
4. Exercise current Windows UI at minimum/wide sizes: creation name/owner/viewpoint,
one-step history, stale/raw-draft rejection, first Escape/modal priority,
recovery, clicked root/ALT action/observation/reuse, exact caller return,
same-size Fit and natural subsequent scrolling. Use independent semantic
predictions, not only screenshots or serialization of observed outputs.
5. Review current external Gitea handoffs before importing staged prerequisites,
empty-state actions, contextual chooser or source CLI. Validate final combined
workflows and preserve explicit ownership/qualification semantics.
6. After actual B4 test/native acceptance, prepare a distinct runnable Windows
prerelease with current-toolchain notices and exact executable/source hashes.
Existing MCP credentials have now completed a scoped branch push and remote
readback; release attachment upload still needs concrete-artifact verification.
Codex-9e8r6t retains the earlier named-authoring prerelease ownership.
7. Linux setup/downloads/builds/checking are paused by direct user instruction.
Wait for the user to resolve WSL2 networking and explicitly instruct resumption.
Preserve installed Ubuntu, exact VirtioProxy configuration and complete/partial
offline inputs. Networking remains unresolved; preparation is not Linux
acceptance. Follow the current WSL/offline records rather than old reboot plans.
8. Incorporate the owner clarification in issue2: process/scenario interaction
should use consistent direct canvas workflows while preserving domain
constraints. Codex-956atf owns the shared canvas design; B4 rendering and
prerequisite forms support it but do not close this requirement.
Deliver usable, complete OA/SA engineering workflows on Windows and Linux, with
one precise reST contract and equivalent graphical, textual and REST editing.
Constraints/calculations, quantities/units and reusable libraries are explicit
requirements alongside behavior/state modeling, traceability and navigable help.
Small and large models must be practical to author entirely in uncluttered text;
evaluate complete authored and stabilized models, not abbreviated grammar sketches.
Later Arcadia layers remain future scope. Compilation, metadata fields and polished
screens alone do not satisfy this outcome.
Retain the shared typed core, stable identity, semantic ownership/allocation distinct
from geometry, validated atomic commands and exact source/history. Persistent
extensions need coherent references/deletion, transition, source/migration,
API/schema and reachable graphical authoring. Define observable acceptance before
broad downstream changes. Do not silently promote a design proposal to a decision.
## Capability milestones
| Outcome | Work and acceptance |
| --- | --- |
| Usable direct behavior/state engineering | Continuous ordered/explicit process authoring, clicked root/nested scenario insertion, exact reuse bindings/caller return, state naming/creation, recovery and source-preserving history. Complete B4 lifetime rendering with actual lifetime bounds, branch-local Delete activation clipping, incomplete lanes and self-arrow endpoints. |
| Precise engineering analysis | Refine property-owned baseline values/formulas plus explicit ModelBaseline/Case one-level overrides. Close quantity kind/unit/value, exact/approximate numerical policy, dependencies/cycles, false/missing/error, complete freeze and definition provenance. Implement coherent validated core/source/REST/GUI/history contracts. An exact-duration slice alone cannot satisfy the full goal. |
| Maintained reusable libraries | Resolve source-only normalization, pinned offline definitions/closure, provenance versus checked same-origin compatibility, source→local instance maps, explicit three-way updates/local divergence, subjectless analysis closure and OA/SA mounted-data transition policy. Demonstrate two projects/two instances, cosmetic and semantic upgrades, conflicts/detach and rollback. |
| Practical complete textual and graphical tasks | Measure small/large text-only creation, cross-file reference navigation, safe alias rename and lossless patch preview. Integrate source CLI and bounded contextual choices; actual keyboard/IME, repeated names, stale events, recovery and minimum geometry must pass. Keep offline guide/examples and normative source documentation aligned. |
| Complete engineering stories | Close OA↔SA transition/reconciliation, typed data/interfaces/state, artifacts, saved views, hosted workflows and tutorial tasks against the full current goal. Confirm parity and precise specification rather than inheriting historical exclusions. |
| Reproducible Windows/Linux release | Current tests/format/strict Clippy, reST/OpenAPI/migration/example gates; real GUI and REST transport/restart/ETag conflict acceptance; matching optimized desktop/server packages, complete notices/archive hashes and clean-runtime launch. Fresh independent requirements/domain/parity/architecture/UX/runtime review resolves material findings before release. |
| Milestone | Work and acceptance | Dependency/current limit |
| --- | --- | --- |
| 1. Reliable direct authoring | Exercise actual minimum/wide native creation, allocation, editing, rejection, cancellation and history without implicit draft application or placement drift. Integrate compact named behavior creation after its coherent external gate. | Named creation4bb4aaf is imported; combined desktop676/676, format and strict Clippy pass at2cc4d11. Native80cf197 passes all four OA creations/history/reopen; new workspace fixes and production chooser await compilation. Workspace/B3/palette/recovery/packages are imported. Root owns this host's Cargo/native lease. |
| 2. Coherent process/scenario work | Interactive ordered/explicit process editing; clicked root/nested scenario insertion; exact reuse bindings and caller return; measured canvas/zoom/scroll; visible scoped Create/Delete lifetimes. Validate model/source/identity and no unintended edit during navigation, including stale/deleted/retargeted references and recovery. | B1/B2/B3 regressions pass in the full suite. Local B4 remains cfg(test); Codex-ytr83r owns production Scene integration and its independent native gate. Use the independently self-checked oracle for native checkpoints. |
| 3. Precise engineering analysis | Compare owned properties, analysis contexts and existing-data/external alternatives; settle quantity kind/unit/value, numerical policy, calculation/binding and Boolean/error/unknown semantics through falsifying examples. Implement mixed-unit budgets and reusable calculation/constraint definitions with full UI/source/REST parity and exact history/recovery. | B/shared-check/full-capture and R6/R7 architecture ratified after complete fixtures and criticism. Exact numeric/work-meter foundation is undeclared and under independent review. Approximate profile and full evaluator parity remain required. |
| 4. Maintained reusable libraries | Compare package/definition/instance alternatives. Establish pinned identity/version/dependency closure, local edits, import bindings, detach and explicit three-way updates. Demonstrate two projects, two independent instances, offline reopen, conflicting updates and stable unaffected identities. | Exact pins plus verified same-origin typed compatibility, explicit source preparation/acceptance and three-way editable updates are ratified. Finite120-assertion experiment is not production contract/archive/consumer implementation. |
| 5. Understandable complete engineering tasks | Close teaching, OA↔SA transition/reconciliation, typed state/data/interface, artifact, saved-view and hosted workflows. Establish complete text-only creation/refactoring/modular maintenance with stable identity. Replace concrete long homogeneous selectors with contextual/searchable bounded choices; verify large models, keyboard access, minimum sizes and failure/recovery. Keep offline guide/examples aligned. | A complete warning-free text-only model probe passes source/identity/rename/reparent/file-move checks; measure usability separately. Anchored chooser passes13 isolated mounted tests under cfg(test); production inspector/review integration is active; its compile/native gates remain open. Separate-host CLI is coherent at01b76a1 and awaits import here. Reassess current goal rather than copying historical scope exclusions. |
| 6. Release confidence and delivery | Fresh current Windows/Linux suites, strict Clippy/format, normative reST/OpenAPI/migration/examples; actual GUI/service transport, optimized builds, runtime/notices/archive/provenance/checksums and clean-environment launch. Fresh independent requirements/domain/parity/architecture/UX/runtime review; resolve material findings, then final handoff. | Integrates all preceding capabilities. Linux runtime remains open; use an approved supported environment, preserving historical failed/denied experiments. Historical previews are not final distributions. |
Milestones run adaptively where independent work helps. Analysis and libraries
share definition/identity assumptions and must converge before broad migrations.
Other-host slices remain coordinated through issue #1 and Wiki. Native/Cargo
leases are host-local; source ownership and exact transfer heads are shared.
Milestones are capability gates, not a rigid serial schedule. Root can continue
build/native acceptance while independent foundational investigations compare
alternatives. Libraries and analysis share definition-resolution assumptions and
must converge coherently before broad model/schema edits. User workflows and
cross-representation evaluators guide implementation boundaries.
## Evaluator boundaries and replanning
## Immediate execution sequence
Use authoritative source semantics, exact UUID/source preservation and actual
user workflows. Source-only experiments are not accepted new grammar; mounted
widget tests are not native readability; synthetic packaging checks are not
Linux runtime. Preserve old environmental failures as evidence without inventing
current blockers. Continue independent work when platform or transfer work waits.
1. Complete the coherent workspace/production chooser build and focused gates,
then stage a new GUI for real engineering tasks. Full676/format/Clippy passed
for2cc4d11; working changes require their own verification.
2. Implement the user-required common canvas interactions for Process and Scenario
in issue2 after the bounded shared-viewport/domain-constraint design decision.
Treat this as direct-manipulation acceptance, not a tool-shelf restyle.
3. Exchange exact verified commits with the separate host, preserving its workspace
ownership. Direct authenticated publication works. CLI01b and exact evidence2ec
are imported; next contextual-source and B4 handoffs retain their owners.
4. Use the self-checked [scenario oracle](scenario-authoring-contract.rs), follow
its [native procedure](fixtures/scenario-authoring/README.md), and compare exact
whole snapshots for Undo/Redo/reopen at minimum/wide sizes.
5. Exact numeric/unit/work-meter foundation26/26 unit tests pass at7d14bd0; complete
its strict gate while actual schema8 core work proceeds in an isolated worktree.
Extend actual model/source/API/GUI under the ratified
B/R6/R7/full-capture and exact-pin/typed-compatibility contracts. Preserve explicit
approximate numeric, complete consumer/archive and two-project release gates.
6. Update current ledgers and durable decisions when evidence changes the plan.
Reassess unresolved assumptions before expanding downstream implementation.
[Archived prior plans](history/2026-09-11-ytr83r-integration/README.md) preserve
all previous milestone detail. Update this plan when a counterexample changes
the right next action; do not lower completion criteria after substantial effort.
## Coordination and evidence rules
Use Gitea issue #1 and Wiki for current ownership/handoffs; read before resuming.
Codex-956atf, Codex-9e8r6t and Codex-ytr83r are distinct hosts. Cargo/native leases
are host-local; shared source ownership is coordinated.9e8r6t owns the next runnable
Windows named-authoring prerelease and contextual-source/CLI; ytr83r owns B4 Scene
and its platform gates. Root owns local chooser/numeric/workspace integration.
Updated AGENTS requires runnable milestone prereleases. User#178 authorizes existing
MCP credential reuse for Git/REST; secrets must never be printed or committed.
Direct branch publication replaces bundle work when available; old exact bundles
remain valid evidence and transfer branches must not be merged.
Record exact source/binary, command exit, test scope and native model/source oracle.
Do not add focused reruns to full-suite totals or transfer old binary acceptance.
Older denied Linux workaround instructions remain applicable to that action; do
not bypass them. Older stopped native sessions do not prevent currently authorized
native work. The [archived plan](history/2026-09-10-ledgers-before-continuation-refresh/PLAN.md)
and [archived progress](history/2026-09-10-ledgers-before-continuation-refresh/STATE.md)
preserve all previous strategy and scoped evidence; archive report links resolve
from their original `harness/` directory.
-41
View File
@@ -1,41 +0,0 @@
# Current engineering work and evidence
Start with the [user goal](../PROJECT_GOAL.md), [current status](STATE.md),
[execution plan](PLAN.md), [task ledger](TASK_LEDGER.md) and
[decisions](DECISIONS.md). Follow [AGENTS.md](../AGENTS.md) and current
[Gitea coordination](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1)
before changing shared work. Dated reports describe their own source, host and
artifact; they are not current instructions or acceptance of later changes.
## Runnable milestones
- [Windows Studio and REST server authoring preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.authoring.20260911)
- [Standalone Windows source CLI preview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/cli-v0.2.0-preview.authoring.20260911)
These are unfinished development milestones. Their release notes and validation
attachments define exactly what was shipped and tested. Full OA/SA engineering,
analysis, maintained libraries and current Linux acceptance remain unfinished.
## Current local evidence
| Question | Authoritative record |
| --- | --- |
| Which source was integrated? | [Integration audit](release-integration-ytr83r-20260911.md), [CLI import review](release-cli-integration-ytr83r-20260911.md) |
| What does lifetime rendering mean? | [Implementation and corrected review findings](release-lifecycle-render-ytr83r-20260911.md), [language specification](../docs/language.rst) |
| What will native behavior be compared against? | [Independent fixture and procedure](fixtures/scenario-lifecycle-native/README.md), [exact pre-native evidence](experiments/lifecycle-native-ytr83r-20260911/) |
| Which checks ran on this host? | [Host build record](release-host-bootstrap-ytr83r-20260911.md), [current status](STATE.md) |
| What remains for Windows packaging? | [Current runtime/notice preflight](release-b4-package-preflight-ytr83r-20260911.md), [publication path](release-publication-path-ytr83r-20260911.md) |
| Is Linux available? | [WSL outcomes and current user pause](release-wsl-postboot-ytr83r-20260911.md), [preserved offline inputs](release-linux-offline-preparation-ytr83r-20260911.md); setup/build/checking waits for the user's networking-resolution signal |
| What changed for Linux delivery? | [Three-binary CLI packaging and checks](release-linux-cli-packaging-ytr83r-20260911.md), [portable REST restart probe](release-rest-probe-portability-ytr83r-20260911.md); native execution remains pending |
| Which bounded follow-ups are proposals? | [Explicit Action allocation](release-qualified-action-design-ytr83r-20260911.md), [inheritance scaling diagnosis](release-inheritance-scaling-diagnosis-ytr83r-20260911.md) |
External staged-source, chooser and analysis/library owners maintain their current
handoffs in Gitea and the [shared Wiki](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/wiki/Home).
Import exact coherent source checkpoints before transferring conclusions to this checkout.
## History
[Archived integration ledgers](history/2026-09-11-ytr83r-integration/README.md)
preserve source-specific facts and earlier ownership. Existing dated evidence and
Git history remain available. Preserve unique findings and update links before
moving or removing material; active issue #1 remains open for the unfinished goal.
-64
View File
@@ -1,64 +0,0 @@
# Resume after the user-requested reboot — 2026-09-11
Codex-ytr83r /root. The complete first-release goal remains active and unfinished.
Read AGENTS.md, PROJECT_GOAL.md, current Gitea issues1/2 and Wiki before resuming.
This is a pause for the user's reboot, not a completed milestone or failed test.
## Saved source and evidence
Branch: codex/release1-ytr83r. Production source is B4 lifetime rendering52f6e17
plus CLI01b imported as023e3a8. b96286e records exact core/oracle evidence and
current harness index. d041212 preserves owner instruction-sync history and the
byte-exact supplied PROJECT_GOAL. a9a5768 records the Linux trial and contextual
source integration review. Later checkpoint commits add this resumption note.
Core all-targets passed433 tests, one intentionally ignored probe; the optional
probe separately passed. The independent lifecycle oracle and all six frozen
baseline/rename files passed before any native GUI launch. See the harness index.
No current desktop compile/test/native acceptance has been established.
## Interrupted Windows build
Cargo session93786 was interrupted at the user's request while compiling Iced
dependencies (last log entry cc1.4.5). It exited1 from interruption; no final gate
status JSON was emitted. A readback found no cargo.exe/rustc.exe process. Exact
partial log and explicit interruption record are in
experiments/lifecycle-native-ytr83r-20260911/. Preserve cached target outputs.
Use the existing ignored task-local toolchain, one Cargo job, after checking no
other writer is active. From the repository root in PowerShell:
```powershell
& '.local\run-gate.ps1' -GateName 'ytr83r-desktop-lifecycle-resume' -CargoArgs @('test','--locked','--offline','-p','oa-desktop','scenario_lifecycle','--','--test-threads=1')
```
The wrapper loads .local/toolchain/env.ps1. Then run appropriate direct-canvas,
full/strict desktop, server/CLI and GUI build gates serially. Review the new
typography report: wide Unicode Create labels can overflow the fixed capsule.
The proposed clipping correction is not implemented; preserve existing fixtures
and define separate expected Unicode cases before native output. Actual Windows
wide/minimum/Fit/Save/Undo/Redo/reopen acceptance and B4 packaging remain pending.
## Linux after reboot
Recheck wsl --status and wsl --list --verbose. Before reboot, no distribution was
installed; WSL reported virtualization unavailable and Windows had a pending
reboot. Hardware virtualization must be available (firmware or nested VM host).
No host setting was changed and no reboot was performed by the agent.
Prefer a working WSL2 Ubuntu for real Linux build/runtime validation. The verified
Ubuntu image and QEMU cache remain under .local/linux-host-ytr83r. Its bounded
UEFI/TCG pilot ended without guest-ready evidence; all owned pilot processes
stopped. Do not repeat failed delivery or infer Linux acceptance from the image.
## Coordination and next integration
Codex-9e8r6t owns contextual prerequisites; df6f5956 is available locally and has
a reviewed minimal import path, with two B4-preserving merges. Later short-host
layout and Save-procedure corrections are separate; inspect the current remote
handoff before import. Codex-956atf owns chooser, shared constrained canvas design
for issue2, and analysis/library contracts. Local audit agents have stopped their
assigned work. They must recheck coordination before taking new tasks.
The two published Windows authoring/CLI prereleases remain available. No B4
release should be published until actual current tests/native acceptance pass.
+181 -36
View File
@@ -1,57 +1,202 @@
# Integrated checkpoint — 2026-09-12
# Progress Ledger — first release
Codex-ytr83r, isolated branch `codex/canvas-union-ytr83r-20260911`. Current source `33e30c1` combines the a57 canvas, cb91525 grapheme and narrow saved-destination repair corrections. Its 530 frozen inputs match HEAD; independent repair review found no actionable finding. Windows desktop tests, strict Clippy, GUI build and bounded matching native/package acceptance passed. The [canvas-repair Windows prerelease](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.canvas-repair.20260912) is published; ZIP, checksum and validation attachments were downloaded and hash-verified. [Publication record](release-canvas-repair-publication-20260912.md) preserves the exact identities. This is not a full [user-goal](../PROJECT_GOAL.md) completion claim. Live [canvas integration](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2), [native/release acceptance](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/5), [saved-destination repair](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/6) and [project overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1) retain ownership and plans. Earlier production `865483b` and its package remain preserved.
Updated 2026-09-11, coordination through Gitea #245 and issue2. This ledger describes the
Codex-956atf checkout; external-host evidence is labeled separately. The entire
previous ledger is preserved [byte-for-byte](history/2026-09-10-ledgers-before-continuation-refresh/STATE.md).
Historical dates, binaries, process IDs and stopped-session instructions in that
archive are evidence history, not current status or authorization.
## Verified code and capabilities
## Current objective and source
Source `33e30c1` binds repair dirty state to the actual saved destination, preserving accepted-model history even when Apply is an identical-content no-op. It selectively incorporates peer `33c6d83` in three files. Windows focused repair **5**, full desktop **761**, strict desktop all-targets Clippy and GUI build passed; the five are included in 761. All **530 files / 9,727,025 bytes** remained unchanged between gates and matched HEAD after the build. The preserved GUI `50486253…` passed matching repair/history/recovery and Unicode selection/passive-save checks, packaged walkthrough/help smoke and normal exit0 with empty logs. All559 package files stayed unchanged; fresh extracted server25-request editing/persistence checks passed. [Current validation and artifact hashes](release-canvas-repair-validation-20260912.md) links the exact raw archives and independent reviews. [Repair union and limits](release-repair-union-ytr83r-20260912.md), [closed gates and exact inputs](experiments/repair-union-windows-33e30c1-20260912/README.md), [verbatim independent review](release-saved-destination-independent-review-20260912.md).
Deliver the complete, usable OA/SA first release in [PROJECT_GOAL.md](../PROJECT_GOAL.md),
including graphical/source/REST parity, constraints/calculations, quantities/units,
reusable model libraries, traceability, offline guidance and Windows/Linux release
acceptance. Direct behavior/scenario authoring and reduced cognitive load remain
immediate priorities. Complete small and large models must be practical to develop
through uncluttered text alone. The release is incomplete.
Source `cb91525` preserves extended grapheme clusters in the shared display helper. Its four focused tests, full desktop 756 and strict desktop Clippy passed; the four overlap the 756. Its GUI build was intentionally superseded by the repair union; matching combined33e native Unicode evidence is recorded above, without reattributing that result to cb91525. [Correction and limits](release-grapheme-shortening-ytr83r-20260911.md), [closed Windows evidence](experiments/grapheme-windows-cb91525-20260911/README.md).
Local branch: `codex/behavior-authoring-20260910`. Last compiled source checkpoint
is `2cc4d115d0ff28423c7ed87fd5f4065efb011f2d`, including external named creation
`4bb4aaf` as `1516949`, the isolated chooser and final baseline/library contracts.
Full desktop676/676, formatting and strict Clippy pass at that source. The active
native GUI remains immutable80cf197. New workspace and production chooser changes
are outside those artifacts and await a coherent build. Current committed source
adds exact numeric foundation7d14bd0, imported CLIbc311bc and evidence/native
dc0f2e1. Numeric26/26 unit tests and strict core all-target Clippy pass. Shared
instruction ancestrycab01c3 is merged asc7ed894, retaining the exact uploaded goal.
The authoritative goal was copied byte-identically from attachment
`a49d1780-25d5-4778-ad68-6140c60e4207/goal-objective.md`; SHA256
`DD58F75C93D55FE16ED141A23D834898FB6EBC625AE7E7AE379DA552A8312435`.
See [Task Ledger](TASK_LEDGER.md) for knowledge/decisions and [Plan](PLAN.md)
for capability acceptance. Older goal paths are superseded.
The isolated `a57c334` union selectively combines peer `a31b40d` canvas/chooser/form prerequisites with B4 lifetime rendering and contextual source/child-work corrections. Windows lifecycle **27**, full desktop **752**, strict desktop Clippy and Studio build passed on 529 unchanged frozen inputs. GUI `69DBB90F…` passed bounded staged source/consumer creation, Cancel, Save, Undo/Redo and reopen checks: 13 oracles and 45 screenshots at the default wide window only. [Canvas checkpoint](release-canvas-union-ytr83r-20260911.md), [staged archive](experiments/staged-source-native-a57c334-20260911/README.md), [independent review](release-staged-native-a57-independent-review-20260911.md). A fresh a57 camera session subsequently passed bounded OA 100%/125%/Fit and minimum 1100×700 client Fit66 checks, OA/SA pan and moved Create/Delete hit selection, exact passive Save equality and normal exit 0. The 18 images support bounded clipping without toolbar bleed; labels are small at 66%. [Camera archive and limits](experiments/canvas-camera-a57c334-20260912/README.md), [verbatim independent review](release-camera-a57-independent-review-20260912.md). The [earlier picker failure](experiments/canvas-native-close-a57c334-20260911/README.md) remains historical evidence. These pre-grapheme a57 results do not establish current `33e30c1` native or complete usability acceptance.
## Completed work and evidence
Earlier published checkpoint `3ade397008cffe826d5f5a70e7c0233159e3bd42` contains production `29084050a7c020e0fb488be37c6e1ab235769cc0`; push and exact remote readback passed. It combines prior named behavior creation/history integration (`c81e285`, `e207b04`), corrected creation-oracle evidence (`b853a1e`), CLI-only integration (`023e3a8`) and B4 lifecycle rendering.
The creation bundle was fetched anonymously and verified at105022 bytes,
SHA256 `0DEFAE91A56430076C432758F923BC0675911DA9FA7588C9961FF15F47EAEBF6`.
Its coherent commit adds named Process/Chain/Scenario/Machine drafts, bounded
owner search, recovery/selection guards, offline guide and measured textual
examples. The duplicate Sensor correction was not reapplied. Both hosts' report
additions and exact experiment bytes are retained; this host's goal and ledgers
remain authoritative locally. External653-test/native evidence is attributed in
[its integrated report](release-integrated-gates-20260910.md), not claimed as a
local run. The separate isolated chooser at `c4e1580` passed13/13 mounted tests
under cfg(test); it is not a production selector yet.
B4 renders local lifetimes, branch-local activation termination, neutral incomplete lanes, Create capsules and Delete markers with original occurrence identity. Real Windows OA/SA six-case matrices and separate name-only Save/Undo/Redo/reopen workflows passed complete independent Project, reachable-source and physical-file comparisons. These are bounded fixture checks, not complete product usability acceptance.
| Scope | Current evidence and limits |
| --- | --- |
| Reproducible local toolchain | Ignored `.local/toolchain/env.ps1` selects Rust/Cargo 1.98.1 GNU/LLVM and verified LLVM-MinGW 20260908 (clang 23.1.1), with one Cargo job. First cold desktop test build completed in 58m03s; one unused import warning was corrected separately. |
| B1 process authoring | `01bb52a` includes interactive empty/ordered/explicit topology and presentation-only inferred paths; first conversion preserves ordered adjacency atomically. All topology regressions pass within the current full desktop suite. Native acceptance is pending. |
| B2 scenario authoring | Clicked root/operand/slot and optional lifeline feed Action/State/Reuse/Fragment adapters. Pointer-to-App, cancellation, stale-event, recovery and history tests pass within the current full desktop suite. |
| B3 caller navigation and workspace | External `3978bbe` imported as `865cd55` fixes visible-owner restoration, pending-draft guards and remaining-height workspace. Root `80cf197` keys the viewport Sensor by document/scenario; a real mounted regression failed before the fix and now passes. The full suite is green; actual OA caller return and source-neutral navigation pass. Remaining native scope is explicit below. |
| Architecture palette | External palette commit `7d1df4f` was cherry-picked as `aed40d7`, retaining local invariant tests. Captured Escape, stale Submit/raw retention and bounded card fixes are source-integrated. External native results do not transfer to this checkout. |
| Typed stored-history F1 | `b6b76ef` fixes nullable constructor cache discrimination; `4ecad78` fixes NotApplicable seeding by checking actual enum alternatives. Thirteen focused tests passed and are included in the current full suite; core validation remains authoritative. |
| Lifeline lifecycle B4 | `eb5161b` adds pure local/scope-aware lifecycle projection with ten tests, declared under `cfg(test)` only. It is not production Scene wiring or native acceptance. [Design](release-scenario-lifecycle-design-20260910.md). |
| Native scenario oracle | [Independent checker](scenario-authoring-contract.rs) compiled/self-checked and verified the generated62-element/two-source baseline. Native OA Action/State/Reuse/ALT-fragment, each exact Save/Undo/Redo, and final native reopen pass at minimum size; caller identity/scroll return and source-neutral navigation pass. Remaining prescribed checkpoints are open. [Native scope](release-scenario-native-20260911.md), [oracle inputs](fixtures/scenario-authoring/README.md). |
| Normative documentation | Strict Docutils 0.22.2 language HTML regeneration passed. `semantic_schema.py --check --validate` passed 72 source7 fixtures, 204 schemas, 14 families/90 scalar leaves. These are documentation/schema gates, not a Rust build or runtime pass. |
| Comparable-product study | Actual isolated Capella 7.1 scenario/activity/lifeline/LOOP creation was saved and XML inspected, then closed normally. [Report](release-capella-scenario-benchmark-20260910.md). Current Studio native evidence is recorded separately. |
| New requirement audit | [Independent delta report](release-requirements-delta-20260910.md) establishes that current expressions/unit labels are descriptive and source includes are not maintained cross-project libraries. Refined B/R6/R7 and exact-pin/same-origin compatibility contracts are ratified; current source7 does not implement them. |
| Packaging integration | External `b98f135` imported as `6d2b961`; all 25 checked-in exact notices match the manifest. `scripts/test-package-artifacts.py` passes 11/11 and `scripts/test-windows-notices.ps1` passes synthetic package/runtime/source/font checks. Actual optimized distributions and clean-environment launches remain open. |
- [Native lifecycle observations and limits](release-lifecycle-native-ytr83r-20260911.md).
- [OA evidence archive](lifecycle-native-ui-oa-archive-20260911.md) and [SA evidence archive](lifecycle-native-ui-sa-archive-20260911.md).
- [Lifecycle implementation and independent findings](release-lifecycle-render-ytr83r-20260911.md).
- [Creation and source integration evidence](release-staged-source-integration-ytr83r-20260911.md).
## Active work, ownership and unresolved findings
Source `321897fd55af9e07afa2c6ebf1e50be49e86f7d9` integrates the published contextual source-creation/minimum-card corrections from b2c3e540 with B4 and the header clipping correction. Its own Windows desktop all-targets gate passed 704 tests. [Exact selective integration, independent union review and Windows results](release-contextual-integration-update-ytr83r-20260911.md).
Root Codex-956atf owns this host's integration, serialized Cargo and native UI.
The source2cc4d11 gate completed with exit0: **676 passed, zero failed/ignored**,
tests611.24 seconds after warning-free compilation10m47s. Command:
`cargo test --locked -p oa-desktop --bin arcadia-studio -- --test-threads=1`.
Log `.local/logs/desktop-creation-chooser-20260911.log`. Immutable executable
`.local/creation-chooser-20260911/tests-2cc4d11/arcadia-studio-tests.exe`, SHA256
`8D7FB35071F155B3BD07CE1885A1136B2D2EF71C4FF8C704665202499C86A721`.
Its manifest pins256 tracked input hashes. Format passed; strict all-target desktop
Clippy passed7m18s after the isolated manual-clamp correction. Final logs are copied
beside the immutable binary. These gates supersede the historical642 total, not the
current uncompiled working changes. Focused counts are included, never additive.
Published production `865483b4c66361a01fe0d9e731859220bafb4136` adds the reviewed child-work correction: first refinement transfers existing Allocation and RoleAssignment identities, metadata and artifact references to the child; later siblings inherit only a unique performer. OA/SA regressions cover nested children, roles, folded grouping, unallocated/mixed controls and exact history. The old extracted Studio `BF9C2E61…` passed OA first-child, sibling and grandchild Save/Undo/Redo checks, then confirmed OA reopen/Save and the SA first/sibling/grandchild, Undo/Redo and reopen/Save continuation. Original identity carriers and complete source/physical snapshots matched. These are native results for **865483b/BF9**, not a57/69DB. [Original OA evidence](experiments/native-child-oa-865483b-20260911/README.md), [closed continuation](experiments/native-child-continuation-865483b-20260911/README.md), [independent continuation review](release-native-child-continuation-review-20260911.md), [fixture/oracle](fixtures/child-work-native/README.md) and [issue4](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/4) preserve the exact scope.
Actual native Studio remains `.local/native-qa/studio-80cf197/`, GUI SHA256
`A5ED6DF334B1BFB620425AFDF3E863F0B522990E6FB47C6C401A71CF4443954A`.
That GUI build passed3m28s. At1100x700, all four OA creation checkpoints and final
reopen now pass complete Project/two-source prediction and exact UUID restoration.
Preserved OA snapshots end in `oa-reopened/`; separate SA Action Save/Undo/Redo
also passes complete oracle/history comparisons, ending in `sa-action-redo/`,
63objects. The GUI is saved at SA A caller, no active form. SA State/Reuse/Fragment
and final reopen remain. No new-fix acceptance is inferred.
[Native report](release-scenario-native-20260911.md) records details and two real
workspace findings: new forms retain downward scroll, and opening a form can hide
its clicked marker below the reduced canvas. Root has written a fresh form-session
key, guarded Name focus, one-time marker reveal and three meaningful mounted/App
regressions. They are not yet compiled. The isolated chooser's corrected realistic
typing/paste/IME gate passes13/13; production inspector/review integration is now
source is now frozen, with exact row/facet/group/attachment guards. Independent
review exposed and closed three focus defects at source level, including the
legitimate Review rationale input. Fourteen new App/mounted tests await execution;
the earlier isolated13 is separately scoped. [Review](release-reference-chooser-production-review-20260911.md).
## Verification scope
User [issue2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2)
requires Process and Scenario to behave as actual canvases like architecture/state
diagrams. Current scrollable diagrams/tool shelves do not close that requirement.
behavior_audit owns a bounded shared-canvas design; root re-observed the saved
native scenario and its restricted viewport, without new model input.
On this Windows host, core all-targets passed433 tests with one intentionally ignored scaling probe; that probe separately passed with unoptimized timings. Production29084050 passed focused lifecycle22 and full desktop675 tests (the22 are included in675), and the ordinary GUI build/launch. An equivalent test-helper let-chain correction subsequently passed strict workspace/all-targets Clippy and its focused regression1/1. These results predate the newly identified header-rendering correction.
The [baseline contract](release-analysis-baseline-contract-20260911.md) supplies a
complete17-UUID proposed model,11 exact fixtures, explicit ModelBaseline/Case,
property bases and shared checks. Its transport fixtures are not parser/evaluator
acceptance. The [library contract](release-library-compatibility-contract-20260911.md)
has120 executed finite-prototype assertions, separate-process reopen and11 complete
expected/reopened file comparisons. Final81-file manifest was independently
verified; preliminary output is retained and labeled superseded. All166 experiment
files retain exact Git bytes. Root ratified B/R6/R7/full private capture, nominal
typed compatibility alongside exact pins, explicit source normalization and OA/SA
dispositions in [DECISIONS](DECISIONS.md).
- [Exact current-host logs and inputs](experiments/lifecycle-native-ytr83r-20260911/).
- [Test-only Clippy correction](lifecycle-clippy-correction-ytr83r-20260911.md).
- [Current-host toolchain setup](release-host-bootstrap-ytr83r-20260911.md).
- [Independent Windows gap review](release-windows-gap-review-ytr83r-20260911.md).
The [independent refined review](release-refined-contracts-review-20260911.md)
passes292 fixture/identity checks but finds C1:333 stored AST nodes can expand to
over2billion calls. Request-local dynamic/numeric work accounting is mandatory;
static depth/node limits alone fail. C2 is a finite-prototype threshold-unit gap,
not a passing production type-checker. Exact arithmetic, unit/dimension operations
and terminal work meter are declared/committed at7d14bd0; their26/26 unit gate has
[exact source/artifact evidence](release-analysis-numeric-gate-20260911.md).
requirements_delta now owns actual schema8 definitions, expressions, commands,
references, migration and static admission in the isolated
`.local/analysis-core-worktree-20260911/`, based at7d14bd0. It remains uncompiled.
semantics_critic reviews the bounded decoder strategy separately; existing default
recursion rejection stays until a full allocation/lifecycle gate is justified.
Root owns compilation. Numeric/runtime and static-admission ceilings remain
explicitly provisional pending measured gates. Approximate engineering calculations,
full evaluator/model/source/REST/GUI parity and actual library maintenance are open.
The closed 321897f Windows gate passed 704 desktop tests with zero failed/ignored/filtered; all 261 recorded build inputs remained unchanged. Matching server `ABA87A5F…` passed semantic HTTP checks (204 schemas, 19 values, 9 rejected invalid shapes) and the corrected REST probe's 25 requests. [Closed runtime evidence](experiments/windows-runtime-321897f-20260911/README.md) preserves both earlier source-version failures. [Earlier integration checkpoint evidence](experiments/contextual-lifecycle-integration-ytr83r-20260911/README.md) preserves server/CLI 68 across 7 targets, schema 72/204 after the missing-jsonschema correction, and the old GUI's wide-name passive Save comparison.
Codex-9e8r6t and Codex-ytr83r are separate hosts/teams. The former owns contextual
prerequisite creation and source CLI. CLI01b76a1 is imported asbc311bc; exact
corrected evidence2ec72d4 is imported atdc0f2e1, preserving root's separate native
report. External675 staged-source and CLI12/183-assertion results remain externally
attributed. Its runnable Studio/server preview release1 and standalone CLI preview
release2 are published; see their release notes for exact source/runtime limits.
Strict language/API HTML regeneration and the72-fixture source7 schema gate pass
locally after that evidence import. Codex-ytr83r owns B4
production Scene wiring, independent native oracle and local Linux route. Its
rendering source has independent criticism but no passing build/native gate yet.
Do not duplicate these changes. Preserve root's distinct scenario-scroll/focus hook
when integrating other hosts' small App/behavior bridges.
The later 865483b Windows desktop gate passed **707 tests**, including the three focused child-work regressions, with zero failed/ignored/filtered. All 261 recorded build inputs stayed unchanged. The matching debug GUI build and strict **desktop-only** all-targets Clippy passed; child-work and staged-source checker compilation/admission also passed. [Closed gates and exact inputs](experiments/windows-gates-865483b-20260911/README.md). These checks do not establish the remaining native rendering or packaged acceptance.
Updated AGENTS4b8465c8 adds concise shared maintenance to milestone releases. Gitea
user#178 explicitly authorizes programmatic reuse of existing MCP credentials for
Git/REST and relevant commits/tags/prereleases, without printing or committing them.
Direct authenticated push/readback passed on this host, most recently publishing
dc0f2e1. The subsequent owner's instruction commitcab01c3 has been fetched and
merged locally; no force push or additional credentials are required.
Historical verified bundle exchange remains reproducible: c6d296a bundle on
`codex/behavior-transfer-20260910`,334830 decoded bytes, SHA256
`526A80E2D32ED0E57A7B2372B5F02D4652E8751DFEBA81953C4F045225D6AACB`,
prerequisite1797516. Both other hosts received it. Never merge transfer branches.
Older bundles/logs retain historical scope in Git and the archived ledger.
The extracted package's unchanged server `ABA87A5F…` passed the actual HTTP schema checks (204/19/9) and 25-request REST editing/persistence/restart probe with a restricted process PATH and unrelated working directory. Seven raw response pairs and exact source snapshots matched; all five test-owned server processes stopped. The first REST run's PowerShell-added PATH directory is preserved as historical evidence; the corrected restricted-PATH rerun is authoritative. [Packaged server evidence](experiments/packaged-server-865483b-20260911/README.md). This completed the earlier bounded server runtime acceptance. Fresh33e package/server/GUI results are linked above; the old receipts retain their own source attribution.
## Remaining gates and highest-value next actions
## Material limitations
1. Freeze and compile the new workspace/chooser integration, resolve focused
failures, then build an immutable GUI and verify both native workspace fixes.
The676-test gate is complete for2cc4d11; new changes require their own evidence.
2. Publish coherent checkpoints directly; integrate9e8r6t's next staged-source
work, then ytr83r B4 only after its coherent handoff. CLI/evidence import is done.
Preserve ownership and distinguish every external versus local gate.
3. Use the self-checked independent oracle to exercise current Studio at
minimum/wide sizes: named creation, direct process/scenario edits, nested reuse,
caller return, lifeline lifecycle, invalid/cancel/recovery, Save/Undo/Redo/reopen.
Compare complete model/source and exact identities, not screenshots alone.
4. Compile/review/measure the numeric foundation, then implement the ratified
analysis/library contracts with shared-core/source/API/graphical parity. Use real
budgets, unit/kind failures, complete text-only and two-project update workflows.
Integrate the bounded chooser and run its actual keyboard/minimum-size gates.
5. Complete current teaching, transitions, semantic review, state/data, saved-view,
artifact, hosted transport and migration acceptance. Historical scoped passes
remain useful references, never blanket current acceptance.
6. Complete Windows/Linux build/test/native/runtime and optimized package gates,
notices, provenance and clean-environment launch. WSL has no installed distro
here; Linux acceptance remains open. Historical denied guest workarounds must
not be retried or split to bypass review. No host restart is authorized.
7. Run fresh independent cross-disciplinary end-state review, resolve material
findings and reconcile specification/help/coverage before final handoff.
Earlier native wide Latin/CJK checks found neutral lifeline header text overflowing its card while Create labels stayed contained. The [header correction](lifecycle-header-overflow-correction-ytr83r-20260911.md) is included in 865483b and retained in a57. Actual corrected wide-W/CJK observations in BF9 show bounded headers and Create labels, including selected zoom, scroll and minimum-window cases. Both complete wide and cluster sources remained unchanged after passive Save. Cluster checks exposed a separate scalar-shortening defect: an accent or joined emoji can be split at the label cutoff. The later cb91525 correction and current33e accent/ZWJ selection/passive-save checks resolve the observed string-splitting case. Long wide labels can still be hard-clipped and arbitrary font/DPI presentation is unverified; see the historical [native typography record](experiments/lifecycle-header-correction-native-865483b-20260911/README.md).
## Reassessment
The earlier capture failures and pending Open dialog are historical: later confirmed OA and SA reopen/Save actions are preserved in the [continuation archive](experiments/native-child-continuation-865483b-20260911/README.md). Its first OA `reopened-1948` receipts remain data-only evidence. Bounded a57 staged and camera workflows above are complete; full focus/keyboard, every clipping edge/zoom and accessibility remain unverified. Bounded combined `33e30c1` native/package acceptance is now linked above. Small 66% labels are a recorded limitation. The BF9 Home page, scenario guide and bundled water-pressure example opened; one normal Alt+F4 then completed, with the process absent and empty stdout/stderr. The [old Home/Guide/close archive](experiments/packaged-gui-close-865483b-20260911/README.md) preserves those old865/BF9 observations and package readback. No exit code was captured for that older BF9 process; the separate a57 exit 0 does not change its evidence.
Meaningful progress continues: concrete interaction defects have been found and
corrected,676 desktop tests pass and all four OA native creation/history/reopen
checks pass. Contracts have converged through independent falsification; numeric
implementation now addresses the exposed dynamic-work risk. The revised semantic
requirements are no longer hidden behind metadata.
Passing older suites or accumulating prose would not close the user problem.
Current highest information gain comes from actual build/native results and
independently criticized foundational examples, while preserving existing identity,
source and atomicity contracts. Replan when those results falsify a proposal.
The selected chooser/canvas union now has a57 source and Windows gate evidence; broader persistent graphical manipulation and separate text-navigation contributions retain their own integration/acceptance boundaries. Calculations, quantities/units, maintained libraries, practical large-model textual workflows, complete OA↔SA engineering parity, documentation and fresh cross-disciplinary review remain full-goal requirements. See the [analysis proposal](release-analysis-semantics-design-20260910.md), [library proposal](release-libraries-semantics-design-20260910.md), [text-authoring design](release-textual-authoring-design-20260911.md) and [decisions](DECISIONS.md) for attributed design evidence, not completed implementation claims.
## Historical evidence access
## Platform and published deliverables
**Linux setup, downloads, builds and checks are paused by explicit user instruction until the user reports networking resolved and instructs resumption.** Ubuntu26.04/WSL2 shell execution was observed; guest networking remained unavailable. Offline preparation was a workaround, not a network fix or Linux acceptance. Host VPN settings were not inspected or changed. [WSL evidence](release-wsl-postboot-ytr83r-20260911.md) and [preserved offline inputs](release-linux-offline-preparation-ytr83r-20260911.md) retain exact outcomes.
Peer Codex-9e8r6t published the separately verified [Windows contextual prerelease](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/releases/tag/v0.2.0-preview.contextual.20260911), source `b2c3e540`; [its publication handoff](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2#issuecomment-406) binds its artifacts and original-host checks. It is not a B4 release from this branch.
## Record navigation
The [one-time record migration](history/2026-09-11-ytr83r-record-migration/README.md) preserves the exact prior STATE ledger and marks PLAN/TASK_LEDGER superseded. [Earlier integration history](history/2026-09-11-ytr83r-integration/README.md) preserves source-attributed older results. Historical leases, failed approaches and next-step wording do not override current Gitea coordination or the Linux pause.
The [complete prior STATE](history/2026-09-10-ledgers-before-continuation-refresh/STATE.md)
retains exact historical binary hashes, counts, logs, process closure, bundle hashes
and all report links. Links inside the byte-preserved archive retain their original
`harness/` context; resolve those relative report paths from `harness/`.
Useful direct entries: [source7 native](release-source7-native-acceptance-20260907.md),
[teaching](release-teaching-native-20260906.md),
[artifact native](release-artifacts-native-20260906.md),
[coverage baseline](release-current-coverage-20260906.md),
[Linux gate](release-source7-linux-gate-20260907.md),
[Linux cross-build](release-source7-linux-gui-cross-build-20260907.md),
[denied boot experiment](release-source7-linux-server-corrected-gate-20260907.md).
+203 -125
View File
@@ -1,145 +1,223 @@
> Superseded on 2026-09-11. This document is a preserved historical checkpoint.
> Live plans, priorities, ownership and handoffs now reside in [Gitea issue 1](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1#issuecomment-410) and [issue 2](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/2).
> Do not resume synchronizing this ledger. See [STATE.md](STATE.md) for the integrated checkpoint.
# Task Ledger — first release
Updated 2026-09-11 for Codex-ytr83r. The full
[user goal](../PROJECT_GOAL.md) and [current progress](STATE.md) remain authoritative.
[Exact host ledger archives](history/2026-09-11-ytr83r-integration/README.md)
preserve prior facts, dates and reasoning without transferring their acceptance.
Updated 2026-09-11. [STATE.md](STATE.md) owns current execution/evidence;
[PLAN.md](PLAN.md) owns capability milestones. The entire former Task Ledger is
[preserved byte-for-byte](history/2026-09-10-ledgers-before-continuation-refresh/TASK_LEDGER.md).
Its old goal references, aggregate test claims and stopped-session directives retain
historical scope. New semantic decisions are recorded explicitly in DECISIONS.md;
prototype results retain their separate scope.
## Goal and given requirements
Current compiled checkpoint2cc4d11 includes external creation4bb4aaf as1516949,
isolated chooser13, and final baseline/library contracts. Local full676/format/
strict Clippy pass; native80cf197 now passes four OA creations, exact history and
reopen. Separate native SA Action/history also passes. Exact numeric foundation
is declared at7d14bd0 and passes26/26 unit tests plus strict core all-target Clippy.
Working workspace/production chooser changes remain outside compiled artifacts;
independent chooser source review closed three focus findings,14 new host tests
are prepared. Schema8 core implementation is isolated and uncompiled. External
results retain their host/source attribution.
Deliver a complete, usable OA/SA release on Windows and Linux, with one explicit
reST semantic contract and equivalent graphical, textual and REST access. Include
constraints/calculations, quantities/units, maintained libraries, behavior/state,
traceability and navigable offline help. Reduce cognitive load and visual clutter;
small and large models must be practical using text alone. Later Arcadia layers
remain future scope. Compilation or a pleasant happy path is not completion.
Issue2 further requires consistent direct process/scenario canvas interaction;
the owner's clarification allows domain constraints rather than arbitrary movement.
## Goal and success criteria
At15:28UTC on2026-09-11, the user explicitly deferred Linux builds and checking
until they resolve WSL2 networking and instruct resumption. Linux setup/downloads
are paused with inputs preserved. Continue independent Windows work; do not treat
offline preparation as a networking fix or Linux acceptance.
Deliver the usable, feature-complete OA/SA first release specified in
[PROJECT_GOAL.md](../PROJECT_GOAL.md). It is the exact copy of the user's revised
`a49d1780-25d5-4778-ad68-6140c60e4207/goal-objective.md`, SHA256
`DD58F75C93D55FE16ED141A23D834898FB6EBC625AE7E7AE379DA552A8312435`.
Success requires coherent explicit semantics, practical engineering workflows,
equivalent graphical/textual/REST behavior, real Windows/Linux runtime evidence,
recoverable authoring, relevant automated evaluators, precise documentation,
reproducible packages and fresh independent cross-disciplinary end-state review.
No known blocker or high-impact finding may remain unresolved. Later Arcadia
layers remain future scope. Code, a passing slice or an attractive screen alone
cannot establish completion.
## Given facts
- Rust/Iced desktop and a deployable REST service serve system engineers. OA/SA
transitions in both directions must be understandable, explicit and useful.
- The revised goal explicitly requires constraints/calculations, quantities/units,
reusable model libraries, state/behavior modeling and strong standardized
specification/API, as well as textual/graphical notation and artifact traceability.
- Human/AI/Git-friendly source and a concrete unambiguous reST specification must
match graphical editing. The source editor needs highlighting/linting; help must
be offline, concise and navigable.
- The newest explicit goal requires uncluttered notation that reduces cognitive
load and supports development of complete small and large models using text alone.
- Direct behavior/scenario diagram authoring and reduced cognitive load are current
priorities. Long homogeneous selectors and bloated persistent creation controls
are rejected. Study actual Capella's useful domain workflows and nesting without
mechanically copying its interaction problems.
- The user authorizes autonomous bounded engineering, native Capella/Studio
evaluation and explicit independent delegation, exclusively with GPT-6 Astra
at effort appropriate to the question. Root owns synthesis/integration.
- Project instructions require Gitea coordination, ownership preservation and
durable handoffs. Current root reserves one Cargo/native operator per host.
Updated AGENTS4b8465c8 requires coordinated runnable milestone prereleases and
concise current shared maintenance with unique historical evidence preserved;
user Gitea#178 authorizes programmatic existing-MCP-credential reuse for relevant
Git/REST publication without printing/committing secrets. Restart and usage reset
remain unauthorized.
- Gitea issue2 explicitly requires actual Process/Scenario canvases with the same
interaction approach as architecture/state diagrams. Existing pointer edits
inside scrollable projections do not by themselves satisfy that requirement.
## Verified facts
- Local start was cleanff615d0. Root decoded/hash-verified/imported published3978,
c6 and4bb bundles; merged c6 as c81e285 and integrated creation4bb at e207b04. There were
no production cherry-pick conflicts; docs/attributes require evidence union.
The current goal is copied from the user's latest file.
- Read-only integration comparison finds equivalent workspace/Sensor code in c6;
replaying3978 or d114 is unnecessary. Core source is unchanged betweenff andc6.
B4 now has a production Scene bridge and independently reviewed source, with
focused lifecycle22/22 and full desktop675/675 passed on source2908405. The
ordinary GUI build and real OA/SA lifecycle matrices now ran; OA full name-only
Save/Undo/Redo/reopen and the corresponding SA workflow passed exact independent
comparisons. Corrected workspace/all-targets strict Clippy also passed0;
Unicode appearance remains open. See the
[native record](release-lifecycle-native-ytr83r-20260911.md) for exact scope.
- Imported source implements named behavior creation, exact prepared-batch
correlation, success-only selection, raw-draft guards, recoverable original
layer and modal-priority Escape. These are source facts; combined local runtime
acceptance is pending.
- Earlier-host desktop642 and653 suites, scoped native scenarios/creation,
packaging25 notices and source experiments retain exact attribution in
[STATE.md](STATE.md). They are not new local passes.
- The original transferred creation oracle incorrectly equates physical disk files
with reachable sources during Undo. Later external v2 corrects the evaluator
with explicit origin/retained-byte rules; exact v2/evidence2ec was imported at
b853a1e after complete path/hash preflight. New host execution is still pending.
- Isolated Rust1.98.1/LLVM23.1.1 and locked dependencies are available. Packaging
11/11 and synthetic Windows notice checks pass. Current core all-targets passed
433 tests; its optional inheritance probe separately passed with slow debug
timings that require controlled optimized interpretation. Focused lifecycle
passed22/22 after two installed-Iced API corrections; full desktop passed675/675.
Strict reST/HTML generation passes with Docutils0.22.2.
- B4 independent criticism found and resolved self-caption overlap and two dense
minimum-Fit hit-selection counterexamples. Shared nearest marker/message-stroke
arbitration preserves exact marker/header/label selection. Ten real-Scene tests
and two clip geometry tests passed in the22-test focused gate. The independent twelve-scenario native oracle passed compile,
admission, self-check and exact baseline/name-only predictions before GUI output.
- The separate baseline-context experiment passed218 assertions; explicit
one-level overrides reduce repeated stable assumptions while complete frozen
definition environments remain necessary. Proposed syntax is not source7.
- After the user's virtualization/reboot change, supported Ubuntu26.04 install
completed exit0 and a real x86_64 WSL2 kernel shell completed. Later VM startups
timed out before apt. Owner328's exact VirtioProxy configuration now supports
a persistent root shell, but requested IP/DNS/HTTPS checks failed. Signed offline
dependency preparation advances without host VPN inspection or changes. This
establishes guest execution, not Linux builds or GUI acceptance; exact outcomes are
in the [WSL record](release-wsl-postboot-ytr83r-20260911.md).
- The independent scenario checker compiled with warnings denied and passed its
self-check, deterministic fixture generation and complete baseline check on this
host. Its eight OA/SA checkpoints and exact UUID/source restoration comparisons
are evaluator evidence, not native Studio acceptance. Exact inputs/hashes are in
[the fixture instructions](fixtures/scenario-authoring/README.md).
## Assumptions and unresolved questions
- The historical native implementation is `80cf197` on `codex/behavior-authoring-20260910`, following
baseline `1797516`. Local commits cover B1/B2/B3 authoring, nullable history cache
(`b6b76ef`), external palette integration (`aed40d7`), cfg(test) lifecycle projection
(`eb5161b`), history seed correction (`4ecad78`) and exact-identity native oracle.
Later imports add focus recovery, exact packaging notices and scenario workspace/
owner restoration. Root80cf197 fixes document/scenario viewport Sensor identity
with a real mounted red-before/green-after regression.
- This fresh host has task-local Rust/Cargo 1.98.1 GNU/LLVM and verified LLVM-MinGW.
The integrated2cc4d11 desktop suite passes676/676, zero failed/ignored, with a
warning-free build; formatting and strict Clippy pass. History, pure lifecycle,
creation and isolated chooser are included. New working code still needs its
own gate. Native80cf197 evidence is separate. [Exact hashes/logs](STATE.md).
- Strict language HTML regeneration and the source7 OpenAPI checker passed locally:
72 fixtures, 204 schemas and 90 scalar leaves. Actual isolated Capella scenario,
activity/lifeline and LOOP creation was saved/inspected; this is reference study,
not Studio acceptance. [Native benchmark](release-capella-scenario-benchmark-20260910.md).
- Core/source7 already implements substantial direct behavior, typed data/ports,
contextual chain links, maintained message-operation identity, semantic reviews,
artifacts, hosted source envelopes and recovery. Historical absence claims must
be checked against code and newer scoped evidence, not copied forward.
- [Requirements audit](release-requirements-delta-20260910.md): Constraint and other
expression strings are descriptive, with no calculation/binding/result evaluator;
unit strings persist/inherit without dimensional compatibility or conversion;
named types/sequence/scenario reuse and includes exist, but there is no maintained
cross-project package/version/instance/update contract. These are explicit gaps.
- The same audit identifies concrete unbounded Constraint target choices and generic
expanded collection editors. Explorer already has hierarchy/search/paging; the
problem is targeted, not a claim that all selection is flat.
- Gitea #79's same-selected-ID/visible-owner defect is fixed in the imported
scenario checkpoint; owner restoration and pending-draft guards pass current
tests. B4 independent review #114 adds Scene integration requirements for actual
lifetime extents, branch-local activation clipping, incomplete lanes and self arrows.
- Codex-9e8r6t and Codex-ytr83r have independent host-local Cargo/native leases.
Named creation/workspace/palette/package integration is imported here.9e8r6t's
coherent CLI/evidence checkpoint01b76a1 awaits import; it owns staged-source and
the next runnable Windows prerelease. ytr83r owns production B4 and its native
oracle.9e8r6t successfully reused existing credentials for direct push; this
host is setting up the same authorized publication path.
- Current Linux acceptance is open; this host has no installed WSL distribution.
Historical Linux core/server/cross-build results have exact older snapshot scope.
The earlier denied guest workaround did not run and must not be bypassed.
- Combining already coherent desktop slices should preserve behavior; fresh build,
automated and native gates must test that assumption.
- The baseline/library designs can fit existing atomic Project/Command/source
boundaries; exact references, deletion, transition, imports, freezing and
numeric rules still need ratified contracts and falsification.
- Pinned package provenance must be distinguished from checked type compatibility;
unchanged quantity kinds cannot become incompatible merely through cosmetic
repackaging without a deliberate policy. Energy and Torque remain distinct.
- Ubuntu26.04/WSL2 is the current supported Linux route; reliable repeat startup,
guest toolchain/dependencies and GUI/service/package execution remain open.
Initial shell success does not erase later startup timeouts. Old QEMU work is
retained as history, not the next route to repeat.
- Large-model human/AI task usability, cross-file refactoring, actual chooser
latency/keyboard/minimum geometry and complete native lifecycle behavior remain
to be measured. Parser admission or a widget experiment is insufficient.
## Assumptions
## Candidate designs and selected directions
- Compact contextual tools and an adequately sized scenario workspace can reduce
friction; only actual engineering tasks at minimum/wide sizes can validate this.
- The ratified bounded pure analysis and pinned-library architecture can satisfy
real engineering work without full language/runtime adoption. This remains a
product adequacy assumption until actual budgets, approximate calculations and
two-project/source/API/native workflows pass; metadata is insufficient.
- Existing shared core/source/history boundaries remain suitable for additions;
foundational examples and independent criticism must test that assumption.
The imported [analysis proposal](release-analysis-semantics-design-20260910.md),
[library proposal](release-libraries-semantics-design-20260910.md),
[paired critique](release-semantic-proposals-review-20260910.md) and
[textual authoring design](release-textual-authoring-design-20260911.md) retain
alternatives and counterexamples. Metadata-only analysis and bare source includes
do not meet evaluated quantities or maintained libraries.
## Open questions
Codex-956atf root selected property-owned baseline values/formulas with explicit
ModelBaseline/Case evaluation and one-level case overrides for contract refinement.
No hidden default case or UI-dependent evaluation; inherited formulas use the
effective requested case, and complete freeze captures definitions/provenance.
Exact numeric, library normalization/type compatibility and transition details
remain open. This direction is recorded in [DECISIONS.md](DECISIONS.md), not silently
treated as completed production semantics.
- Do the ratified owned-value/shared-check/R6/R7 contracts meet real engineering
budgets once implemented, including approximate numeric requirements? What
measured ceilings keep work bounded without rejecting ordinary studies?
- Does actual package closure/canonical consumer typing/source preparation and
three-way update preserve every stable identity and local engineering edit?
- What migration and OA↔SA treatment follows from those contracts without guessing
executable meaning from old strings or silently changing old models?
- How can text-only creation establish library instance bases/maps, preserve
compatibility across cosmetic package revisions, export subjectless cases, and
transition mounted OA/SA data without hidden GUI normalization or silent forks?
- Will full evaluator and private-capture replay preserve effective-case and
deterministic work-limit behavior across direct, REST and cache-warm execution?
- Which source-preserving refactoring/navigation and grammar changes reduce actual
engineering effort while retaining stable identity and cross-file ownership?
- Do current real gestures, exact caller return, nested scopes and lifecycle markers
remain usable and correct under minimum sizing, scrolling, zoom and dirty drafts?
- Which uncovered teaching, traceability, transition, saved-view, state/data, hosted
and platform cases remain after current-binary acceptance? What supported Linux
environment can complete the remaining actual runtime gate?
- Does "standardized" require any external conformance beyond the project's precise
interoperable spec/API? Full OMG conformance is not inferred merely from comparison
to SysML v2; explicit claims require a mapped and verified contract.
The external staged prerequisite design reserves source/consumer identities,
keeps the parent context/rawtext/slot, stages locally and creates source+consumer
in one atomic final command. It must not implicitly allocate work, involve
capabilities, retarget an Exchange-fixed endpoint or accept a different editor.
Qualified Action allocation remains a distinct engineering choice.
## Candidate hypotheses / designs
## Retained implementation decisions
The [requirements delta](release-requirements-delta-20260910.md) compares metadata/
snippets, bounded pure engineering analysis with maintained pinned libraries,
external evaluators, and full standards adoption. Metadata alone fails the explicit
new semantics; external tools alone still need binding/result contracts. The report
recommends the bounded route for root synthesis, not as a ratified decision.
- Keep one authoritative typed Project, validated atomic Commands, stable
identities and explicit references. Semantic ownership/allocation/order and
diagram geometry remain distinct; forms are adapters, not a second metamodel.
- Preserve source ownership/comments/includes and exact model/source history.
Writeback succeeds before adoption. Invalid/rejected raw drafts survive and
browsing/tool selection does not silently accept another editor. Recovery
validates/replans rather than trusting serialized commands.
Independent maximum-reasoning tracks now compare owned engineering properties
versus analysis contexts; calculation definitions/applications; numeric/unit rules;
and copy, linked-definition, live-instance or pinned-definition/editable-instance
library architectures. Require concrete falsifiers, source/API migration effects,
identity/history/recovery behavior and independent critique before convergence.
These alternatives converged after concrete experiments and independent review:
B property bases/shared checks/full capture, R6/R7, exact pins with same-origin
typed compatibility and explicit portable normalization are now ratified. Complete
11-file proposed baseline and120-assertion finite library prototype remain distinct
from production admission. Independent292-check review exposed C1 dynamic-work
expansion and a C2 prototype threshold-unit gap; implementation must close both.
The text-only40-identity fixture and13 mounted chooser tests pass. Production
numeric foundation and inspector/review chooser are active, with gates pending.
The subsequent context experiment passed218 assertions: shared property baselines
and one-level overrides reduce repeated input maintenance while retaining explicit
independent freeze. Root selected that B direction for precise contract refinement;
quantity/library/source details remain open. [Decision](DECISIONS.md),
[experiment and limits](release-analysis-context-experiment-20260911.md).
## Decisions retained
- Keep explicit Rust crate responsibilities and one authoritative typed Project
with validated atomic Commands. A thin source CLI reuses the shared core. Separate semantic identity/ownership, allocation/reference/order and
diagram geometry. Forms are closed adapters, not a second metamodel. New persistent
semantics require core/source/UI/API/reference/transition parity. [Decisions](DECISIONS.md).
- Preserve authored source ownership/comments/includes, exact identity and accepted
model/source history. Source writeback must succeed before adoption. Raw invalid,
excluded or rejected drafts survive; navigation/tools must not implicitly accept
another editor. Recovery validates/replans rather than trusting serialized commands.
- Semantic reconciliation uses finite typed facets, frozen baselines, explicit
mappings and one atomic candidate. Names alone never establish identity.
- OA↔SA transitions are explicit engineering decisions. New persistent semantics
need validation/reference/deletion/transition/source/API/UI/history parity.
- Exact evidence bytes and host attribution survive integration. No focused
test total is added to a full-suite count; old binary acceptance does not
transfer to newly merged code.
mappings, actual destination slots and a single atomic candidate. Do not infer
identity by name or apply reviewed rows as sequential independent edits.
[Foundation](release-semantic-batch-foundation-design.md),
[artifact contract](release-semantic-artifacts.md).
- Layer transitions are explicit engineering decisions, not automatic propagation.
Behavior-root navigation must establish the requested owner while preserving
unrelated architecture presentation. Ports/allocation and contextual occurrences
retain their distinct domain meaning. [Source7 contract](release-source7-integration-decision-20260906.md).
- Hosted revision/source envelopes and ETag checks remain atomic; credentials stay
out of persisted models and repository. Linux journal publication retains the
selected no-replace invariant, with measured limits, not an overwrite fallback.
[Journal decision](release-linux-journal-publication.md).
- Repair/evaluate the compact palette informed by native Capella evidence; do not
return to the rejected horizontal toolbar. Lifecycle projection remains isolated
under cfg(test) until coordinated Scene integration and actual acceptance.
- Preserve historical scope decisions as history. In particular, architecture-only
SVG is implemented and behavior SVG was deferred; that is not proof of satisfying
the revised release objective. Reassess older exclusions against current scope.
## Current strategy and coordination
## Current strategy
Root owns local integration, serialized builds, native inputs and final synthesis.
The local audit resolves evidence and reviews source; independent server review
has its own report. Codex-9e8r6t owns staged sources/first actions and source CLI;
Codex-956atf owns chooser, semantic contracts and the shared canvas design. Read Gitea issues #1/#2 and Wiki before
resuming, obtain exact coherent transfer heads, and respect their source ownership.
Finish the new workspace/chooser compile and native loop; independently review
and measure the numeric foundation before expanding the ratified semantics.
Integrate coherent external CLI/staged-source/B4 checkpoints while preserving
ownership and source attribution. Use independent complete model/source oracles
and exact identity/history comparisons. Publish coordinated runnable milestone
prereleases; close actual analysis/library/source/API/native, cross-platform,
service/package and independent end-state gates before the final handoff.
First finish and verify the integrated current source using the imported corrected
evaluator and newly prepared independent lifetime oracle. Then close remaining modeling/textual,
analysis/library and platform gates with independent criticism and whole-task
acceptance. Update facts, assumptions and decisions as evidence changes them.
The [archived Task Ledger](history/2026-09-10-ledgers-before-continuation-refresh/TASK_LEDGER.md)
and [archived STATE](history/2026-09-10-ledgers-before-continuation-refresh/STATE.md)
preserve all older claims, decisions and evidence links exactly. Relative report
links inside those byte-preserved files resolve from their original `harness/`
context. Their historical test totals and process holds are not live status.
-156
View File
@@ -1,156 +0,0 @@
//! Native issue4 fixture/oracle. generate DIR | check oa|sa STAGE ENTRY
//! snapshot ENTRY DIR | compare ENTRY DIR | self-check
use oa_core::{source_project::*, *};
use std::{collections::{BTreeMap, BTreeSet}, fs, path::Path};
fn id(n: u128) -> Id { Id::from_u128(0xc1140000000040008000000000000000 + n) }
fn work(layer: Layer) -> Kind { if layer == Layer::Oa { Kind::Activity } else { Kind::Function } }
fn fixture(layer: Layer) -> Project {
let mut p = Project::empty();
p.id = id(1); p.alias = "child_work_native".into(); p.name = format!("Issue4 {layer:?} child work");
for (n, kind, name) in [(10, if layer == Layer::Oa { Kind::Entity } else { Kind::System }, "Performer"), (11, work(layer), "Parent work")] {
p.elements.insert(id(n), Element { id: id(n), alias: format!("seed_{n}"), name: name.into(), description: format!("Preserve original {name}"), parent: None, body: ElementBody::new(kind, layer).unwrap() });
}
p.relations.insert(id(20), Relation { id:id(20), alias:"original_allocation".into(), name:"Original responsibility".into(), description:"Preserve allocation engineering rationale 雨".into(), body:RelationBody::Allocation { source:id(10), target:id(11) } });
if layer == Layer::Oa {
for n in [12,13] {
p.elements.insert(id(n), Element { id:id(n), alias:format!("role_{n}"), name:format!("Responsibility role {n}"), description:"Preserve role".into(), parent:None, body:ElementBody::new(Kind::Role, layer).unwrap() });
for (r, kind, source, target) in [(n+10,RelationKind::RoleAssignment,id(n),id(11)),(n+20,RelationKind::RoleRealization,id(10),id(n))] {
p.relations.insert(id(r), Relation { id:id(r), alias:format!("responsibility_{r}"), name:format!("Role link {r}"), description:format!("Retain role link metadata {r}"), body:RelationBody::new(kind,source,target) });
}
}
}
for (index, owner) in [id(20),id(22),id(23)].into_iter().filter(|owner| p.relations.contains_key(owner)).enumerate() {
let key=id(50+index as u128);
p.artifact_links.insert(key, ArtifactLink { id:key, alias:format!("evidence_{index}"), name:format!("Responsibility evidence {index}"), description:"Keep owner identity and evidence metadata".into(), owner, relation:ArtifactRelation::References, uri:format!("https://example.invalid/evidence/{index}"), revision:Some("review-7".into()), fragment:Some("assignment".into()) });
}
p.diagrams.insert(id(2), Diagram { id:id(2), alias:"allocation_view".into(), name:format!("{layer:?} allocated work"), description:"Native child-work fixture".into(), layer, projection:ProjectionKind::Allocation, routes:vec![], view_options:Default::default(), nodes:BTreeMap::new() });
for (n,element,x,y,collapsed) in [(40,id(10),60.,60.,false),(41,id(11),84.,124.,true)] {
p.diagrams.get_mut(&id(2)).unwrap().nodes.insert(id(n), NodePlacement { id:id(n), alias:format!("seed_view_{n}"), element, position:Position{x,y}, size:Size{width:224.,height:94.}, collapsed, port_anchors:vec![] });
}
execute_batch(&p,&[]).unwrap()
}
fn bundle(p:&Project)->CompiledSourceProject {
let source=language::format(p).replacen("{\n","{\n include \"rationale.arcadia\";\n",1);
let raw=SourceBundle { entry:"project.arcadia".into(), files:BTreeMap::from([("project.arcadia".into(),source),("rationale.arcadia".into(),"// Preserve included note bytes: responsibility 雨.\r\n".into())]) };
let result=compile_bundle(&raw,ProjectLimits::default()).unwrap();
assert_eq!(result.bundle,raw); assert_eq!(&result.decoded.project,p); result
}
fn load(entry:&Path)->CompiledSourceProject {
let raw=source_files::load_project(entry,ProjectLimits::default()).unwrap().reachable_bundle();
let result=compile_bundle(&raw,ProjectLimits::default()).unwrap();
assert_eq!(result.bundle,raw,"No source normalization permitted");
execute_batch(&result.decoded.project,&[]).unwrap(); result
}
fn advance(base:&mut CompiledSourceProject,commands:Vec<Command>) {
let expected=execute_batch(&base.decoded.project,&commands).unwrap();
*base=update_bundle(base,&expected,ProjectLimits::default()).unwrap();
}
#[derive(Clone, Copy)]
struct Keys { children:[Id;3], nodes:[Id;3], allocation:Id }
fn expected(layer:Layer,stage:usize,k:Keys)->CompiledSourceProject {
assert!(stage<=3);
let mut base=bundle(&fixture(layer));
for step in 0..stage {
let p=&base.decoded.project;
let key=k.children[step];
let name=format!("New {}",work(layer).label().to_lowercase());
let element=Element { id:key, alias:p.fresh_alias(&name,"element",key), name, description:String::new(), parent:Some(if step==2 { k.children[0] } else { id(11) }), body:ElementBody::new(work(layer),layer).unwrap() };
let mut commands=vec![Command::CreateElement(element.clone())];
if step==1 {
commands.push(Command::CreateRelation(Relation { id:k.allocation, alias:p.fresh_alias("Performs","relation",k.allocation), name:"Performs".into(), description:String::new(), body:RelationBody::Allocation { source:id(10),target:key } }));
} else {
for original in p.relations.values().filter(|r| [id(20),id(22),id(23)].contains(&r.id)) {
let mut r=original.clone();
match &mut r.body { RelationBody::Allocation{target,..}|RelationBody::RoleAssignment{target,..}=>*target=key,_=>unreachable!() }
commands.push(Command::EditRelation(r.id,r));
}
}
if step==0 {
let mut node=p.diagrams[&id(2)].nodes[&id(41)].clone(); node.collapsed=false;
commands.push(Command::EditNode{diagram:id(2),original:node.id,node});
}
let mut with_element=p.clone(); with_element.elements.insert(key,element);
let (x,y)=[(108.,188.),(108.,298.),(132.,252.)][step];
commands.push(Command::CreateNode { diagram:id(2), node:NodePlacement { id:k.nodes[step], alias:with_element.fresh_alias(&format!("view_{}",with_element.named(key)),"node",k.nodes[step]), element:key,position:Position{x,y},size:Size{width:224.,height:94.},collapsed:false,port_anchors:vec![] } });
if step==2 {
// Grandchild expands first child to bottom370; sibling follows at394
// (24px reflow gap), independently predicted for this fixed fixture.
let mut sibling=p.diagrams[&id(2)].nodes[&k.nodes[1]].clone(); sibling.position.y=394.;
commands.push(Command::EditNode{diagram:id(2),original:sibling.id,node:sibling});
}
advance(&mut base,commands);
}
base
}
fn only(ids:Vec<Id>,role:&str)->Id { assert_eq!(ids.len(),1,"Exactly one {role}"); ids[0] }
fn admit(actual:&Project,layer:Layer,stage:usize)->Keys {
let mut k=Keys{children:[Id::nil();3],nodes:[Id::nil();3],allocation:Id::nil()};
if stage==0 { return k; }
let target=actual.relations[&id(20)].target();
k.children[0]=if stage==3 { actual.elements[&target].parent.unwrap() } else { target };
if stage>=2 {
k.children[1]=only(actual.elements.values().filter(|e|e.parent==Some(id(11))&&e.id!=k.children[0]&&e.kind()==work(layer)).map(|e|e.id).collect(),"second child");
k.allocation=only(actual.relations.values().filter(|r|r.kind()==RelationKind::Allocation&&r.source()==id(10)&&r.target()==k.children[1]).map(|r|r.id).collect(),"new sibling allocation");
}
if stage==3 { k.children[2]=target; }
for step in 0..stage {
let e=&actual.elements[&k.children[step]];
assert_eq!(e.kind(),work(layer)); assert_eq!(e.layer(),layer);
assert_eq!(e.parent,Some(if step==2{k.children[0]}else{id(11)}));
k.nodes[step]=only(actual.diagrams[&id(2)].nodes.values().filter(|n|n.element==e.id).map(|n|n.id).collect(),"child placement");
}
let seed=fixture(layer); let mut used=BTreeSet::new();
for key in k.children[..stage].iter().chain(&k.nodes[..stage]).copied().chain((stage>=2).then_some(k.allocation)) {
assert_eq!(key.get_version_num(),4,"Native fresh UUIDv4 required");
assert!(!seed.contains(key)&&used.insert(key),"Fresh distinct role identity required");
}
k
}
fn check(actual:&CompiledSourceProject,layer:Layer,stage:usize) {
let prediction=expected(layer,stage,admit(&actual.decoded.project,layer,stage));
assert_eq!(actual.decoded.project,prediction.decoded.project,"Complete typed model mismatch, including placements and references");
assert_eq!(actual.bundle,prediction.bundle,"Exact source mismatch, including included note bytes");
}
fn history(actual:&CompiledSourceProject,layer:Layer,stage:usize,first:&CompiledSourceProject,second:Option<&CompiledSourceProject>) {
check(first,layer,1);
let first_keys=admit(&first.decoded.project,layer,1);
let current_keys=admit(&actual.decoded.project,layer,stage);
assert_eq!(current_keys.children[0],first_keys.children[0],"First child identity must match original first-commit carrier");
assert_eq!(current_keys.nodes[0],first_keys.nodes[0],"First placement identity must match original first-commit carrier");
if stage==3 {
let second=second.expect("Original second-commit carrier required");
check(second,layer,2);
history(second,layer,2,first,None);
let second_keys=admit(&second.decoded.project,layer,2);
assert_eq!(current_keys.children[1],second_keys.children[1],"Sibling identity must persist");
assert_eq!(current_keys.nodes[1],second_keys.nodes[1],"Sibling placement identity must persist");
assert_eq!(current_keys.allocation,second_keys.allocation,"Sibling allocation identity must persist");
}
}
fn write_bundle(dir:&Path,raw:&SourceBundle) {
assert!(!dir.exists(),"Use a new evidence directory"); fs::create_dir_all(dir).unwrap();
for (name,bytes) in &raw.files { let path=dir.join(name); if let Some(parent)=path.parent(){fs::create_dir_all(parent).unwrap();} fs::write(path,bytes).unwrap(); }
}
fn main() {
assert_eq!(SCHEMA_VERSION,7);
let args:Vec<_>=std::env::args().collect();
match args.get(1).map(String::as_str) {
Some("generate")=>{assert_eq!(args.len(),3);let dir=Path::new(&args[2]);assert!(!dir.exists()); for (name,layer) in [("oa",Layer::Oa),("sa",Layer::Sa)] {write_bundle(&dir.join(name),&bundle(&fixture(layer)).bundle);} println!("PASS deterministic OA/SA native fixtures generated");}
Some("check")=>{
let layer=match args[2].as_str(){"oa"=>Layer::Oa,"sa"=>Layer::Sa,_=>panic!("oa|sa")};
let stage=args[3].parse::<usize>().unwrap();assert!(stage<=3);
assert_eq!(args.len(),5+stage.saturating_sub(1),"Stages2/3 require original stage1/stage2 checkpoint directories");
let actual=load(Path::new(&args[4]));check(&actual,layer,stage);
if stage>=2 {
let first=load(&Path::new(&args[5]).join("project.arcadia"));
let second=if stage==3 {Some(load(&Path::new(&args[6]).join("project.arcadia")))} else {None};
history(&actual,layer,stage,&first,second.as_ref());
}
println!("PASS complete model, exact source and pinned history stage {stage}");
}
Some("snapshot")=>{assert_eq!(args.len(),4);let actual=load(Path::new(&args[2]));write_bundle(Path::new(&args[3]),&actual.bundle);println!("PASS immutable source checkpoint");}
Some("compare")=>{assert_eq!(args.len(),4);let actual=load(Path::new(&args[2]));let checkpoint=load(&Path::new(&args[3]).join(&actual.bundle.entry));assert_eq!(actual.bundle,checkpoint.bundle,"Saved reachable source bytes changed");assert_eq!(actual.decoded.project,checkpoint.decoded.project);println!("PASS exact checkpoint bytes and model");}
Some("self-check")=>{for layer in [Layer::Oa,Layer::Sa]{let keys=Keys{children:std::array::from_fn(|_|Id::new_v4()),nodes:std::array::from_fn(|_|Id::new_v4()),allocation:Id::new_v4()};for stage in 0..=3 {let candidate=expected(layer,stage,keys);check(&candidate,layer,stage);if stage>=2 {history(&candidate,layer,stage,&expected(layer,1,keys),(stage==3).then(||expected(layer,2,keys)).as_ref());}let mut altered=candidate.decoded.project.clone();altered.elements.get_mut(&id(11)).unwrap().description="Unrequested mutation".into();assert_ne!(expected(layer,stage,admit(&altered,layer,stage)).decoded.project,altered);}}println!("PASS oracle fixed points and unrelated-mutation discriminator");}
_=>panic!("generate DIR | check oa|sa STAGE ENTRY [STAGE1DIR] [STAGE2DIR] | snapshot ENTRY DIR | compare ENTRY DIR | self-check"),
}
}

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