Compare commits
43
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fdfe43f4b2 | ||
|
|
e92e776a1d | ||
|
|
edd6a603ed | ||
|
|
4dacb6f430 | ||
|
|
6595a8ad1b | ||
|
|
bcc15ee225 | ||
|
|
d6dd2b0920 | ||
|
|
5bd2c0bec8 | ||
|
|
34cdca132e | ||
|
|
c455b7ce9d | ||
|
|
deaec47a12 | ||
|
|
31d2d73744 | ||
|
|
c26ee6d263 | ||
|
|
59ccfe0e3a | ||
|
|
a5ffd10a0d | ||
|
|
10c870b464 | ||
|
|
0de2dfa1ed | ||
|
|
75d4c39bd7 | ||
|
|
4c789e6cfa | ||
|
|
920b718f62 | ||
|
|
0bc599dade | ||
|
|
e7ceff11af | ||
|
|
1f05d971e2 | ||
|
|
5dece0542c | ||
|
|
51797790bd | ||
|
|
31695dcc49 | ||
|
|
51b6b5a123 | ||
|
|
0960931cd4 | ||
|
|
436d8ee068 | ||
|
|
4b782a71fc | ||
|
|
66a1f1690f | ||
|
|
911f63e63c | ||
|
|
46b4f3c0d0 | ||
|
|
f23615ffeb | ||
|
|
fdf756e6a4 | ||
|
|
7aa007b077 | ||
|
|
0c53c21030 | ||
|
|
4dd0f60c75 | ||
|
|
60f714853f | ||
|
|
5511755a5e | ||
|
|
51846f5a2b | ||
|
|
436ea4b29a | ||
|
|
46c8783c72 |
@@ -70,3 +70,17 @@ 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
|
||||
|
||||
@@ -35,20 +35,28 @@ Use Gitea for live coordination and the relevant code version for implementation
|
||||
|
||||
## Coordination and integration
|
||||
|
||||
All independent agents coordinating through Gitea run on completely separate machines, each with its own clone, filesystem, tools, processes, and runtime. Gitea and pushed Git history are the shared communication and delivery channels. Local files (including `harness`), unpushed commits, worktrees, test results, and localhost services are not accessible to peers. Matching branch names or paths do not imply synchronized contents.
|
||||
|
||||
1. On starting or resuming, including after compaction, inspect the working branch, local changes, applicable instructions, relevant shared records, and existing implementation. Recover context from authoritative records. Check for newer shared instructions on the actual remote default branch without overwriting local work. Read what is relevant; avoid repeated full-history scans and constant polling.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, branch/worktree, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work. Record one integration/release owner when a milestone needs shared coordination. Use separate worktrees or checkouts, partition files/interfaces, and coordinate changes to shared specifications and documents.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and uncommitted or unpublished work; make required artifacts accessible to other sessions. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Inspect upstream changes, resolve conflicts while preserving others' edits, and verify the combined result. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
2. Search before creating tasks. Claim a meaningful outcome in its Gitea issue with a stable session identity, scope, remote repository/branch, and acceptance criteria. Keep small steps within that task. Recheck after claiming; comments are not atomic locks. Respect existing ownership and resolve overlapping claims before overlapping work.
|
||||
3. Independent sessions are peers. Each owns its contribution and delegated work on its own machine. Record one integration/release owner when a milestone needs shared coordination. Use distinct remote work branches, partition files/interfaces, and coordinate changes to shared specifications and documents. Worktrees provide local isolation only; they do not share work between machines.
|
||||
4. At meaningful checkpoints or handoffs, record changed results, commit/PR links, verification commands and environment, blockers, and next actions. Identify the tested code version and label uncommitted or unpublished work as local-only. Before asking peers to consume results, push the required code and versioned documentation, including relevant `harness` updates; publish other required artifacts in Gitea and link them. Use repository-relative paths with commit references, reproducible commands, and necessary environment details; local paths and localhost URLs alone are not handoffs. Do not assume another agent is running or has read a message; silence alone does not establish abandonment. Record evidence and preserve existing work when reassigning a task.
|
||||
5. Follow project review and merge conventions. Fetch the relevant remote branches and inspect the identified commits before reviewing or integrating another machine's work. Resolve conflicts while preserving others' edits, and verify the combined result locally; report material environment differences. Local instruction-file edits reach peers only after publication and retrieval. Do not overwrite others' work or force-push shared history to resolve coordination problems.
|
||||
6. Close work when its acceptance criteria are met and the result is integrated or delivered as appropriate. Mark duplicates and superseded tasks explicitly with replacement links. Then choose further useful unowned work while the requested objective remains incomplete.
|
||||
|
||||
Use Gitea MCP for shared coordination. If unavailable, report the failure, buffer necessary handoff information locally, and continue already-owned or clearly non-overlapping work. Reconcile with current Gitea state when access returns; do not claim an ownership update, review, or publication succeeded without confirmation.
|
||||
|
||||
## Delegation and reasoning
|
||||
|
||||
Use GPT-6 Astra exclusively. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
Use GPT-6 Astra exclusively. Gitea peers are independent remote sessions, not subagents controlled by this session. Spawned subagents use the execution environment actually provided by the agent tools; do not assume they share files, and publish their results before Gitea peers need them. Delegate bounded independent research, design, implementation, or review when it improves progress, judgment, or preserves the parent's context. Give subagents the relevant context, ownership boundaries, result format, and evidence requirements. Use fresh critics when practical. The parent owns synthesis and integration and normally consolidates results into Gitea. Choose concurrency according to useful independent work and available resources, including other sessions; there is no agent-count quota.
|
||||
|
||||
Use the highest available reasoning effort for orchestration and foundational semantic, methodological, formal, or cross-domain decisions. For subagents, use extra-high for difficult design, adversarial review, or unusually hard implementation; high for normal implementation, targeted research, correctness review, debugging, and runtime evaluation; medium for routine exploration, mechanical changes, builds, and tests; and lower for trivial work. Allocate by difficulty, not length, using supported controls; do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
Optimize reasoning effort for verified outcomes per cost, including review, retries, and repair:
|
||||
|
||||
- Main agent: default to high for planning, synthesis, integration, and acceptance decisions; medium for dispatching a settled plan and routine coordination.
|
||||
- Subagents: default to medium for bounded implementation and targeted research; low for straightforward retrieval, mechanical edits, and running established checks; high for complex debugging, correctness review, adversarial analysis, or ambiguous design.
|
||||
- Use extra-high selectively for difficult foundational semantic, methodological, formal, or cross-domain decisions. Reserve max for exceptional unresolved problems where additional reasoning is likely to justify its cost. Retain higher settings when evidence supports their value.
|
||||
|
||||
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
|
||||
## Memory and maintenance
|
||||
|
||||
|
||||
+160
-27
@@ -142,12 +142,87 @@ 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') {
|
||||
@@ -224,7 +299,7 @@ pub fn tokenize(source: &str) -> Vec<TokenSpan> {
|
||||
span: Span { start, end: cursor },
|
||||
});
|
||||
}
|
||||
tokens
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn lint(source: &str) -> Vec<Diagnostic> {
|
||||
@@ -1230,11 +1305,7 @@ impl Parser<'_> {
|
||||
self.cursor += 1;
|
||||
let offset = self.current().span.start;
|
||||
let value = self.quoted()?;
|
||||
if value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|i| value.as_bytes()[i] == b'-')
|
||||
{
|
||||
if !has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
@@ -1962,8 +2033,49 @@ 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))
|
||||
@@ -1972,17 +2084,21 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
// 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];
|
||||
@@ -2007,7 +2123,16 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
}
|
||||
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")
|
||||
@@ -2015,39 +2140,51 @@ pub fn analyze(source: &str) -> Analysis {
|
||||
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();
|
||||
id = Id::parse_str(&text).ok();
|
||||
if has_uuid_header_shape(&text) {
|
||||
id = Id::parse_str(&text).ok();
|
||||
}
|
||||
}
|
||||
j += 2;
|
||||
}
|
||||
let mut end = significant
|
||||
.get(j)
|
||||
.map_or(alias_token.span.end, |t| t.span.end);
|
||||
// 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);
|
||||
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(DeclarationSpan {
|
||||
alias: alias.into(),
|
||||
id,
|
||||
kind: word.into(),
|
||||
span: Span {
|
||||
start: t.span.start,
|
||||
end,
|
||||
declarations.push(OutlineDeclaration {
|
||||
declaration: 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,
|
||||
});
|
||||
}
|
||||
Analysis {
|
||||
tokens,
|
||||
diagnostics: lint(source),
|
||||
declarations,
|
||||
}
|
||||
Ok((declarations, has_unmatched_braces))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_outline_tests.rs"]
|
||||
mod outline_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "language_nesting_tests.rs"]
|
||||
mod nesting_tests;
|
||||
@@ -2442,11 +2579,7 @@ 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 value.len() != 36
|
||||
|| ![8, 13, 18, 23]
|
||||
.into_iter()
|
||||
.all(|index| value.as_bytes()[index] == b'-')
|
||||
{
|
||||
if !super::has_uuid_header_shape(&value) {
|
||||
return Err(Error {
|
||||
code: "invalid_source",
|
||||
offset,
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
//! 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(¤t, limits()).unwrap();
|
||||
let shift = current.len() - old.len();
|
||||
let old_role = &old_outline.declarations[0];
|
||||
let current_role = ¤t_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);
|
||||
}
|
||||
+245
-26
@@ -2,12 +2,15 @@ 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)]
|
||||
@@ -427,9 +430,18 @@ pub enum Message {
|
||||
Linted(u64, Result<Vec<SourceDiagnostic>, String>),
|
||||
SourceDiagnostic(usize),
|
||||
SourceRelated(SourceLocation),
|
||||
SourceViewport,
|
||||
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),
|
||||
SourceForSelection,
|
||||
SourceNavigation(crate::source_navigation::Event),
|
||||
SourceFindClose(crate::source_navigation::SymbolContext, Id),
|
||||
Save(bool),
|
||||
SavePath(Option<PathBuf>),
|
||||
Saved(Result<Option<FileSnapshot>, SaveFailure>),
|
||||
@@ -507,6 +519,17 @@ 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,
|
||||
@@ -615,6 +638,13 @@ 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,
|
||||
@@ -668,6 +698,7 @@ 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();
|
||||
@@ -682,6 +713,9 @@ 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
|
||||
@@ -935,6 +969,8 @@ 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();
|
||||
@@ -946,6 +982,8 @@ 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();
|
||||
@@ -1046,22 +1084,70 @@ impl App {
|
||||
crate::model_menu::popup(self),
|
||||
)
|
||||
.into();
|
||||
crate::behavior::scenario::focus::root(
|
||||
behavior::canvas_workspace::focus_root(
|
||||
crate::hosted::view::overlay(self, self.reference_overlay(base)),
|
||||
behavior::canvas_workspace::Scope::input_owner(self),
|
||||
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.scenario_focus_owner(),
|
||||
self.source_symbols.raw_interaction.clone(),
|
||||
)
|
||||
}
|
||||
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.behavior.source_active(),
|
||||
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()),
|
||||
)
|
||||
.map(|()| Message::Escape),
|
||||
iced::event::listen_with(|event, status, _| {
|
||||
@@ -1108,6 +1194,17 @@ 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);
|
||||
}
|
||||
@@ -1118,6 +1215,19 @@ 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;
|
||||
@@ -1207,6 +1317,75 @@ 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;
|
||||
}
|
||||
@@ -1274,7 +1453,7 @@ impl App {
|
||||
| Message::CanvasResized(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Request(Pending::Close(_))
|
||||
)
|
||||
{
|
||||
@@ -1320,7 +1499,7 @@ impl App {
|
||||
| Message::Exported(_)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
)
|
||||
{
|
||||
return Task::none();
|
||||
@@ -1358,7 +1537,7 @@ impl App {
|
||||
| Message::CheckpointLoaded(..)
|
||||
| Message::WindowResized(_)
|
||||
| Message::CanvasResized(_)
|
||||
| Message::SourceViewport
|
||||
| Message::SourceViewport(_)
|
||||
| Message::Lint(_)
|
||||
| Message::Linted(_, _)
|
||||
| Message::Saved(_)
|
||||
@@ -1969,6 +2148,8 @@ 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();
|
||||
@@ -1985,6 +2166,12 @@ 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;
|
||||
}
|
||||
@@ -3036,6 +3223,9 @@ 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);
|
||||
@@ -3104,13 +3294,28 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::SourceRelated(location) => self.jump_to_source(location),
|
||||
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::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::ApplySource => {
|
||||
@@ -3157,6 +3362,7 @@ 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 {
|
||||
@@ -3165,13 +3371,22 @@ impl App {
|
||||
"source-find"
|
||||
});
|
||||
}
|
||||
Event::Close => self.source_navigation.open = false,
|
||||
Event::Close => {
|
||||
let was_open = self.source_navigation.open;
|
||||
self.source_navigation.open = false;
|
||||
if was_open {
|
||||
self.restore_source_editor_focus();
|
||||
}
|
||||
}
|
||||
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();
|
||||
}
|
||||
@@ -3182,8 +3397,7 @@ impl App {
|
||||
.destination(self.source_editor.line_count())
|
||||
{
|
||||
Ok(cursor) => {
|
||||
self.source_jump = Some(cursor);
|
||||
self.source_select_line = true;
|
||||
self.queue_source_reveal(cursor, true);
|
||||
}
|
||||
Err(error) => self.status = error,
|
||||
},
|
||||
@@ -3266,6 +3480,9 @@ 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;
|
||||
@@ -3866,7 +4083,7 @@ impl App {
|
||||
self.path = Some(path);
|
||||
self.document.bind_repair_baseline(disk.reachable_bundle());
|
||||
self.source_workspace = Workspace::new(&disk.bundle, false);
|
||||
self.source_workspace.reachable = disk.reachable.clone();
|
||||
self.source_workspace.set_reachable(disk.reachable.clone());
|
||||
self.show_source_file(false);
|
||||
self.source_diagnostics = error.diagnostics().to_vec();
|
||||
self.source_version += 1;
|
||||
@@ -3918,11 +4135,13 @@ impl App {
|
||||
let column = prefix
|
||||
.rfind('\n')
|
||||
.map_or(offset, |start| offset - start - 1);
|
||||
self.source_jump = Some(text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
});
|
||||
self.source_select_line = true;
|
||||
self.queue_source_reveal(
|
||||
text_editor::Cursor {
|
||||
position: text_editor::Position { line, column },
|
||||
selection: None,
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
fn create_source_file(&mut self) -> Result<(), String> {
|
||||
|
||||
@@ -70,6 +70,7 @@ 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,6 +225,7 @@ 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(),
|
||||
@@ -299,6 +300,7 @@ 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(),
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,445 @@
|
||||
//! 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
@@ -213,6 +213,14 @@ 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,
|
||||
@@ -345,6 +353,14 @@ 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,
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
//! 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
@@ -1117,6 +1117,14 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"]
|
||||
mod behavior_layout_scene_experiment;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"]
|
||||
mod behavior_anchor_plan_experiment;
|
||||
|
||||
struct BoxItem {
|
||||
id: Id,
|
||||
rect: Rectangle,
|
||||
@@ -1156,6 +1164,17 @@ 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>,
|
||||
@@ -1317,6 +1336,8 @@ 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.);
|
||||
#[cfg(test)]
|
||||
behavior_anchor_plan_experiment::scene_extent(&mut scene);
|
||||
if scenario {
|
||||
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
|
||||
p,
|
||||
@@ -1424,6 +1445,10 @@ impl Scene {
|
||||
// the preceding action/state/reference box, before recording rows.
|
||||
*y += 16.;
|
||||
}
|
||||
#[cfg(test)]
|
||||
behavior_layout_scene_experiment::resolve_row_anchor(*id, y);
|
||||
#[cfg(test)]
|
||||
behavior_anchor_plan_experiment::row_anchor(*id, y);
|
||||
if ordered {
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
@@ -1448,6 +1473,8 @@ impl Scene {
|
||||
(*y - top).max(95.),
|
||||
),
|
||||
);
|
||||
#[cfg(test)]
|
||||
let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect);
|
||||
self.boxes.insert(
|
||||
0,
|
||||
BoxItem {
|
||||
@@ -1632,6 +1659,8 @@ impl Scene {
|
||||
}
|
||||
if ordered {
|
||||
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
|
||||
#[cfg(test)]
|
||||
behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds);
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -1831,17 +1860,7 @@ impl Scene {
|
||||
.or_else(|| {
|
||||
self.lines
|
||||
.iter()
|
||||
.find(|line| {
|
||||
!line.label.is_empty()
|
||||
&& Rectangle::new(
|
||||
line.label_at,
|
||||
Size::new(
|
||||
(line.label.chars().count() as f32 * 6.8 + 8.).min(225.),
|
||||
25.,
|
||||
),
|
||||
)
|
||||
.contains(point)
|
||||
})
|
||||
.find(|line| !line.label.is_empty() && line.label_bounds().contains(point))
|
||||
.map(|line| line.id)
|
||||
})
|
||||
.or_else(|| self.near_sequence_item(point))
|
||||
|
||||
@@ -17,6 +17,7 @@ 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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};
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,276 @@
|
||||
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());
|
||||
}
|
||||
@@ -27,6 +27,8 @@ 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,
|
||||
@@ -415,6 +417,171 @@ 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() {
|
||||
@@ -1148,6 +1315,14 @@ 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
|
||||
{
|
||||
@@ -1575,6 +1750,189 @@ 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,
|
||||
@@ -1586,6 +1944,7 @@ mod tests {
|
||||
epoch: 3,
|
||||
},
|
||||
selected: f.machine,
|
||||
navigation_focus: None,
|
||||
tool,
|
||||
zoom,
|
||||
pan: Vector::new(20., 30.),
|
||||
|
||||
@@ -165,6 +165,9 @@ 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,
|
||||
|
||||
@@ -5,6 +5,7 @@ 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::{
|
||||
@@ -910,6 +911,13 @@ 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,
|
||||
@@ -918,26 +926,98 @@ struct GraphNode {
|
||||
control: Option<ControlKind>,
|
||||
source: Option<Id>,
|
||||
preview: Vec<(OccurrenceEndpoint, String)>,
|
||||
preview_links: Vec<(OccurrenceEndpoint, OccurrenceEndpoint)>,
|
||||
preview_links: Vec<PreviewLink>,
|
||||
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(|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.),
|
||||
)
|
||||
})
|
||||
.map(|index| self.preview_rect(index))
|
||||
.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,
|
||||
@@ -986,6 +1066,8 @@ struct Graph<'a> {
|
||||
ordered: bool,
|
||||
size: Size,
|
||||
selected: Id,
|
||||
selected_preview: Option<OccurrenceEndpoint>,
|
||||
selected_preview_link: Option<PreviewLink>,
|
||||
scale: f32,
|
||||
}
|
||||
impl<'a> Graph<'a> {
|
||||
@@ -1134,17 +1216,16 @@ impl<'a> Graph<'a> {
|
||||
occurrence: r.target(),
|
||||
})
|
||||
})
|
||||
.map(|r| {
|
||||
(
|
||||
OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
occurrence: r.source(),
|
||||
},
|
||||
OccurrenceEndpoint {
|
||||
references: vec![e.id],
|
||||
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(),
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
@@ -1319,6 +1400,8 @@ impl<'a> Graph<'a> {
|
||||
ordered: !order.is_empty(),
|
||||
size,
|
||||
selected,
|
||||
selected_preview: None,
|
||||
selected_preview_link: None,
|
||||
scale: 1.,
|
||||
}
|
||||
}
|
||||
@@ -1548,39 +1631,41 @@ impl canvas::Program<Message> for Graph<'_> {
|
||||
let mini: BTreeMap<_, _> = n
|
||||
.preview
|
||||
.iter()
|
||||
.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.),
|
||||
),
|
||||
)
|
||||
})
|
||||
.map(|(id, _)| (id.clone(), n.endpoint_rect(id)))
|
||||
.collect();
|
||||
for (a, b) in &n.preview_links {
|
||||
let a = mini[a];
|
||||
let b = mini[b];
|
||||
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);
|
||||
draw_arrow(
|
||||
&mut f,
|
||||
&[
|
||||
Point::new(a.x + a.width, a.center_y()),
|
||||
Point::new(b.x, b.center_y()),
|
||||
],
|
||||
style::MUTED,
|
||||
1.,
|
||||
&points,
|
||||
if selected { style::TEAL } else { style::MUTED },
|
||||
if selected { 3. } else { 1. },
|
||||
);
|
||||
}
|
||||
for (id, name) in &n.preview {
|
||||
let r = mini[id];
|
||||
f.fill_rectangle(r.position(), r.size(), Color::WHITE);
|
||||
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.stroke(
|
||||
&Path::rectangle(r.position(), r.size()),
|
||||
Stroke::default().with_color(style::LINE),
|
||||
Stroke::default()
|
||||
.with_color(if selected {
|
||||
style::TEAL
|
||||
} else {
|
||||
style::LINE
|
||||
})
|
||||
.with_width(if selected { 2.5 } else { 1. }),
|
||||
);
|
||||
draw_text(
|
||||
&mut f,
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,439 @@
|
||||
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,6 +183,32 @@ 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
|
||||
|
||||
@@ -41,6 +41,8 @@ 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,
|
||||
@@ -1193,6 +1195,14 @@ 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
|
||||
{
|
||||
@@ -1902,6 +1912,7 @@ mod tests {
|
||||
connection: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
};
|
||||
@@ -1976,6 +1987,7 @@ mod tests {
|
||||
connection: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
zoom: 0.6,
|
||||
pan: Vector::new(10., 20.),
|
||||
};
|
||||
@@ -2092,6 +2104,7 @@ mod tests {
|
||||
diagram,
|
||||
context,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
selected_bend: None,
|
||||
zoom,
|
||||
pan: Vector::new(20., 30.),
|
||||
@@ -2259,6 +2272,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom,
|
||||
pan: Vector::new(150., 300.),
|
||||
context: Context {
|
||||
@@ -2334,6 +2348,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom: 0.5,
|
||||
pan: Vector::new(150., 300.),
|
||||
context: Context {
|
||||
@@ -2430,6 +2445,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::new(150., 300.),
|
||||
context: Context {
|
||||
@@ -2557,6 +2573,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom: 0.7,
|
||||
pan: Vector::new(350., 350.),
|
||||
context: Context {
|
||||
@@ -2610,6 +2627,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: p.id,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::new(100., 400.),
|
||||
context: Context {
|
||||
@@ -2683,6 +2701,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected: edge,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::new(100., 400.),
|
||||
context: Context {
|
||||
@@ -2715,6 +2734,7 @@ mod tests {
|
||||
view_index: None,
|
||||
diagram,
|
||||
selected,
|
||||
navigation_focus: None,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
context: Context {
|
||||
|
||||
@@ -57,6 +57,7 @@ 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,6 +65,7 @@ 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,6 +6,7 @@ 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,6 +178,7 @@ 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(),
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,386 @@
|
||||
//! 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);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
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.);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
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()
|
||||
}
|
||||
@@ -805,7 +805,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",
|
||||
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",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Edit and review source",
|
||||
@@ -818,6 +818,18 @@ 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.",
|
||||
|
||||
@@ -7,6 +7,7 @@ mod context_menu;
|
||||
mod data_values;
|
||||
mod data_view;
|
||||
mod diagram;
|
||||
mod diagram_usage;
|
||||
mod document;
|
||||
mod explorer;
|
||||
mod explorer_view;
|
||||
|
||||
@@ -281,6 +281,11 @@ 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),
|
||||
|
||||
@@ -126,28 +126,8 @@ fn groups(p: &Project, selected: Id) -> Vec<Group> {
|
||||
]
|
||||
}
|
||||
|
||||
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> {
|
||||
pub fn view(app: &crate::app::App, selected: Id) -> Element<'_, Message> {
|
||||
let p = app.project();
|
||||
let mut content = column![
|
||||
text(short(p.named(selected), 50)).size(18),
|
||||
text("Follow ownership, usage and engineering relationships.")
|
||||
@@ -203,25 +183,8 @@ pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
|
||||
content = content.push(container(entry).padding(8).style(style::subtle));
|
||||
}
|
||||
}
|
||||
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),
|
||||
);
|
||||
}
|
||||
content =
|
||||
content.push(action("Show in diagrams…", app.diagram_usage_message(selected)).width(Fill));
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
//! 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)]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
//! 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())
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! 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;
|
||||
@@ -0,0 +1,190 @@
|
||||
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
@@ -0,0 +1,989 @@
|
||||
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: ¤t,
|
||||
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: ¤t.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(¤t),
|
||||
Action::Definition,
|
||||
);
|
||||
assert_eq!(result.disposition, Disposition::NoDefinition);
|
||||
assert!(snapshot.matches(input));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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::{
|
||||
language,
|
||||
Id, language,
|
||||
source_project::{self, CompiledSourceProject, ProjectLimits, SourceBundle, SourceDiagnostic},
|
||||
};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -86,6 +86,7 @@ pub struct Buffer {
|
||||
pub cursor: Cursor,
|
||||
past: Vec<String>,
|
||||
future: Vec<String>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl Buffer {
|
||||
@@ -99,14 +100,20 @@ 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>,
|
||||
@@ -117,6 +124,35 @@ 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(),
|
||||
@@ -136,6 +172,8 @@ 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
|
||||
@@ -154,6 +192,8 @@ 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
|
||||
@@ -191,11 +231,20 @@ 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 {
|
||||
@@ -268,6 +317,8 @@ 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
|
||||
}
|
||||
@@ -281,6 +332,7 @@ 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())
|
||||
@@ -291,7 +343,13 @@ 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;
|
||||
}
|
||||
@@ -303,10 +361,22 @@ 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(|| Buffer::new(text.clone()));
|
||||
self.files.entry(path.clone()).or_insert_with(|| {
|
||||
let mut buffer = Buffer::new(text.clone());
|
||||
buffer.generation = generation;
|
||||
buffer
|
||||
});
|
||||
}
|
||||
self.reachable = reachable;
|
||||
}
|
||||
@@ -314,6 +384,7 @@ 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();
|
||||
@@ -376,6 +447,148 @@ 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 {
|
||||
|
||||
@@ -377,7 +377,8 @@ 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());
|
||||
send(&mut app, Message::SourceViewport);
|
||||
let ticket = app.source_reveal_ticket();
|
||||
send(&mut app, Message::SourceViewport(ticket));
|
||||
assert!(app.source_jump.is_none());
|
||||
send(&mut app, Message::SourceForSelection);
|
||||
assert!(app.source_jump.is_some());
|
||||
@@ -409,7 +410,8 @@ fn source_find_navigates_an_invalid_draft_without_applying_or_losing_it() {
|
||||
cursor.selection,
|
||||
Some(text_editor::Position { line: 0, column: 9 })
|
||||
);
|
||||
send(&mut app, Message::SourceViewport);
|
||||
let ticket = app.source_reveal_ticket();
|
||||
send(&mut app, Message::SourceViewport(ticket));
|
||||
assert_eq!(app.source_editor.cursor(), cursor);
|
||||
send(
|
||||
&mut app,
|
||||
@@ -1269,7 +1271,26 @@ fn revealing_an_implicit_function_port_unfolds_its_visible_architecture_owner()
|
||||
.get_mut(&ancestor)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
assert!(crate::navigation::diagrams_containing(&p, port).contains(&diagram));
|
||||
// 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))
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
send(&mut app, Message::RevealInDiagram(diagram, port));
|
||||
assert_eq!(app.selected, port);
|
||||
@@ -1281,6 +1302,12 @@ 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]
|
||||
|
||||
+508
-10
@@ -1,3 +1,4 @@
|
||||
mod source_symbols;
|
||||
pub(crate) mod traceability;
|
||||
use crate::{
|
||||
app::{App, Choice, FileOperation, Message, Page, Pending},
|
||||
@@ -449,6 +450,9 @@ 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(),
|
||||
@@ -469,10 +473,17 @@ 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(Message::CanvasResized)
|
||||
.on_resize(Message::CanvasResized),
|
||||
.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)
|
||||
})),
|
||||
crate::app::architecture_authoring::palette(app)
|
||||
]
|
||||
.height(Fill)
|
||||
@@ -588,7 +599,7 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
return container(
|
||||
column![
|
||||
tabs,
|
||||
scrollable(crate::navigation::view(p, app.selected)).height(Fill)
|
||||
scrollable(crate::navigation::view(app, app.selected)).height(Fill)
|
||||
]
|
||||
.spacing(14),
|
||||
)
|
||||
@@ -1022,7 +1033,7 @@ fn source(app: &App) -> View<'_, Message> {
|
||||
),
|
||||
action("Next", Message::SourceNavigation(SourceEvent::Next(false))),
|
||||
label(app.source_navigation.label()),
|
||||
action("Close", Message::SourceNavigation(SourceEvent::Close)),
|
||||
action("Close", app.source_find_close_message()),
|
||||
]
|
||||
.spacing(6)
|
||||
.align_y(iced::Center),
|
||||
@@ -1116,8 +1127,12 @@ fn source(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
}
|
||||
let editor = widget::text_editor(&app.source_editor)
|
||||
.id("source-editor")
|
||||
.id(app.source_editor_id())
|
||||
.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)
|
||||
@@ -1126,8 +1141,7 @@ fn source(app: &App) -> View<'_, Message> {
|
||||
app.source_snapshot.clone(),
|
||||
crate::highlight::format,
|
||||
);
|
||||
column![
|
||||
container(
|
||||
let header = container(
|
||||
column![
|
||||
row![
|
||||
container(heading(
|
||||
@@ -1170,17 +1184,501 @@ 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),
|
||||
widget::sensor(editor).key((app.source_workspace.active.clone(),app.source_jump))
|
||||
.on_show(|_| Message::SourceViewport).on_resize(|_| Message::SourceViewport)
|
||||
.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,
|
||||
]
|
||||
.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(
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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.
|
||||
@@ -2642,6 +2642,46 @@ 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">@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
|
||||
|
||||
@@ -2064,6 +2064,51 @@ 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
|
||||
|
||||
@@ -346,3 +346,80 @@ 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.
|
||||
|
||||
## Diagram representation navigation — 2026-09-12
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
## Saved Scenario spacing gestures — 2026-09-13
|
||||
|
||||
Codex-ytr83r /root. For the next Saved Scenario editing increment, dragging a
|
||||
lifeline header changes that view's lane x; dragging a message body or label
|
||||
changes that view's occurrence send-y. Preserve shared Scenario order and all
|
||||
other saved layout records. Keep semantic reordering explicit in the sequence
|
||||
outline; do not infer it from crossing another drawn message. Automatic views
|
||||
retain their existing derived-layout editing behavior.
|
||||
|
||||
This follows the user's requirement for directly editable diagrams and the
|
||||
[existing sparse layout contract](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/aed07176ab453b981d5f9c76e504c531f6dd7f42/harness/scenario-layout-contract-candidate.md).
|
||||
The [native Capella comparison](experiments/capella-scenario-observations-20260913/README.md)
|
||||
provides bounded primary evidence: receiver-execution movement changed only the
|
||||
diagram file; separate message/lane Undo restored both files exactly. Two arrow-body
|
||||
drag attempts did not move that example, so use the drawn body/label as discoverable
|
||||
Studio targets while distinguishing activation bars and lifecycle markers by role.
|
||||
This observation does not establish Capella's general crossing or reordering rules.
|
||||
|
||||
Reject implicit shared reordering on a Saved spacing drag: another view's unchanged
|
||||
coordinate records do not ensure unchanged appearance when automatic anchors
|
||||
recompute. Reject a generic permutation of message coordinates: actions, fragments,
|
||||
opaque references and previously crossed positions do not supply interchangeable
|
||||
slots. Reject resetting arrangements to automatic because it discards authored intent.
|
||||
|
||||
Preview from one captured accepted Project and visible camera; resolve the complete
|
||||
candidate geometry without mutating source or history. Commit one existing command
|
||||
only after a valid release and successful source update. Invalid previews, cancellation
|
||||
and return to origin preserve accepted values and camera; an ordinary click still
|
||||
selects its semantic target. Preserve explicit zero/empty/absent overrides and real
|
||||
Redo. Feasible source-order crossings remain diagnostic, not silently clamped.
|
||||
|
||||
Implementation and native acceptance remain pending at this decision. Require
|
||||
actual same-batch and multi-frame input, stale-token cancellation, preview/commit
|
||||
geometry agreement, one Undo/Redo and source reopen, plus measured large-model drag
|
||||
cost. Revisit target affordances or event flow if these workflows reveal accidental
|
||||
reordering, hidden target ambiguity, camera jumps or unacceptable preview latency.
|
||||
|
||||
+9
-7
@@ -1,12 +1,14 @@
|
||||
# Integrated checkpoint — 2026-09-11
|
||||
# Integrated checkpoint — 2026-09-12
|
||||
|
||||
Codex-ytr83r, isolated branch `codex/canvas-union-ytr83r-20260911`. Current source `cb91525` adds the three-file grapheme shortening correction to the combined `a57c334b302eb13352b4bb65df1c75d94d002348` source checkpoint; its remaining Windows gates and matching native checks remain pending. The a57/108b checkpoint evidence remains preserved; this source statement is not a release claim. Main checkpoint `32334c6` / production `865483b` and its existing package remain preserved. The full [user goal](../PROJECT_GOAL.md) remains incomplete. 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) and the [project overview](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/1) retain ownership and plans. This file scopes code and evidence.
|
||||
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.
|
||||
|
||||
## Verified code and capabilities
|
||||
|
||||
Source `cb91525` changes the shared display helper to preserve extended grapheme clusters and records the direct locked dependency edge. Independent source review found no remaining actionable finding; four focused shortening tests passed at 20:59:36.0538079 UTC, with 752 filtered. Full desktop, strict Clippy, build and matching native checks remain pending in this record. The corrected 529-file input freeze supersedes only the premature pre-resolution freeze, against which no tests/build ran. [Correction, exact freezes and limits](release-grapheme-shortening-ytr83r-20260911.md), [verbatim independent review](release-grapheme-shortening-review-20260911.md).
|
||||
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).
|
||||
|
||||
The isolated `a57c334` union selectively combines peer `a31b40d` canvas/chooser/form prerequisites with the preserved B4 lifetime renderer, contextual source/minimum-card corrections and responsibility-preserving child-work creation. Pan-aware header/Create clips and pointer inversion received independent source review. Windows lifecycle **27**, full desktop **752**, strict desktop all-target Clippy and Studio build passed; all 529 frozen inputs matched HEAD and remained unchanged. The new unoptimized GUI is `69DBB90F…`; matching a57 native checks are in progress locally; complete acceptance remains pending. [Canvas union checkpoint, source/gate archives and exact limits](release-canvas-union-ytr83r-20260911.md).
|
||||
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).
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -34,13 +36,13 @@ The closed 321897f Windows gate passed 704 desktop tests with zero failed/ignore
|
||||
|
||||
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.
|
||||
|
||||
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 completes bounded server runtime acceptance, while the new Windows preview remains unpublished pending the separate GUI checks.
|
||||
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.
|
||||
|
||||
## Material limitations
|
||||
|
||||
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. Full Unicode presentation and matching a57 native acceptance remain open; see the [native typography record](experiments/lifecycle-header-correction-native-865483b-20260911/README.md).
|
||||
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).
|
||||
|
||||
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; they are not promoted to successful-open observations. Contextual-source workflows, matching new-canvas pan/zoom/clipping/focus behavior and complete combined native/package acceptance remain unfinished. 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 [closed Home/Guide/normal-close archive](experiments/packaged-gui-close-865483b-20260911/README.md) preserves these bounded old865/BF9 observations and complete package readback. No application exit code was captured; no exit0 or new-source native pass is inferred.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
wiring.patch -text -whitespace
|
||||
@@ -0,0 +1,95 @@
|
||||
# Complete anchor plan into actual Scene — pending execution
|
||||
|
||||
Prepared 2026-09-12 in isolated branch `codex/behavior-anchor-plan-ytr83r-20260912`, base `66a1f1690f778c8814bfefff7f46fe7f6bbe520e`. Parent owns compilation and publication. **Not compiled or executed.** Only test-only additions to `crates/oa-desktop/src/behavior.rs` and this new directory were authored. The existing floor experiment and its fixtures are unchanged. All new production-file statements are guarded by `cfg(test)`; normal production rendering is unchanged.
|
||||
|
||||
## Bounded question
|
||||
|
||||
Can a complete ephemeral anchor plan drive the actual Scene at exact requested y coordinates without the old running-cursor floor, preserve unspecified anchors and the entire Document, and maintain covered extents and real lifecycle derivation? This isolates the integration seam from the unresolved choice of a product movement policy.
|
||||
|
||||
The old floor candidate imposed existing seed increments on every requested row. This candidate seeds missing anchors from the actual baseline Scene, then replaces explicitly requested coordinates exactly. It does not impose sequence-order constraints or overlap spacing. Source-order crossings are returned as diagnostic data, not silently clamped or converted into semantic Reorder. This is a mechanical projection experiment, not evidence that crossed vertical ordering is acceptable UX.
|
||||
|
||||
`Candidate` is an external map keyed by occurrence UUID. `Plan` is a complete resolved input for the currently visible Scene, including operand headers missing from the ordinary rows map. It contains row roles, derived message send/receive y values, frame bounds, sequence extents and canvas height. It is not a model, parser or REST payload.
|
||||
|
||||
## Actual code boundary and wiring
|
||||
|
||||
`wiring.patch` records the exact production-file delta from the base commit. The five new cfg(test) integration points are:
|
||||
|
||||
1. Include this module as a private child of `behavior`, next to the unchanged floor module.
|
||||
2. Immediately after the old floor hook, consume the complete plan's exact occurrence anchor, before Scene records rows and constructs the occurrence's existing geometry.
|
||||
3. Before inserting an existing fragment BoxItem, use the plan's frame bounds. Existing fragment paint and label code remain in place.
|
||||
4. Immediately after recording a sequence's bounds, substitute planned bounds and update the existing final insertion axis. An earlier event placed lower than the last event cannot truncate the scope merely because traversal ends earlier.
|
||||
5. Before current lifecycle and activation construction, substitute the covered canvas height. Current `Rendering::new`, message alignment, activation generation and drawing remain responsible for their actual geometry.
|
||||
|
||||
The plan is scoped per thread, inactive by default, restored by Drop during unwinding, and has a deliberate-panic test requiring the exact sentinel payload. Other tests on other threads cannot consume its plan. The original floor hooks remain operational when their own scope is active; the new plan is otherwise absent.
|
||||
|
||||
Footprints are measured from the actual baseline named boxes, message polylines and label bounds, translated by requested row deltas. This avoids cloning the renderer's line or lifecycle algorithms. Empty activation bars are excluded because the real renderer derives those later. Frames conservatively preserve seed extents and can grow downward; growth adds an explicitly experimental 20px presentation margin. A move requiring upward frame growth returns `FixedFrameHeaderWouldMove`, because the candidate cannot move an unspecified painted header while retaining its FrameStart and insertion slot. Mere overlap inside the existing frame is not categorically rejected. Sequence bounds preserve seed extents and measured edge padding while expanding to cover outliers; canvas height uses the original scope-to-canvas margin. Neither these margins nor nonshrinking seed extents are adopted user requirements. This is not a general container-resize solver.
|
||||
|
||||
## Independently expected checks
|
||||
|
||||
Thirteen new test functions use the existing hand-authored fixtures through the actual parser and Document bundle loader. Numeric expectations below are independently stated rather than generated from the new planner:
|
||||
|
||||
| Case | Expected actual Scene result |
|
||||
| --- | --- |
|
||||
| Empty complete plan, both fixtures | Same rows, named boxes and marker kinds, lines and labels, scopes, slots, lifecycle, open calls and size as the unmodified baseline Scene |
|
||||
| Second delivery240→200 | First148; second200; every other occurrence anchor unchanged; both second-message endpoints200; repeated exchange references retain different occurrence IDs |
|
||||
| Nested action477→457 | Box top437, height70; actual fragment metadata and active header332..370 retained, header hit still resolves the fragment, before-slot290 and label unchanged; operand bounds and all other anchors unchanged |
|
||||
| Nested action→300 | Proposed footprint280..350 would relocate unspecified frame top332 to260; reject with `FixedFrameHeaderWouldMove` before rendering |
|
||||
| Nested action→353 / admitted380 overlap | Proposed353 gives footprint333 and operand start311, outside fragment start332; reject with `OperandScopeOutsideEnclosingInterval`. At380 the box starts360 and overlaps the fixed header while scope338..530 stays feasible; actual lifecycle diagnostics remain empty, post-fragment alive span530..845 and later self-call bars remain unchanged |
|
||||
| Create243→50 or84 | Reject with `LifecycleAnchorOutsideRenderScope`; actual root lifecycle span starts85 even when insertion-scope planning expands higher. At85 the real birth marker and alive span85..427 remain present |
|
||||
| Death427→380 | Create243 and call335 stay fixed; real alive span243..380; actual bar335..380; death marker/hit moves to380; unresolved-call cue remains, no Reply is invented |
|
||||
| Self-call636→610 | Actual send610/receive634, using the current24 offset; reply728 unchanged; MessageSend role distinguished from the lifecycle MessageReceive role |
|
||||
| Signed zero and candidate restoration | Both0 and-0 return the exact original sparse candidate; no first-edit materialization; restoring the original candidate restores Scene geometry |
|
||||
| First event→1000, last remains728 | First1000/second240, SourceOrderCrossing diagnostic, source order unchanged; root scope end1026 and height1113 cover the earlier outlier rather than the traversal tail |
|
||||
| Nested action→1000 | Box top980/bottom1050; enclosing frame bottom1070; actual root scope/canvas cover it; following self-call636 remains fixed and overlap is diagnosed |
|
||||
| Invalid/unsupported input | Unknown anchor, nonfinite y, compound frame movement and same-scope inverted birth/death fail explicitly |
|
||||
| Scoped execution | Another thread sees baseline geometry; a deliberate panic restores the baseline scope |
|
||||
|
||||
The primary cases compare the entire Project and original SourceBundle and require the real Document to remain clean with no Undo/Redo entry. Canonical parser roundtrip is used, but it covers only the semantic Project: layout stays external. Candidate restoration is a reversible-sidecar check, **not** a production Undo implementation. No command has been added to Document.
|
||||
|
||||
## Explicit limitations and falsifiers
|
||||
|
||||
- Crossing source-ordered events is deliberately drawable with diagnostics. That proves removal of the mechanical floor, not adoption of unrestricted vertical notation. The crossing output remains an independent product-policy falsifier.
|
||||
- An expanded frame can overlap the next event. Its diagnostic preserves that evidence instead of shifting the next event implicitly. The experiment does not establish a frame/header constraint policy or all feasible layouts.
|
||||
- Upward growth that would relocate an unspecified frame header is explicitly unsupported. This does not ban arbitrary overlap gaps; the numeric380 example remains admitted. Full compound/header movement policy remains unresolved.
|
||||
- A proposed operand interval escaping its actual enclosing fragment/caller interval returns `OperandScopeOutsideEnclosingInterval`. The353 example would otherwise cause the real lifecycle traversal to report MissingGeometry and make the later lifetime unresolved, potentially suppressing a self-call bar despite stable row coordinates. No generic gap or sibling-order rule is added. General nested/cross-operand placement remains unproven.
|
||||
- Lifecycle receive anchors must be within the actual effective Rendering scope: root85..height-20, or its recorded non-root scope with the renderer's existing empty-scope allowance. Outside coordinates are rejected before exposing a Scene whose row exists but whose marker/lifetime is missing. This bounds the inspected bridge; it does not establish general ancestor/operand ordering or cross-operand lifetime support.
|
||||
- The same-scope creation-after-deletion case returns `LifetimeInversionNotSupported` before rendering. Current lifecycle intervals cannot truthfully depict that inversion without another representation policy. This is an experimental bridge limitation, not permission to reject semantically valid models or a user-approved global movement rule. Cross-operand lifecycle combinations remain unproven.
|
||||
- Compound frame/operand movement, box resizing, horizontal layout, independent send/receive movement, route editing, drag transaction stamps, pointer capture, dirty-source recovery, persistence, REST and native UI are not implemented.
|
||||
- Self-call send and receive roles are explicit, but the current activation engine still owns its existing row-based behavior. Peer #21's typed activation integration must consume the actual receive anchor and retain the existing lifetime clipping. These tests do not silently bless the old activation-start discrepancy.
|
||||
- Scope/containment walking is deliberately straightforward and not a large-model performance implementation. There is no responsiveness claim.
|
||||
- Some self-message fixture exchanges are unassigned because the current global relation model forbids self edges. The existing fixture's known incomplete-model warnings remain; tests reject semantic errors.
|
||||
|
||||
No storage or parser design should be adopted from this adapter. Coordinate any eventual payload with prospective #7, whose required addressing must preserve diagram/owner/occurrence identity and explicit anchor roles. Coordinate inverse resize with #16 and typed activation/lifetime union with #21 before broad UI work.
|
||||
|
||||
The accumulating frame, scope and lifecycle dependencies show that this is only a bridge for demonstrated feasible positions, **not an adoptable free-editing architecture**. It neither establishes unrestricted placement nor selects a semantic constraint policy. Further fundamental contradictions should become explicit falsifiers for parent replanning, not an expanding list of speculative guards in this prototype.
|
||||
|
||||
Parent reports [peer #21 comment716](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/21#issuecomment-716), posted2026-09-12 23:06UTC: Codex-956atf owns an isolated typed-activation/lifecycle source union. Its intended order is finalized rows/height, lifecycle construction and Create/Delete alignment, then typed Call/Reply attachment, with receiver/scope clipping, logical reply timing distinct from clipped geometry, no resurrection after Delete, and unanswered-call cues preserved. That WIP is not integrated here. Retain the explicit send/receive seam and review the future union against these fixtures; this paragraph records a coordination dependency, not verified acceptance of peer code.
|
||||
|
||||
## Reproduction and handoff
|
||||
|
||||
At the parent's chosen compiler checkpoint, use the configured Windows Rust/linker environment:
|
||||
|
||||
```powershell
|
||||
cargo test -p oa-desktop --bin arcadia-studio --locked --offline -- behavior_layout_scene_experiment behavior_anchor_plan_experiment --list
|
||||
cargo test -p oa-desktop --bin arcadia-studio --locked --offline -- behavior_layout_scene_experiment behavior_anchor_plan_experiment --nocapture
|
||||
```
|
||||
|
||||
The listing must contain thirteen complete-anchor tests and ten existing floor/lifetime tests. The following execution must report all23 passed,0 failed,0 ignored. Both filter strings are passed to the same test binary. Preserve compile failures, parser failures, numeric mismatches and diagnostics as results. Do not claim success from a zero-test filtered run or from compiling only production targets. An intentional panic should be followed by its restoration test passing; another panic payload is a failure.
|
||||
|
||||
## Static review correction history
|
||||
|
||||
The frozen attempt1 candidate was preserved before these corrections. The [authoritative attempt1 archive](../behavior-anchor-plan-review-20260912/attempt1/manifest.json) preserves all five original files; the adjacent review report records the source-verified counterexamples. The pre-review failures are static, unexecuted findings: nested300 silently moved the frame header to260 while row332/slot290 stayed fixed, and Create50 was omitted as MissingGeometry by the actual root lifetime span starting85. The corrected candidate adds named unsupported errors and independent numeric checks rather than recording either original behavior as acceptance. Production hooks and the original floor experiment remain unchanged.
|
||||
|
||||
Before the third correction, parent preserved all five attempt2 files and matching hashes under `harness/experiments/behavior-anchor-plan-review-20260912/attempt2`. That static, unexecuted candidate still admitted nested353: its operand start311 preceded enclosing fragment332, causing MissingGeometry/Unresolved in the actual lifecycle source path. The third correction rejects that exact unsupported scope escape and replaces the positive overlap example with380, including actual lifecycle diagnostics, post-fragment spans and activation-bar preservation checks. The parent-owned review archive is not modified by this experiment.
|
||||
|
||||
Record exact compiled commit/uncommitted patch, commands and results before publication. Source hashes supplied with the handoff identify prepared bytes, not tested code. Root should publish the complete directory and exact behavior.rs delta so remote peers can reproduce it; workspace-local paths are not remote handoffs.
|
||||
|
||||
## Modeling intent: primary-source comparison
|
||||
|
||||
The [Sirius sequence user guide](https://eclipse.dev/sirius/doc/user/sequences/Sequence%20Diagrams.html#SequenceDiagramsSemantics) explicitly ties displayed event order to semantic order: moving an event across another can update the model. It separately describes horizontal lifeline movement, Create/Delete attachment and blank-space gestures. This is framework evidence; the exact Capella modifier, feedback and saved-file effects were not verified natively.
|
||||
|
||||
The [Sirius event-reorder specification](https://eclipse.dev/sirius/doc/specifier/sequences/Sequence%20Diagrams.html#EventReorderTool) provides separate predecessor information for start/send and finish/receive, including coupled synchronous-message movement. That supports preserving distinct endpoint roles; it does not specify a separate user-selected reorder mode.
|
||||
|
||||
Root inference: the user request for intuitive whole-arrow movement does not by itself require arbitrary cosmetic chronology crossing. Compare two coherent product gestures: presentation movement within semantic order plus an explicit reorder action, versus direct dragging that previews and commits a semantic reorder when crossing an event. Evaluate ordinary spacing, intentional order changes, accidental crossing, invalid call/reply/lifetime moves, cross-operand moves, discoverability, one-step Undo and exact preview/reopen behavior. A semantic reorder may change Project/source; presentation-only adjustment must retain semantic content. The current sidecar experiment tests the latter boundary and is not a universal nonmutation requirement for all future drag operations. No gesture or public schema is selected by this note.
|
||||
|
||||
Root formatting completed for the new Rust modules. Independent correction review found no new blocking source finding after the operand-scope repair, including non-vacuous actual lifetime/bar assertions. Compiler and runtime results remain pending. [Prepared input hashes](prepared-inputs.json) bind the current candidate, not an accepted build.
|
||||
@@ -0,0 +1,448 @@
|
||||
//! Experimental complete anchor input for the actual Scene, cfg(test) only.
|
||||
//! No persistence schema and no replacement line/lifecycle/activation renderer.
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
mod tests;
|
||||
|
||||
type Candidate = BTreeMap<Id, f32>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AnchorRole {
|
||||
MessageSend,
|
||||
MessageReceive,
|
||||
Observation,
|
||||
FrameStart,
|
||||
OperandStart,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Anchor {
|
||||
y: f32,
|
||||
role: AnchorRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct MessageAnchors {
|
||||
send_y: f32,
|
||||
receive_y: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Diagnostic {
|
||||
SourceOrderCrossing { scope: Id, before: Id, after: Id },
|
||||
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum PlanError {
|
||||
UnknownAnchor(Id),
|
||||
NonFinite(Id),
|
||||
CompoundMoveNotImplemented(Id),
|
||||
FixedFrameHeaderWouldMove { frame: Id },
|
||||
OperandScopeOutsideEnclosingInterval { operand: Id, fragment: Id },
|
||||
LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id },
|
||||
LifetimeInversionNotSupported { create: Id, delete: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Plan {
|
||||
anchors: BTreeMap<Id, Anchor>,
|
||||
messages: BTreeMap<Id, MessageAnchors>,
|
||||
frames: BTreeMap<Id, Rectangle>,
|
||||
scopes: BTreeMap<Id, (f32, f32)>,
|
||||
height: f32,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match p.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. } => Some(body),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn role(body: &OccurrenceBody) -> AnchorRole {
|
||||
match body {
|
||||
OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
} => AnchorRole::MessageReceive,
|
||||
OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend,
|
||||
OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart,
|
||||
OccurrenceBody::Branch { .. } => AnchorRole::OperandStart,
|
||||
_ => AnchorRole::Observation,
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(p: &Project, id: Id, owner: Id) -> bool {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
for _ in 0..p.elements.len() {
|
||||
match current {
|
||||
Some(parent) if parent == owner => return true,
|
||||
Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn depth(p: &Project, id: Id) -> usize {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
let mut result = 0;
|
||||
while let Some(parent) = current {
|
||||
result += 1;
|
||||
if result > p.elements.len() {
|
||||
break;
|
||||
}
|
||||
current = p.elements.get(&parent).and_then(|e| e.parent);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
||||
let x = a.x.min(b.x);
|
||||
let y = a.y.min(b.y);
|
||||
Rectangle::new(
|
||||
Point::new(x, y),
|
||||
Size::new(
|
||||
(a.x + a.width).max(b.x + b.width) - x,
|
||||
(a.y + a.height).max(b.y + b.height) - y,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Actual named shapes/message paths supply footprint measurements; activation
|
||||
/// bars are intentionally excluded because current Scene derives them later.
|
||||
fn footprints(
|
||||
scene: &Scene,
|
||||
anchors: &BTreeMap<Id, Anchor>,
|
||||
seed: &BTreeMap<Id, Anchor>,
|
||||
) -> BTreeMap<Id, Rectangle> {
|
||||
let mut out = BTreeMap::new();
|
||||
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
|
||||
let Some(anchor) = anchors.get(&item.id) else {
|
||||
continue;
|
||||
};
|
||||
let mut rect = item.rect;
|
||||
rect.y += anchor.y - seed[&item.id].y;
|
||||
out.insert(item.id, rect);
|
||||
}
|
||||
for line in &scene.lines {
|
||||
let Some(anchor) = anchors.get(&line.id) else {
|
||||
continue;
|
||||
};
|
||||
let delta = anchor.y - seed[&line.id].y;
|
||||
let mut rect = line.label_bounds();
|
||||
rect.y += delta;
|
||||
for point in &line.points {
|
||||
rect = union(
|
||||
rect,
|
||||
Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO),
|
||||
);
|
||||
}
|
||||
out.entry(line.id)
|
||||
.and_modify(|old| *old = union(*old, rect))
|
||||
.or_insert(rect);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn covered_bounds(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
footprints: &BTreeMap<Id, Rectangle>,
|
||||
) -> Option<Rectangle> {
|
||||
footprints
|
||||
.iter()
|
||||
.filter(|(id, _)| inside(p, **id, owner))
|
||||
.map(|(_, rect)| *rect)
|
||||
.reduce(union)
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
fn build(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
baseline: &Scene,
|
||||
desired: &Candidate,
|
||||
) -> Result<Self, PlanError> {
|
||||
let mut seed: BTreeMap<_, _> = baseline
|
||||
.rows
|
||||
.iter()
|
||||
.map(|(id, (_, y))| {
|
||||
(
|
||||
*id,
|
||||
Anchor {
|
||||
y: *y,
|
||||
role: role(occurrence(p, *id).expect("Scene occurrence")),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
// Operand headers are real Scene boxes, but are not entries in rows.
|
||||
for item in &baseline.boxes {
|
||||
if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) {
|
||||
seed.insert(
|
||||
item.id,
|
||||
Anchor {
|
||||
y: item.rect.y,
|
||||
role: AnchorRole::OperandStart,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut anchors = seed.clone();
|
||||
for (&id, &y) in desired {
|
||||
let anchor = anchors.get_mut(&id).ok_or(PlanError::UnknownAnchor(id))?;
|
||||
if !y.is_finite() {
|
||||
return Err(PlanError::NonFinite(id));
|
||||
}
|
||||
if matches!(
|
||||
anchor.role,
|
||||
AnchorRole::FrameStart | AnchorRole::OperandStart
|
||||
) && y != anchor.y
|
||||
{
|
||||
return Err(PlanError::CompoundMoveNotImplemented(id));
|
||||
}
|
||||
anchor.y = y;
|
||||
}
|
||||
let mut messages = BTreeMap::new();
|
||||
for line in &baseline.lines {
|
||||
if let (Some(anchor), Some(first), Some(last)) = (
|
||||
anchors.get(&line.id),
|
||||
line.points.first(),
|
||||
line.points.last(),
|
||||
) {
|
||||
let delta = anchor.y - seed[&line.id].y;
|
||||
messages.insert(
|
||||
line.id,
|
||||
MessageAnchors {
|
||||
send_y: first.y + delta,
|
||||
receive_y: last.y + delta,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
// The current lifecycle bridge needs positive birth/death intervals.
|
||||
// This is an explicit unsupported crossing, not a new semantic rule.
|
||||
for (&create, message) in &messages {
|
||||
if let Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create,
|
||||
receiver: Some(lane),
|
||||
..
|
||||
}) = occurrence(p, create)
|
||||
{
|
||||
for (&delete, finish) in &messages {
|
||||
if p.elements[&create].parent == p.elements[&delete].parent
|
||||
&& matches!(occurrence(p, delete), Some(OccurrenceBody::Exchange { message_kind: MessageKind::Delete, receiver: Some(other), .. }) if other == lane)
|
||||
&& message.receive_y > finish.receive_y
|
||||
{
|
||||
return Err(PlanError::LifetimeInversionNotSupported { create, delete });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let old_footprints = footprints(baseline, &seed, &seed);
|
||||
let mut drawn = footprints(baseline, &anchors, &seed);
|
||||
let mut frames = BTreeMap::new();
|
||||
let mut containers: Vec<_> = seed
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. })))
|
||||
.collect();
|
||||
containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id)));
|
||||
for id in containers {
|
||||
let original = old_footprints[&id];
|
||||
let mut rect = original;
|
||||
if let Some(covered) = covered_bounds(p, id, &drawn) {
|
||||
// Keep seed extents where possible; 20px growth padding is an
|
||||
// explicit experimental presentation preference, not semantics.
|
||||
// Upward envelope growth would move the painted frame header
|
||||
// while its fixed FrameStart/row/slot remained at the seed.
|
||||
// Reject that unsupported compound change, not mere overlap.
|
||||
if covered.y < rect.y {
|
||||
return Err(PlanError::FixedFrameHeaderWouldMove { frame: id });
|
||||
}
|
||||
if covered.y + covered.height > rect.y + rect.height {
|
||||
rect.height = covered.y + covered.height + 20. - rect.y;
|
||||
}
|
||||
}
|
||||
frames.insert(id, rect);
|
||||
drawn.insert(id, rect);
|
||||
}
|
||||
let mut scopes = baseline.sequence_bounds.clone();
|
||||
for (&scope, bounds) in &mut scopes {
|
||||
if let (Some(old), Some(new)) = (
|
||||
covered_bounds(p, scope, &old_footprints),
|
||||
covered_bounds(p, scope, &drawn),
|
||||
) {
|
||||
let top_padding = (old.y - bounds.0).max(0.);
|
||||
let bottom_padding = (bounds.1 - old.y - old.height).max(0.);
|
||||
if new.y < bounds.0 {
|
||||
bounds.0 = new.y - top_padding;
|
||||
}
|
||||
bounds.1 = bounds.1.max(new.y + new.height + bottom_padding);
|
||||
}
|
||||
}
|
||||
let tail = baseline.size.height - baseline.sequence_bounds[&owner].1;
|
||||
let height = baseline.size.height.max(scopes[&owner].1 + tail);
|
||||
for (&operand, &(start, end)) in &scopes {
|
||||
if !matches!(occurrence(p, operand), Some(OccurrenceBody::Branch { .. })) {
|
||||
continue;
|
||||
}
|
||||
let fragment = p.elements[&operand].parent.expect("operand fragment");
|
||||
let caller = p.elements[&fragment]
|
||||
.parent
|
||||
.expect("fragment caller sequence");
|
||||
let enclosing_frame = frames[&fragment];
|
||||
let (caller_start, caller_end) = if caller == owner {
|
||||
(85., height - 20.)
|
||||
} else {
|
||||
let (a, b) = scopes[&caller];
|
||||
(a, if a == b { a + 26. } else { b })
|
||||
};
|
||||
let enclosing_start = anchors[&fragment].y.max(caller_start);
|
||||
let enclosing_end = (enclosing_frame.y + enclosing_frame.height).min(caller_end);
|
||||
let effective_end = if start == end { start + 26. } else { end };
|
||||
// Actual lifecycle traversal cannot enter an operand that escapes
|
||||
// this enclosing interval. This guards bridge feasibility only;
|
||||
// it is not a generic event-spacing or sibling-ordering policy.
|
||||
if start < enclosing_start || effective_end > enclosing_end {
|
||||
return Err(PlanError::OperandScopeOutsideEnclosingInterval { operand, fragment });
|
||||
}
|
||||
}
|
||||
for (&id, message) in &messages {
|
||||
if matches!(
|
||||
occurrence(p, id),
|
||||
Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create | MessageKind::Delete,
|
||||
..
|
||||
})
|
||||
) {
|
||||
let scope = p.elements[&id].parent.expect("lifecycle sequence owner");
|
||||
// Match Rendering::new's effective scope, not the insertion
|
||||
// scope: it overrides the root span to 85..height-20 and gives
|
||||
// empty non-root scopes the existing 26px lifetime allowance.
|
||||
let (start, end) = if scope == owner {
|
||||
(85., height - 20.)
|
||||
} else if let Some(&(start, end)) = scopes.get(&scope) {
|
||||
(start, if start == end { start + 26. } else { end })
|
||||
} else {
|
||||
return Err(PlanError::LifecycleAnchorOutsideRenderScope {
|
||||
occurrence: id,
|
||||
scope,
|
||||
});
|
||||
};
|
||||
if message.receive_y < start || message.receive_y > end {
|
||||
return Err(PlanError::LifecycleAnchorOutsideRenderScope {
|
||||
occurrence: id,
|
||||
scope,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut diagnostics = Vec::new();
|
||||
for element in p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.id == owner || inside(p, e.id, owner))
|
||||
{
|
||||
if let Some(steps) = element.steps() {
|
||||
for pair in steps.windows(2) {
|
||||
if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) {
|
||||
if a.y > b.y {
|
||||
diagnostics.push(Diagnostic::SourceOrderCrossing {
|
||||
scope: element.id,
|
||||
before: pair[0],
|
||||
after: pair[1],
|
||||
});
|
||||
}
|
||||
if let Some(frame) = frames.get(&pair[0]) {
|
||||
if frame.y + frame.height > b.y {
|
||||
diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor {
|
||||
frame: pair[0],
|
||||
following: pair[1],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
anchors,
|
||||
messages,
|
||||
frames,
|
||||
scopes,
|
||||
height,
|
||||
diagnostics,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static PLAN: RefCell<Option<Plan>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Scope(Option<Plan>);
|
||||
impl Scope {
|
||||
fn install(plan: &Plan) -> Self {
|
||||
Self(PLAN.with(|slot| slot.replace(Some(plan.clone()))))
|
||||
}
|
||||
}
|
||||
impl Drop for Scope {
|
||||
fn drop(&mut self) {
|
||||
PLAN.with(|slot| {
|
||||
slot.replace(self.0.take());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn row_anchor(id: Id, y: &mut f32) {
|
||||
PLAN.with(|slot| {
|
||||
if let Some(plan) = slot.borrow().as_ref() {
|
||||
*y = plan.anchors.get(&id).expect("complete Scene anchor plan").y;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn frame_bounds(id: Id, fallback: Rectangle) -> Rectangle {
|
||||
PLAN.with(|slot| {
|
||||
slot.borrow()
|
||||
.as_ref()
|
||||
.map_or(fallback, |plan| plan.frames[&id])
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn scope_end(owner: Id, y: &mut f32, bounds: &mut BTreeMap<Id, (f32, f32)>) {
|
||||
PLAN.with(|slot| {
|
||||
if let Some(plan) = slot.borrow().as_ref() {
|
||||
bounds.insert(owner, plan.scopes[&owner]);
|
||||
*y = plan.scopes[&owner].1 + 42.; // Existing insertion-axis offset.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn scene_extent(scene: &mut Scene) {
|
||||
PLAN.with(|slot| {
|
||||
if let Some(plan) = slot.borrow().as_ref() {
|
||||
scene.size.height = plan.height;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render(p: &Project, owner: Id, plan: &Plan) -> Scene {
|
||||
let _scope = Scope::install(plan);
|
||||
Scene::sequence(p, owner, owner)
|
||||
}
|
||||
|
||||
fn drag(original: &Candidate, frame: &Scene, target: Id, delta_y: f32) -> Candidate {
|
||||
assert!(delta_y.is_finite());
|
||||
if delta_y == 0. {
|
||||
return original.clone();
|
||||
}
|
||||
let mut result = original.clone();
|
||||
result.insert(target, frame.rows[&target].1 + delta_y);
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"base": "66a1f1690f778c8814bfefff7f46fe7f6bbe520e",
|
||||
"status": "Formatted candidate; uncompiled and unexecuted",
|
||||
"files": [
|
||||
{
|
||||
"path": "crates/oa-desktop/src/behavior.rs",
|
||||
"bytes": 137409,
|
||||
"sha256": "5884ba35ad994eb5936ca29c071a5002efc221dc42ef35e2265d50593bdf60ec"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/mod.rs",
|
||||
"bytes": 15658,
|
||||
"sha256": "cfa848bcf1f8f48324f5cd746944c9b8b4959e6aa086a7726b922d6a01cbbf3c"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/tests.rs",
|
||||
"bytes": 25567,
|
||||
"sha256": "c93b5668872217888c517df549fb6bea99fbc5642fdf8c2e55bdd92537df07b8"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/README.md",
|
||||
"bytes": 16350,
|
||||
"sha256": "da062779c90c7b9f0696f697521c1b0055c58d473329ac1817a55b0ba0f88370"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/wiring.patch",
|
||||
"bytes": 2131,
|
||||
"sha256": "4ce4bc45fbc419eba6a07b048b82efb8d85525514e3d2e56421948e7733f40fd"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{
|
||||
language,
|
||||
source_project::{ProjectLimits, SourceBundle},
|
||||
validation,
|
||||
};
|
||||
|
||||
fn id(p: &Project, alias: &str) -> Id {
|
||||
p.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn fixture(lifetime: bool) -> (Document, Id) {
|
||||
let (name, input, owner) = if lifetime {
|
||||
(
|
||||
"lifetime.arcadia",
|
||||
include_str!("../behavior-layout-scene-20260912/lifetime.arcadia"),
|
||||
"lifetime_conversation",
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"fixture.arcadia",
|
||||
include_str!("../behavior-layout-scene-20260912/fixture.arcadia"),
|
||||
"conversation",
|
||||
)
|
||||
};
|
||||
let p = language::parse(input).expect("existing real Arcadia fixture");
|
||||
let errors: Vec<_> = validation::validate(&p)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let bundle = SourceBundle {
|
||||
entry: name.into(),
|
||||
files: BTreeMap::from([(name.into(), input.into())]),
|
||||
};
|
||||
let document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(document.project(), &p);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
let owner = id(&p, owner);
|
||||
(document, owner)
|
||||
}
|
||||
|
||||
fn passive(document: &Document, before: &Project, source: &SourceBundle) {
|
||||
assert_eq!(document.project(), before);
|
||||
assert_eq!(document.bundle(), source);
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_undo() && !document.can_redo());
|
||||
}
|
||||
|
||||
fn row(scene: &Scene, id: Id) -> f32 {
|
||||
scene.rows[&id].1
|
||||
}
|
||||
fn line(scene: &Scene, id: Id) -> &Line {
|
||||
assert_eq!(scene.lines.iter().filter(|line| line.id == id).count(), 1);
|
||||
scene.lines.iter().find(|line| line.id == id).unwrap()
|
||||
}
|
||||
fn named_box(scene: &Scene, id: Id) -> &BoxItem {
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|item| item.id == id && !item.title.is_empty())
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn same_scene(a: &Scene, b: &Scene) {
|
||||
assert_eq!(a.rows, b.rows);
|
||||
assert_eq!(a.sequence_bounds, b.sequence_bounds);
|
||||
assert_eq!(a.size, b.size);
|
||||
assert_eq!(a.lanes, b.lanes);
|
||||
assert_eq!(a.lifecycle, b.lifecycle);
|
||||
assert_eq!(a.open_calls, b.open_calls);
|
||||
let boxes = |s: &Scene| {
|
||||
s.boxes
|
||||
.iter()
|
||||
.map(|b| (b.id, b.rect, b.title.clone(), b.detail.clone(), b.marker))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let lines = |s: &Scene| {
|
||||
s.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
(
|
||||
l.id,
|
||||
l.points.clone(),
|
||||
l.label_at,
|
||||
l.label.clone(),
|
||||
l.dashed,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let slots = |s: &Scene| {
|
||||
s.slots
|
||||
.iter()
|
||||
.map(|slot| {
|
||||
(
|
||||
slot.slot.parent,
|
||||
slot.slot.before,
|
||||
slot.y,
|
||||
slot.label.clone(),
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(boxes(a), boxes(b));
|
||||
assert_eq!(lines(a), lines(b));
|
||||
assert_eq!(slots(a), slots(b));
|
||||
}
|
||||
|
||||
fn others_fixed(before: &Scene, after: &Scene, changed: Id) {
|
||||
assert_eq!(before.rows.len(), after.rows.len());
|
||||
for (&id, original) in &before.rows {
|
||||
if id != changed {
|
||||
assert_eq!(&after.rows[&id], original, "unexpected movement of {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_frame_metadata(before: &Scene, after: &Scene, frame: Id) {
|
||||
let original = named_box(before, frame);
|
||||
let current = named_box(after, frame);
|
||||
assert_eq!(
|
||||
(
|
||||
current.id,
|
||||
current.rect,
|
||||
¤t.title,
|
||||
¤t.detail,
|
||||
current.marker
|
||||
),
|
||||
(
|
||||
original.id,
|
||||
original.rect,
|
||||
&original.title,
|
||||
&original.detail,
|
||||
original.marker
|
||||
)
|
||||
);
|
||||
assert_eq!(before.rows[&frame], after.rows[&frame]);
|
||||
assert_eq!(row(after, frame), 332.);
|
||||
// Scene::hit uses the first38px as the frame's active painted header.
|
||||
let header = Rectangle::new(
|
||||
Point::new(original.rect.x, original.rect.y),
|
||||
Size::new(original.rect.width, 38.),
|
||||
);
|
||||
assert_eq!((header.y, header.y + header.height), (332., 370.));
|
||||
let probe = Point::new(header.x + 8., header.y + 8.);
|
||||
assert_eq!(before.hit(probe), Some(frame));
|
||||
assert_eq!(after.hit(probe), Some(frame));
|
||||
let original_slot = before
|
||||
.slots
|
||||
.iter()
|
||||
.find(|slot| slot.slot.before == Some(frame))
|
||||
.unwrap();
|
||||
let current_slot = after
|
||||
.slots
|
||||
.iter()
|
||||
.find(|slot| slot.slot.before == Some(frame))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(
|
||||
current_slot.slot.parent,
|
||||
current_slot.slot.before,
|
||||
current_slot.y,
|
||||
¤t_slot.label
|
||||
),
|
||||
(
|
||||
original_slot.slot.parent,
|
||||
original_slot.slot.before,
|
||||
original_slot.y,
|
||||
&original_slot.label
|
||||
)
|
||||
);
|
||||
assert_eq!(current_slot.y, 290.);
|
||||
}
|
||||
|
||||
fn actual_lifetime_projection(
|
||||
p: &Project,
|
||||
owner: Id,
|
||||
scene: &Scene,
|
||||
) -> Vec<scenario_lifecycle::LaneLifetime> {
|
||||
// Same actual rows/scope inputs as Rendering::new, including its root span
|
||||
// and empty-operand allowance; call the real projector for diagnostics.
|
||||
let mut scopes = scene.sequence_bounds.clone();
|
||||
for (&scope, (start, end)) in &mut scopes {
|
||||
if scope != owner && *start == *end {
|
||||
*end = *start + 26.;
|
||||
}
|
||||
}
|
||||
scopes.insert(owner, (85., scene.size.height - 20.));
|
||||
scenario_lifecycle::project_lifetimes(
|
||||
p,
|
||||
owner,
|
||||
&scenario_lifecycle::Geometry {
|
||||
rows: &scene.rows,
|
||||
scopes: &scopes,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_seed_plan_preserves_both_actual_scene_and_document_baselines() {
|
||||
for lifetime in [false, true] {
|
||||
let (document, owner) = fixture(lifetime);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let plan = Plan::build(p, owner, &baseline, &Candidate::new()).unwrap();
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
for (&id, (_, y)) in &baseline.rows {
|
||||
assert_eq!(plan.anchors[&id].y, *y);
|
||||
}
|
||||
same_scene(&baseline, &render(p, owner, &plan));
|
||||
let reparsed = language::parse(&language::format(p)).unwrap();
|
||||
same_scene(&baseline, &render(&reparsed, owner, &plan));
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_second_delivery_reaches_200_and_all_other_occurrence_anchors_stay_fixed() {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let first = id(p, "first");
|
||||
let second = id(p, "second");
|
||||
assert_ne!(first, second);
|
||||
for occurrence_id in [first, second] {
|
||||
assert!(
|
||||
matches!(occurrence(p,occurrence_id),Some(OccurrenceBody::Exchange { exchange:Some(edge),.. }) if *edge==id(p,"channel"))
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
(row(&baseline, first), row(&baseline, second)),
|
||||
(148., 240.)
|
||||
);
|
||||
let candidate = drag(&Candidate::new(), &baseline, second, -40.);
|
||||
assert_eq!(candidate, Candidate::from([(second, 200.)]));
|
||||
let plan = Plan::build(p, owner, &baseline, &candidate).unwrap();
|
||||
let moved = render(p, owner, &plan);
|
||||
assert_eq!((row(&moved, first), row(&moved, second)), (148., 200.));
|
||||
others_fixed(&baseline, &moved, second);
|
||||
assert_eq!(line(&moved, first).points, line(&baseline, first).points);
|
||||
let left = moved.lanes[&id(p, "left")];
|
||||
let right = moved.lanes[&id(p, "right")];
|
||||
assert_eq!(
|
||||
line(&moved, second).points,
|
||||
vec![Point::new(left, 200.), Point::new(right, 200.)]
|
||||
);
|
||||
assert_eq!(
|
||||
plan.messages[&second],
|
||||
MessageAnchors {
|
||||
send_y: 200.,
|
||||
receive_y: 200.
|
||||
}
|
||||
);
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
same_scene(&moved, &render(p, owner, &plan));
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_upward_anchor_reaches_457_without_moving_parent_or_followers() {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let nested = id(p, "nested");
|
||||
let fragment = id(p, "fragment");
|
||||
assert_eq!(row(&baseline, nested), 477.);
|
||||
let candidate = drag(&Candidate::new(), &baseline, nested, -20.);
|
||||
let plan = Plan::build(p, owner, &baseline, &candidate).unwrap();
|
||||
let moved = render(p, owner, &plan);
|
||||
assert_eq!(row(&moved, nested), 457.);
|
||||
assert_eq!(named_box(&moved, nested).rect.y, 437.);
|
||||
assert_eq!(named_box(&moved, nested).rect.height, 70.);
|
||||
assert_eq!(
|
||||
named_box(&moved, fragment).rect,
|
||||
named_box(&baseline, fragment).rect
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
named_box(&moved, fragment).rect.y,
|
||||
named_box(&moved, fragment).rect.height
|
||||
),
|
||||
(332., 240.)
|
||||
);
|
||||
fixed_frame_metadata(&baseline, &moved, fragment);
|
||||
assert_eq!(
|
||||
moved.sequence_bounds[&id(p, "operand")],
|
||||
baseline.sequence_bounds[&id(p, "operand")]
|
||||
);
|
||||
others_fixed(&baseline, &moved, nested);
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_move_that_would_relocate_fixed_header_is_rejected_but_mere_overlap_is_not_banned() {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let nested = id(p, "nested");
|
||||
let fragment = id(p, "fragment");
|
||||
let requested = Candidate::from([(nested, 300.)]);
|
||||
// Actual70px action box has row-to-top offset-20: proposed footprint280..350.
|
||||
// The old envelope union would move frame/header top332 to260 while leaving
|
||||
// FrameStart332 and before-slot290. Reject before any such Scene is exposed.
|
||||
let original = named_box(&baseline, nested).rect;
|
||||
assert_eq!(
|
||||
(row(&baseline, nested), original.y, original.height),
|
||||
(477., 457., 70.)
|
||||
);
|
||||
let proposed_top = original.y + (300. - row(&baseline, nested));
|
||||
assert_eq!((proposed_top, proposed_top + original.height), (280., 350.));
|
||||
assert_eq!(
|
||||
Plan::build(p, owner, &baseline, &requested).unwrap_err(),
|
||||
PlanError::FixedFrameHeaderWouldMove { frame: fragment }
|
||||
);
|
||||
fixed_frame_metadata(&baseline, &Scene::sequence(p, owner, owner), fragment);
|
||||
// Overlap inside the existing frame does not itself move its top. This
|
||||
// remains admitted mechanical geometry, not a claim of usable overlap UX.
|
||||
let overlap = Plan::build(p, owner, &baseline, &Candidate::from([(nested, 380.)])).unwrap();
|
||||
let overlapping = render(p, owner, &overlap);
|
||||
assert_eq!(named_box(&overlapping, nested).rect.y, 360.);
|
||||
assert_eq!(overlapping.sequence_bounds[&id(p, "operand")], (338., 530.));
|
||||
fixed_frame_metadata(&baseline, &overlapping, fragment);
|
||||
let original_lifetimes = actual_lifetime_projection(p, owner, &baseline);
|
||||
let moved_lifetimes = actual_lifetime_projection(p, owner, &overlapping);
|
||||
assert_eq!(original_lifetimes.len(), 2);
|
||||
assert_eq!(moved_lifetimes.len(), 2);
|
||||
assert!(
|
||||
original_lifetimes
|
||||
.iter()
|
||||
.all(|lane| lane.uncertainties.is_empty())
|
||||
);
|
||||
assert!(
|
||||
moved_lifetimes
|
||||
.iter()
|
||||
.all(|lane| lane.uncertainties.is_empty()),
|
||||
"{moved_lifetimes:?}"
|
||||
);
|
||||
let receiver = id(p, "right");
|
||||
for scene in [&baseline, &overlapping] {
|
||||
assert_eq!(
|
||||
scene
|
||||
.lifecycle
|
||||
.alive_spans(receiver, owner, 530., scene.size.height - 20.)
|
||||
.iter()
|
||||
.map(|span| (span.start, span.end))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(530., 845.)]
|
||||
);
|
||||
}
|
||||
let call = id(p, "self_call");
|
||||
let bars = |scene: &Scene| {
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|item| item.id == call && item.title.is_empty())
|
||||
.map(|item| (item.rect, item.marker))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert!(!bars(&baseline).is_empty());
|
||||
assert_eq!(bars(&overlapping), bars(&baseline));
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested353_scope_escape_is_named_and_rejected_before_missing_lifecycle_geometry_is_exposed() {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let nested = id(p, "nested");
|
||||
let operand = id(p, "operand");
|
||||
let fragment = id(p, "fragment");
|
||||
let original = named_box(&baseline, nested).rect;
|
||||
assert_eq!(
|
||||
(original.y, baseline.sequence_bounds[&operand].0),
|
||||
(457., 435.)
|
||||
);
|
||||
let proposed_top = original.y + (353. - row(&baseline, nested));
|
||||
let proposed_scope_start = proposed_top - (original.y - baseline.sequence_bounds[&operand].0);
|
||||
assert_eq!(
|
||||
(proposed_top, proposed_scope_start, row(&baseline, fragment)),
|
||||
(333., 311., 332.)
|
||||
);
|
||||
assert_eq!(
|
||||
Plan::build(p, owner, &baseline, &Candidate::from([(nested, 353.)])).unwrap_err(),
|
||||
PlanError::OperandScopeOutsideEnclosingInterval { operand, fragment }
|
||||
);
|
||||
same_scene(&baseline, &Scene::sequence(p, owner, owner));
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_anchor_below_actual_root_render_scope_is_rejected_before_scene_construction() {
|
||||
let (document, owner) = fixture(true);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let create = id(p, "create_receiver");
|
||||
let provider = id(p, "provider");
|
||||
let requester = id(p, "requester");
|
||||
let root_life = baseline
|
||||
.lifecycle
|
||||
.alive_spans(requester, owner, 0., baseline.size.height);
|
||||
assert_eq!(
|
||||
root_life
|
||||
.iter()
|
||||
.map(|span| (span.start, span.end))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(85., 544.)]
|
||||
);
|
||||
for requested in [50., 84.] {
|
||||
assert_eq!(
|
||||
Plan::build(p, owner, &baseline, &Candidate::from([(create, requested)])).unwrap_err(),
|
||||
PlanError::LifecycleAnchorOutsideRenderScope {
|
||||
occurrence: create,
|
||||
scope: owner
|
||||
}
|
||||
);
|
||||
}
|
||||
// At the renderer's actual85 boundary the marker/lifetime remains present.
|
||||
// Source-order crossing is still diagnosed separately, not silently banned.
|
||||
let boundary = Plan::build(p, owner, &baseline, &Candidate::from([(create, 85.)])).unwrap();
|
||||
let moved = render(p, owner, &boundary);
|
||||
let x = moved.lanes[&provider];
|
||||
assert_eq!(row(&moved, create), 85.);
|
||||
assert_eq!(moved.lifecycle.marker_at(Point::new(x, 85.)), Some(create));
|
||||
let alive = moved
|
||||
.lifecycle
|
||||
.alive_spans(provider, owner, 0., moved.size.height);
|
||||
assert_eq!(
|
||||
alive
|
||||
.iter()
|
||||
.map(|span| (span.start, span.end))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(85., 427.)]
|
||||
);
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_death_reaches_380_and_actual_lifetime_and_activation_clip_there() {
|
||||
let (document, owner) = fixture(true);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let create = id(p, "create_receiver");
|
||||
let call = id(p, "service_call");
|
||||
let delete = id(p, "delete_receiver");
|
||||
let provider = id(p, "provider");
|
||||
assert_eq!(
|
||||
(
|
||||
row(&baseline, create),
|
||||
row(&baseline, call),
|
||||
row(&baseline, delete)
|
||||
),
|
||||
(243., 335., 427.)
|
||||
);
|
||||
let candidate = drag(&Candidate::new(), &baseline, delete, -47.);
|
||||
let plan = Plan::build(p, owner, &baseline, &candidate).unwrap();
|
||||
assert_eq!(plan.anchors[&delete].role, AnchorRole::MessageReceive);
|
||||
assert_eq!(
|
||||
plan.messages[&delete],
|
||||
MessageAnchors {
|
||||
send_y: 380.,
|
||||
receive_y: 380.
|
||||
}
|
||||
);
|
||||
let moved = render(p, owner, &plan);
|
||||
assert_eq!(row(&moved, delete), 380.);
|
||||
others_fixed(&baseline, &moved, delete);
|
||||
let spans = moved
|
||||
.lifecycle
|
||||
.alive_spans(provider, owner, 85., moved.size.height - 20.);
|
||||
assert_eq!(
|
||||
spans.iter().map(|s| (s.start, s.end)).collect::<Vec<_>>(),
|
||||
vec![(243., 380.)]
|
||||
);
|
||||
assert!(
|
||||
moved
|
||||
.lifecycle
|
||||
.alive_spans(provider, owner, 380., moved.size.height - 20.)
|
||||
.is_empty()
|
||||
);
|
||||
let x = moved.lanes[&provider];
|
||||
assert_eq!(moved.lifecycle.marker_at(Point::new(x, 380.)), Some(delete));
|
||||
assert_eq!(moved.lifecycle.marker_at(Point::new(x, 427.)), None);
|
||||
assert_eq!(moved.hit(Point::new(x, 380.)), Some(delete));
|
||||
let bars: Vec<_> = moved
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|b| b.id == call && b.title.is_empty())
|
||||
.collect();
|
||||
assert_eq!(bars.len(), 1);
|
||||
assert_eq!(
|
||||
bars[0].rect,
|
||||
Rectangle::new(Point::new(x - 4., 335.), Size::new(8., 45.))
|
||||
);
|
||||
assert_eq!(moved.open_calls, vec![Point::new(x + 12., 364.)]);
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_call_has_explicit_send610_receive634_and_reply_stays728() {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let call = id(p, "self_call");
|
||||
let reply = id(p, "self_reply");
|
||||
let candidate = drag(&Candidate::new(), &baseline, call, -26.);
|
||||
let plan = Plan::build(p, owner, &baseline, &candidate).unwrap();
|
||||
assert_eq!(
|
||||
plan.anchors[&call],
|
||||
Anchor {
|
||||
y: 610.,
|
||||
role: AnchorRole::MessageSend
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
plan.messages[&call],
|
||||
MessageAnchors {
|
||||
send_y: 610.,
|
||||
receive_y: 634.
|
||||
}
|
||||
);
|
||||
let moved = render(p, owner, &plan);
|
||||
assert_eq!(row(&moved, call), 610.);
|
||||
assert_eq!(row(&moved, reply), 728.);
|
||||
assert_eq!(
|
||||
line(&moved, call)
|
||||
.points
|
||||
.iter()
|
||||
.map(|p| p.y)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![610., 610., 634., 634.]
|
||||
);
|
||||
assert_eq!(line(&moved, reply).points, line(&baseline, reply).points);
|
||||
others_fixed(&baseline, &moved, call);
|
||||
// Peer #21 owns the activation start/column contract. This assertion covers
|
||||
// actual message anchors, not the old row-based activation start defect.
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_zero_keeps_sparse_candidate_and_restoring_original_candidate_restores_actual_scene() {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let second = id(p, "second");
|
||||
let original = Candidate::new();
|
||||
for zero in [0., -0.] {
|
||||
assert_eq!(drag(&original, &baseline, second, zero), original);
|
||||
}
|
||||
let changed = drag(&original, &baseline, second, -40.);
|
||||
let plan = Plan::build(p, owner, &baseline, &changed).unwrap();
|
||||
let frame = render(p, owner, &plan);
|
||||
for zero in [0., -0.] {
|
||||
let untouched = drag(&changed, &frame, second, zero);
|
||||
assert_eq!(untouched, changed);
|
||||
same_scene(
|
||||
&frame,
|
||||
&render(
|
||||
p,
|
||||
owner,
|
||||
&Plan::build(p, owner, &baseline, &untouched).unwrap(),
|
||||
),
|
||||
);
|
||||
}
|
||||
// This restores the exact sidecar candidate; it is not a production Undo
|
||||
// command or evidence of a layout source/REST persistence implementation.
|
||||
same_scene(
|
||||
&baseline,
|
||||
&render(
|
||||
p,
|
||||
owner,
|
||||
&Plan::build(p, owner, &baseline, &original).unwrap(),
|
||||
),
|
||||
);
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_is_rendered_exactly_and_diagnosed_with_extents_covering_the_earlier_far_event() {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
let before = p.clone();
|
||||
let source = document.bundle().clone();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let first = id(p, "first");
|
||||
let second = id(p, "second");
|
||||
let plan = Plan::build(p, owner, &baseline, &Candidate::from([(first, 1000.)])).unwrap();
|
||||
assert!(plan.diagnostics.contains(&Diagnostic::SourceOrderCrossing {
|
||||
scope: owner,
|
||||
before: first,
|
||||
after: second
|
||||
}));
|
||||
let moved = render(p, owner, &plan);
|
||||
assert_eq!((row(&moved, first), row(&moved, second)), (1000., 240.));
|
||||
assert_eq!(row(&moved, id(p, "self_reply")), 728.);
|
||||
others_fixed(&baseline, &moved, first);
|
||||
assert_eq!(moved.sequence_bounds[&owner].1, 1026.);
|
||||
assert_eq!(moved.size.height, 1113.);
|
||||
assert!(
|
||||
line(&moved, first)
|
||||
.points
|
||||
.iter()
|
||||
.all(|point| point.y < moved.size.height)
|
||||
);
|
||||
assert_eq!(&p.elements[&owner].steps().unwrap()[..2], &[first, second]);
|
||||
// This is a policy falsifier: mechanically exact coordinates do not prove
|
||||
// that out-of-order vertical notation is understandable or acceptable.
|
||||
passive(&document, &before, &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_outlier_expands_enclosing_geometry_without_using_final_traversal_row_as_extent() {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let nested = id(p, "nested");
|
||||
let fragment = id(p, "fragment");
|
||||
let following = id(p, "self_call");
|
||||
let plan = Plan::build(p, owner, &baseline, &Candidate::from([(nested, 1000.)])).unwrap();
|
||||
assert!(
|
||||
plan.diagnostics
|
||||
.contains(&Diagnostic::FrameExtendsPastFollowingAnchor {
|
||||
frame: fragment,
|
||||
following
|
||||
})
|
||||
);
|
||||
let moved = render(p, owner, &plan);
|
||||
assert_eq!(row(&moved, nested), 1000.);
|
||||
assert_eq!(named_box(&moved, nested).rect.y, 980.);
|
||||
let enclosing = named_box(&moved, fragment).rect;
|
||||
assert_eq!(enclosing.y + enclosing.height, 1070.);
|
||||
assert!(moved.sequence_bounds[&owner].1 >= 1070.);
|
||||
assert!(moved.size.height > 1070.);
|
||||
assert_eq!(row(&moved, following), 636.);
|
||||
others_fixed(&baseline, &moved, nested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_targets_and_lifetime_inversion_fail_explicitly_instead_of_silent_clamping() {
|
||||
let (document, owner) = fixture(true);
|
||||
let p = document.project();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let create = id(p, "create_receiver");
|
||||
let delete = id(p, "delete_receiver");
|
||||
assert_eq!(
|
||||
Plan::build(p, owner, &baseline, &Candidate::from([(delete, 200.)])).unwrap_err(),
|
||||
PlanError::LifetimeInversionNotSupported { create, delete }
|
||||
);
|
||||
assert_eq!(
|
||||
Plan::build(p, owner, &baseline, &Candidate::from([(delete, f32::NAN)])).unwrap_err(),
|
||||
PlanError::NonFinite(delete)
|
||||
);
|
||||
let edge = id(p, "service");
|
||||
assert_eq!(
|
||||
Plan::build(p, owner, &baseline, &Candidate::from([(edge, 200.)])).unwrap_err(),
|
||||
PlanError::UnknownAnchor(edge)
|
||||
);
|
||||
let (other, other_owner) = fixture(false);
|
||||
let fragment = id(other.project(), "fragment");
|
||||
let other_scene = Scene::sequence(other.project(), other_owner, other_owner);
|
||||
assert_eq!(
|
||||
Plan::build(
|
||||
other.project(),
|
||||
other_owner,
|
||||
&other_scene,
|
||||
&Candidate::from([(fragment, 300.)])
|
||||
)
|
||||
.unwrap_err(),
|
||||
PlanError::CompoundMoveNotImplemented(fragment)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_plan_scope_is_inactive_in_other_threads_and_restored_after_deliberate_panic() {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let second = id(p, "second");
|
||||
let plan = Plan::build(p, owner, &baseline, &Candidate::from([(second, 200.)])).unwrap();
|
||||
let caught = std::panic::catch_unwind(|| {
|
||||
let _scope = Scope::install(&plan);
|
||||
assert_eq!(row(&Scene::sequence(p, owner, owner), second), 200.);
|
||||
std::thread::spawn(|| {
|
||||
let (document, owner) = fixture(false);
|
||||
let p = document.project();
|
||||
assert_eq!(
|
||||
row(&Scene::sequence(p, owner, owner), id(p, "second")),
|
||||
240.
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
panic!("deliberate complete-anchor scope unwind");
|
||||
});
|
||||
let payload = caught.expect_err("deliberate panic is required");
|
||||
assert_eq!(
|
||||
payload.downcast_ref::<&str>().copied(),
|
||||
Some("deliberate complete-anchor scope unwind")
|
||||
);
|
||||
same_scene(&baseline, &Scene::sequence(p, owner, owner));
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs
|
||||
index 3f9fd97..f1ef5b7 100644
|
||||
--- a/crates/oa-desktop/src/behavior.rs
|
||||
+++ b/crates/oa-desktop/src/behavior.rs
|
||||
@@ -1121,6 +1121,10 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
|
||||
#[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"]
|
||||
mod behavior_layout_scene_experiment;
|
||||
|
||||
+#[cfg(test)]
|
||||
+#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"]
|
||||
+mod behavior_anchor_plan_experiment;
|
||||
+
|
||||
struct BoxItem {
|
||||
id: Id,
|
||||
rect: Rectangle,
|
||||
@@ -1332,6 +1336,8 @@ 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.);
|
||||
+ #[cfg(test)]
|
||||
+ behavior_anchor_plan_experiment::scene_extent(&mut scene);
|
||||
if scenario {
|
||||
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
|
||||
p,
|
||||
@@ -1441,6 +1447,8 @@ impl Scene {
|
||||
}
|
||||
#[cfg(test)]
|
||||
behavior_layout_scene_experiment::resolve_row_anchor(*id, y);
|
||||
+ #[cfg(test)]
|
||||
+ behavior_anchor_plan_experiment::row_anchor(*id, y);
|
||||
if ordered {
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
@@ -1465,6 +1473,8 @@ impl Scene {
|
||||
(*y - top).max(95.),
|
||||
),
|
||||
);
|
||||
+ #[cfg(test)]
|
||||
+ let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect);
|
||||
self.boxes.insert(
|
||||
0,
|
||||
BoxItem {
|
||||
@@ -1649,6 +1659,8 @@ impl Scene {
|
||||
}
|
||||
if ordered {
|
||||
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
|
||||
+ #[cfg(test)]
|
||||
+ behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds);
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -0,0 +1,8 @@
|
||||
check.log -text -whitespace
|
||||
check-receipt.json -text whitespace=cr-at-eol
|
||||
list.log -text -whitespace
|
||||
list-receipt.json -text whitespace=cr-at-eol
|
||||
tests.log -text -whitespace
|
||||
tests-receipt.json -text whitespace=cr-at-eol
|
||||
inputs-complete-before.json -text whitespace=cr-at-eol
|
||||
inputs-complete-after.json -text whitespace=cr-at-eol
|
||||
@@ -0,0 +1,31 @@
|
||||
# Actual Scene anchor experiments: Windows result
|
||||
|
||||
Tested source: **4b782a71fc011defd39331d3d6d5b73c1506275c**. The complete-anchor candidate and the older floor/lifetime experiment were exercised together against their actual parser, Document and Scene implementation. This result does not cover the later typed-activation renderer or saved-layout implementation.
|
||||
|
||||
| Gate | Result |
|
||||
| --- | --- |
|
||||
| `cargo check -p oa-desktop --bin arcadia-studio --tests --locked --offline` | Exit 0; test-only experiment wiring compiled. |
|
||||
| `cargo test -p oa-desktop --bin arcadia-studio --locked --offline -- behavior_layout_scene_experiment behavior_anchor_plan_experiment --list` | Exit 0; exactly 10 floor/lifetime and 13 complete-anchor tests. |
|
||||
| Same two filters with `--nocapture` instead of `--list` | **23 passed, 0 failed, 0 ignored**; exit 0; 895 other tests filtered out. |
|
||||
|
||||
[Check output](check.log), [check receipt](check-receipt.json), [listing](list.log), [listing receipt](list-receipt.json), [test output](tests.log) and [test receipt](tests-receipt.json) are preserved without line-ending conversion. The two panic messages are deliberate restoration probes: both enclosing tests passed after checking their scoped state restoration. They are not unhandled application crashes.
|
||||
|
||||
The check ran from 2026-09-13T00:14:13.0884576Z through 00:47:39.8146970Z. Listing/build ran from 00:48:09.7268903Z through 01:05:48.2819288Z. Execution ran from 01:06:35.7121760Z through 01:06:41.2500471Z; libtest reported 0.62 seconds for the selected tests. Compilation reported 32m49s for check and 17m31s for the test build on this heavily delayed Windows host. These timings are not a comparative performance or large-model responsiveness result.
|
||||
|
||||
The [before](inputs-complete-before.json) and [after](inputs-complete-after.json) manifests contain the same **574 paths, byte lengths and SHA-256 hashes**, with identical membership. The capture covers Cargo manifests/lockfile, crates, docs, examples, build files, harness fixtures, the existing textual-authoring experiment and both live layout experiment directories. Historical review snapshots are not compiled. [Artifact hashes](artifacts.json) identify the eight raw output/receipt/input files. Raw terminal blank lines are preserved; exact-path attributes exclude raw logs from whitespace rewriting and allow CRLF in receipts/manifests.
|
||||
|
||||
Environment: Windows, Rust/Cargo 1.98.1, `stable-x86_64-pc-windows-gnullvm`, configured LLVM-MinGW/UCRT clang linker, one Cargo build job, incremental compilation disabled, locked offline dependencies, unoptimized profile. The exact shell runner applies the project's local toolchain environment; the portable commands above select the same test groups.
|
||||
|
||||
## What the result establishes
|
||||
|
||||
The independently specified numeric cases in the [prepared candidate](../behavior-anchor-plan-20260912/README.md) passed: upward message and nested-anchor placement, fixed-header and enclosing-scope refusals, actual Create/Delete lifetime geometry, self-message send/receive offsets, earlier outlier coverage, sparse signed-zero restoration, source-order crossing diagnostics and scoped thread/panic isolation. The full semantic Project and SourceBundle/history invariants asserted by those cases passed.
|
||||
|
||||
The old floor test remains a passing **counterexample to that policy**: an upward requested position is still clamped by the floor. Its test passing does not mean the floor satisfies free editing. Likewise the complete plan's explicit unsupported positions and its drawable chronology/overlap diagnostics are retained limitations, not a selected product movement policy.
|
||||
|
||||
## Limits and next integration
|
||||
|
||||
No public layout payload, source syntax, REST command, drag transaction, box/route editing or native workflow is implemented by these test-only hooks. No full regression suite, strict lint or GUI acceptance of a new product behavior is claimed here. Linux remains user-paused.
|
||||
|
||||
Preserve this original result before combining with peer `aadc6396d39ff336d05381d58d545093d3f37a73`. That renderer stores typed activation segments separately from boxes. Its future union must compare the complete activation vector, adapt bar/cue and activation-edge oracles, and strengthen the self-call expectation to receive634 → reply-send728. Retain lifecycle clipping, explicit nonempty/count checks and exact source/history assertions. [Issue 21 comment 754](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/21#issuecomment-754) records the pinned source comparison.
|
||||
|
||||
The selected storage direction and remaining gesture/label requirements are in the [reviewed scenario layout design](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/75cb09dade2cf4b9a176b5e6159f43e2345d196e/harness/scenario-layout-design.md). This bounded experiment does not close full scenario editing or the application goal.
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"sourceCommit": "4b782a71fc011defd39331d3d6d5b73c1506275c",
|
||||
"count": 8,
|
||||
"files": [
|
||||
{
|
||||
"path": "check.log",
|
||||
"bytes": 472,
|
||||
"sha256": "7f9626500c6507b12e039f5383579ecdec07d0ebd69deda6c95d1c44addf57e2",
|
||||
"git_blob": "34d68b0d1bb6eb853b577386b97698cca8b8a5f8"
|
||||
},
|
||||
{
|
||||
"path": "check-receipt.json",
|
||||
"bytes": 401,
|
||||
"sha256": "e4c1208ba0d59f39355a3fe65c2d044cf153376bde32afed53f644ecbcb1d08c",
|
||||
"git_blob": "0a6847e62286928072fc9a8a6f56735bfb909617"
|
||||
},
|
||||
{
|
||||
"path": "list.log",
|
||||
"bytes": 3796,
|
||||
"sha256": "e9d34a70c74aab4aea075f42f15df715339241271912e74445d125823e1d2567",
|
||||
"git_blob": "84d775081a622a61237526328fac103f49e62b27"
|
||||
},
|
||||
{
|
||||
"path": "list-receipt.json",
|
||||
"bytes": 463,
|
||||
"sha256": "df58692a05de2dac138e1f626d9f5fc2e19224e777499024127726c9d3afce03",
|
||||
"git_blob": "b7050a3be5c7456de1b0f64c22db812f6f9fa441"
|
||||
},
|
||||
{
|
||||
"path": "tests.log",
|
||||
"bytes": 4274,
|
||||
"sha256": "83846d9ffe05f0f555307cc5e1ea7097a8d53894e2755683790c6cbca9b9eeda",
|
||||
"git_blob": "167ed1f1852f4237daa8cf6f83cc2deb6b85edc7"
|
||||
},
|
||||
{
|
||||
"path": "tests-receipt.json",
|
||||
"bytes": 472,
|
||||
"sha256": "6e22ee3e80f49b987373eb99c25fcd8f4fe8d4af8b7bf5873e003b225187b7bb",
|
||||
"git_blob": "db735f324df988d7bbb3036d6b95610a29a66114"
|
||||
},
|
||||
{
|
||||
"path": "inputs-complete-before.json",
|
||||
"bytes": 110608,
|
||||
"sha256": "d5f268ab403dcc22abb1991fe04c652123ff32240dff8e9e7010937282ad967d",
|
||||
"git_blob": "3fde735115dcccad217a8ffa8000fec670084d76"
|
||||
},
|
||||
{
|
||||
"path": "inputs-complete-after.json",
|
||||
"bytes": 110608,
|
||||
"sha256": "45f1fc4d805a8948a83fa77bb46d83f59cea64d4af5a1bcc78a9a7a0fbf758b1",
|
||||
"git_blob": "04f0ec9b138c400f404755aa5e604ae4605cc7a4"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"command": "cargo check -p oa-desktop --bin arcadia-studio --tests --locked --offline",
|
||||
"startUtc": "2026-09-13T00:14:13.0884576Z",
|
||||
"endUtc": "2026-09-13T00:47:39.8146970Z",
|
||||
"base": "4b782a71fc011defd39331d3d6d5b73c1506275c",
|
||||
"cargoExitCode": 0,
|
||||
"expectedOldTests": 10,
|
||||
"expectedNewTests": 13,
|
||||
"listedOldTests": null,
|
||||
"listedNewTests": null,
|
||||
"selectionValid": true
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
Compiling oa-core v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\.local\behavior-anchor-plan-ytr83r-20260912\crates\oa-core)
|
||||
Compiling oa-desktop v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\.local\behavior-anchor-plan-ytr83r-20260912\crates\oa-desktop)
|
||||
Compiling oa-server v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\.local\behavior-anchor-plan-ytr83r-20260912\crates\oa-server)
|
||||
Finished `dev` profile [unoptimized] target(s) in 32m 49s
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"command": "cargo test -p oa-desktop --bin arcadia-studio --locked --offline -- behavior_layout_scene_experiment behavior_anchor_plan_experiment --list",
|
||||
"startUtc": "2026-09-13T00:48:09.7268903Z",
|
||||
"endUtc": "2026-09-13T01:05:48.2819288Z",
|
||||
"base": "4b782a71fc011defd39331d3d6d5b73c1506275c",
|
||||
"cargoExitCode": 0,
|
||||
"expectedOldTests": 10,
|
||||
"expectedNewTests": 13,
|
||||
"listedOldTests": 10,
|
||||
"listedNewTests": 13,
|
||||
"selectionValid": true
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
Compiling oa-core v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\.local\behavior-anchor-plan-ytr83r-20260912\crates\oa-core)
|
||||
Compiling oa-server v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\.local\behavior-anchor-plan-ytr83r-20260912\crates\oa-server)
|
||||
Compiling oa-desktop v0.2.0 (C:\Users\User\Documents\Git\ARCADIA-Studio\.local\behavior-anchor-plan-ytr83r-20260912\crates\oa-desktop)
|
||||
Finished `test` profile [unoptimized] target(s) in 17m 31s
|
||||
Running unittests src\main.rs (C:\Users\User\Documents\Git\ARCADIA-Studio\target\debug\deps\arcadia_studio-27db9628b266bf39.exe)
|
||||
behavior::behavior_anchor_plan_experiment::tests::complete_plan_scope_is_inactive_in_other_threads_and_restored_after_deliberate_panic: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::complete_seed_plan_preserves_both_actual_scene_and_document_baselines: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::crossing_is_rendered_exactly_and_diagnosed_with_extents_covering_the_earlier_far_event: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::earlier_death_reaches_380_and_actual_lifetime_and_activation_clip_there: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::lifecycle_anchor_below_actual_root_render_scope_is_rejected_before_scene_construction: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::nested353_scope_escape_is_named_and_rejected_before_missing_lifecycle_geometry_is_exposed: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::nested_move_that_would_relocate_fixed_header_is_rejected_but_mere_overlap_is_not_banned: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::nested_outlier_expands_enclosing_geometry_without_using_final_traversal_row_as_extent: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::nested_upward_anchor_reaches_457_without_moving_parent_or_followers: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::self_call_has_explicit_send610_receive634_and_reply_stays728: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::signed_zero_keeps_sparse_candidate_and_restoring_original_candidate_restores_actual_scene: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::unsupported_targets_and_lifetime_inversion_fail_explicitly_instead_of_silent_clamping: test
|
||||
behavior::behavior_anchor_plan_experiment::tests::upward_second_delivery_reaches_200_and_all_other_occurrence_anchors_stay_fixed: test
|
||||
behavior::behavior_layout_scene_experiment::current_floor_is_an_explicit_counterexample_to_general_free_positioning: test
|
||||
behavior::behavior_layout_scene_experiment::lifetime::delayed_create_call_and_delete_have_real_lifetime_markers_and_exact_clipped_activation: test
|
||||
behavior::behavior_layout_scene_experiment::lifetime::moving_create_moves_real_lifetime_and_followers_without_changing_semantics_or_source: test
|
||||
behavior::behavior_layout_scene_experiment::lifetime::moving_delete_extends_only_to_actual_death_and_zero_motion_preserves_latent_lifetime_intent: test
|
||||
behavior::behavior_layout_scene_experiment::moved_repeat_drives_actual_scene_followers_frames_and_self_message_without_semantic_edits: test
|
||||
behavior::behavior_layout_scene_experiment::nested_anchor_grows_actual_fragment_and_self_call_keeps_its_receive_offset: test
|
||||
behavior::behavior_layout_scene_experiment::real_parser_roundtrip_and_baseline_scene_have_independent_expected_anchors: test
|
||||
behavior::behavior_layout_scene_experiment::scoped_hook_is_inactive_by_default_thread_local_and_restored_after_panic: test
|
||||
behavior::behavior_layout_scene_experiment::source_reorder_keeps_override_bound_to_occurrence_identity_and_does_not_write_back_projection: test
|
||||
behavior::behavior_layout_scene_experiment::zero_pointer_delta_preserves_latent_authored_intent_but_explicit_assignment_can_change_it: test
|
||||
|
||||
23 tests, 0 benchmarks
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"command": "cargo test -p oa-desktop --bin arcadia-studio --locked --offline -- behavior_layout_scene_experiment behavior_anchor_plan_experiment --nocapture",
|
||||
"startUtc": "2026-09-13T01:06:35.7121760Z",
|
||||
"endUtc": "2026-09-13T01:06:41.2500471Z",
|
||||
"base": "4b782a71fc011defd39331d3d6d5b73c1506275c",
|
||||
"cargoExitCode": 0,
|
||||
"expectedOldTests": 10,
|
||||
"expectedNewTests": 13,
|
||||
"listedOldTests": null,
|
||||
"listedNewTests": null,
|
||||
"selectionValid": true
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
Finished `test` profile [unoptimized] target(s) in 3.34s
|
||||
Running unittests src\main.rs (C:\Users\User\Documents\Git\ARCADIA-Studio\target\debug\deps\arcadia_studio-27db9628b266bf39.exe)
|
||||
|
||||
running 23 tests
|
||||
test behavior::behavior_anchor_plan_experiment::tests::complete_seed_plan_preserves_both_actual_scene_and_document_baselines ... ok
|
||||
|
||||
thread 'behavior::behavior_anchor_plan_experiment::tests::complete_plan_scope_is_inactive_in_other_threads_and_restored_after_deliberate_panic' (4328) panicked at crates\oa-desktop\src\..\..\..\harness\experiments\behavior-anchor-plan-20260912\tests.rs:719:9:
|
||||
deliberate complete-anchor scope unwind
|
||||
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
|
||||
test behavior::behavior_anchor_plan_experiment::tests::complete_plan_scope_is_inactive_in_other_threads_and_restored_after_deliberate_panic ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::crossing_is_rendered_exactly_and_diagnosed_with_extents_covering_the_earlier_far_event ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::earlier_death_reaches_380_and_actual_lifetime_and_activation_clip_there ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::lifecycle_anchor_below_actual_root_render_scope_is_rejected_before_scene_construction ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::nested353_scope_escape_is_named_and_rejected_before_missing_lifecycle_geometry_is_exposed ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::nested_move_that_would_relocate_fixed_header_is_rejected_but_mere_overlap_is_not_banned ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::nested_outlier_expands_enclosing_geometry_without_using_final_traversal_row_as_extent ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::self_call_has_explicit_send610_receive634_and_reply_stays728 ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::nested_upward_anchor_reaches_457_without_moving_parent_or_followers ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::unsupported_targets_and_lifetime_inversion_fail_explicitly_instead_of_silent_clamping ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::signed_zero_keeps_sparse_candidate_and_restoring_original_candidate_restores_actual_scene ... ok
|
||||
test behavior::behavior_layout_scene_experiment::current_floor_is_an_explicit_counterexample_to_general_free_positioning ... ok
|
||||
test behavior::behavior_anchor_plan_experiment::tests::upward_second_delivery_reaches_200_and_all_other_occurrence_anchors_stay_fixed ... ok
|
||||
test behavior::behavior_layout_scene_experiment::lifetime::moving_create_moves_real_lifetime_and_followers_without_changing_semantics_or_source ... ok
|
||||
test behavior::behavior_layout_scene_experiment::lifetime::delayed_create_call_and_delete_have_real_lifetime_markers_and_exact_clipped_activation ... ok
|
||||
test behavior::behavior_layout_scene_experiment::moved_repeat_drives_actual_scene_followers_frames_and_self_message_without_semantic_edits ... ok
|
||||
test behavior::behavior_layout_scene_experiment::lifetime::moving_delete_extends_only_to_actual_death_and_zero_motion_preserves_latent_lifetime_intent ... ok
|
||||
test behavior::behavior_layout_scene_experiment::nested_anchor_grows_actual_fragment_and_self_call_keeps_its_receive_offset ... ok
|
||||
test behavior::behavior_layout_scene_experiment::real_parser_roundtrip_and_baseline_scene_have_independent_expected_anchors ... ok
|
||||
test behavior::behavior_layout_scene_experiment::source_reorder_keeps_override_bound_to_occurrence_identity_and_does_not_write_back_projection ... ok
|
||||
test behavior::behavior_layout_scene_experiment::zero_pointer_delta_preserves_latent_authored_intent_but_explicit_assignment_can_change_it ... ok
|
||||
|
||||
thread 'behavior::behavior_layout_scene_experiment::scoped_hook_is_inactive_by_default_thread_local_and_restored_after_panic' (856) panicked at crates\oa-desktop\src\..\..\..\harness\experiments\behavior-layout-scene-20260912\mod.rs:336:9:
|
||||
exercise scoped restoration
|
||||
test behavior::behavior_layout_scene_experiment::scoped_hook_is_inactive_by_default_thread_local_and_restored_after_panic ... ok
|
||||
|
||||
test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 895 filtered out; finished in 0.62s
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
attempt1/** -text
|
||||
attempt2/** -text
|
||||
attempt1/harness/experiments/behavior-anchor-plan-20260912/wiring.patch -whitespace
|
||||
attempt2/harness/experiments/behavior-anchor-plan-20260912/wiring.patch -whitespace
|
||||
@@ -0,0 +1,14 @@
|
||||
# Complete-anchor plan: independent static findings
|
||||
|
||||
The [original five-file candidate and hashes](attempt1/manifest.json), based on66a1f1690f778c8814bfefff7f46fe7f6bbe520e, are preserved exactly. It was not compiled or executed. These are source-verified counterexamples, not recorded runtime failures. Two reviewers independently examined the first; the independent reviewer also traced the second into actual lifecycle projection.
|
||||
|
||||
1. In the original named scenario fixture, setting the nested action anchor to300 moves its box to280..350. Frame coverage expands the fragment from top332 to260 while its FrameStart anchor and Scene row remain332; title and header hit geometry follow260..298, and the before-slot remains290. The existing rows-only unchanged-anchor check cannot detect this split. A bounded repair must keep the frame header invariant or reject this unsupported operation explicitly until a compound/header policy is defined.
|
||||
2. In the lifetime fixture, setting Create to50 passes the candidate admission checks, but the actual lifecycle renderer starts the root span at85. Its projection rejects the birth as missing geometry; the message and row can remain50 while the real birth marker disappears. Admission must account for the actual supported scope or expose an explicit unsupported result, rather than claiming preserved lifecycle geometry.
|
||||
|
||||
Feasible upward movement inside the existing headers/lifetimes remains required by the experiment. Neither finding authorizes turning preferred spacing into a permanent user movement restriction. The corrected candidate, compiler results and production-policy decision remain separate. No native, persistence or Undo acceptance is claimed.
|
||||
|
||||
## Additional scope-boundary finding before execution
|
||||
|
||||
The [two-fix candidate](attempt2/manifest.json) remains uncompiled. Its author found that the admitted nested353 overlap case can expand an operand scope upward to311, before the enclosing fragment starts332. The existing lifecycle projector rejects that scope as missing geometry. Earlier review of the two guards did not establish this ancestor relationship. The next bounded bridge must reject incompatible scope intervals explicitly and test the actual lifecycle diagnostics; overlapping header geometry alone must not become a global spacing ban. These accumulating dependencies show that complete anchors require a coherent scope/lifecycle contract. The legacy renderer is not demonstrated to support arbitrary free placement by changing its cursor.
|
||||
|
||||
The three exact wiring.patch artifacts retain CRLF and patch-context blank lines. Git whitespace checking is disabled only for those raw patch paths; their recorded bytes are preserved. All other changed source and report paths remain checked.
|
||||
+3515
File diff suppressed because it is too large
Load Diff
+71
@@ -0,0 +1,71 @@
|
||||
# Complete anchor plan into actual Scene — pending execution
|
||||
|
||||
Prepared 2026-09-12 in isolated branch `codex/behavior-anchor-plan-ytr83r-20260912`, base `66a1f1690f778c8814bfefff7f46fe7f6bbe520e`. Parent owns compilation and publication. **Not compiled, executed, staged or committed.** Only test-only additions to `crates/oa-desktop/src/behavior.rs` and this new directory were authored. The existing floor experiment and its fixtures are unchanged. All new production-file statements are guarded by `cfg(test)`; normal production rendering is unchanged.
|
||||
|
||||
## Bounded question
|
||||
|
||||
Can a complete ephemeral anchor plan drive the actual Scene at exact requested y coordinates without the old running-cursor floor, preserve unspecified anchors and the entire Document, and maintain covered extents and real lifecycle derivation? This isolates the integration seam from the unresolved choice of a product movement policy.
|
||||
|
||||
The old floor candidate imposed existing seed increments on every requested row. This candidate seeds missing anchors from the actual baseline Scene, then replaces explicitly requested coordinates exactly. It does not impose sequence-order constraints or overlap spacing. Source-order crossings are returned as diagnostic data, not silently clamped or converted into semantic Reorder. This is a mechanical projection experiment, not evidence that crossed vertical ordering is acceptable UX.
|
||||
|
||||
`Candidate` is an external map keyed by occurrence UUID. `Plan` is a complete resolved input for the currently visible Scene, including operand headers missing from the ordinary rows map. It contains row roles, derived message send/receive y values, frame bounds, sequence extents and canvas height. It is not a model, parser or REST payload.
|
||||
|
||||
## Actual code boundary and wiring
|
||||
|
||||
`wiring.patch` records the exact production-file delta from the base commit. The five new cfg(test) integration points are:
|
||||
|
||||
1. Include this module as a private child of `behavior`, next to the unchanged floor module.
|
||||
2. Immediately after the old floor hook, consume the complete plan's exact occurrence anchor, before Scene records rows and constructs the occurrence's existing geometry.
|
||||
3. Before inserting an existing fragment BoxItem, use the plan's frame bounds. Existing fragment paint and label code remain in place.
|
||||
4. Immediately after recording a sequence's bounds, substitute planned bounds and update the existing final insertion axis. An earlier event placed lower than the last event cannot truncate the scope merely because traversal ends earlier.
|
||||
5. Before current lifecycle and activation construction, substitute the covered canvas height. Current `Rendering::new`, message alignment, activation generation and drawing remain responsible for their actual geometry.
|
||||
|
||||
The plan is scoped per thread, inactive by default, restored by Drop during unwinding, and has a deliberate-panic test requiring the exact sentinel payload. Other tests on other threads cannot consume its plan. The original floor hooks remain operational when their own scope is active; the new plan is otherwise absent.
|
||||
|
||||
Footprints are measured from the actual baseline named boxes, message polylines and label bounds, translated by requested row deltas. This avoids cloning the renderer's line or lifecycle algorithms. Empty activation bars are excluded because the real renderer derives those later. Frames conservatively preserve seed extents and expand when covered geometry escapes; growth adds an explicitly experimental 20px presentation margin. Sequence bounds preserve seed extents and measured edge padding while expanding to cover outliers; canvas height uses the original scope-to-canvas margin. Neither these margins nor nonshrinking seed extents are adopted user requirements. This is not a general container-resize solver.
|
||||
|
||||
## Independently expected checks
|
||||
|
||||
Ten new test functions use the existing hand-authored fixtures through the actual parser and Document bundle loader. Numeric expectations below are independently stated rather than generated from the new planner:
|
||||
|
||||
| Case | Expected actual Scene result |
|
||||
| --- | --- |
|
||||
| Empty complete plan, both fixtures | Same rows, named boxes and marker kinds, lines and labels, scopes, slots, lifecycle, open calls and size as the unmodified baseline Scene |
|
||||
| Second delivery240→200 | First148; second200; every other occurrence anchor unchanged; both second-message endpoints200; repeated exchange references retain different occurrence IDs |
|
||||
| Nested action477→457 | Box top437, height70; original fragment top332/height240 retained; operand bounds and all other anchors unchanged |
|
||||
| Death427→380 | Create243 and call335 stay fixed; real alive span243..380; actual bar335..380; death marker/hit moves to380; unresolved-call cue remains, no Reply is invented |
|
||||
| Self-call636→610 | Actual send610/receive634, using the current24 offset; reply728 unchanged; MessageSend role distinguished from the lifecycle MessageReceive role |
|
||||
| Signed zero and candidate restoration | Both0 and-0 return the exact original sparse candidate; no first-edit materialization; restoring the original candidate restores Scene geometry |
|
||||
| First event→1000, last remains728 | First1000/second240, SourceOrderCrossing diagnostic, source order unchanged; root scope end1026 and height1113 cover the earlier outlier rather than the traversal tail |
|
||||
| Nested action→1000 | Box top980/bottom1050; enclosing frame bottom1070; actual root scope/canvas cover it; following self-call636 remains fixed and overlap is diagnosed |
|
||||
| Invalid/unsupported input | Unknown anchor, nonfinite y, compound frame movement and same-scope inverted birth/death fail explicitly |
|
||||
| Scoped execution | Another thread sees baseline geometry; a deliberate panic restores the baseline scope |
|
||||
|
||||
The primary cases compare the entire Project and original SourceBundle and require the real Document to remain clean with no Undo/Redo entry. Canonical parser roundtrip is used, but it covers only the semantic Project: layout stays external. Candidate restoration is a reversible-sidecar check, **not** a production Undo implementation. No command has been added to Document.
|
||||
|
||||
## Explicit limitations and falsifiers
|
||||
|
||||
- Crossing source-ordered events is deliberately drawable with diagnostics. That proves removal of the mechanical floor, not adoption of unrestricted vertical notation. The crossing output remains an independent product-policy falsifier.
|
||||
- An expanded frame can overlap the next event. Its diagnostic preserves that evidence instead of shifting the next event implicitly. The experiment does not establish a frame/header constraint policy or all feasible layouts.
|
||||
- The same-scope creation-after-deletion case returns `LifetimeInversionNotSupported` before rendering. Current lifecycle intervals cannot truthfully depict that inversion without another representation policy. This is an experimental bridge limitation, not permission to reject semantically valid models or a user-approved global movement rule. Cross-operand lifecycle combinations remain unproven.
|
||||
- Compound frame/operand movement, box resizing, horizontal layout, independent send/receive movement, route editing, drag transaction stamps, pointer capture, dirty-source recovery, persistence, REST and native UI are not implemented.
|
||||
- Self-call send and receive roles are explicit, but the current activation engine still owns its existing row-based behavior. Peer #21's typed activation integration must consume the actual receive anchor and retain the existing lifetime clipping. These tests do not silently bless the old activation-start discrepancy.
|
||||
- Scope/containment walking is deliberately straightforward and not a large-model performance implementation. There is no responsiveness claim.
|
||||
- Some self-message fixture exchanges are unassigned because the current global relation model forbids self edges. The existing fixture's known incomplete-model warnings remain; tests reject semantic errors.
|
||||
|
||||
No storage or parser design should be adopted from this adapter. Coordinate any eventual payload with prospective #7, whose required addressing must preserve diagram/owner/occurrence identity and explicit anchor roles. Coordinate inverse resize with #16 and typed activation/lifetime union with #21 before broad UI work.
|
||||
|
||||
Parent reports [peer #21 comment716](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/21#issuecomment-716), posted2026-09-12 23:06UTC: Codex-956atf owns an isolated typed-activation/lifecycle source union. Its intended order is finalized rows/height, lifecycle construction and Create/Delete alignment, then typed Call/Reply attachment, with receiver/scope clipping, logical reply timing distinct from clipped geometry, no resurrection after Delete, and unanswered-call cues preserved. That WIP is not integrated here. Retain the explicit send/receive seam and review the future union against these fixtures; this paragraph records a coordination dependency, not verified acceptance of peer code.
|
||||
|
||||
## Reproduction and handoff
|
||||
|
||||
At the parent's chosen compiler checkpoint, use the configured Windows Rust/linker environment:
|
||||
|
||||
```powershell
|
||||
cargo test -p oa-desktop behavior_anchor_plan_experiment --locked --offline -- --nocapture
|
||||
cargo test -p oa-desktop behavior_layout_scene_experiment --locked --offline -- --nocapture
|
||||
```
|
||||
|
||||
The first filter must execute ten new tests; the second must retain the ten existing floor/lifetime tests. Preserve compile failures, parser failures, numeric mismatches and diagnostics as results. Do not claim success from a zero-test filtered run or from compiling only production targets. An intentional panic should be followed by its restoration test passing; another panic payload is a failure.
|
||||
|
||||
Record exact compiled commit/uncommitted patch, commands and results before publication. Source hashes supplied with the handoff identify prepared bytes, not tested code. Root should publish the complete directory and exact behavior.rs delta so remote peers can reproduce it; workspace-local paths are not remote handoffs.
|
||||
+270
@@ -0,0 +1,270 @@
|
||||
//! Experimental complete anchor input for the actual Scene, cfg(test) only.
|
||||
//! No persistence schema and no replacement line/lifecycle/activation renderer.
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
mod tests;
|
||||
|
||||
type Candidate = BTreeMap<Id, f32>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AnchorRole {
|
||||
MessageSend,
|
||||
MessageReceive,
|
||||
Observation,
|
||||
FrameStart,
|
||||
OperandStart,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Anchor {
|
||||
y: f32,
|
||||
role: AnchorRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct MessageAnchors {
|
||||
send_y: f32,
|
||||
receive_y: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Diagnostic {
|
||||
SourceOrderCrossing { scope: Id, before: Id, after: Id },
|
||||
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum PlanError {
|
||||
UnknownAnchor(Id),
|
||||
NonFinite(Id),
|
||||
CompoundMoveNotImplemented(Id),
|
||||
LifetimeInversionNotSupported { create: Id, delete: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Plan {
|
||||
anchors: BTreeMap<Id, Anchor>,
|
||||
messages: BTreeMap<Id, MessageAnchors>,
|
||||
frames: BTreeMap<Id, Rectangle>,
|
||||
scopes: BTreeMap<Id, (f32, f32)>,
|
||||
height: f32,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match p.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. } => Some(body),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn role(body: &OccurrenceBody) -> AnchorRole {
|
||||
match body {
|
||||
OccurrenceBody::Exchange { message_kind: MessageKind::Create | MessageKind::Delete, .. } => AnchorRole::MessageReceive,
|
||||
OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend,
|
||||
OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart,
|
||||
OccurrenceBody::Branch { .. } => AnchorRole::OperandStart,
|
||||
_ => AnchorRole::Observation,
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(p: &Project, id: Id, owner: Id) -> bool {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
for _ in 0..p.elements.len() {
|
||||
match current {
|
||||
Some(parent) if parent == owner => return true,
|
||||
Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn depth(p: &Project, id: Id) -> usize {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
let mut result = 0;
|
||||
while let Some(parent) = current {
|
||||
result += 1;
|
||||
if result > p.elements.len() { break; }
|
||||
current = p.elements.get(&parent).and_then(|e| e.parent);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
||||
let x = a.x.min(b.x);
|
||||
let y = a.y.min(b.y);
|
||||
Rectangle::new(Point::new(x, y), Size::new(
|
||||
(a.x + a.width).max(b.x + b.width) - x,
|
||||
(a.y + a.height).max(b.y + b.height) - y,
|
||||
))
|
||||
}
|
||||
|
||||
/// Actual named shapes/message paths supply footprint measurements; activation
|
||||
/// bars are intentionally excluded because current Scene derives them later.
|
||||
fn footprints(scene: &Scene, anchors: &BTreeMap<Id, Anchor>, seed: &BTreeMap<Id, Anchor>) -> BTreeMap<Id, Rectangle> {
|
||||
let mut out = BTreeMap::new();
|
||||
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
|
||||
let Some(anchor) = anchors.get(&item.id) else { continue; };
|
||||
let mut rect = item.rect;
|
||||
rect.y += anchor.y - seed[&item.id].y;
|
||||
out.insert(item.id, rect);
|
||||
}
|
||||
for line in &scene.lines {
|
||||
let Some(anchor) = anchors.get(&line.id) else { continue; };
|
||||
let delta = anchor.y - seed[&line.id].y;
|
||||
let mut rect = line.label_bounds();
|
||||
rect.y += delta;
|
||||
for point in &line.points {
|
||||
rect = union(rect, Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO));
|
||||
}
|
||||
out.entry(line.id).and_modify(|old| *old = union(*old, rect)).or_insert(rect);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn covered_bounds(p: &Project, owner: Id, footprints: &BTreeMap<Id, Rectangle>) -> Option<Rectangle> {
|
||||
footprints.iter().filter(|(id, _)| inside(p, **id, owner))
|
||||
.map(|(_, rect)| *rect).reduce(union)
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
fn build(p: &Project, owner: Id, baseline: &Scene, desired: &Candidate) -> Result<Self, PlanError> {
|
||||
let mut seed: BTreeMap<_, _> = baseline.rows.iter().map(|(id, (_, y))| {
|
||||
(*id, Anchor { y: *y, role: role(occurrence(p, *id).expect("Scene occurrence")) })
|
||||
}).collect();
|
||||
// Operand headers are real Scene boxes, but are not entries in rows.
|
||||
for item in &baseline.boxes {
|
||||
if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) {
|
||||
seed.insert(item.id, Anchor { y: item.rect.y, role: AnchorRole::OperandStart });
|
||||
}
|
||||
}
|
||||
let mut anchors = seed.clone();
|
||||
for (&id, &y) in desired {
|
||||
let anchor = anchors.get_mut(&id).ok_or(PlanError::UnknownAnchor(id))?;
|
||||
if !y.is_finite() { return Err(PlanError::NonFinite(id)); }
|
||||
if matches!(anchor.role, AnchorRole::FrameStart | AnchorRole::OperandStart) && y != anchor.y {
|
||||
return Err(PlanError::CompoundMoveNotImplemented(id));
|
||||
}
|
||||
anchor.y = y;
|
||||
}
|
||||
let mut messages = BTreeMap::new();
|
||||
for line in &baseline.lines {
|
||||
if let (Some(anchor), Some(first), Some(last)) = (anchors.get(&line.id), line.points.first(), line.points.last()) {
|
||||
let delta = anchor.y - seed[&line.id].y;
|
||||
messages.insert(line.id, MessageAnchors { send_y: first.y + delta, receive_y: last.y + delta });
|
||||
}
|
||||
}
|
||||
// The current lifecycle bridge needs positive birth/death intervals.
|
||||
// This is an explicit unsupported crossing, not a new semantic rule.
|
||||
for (&create, message) in &messages {
|
||||
if let Some(OccurrenceBody::Exchange { message_kind: MessageKind::Create, receiver: Some(lane), .. }) = occurrence(p, create) {
|
||||
for (&delete, finish) in &messages {
|
||||
if p.elements[&create].parent == p.elements[&delete].parent
|
||||
&& matches!(occurrence(p, delete), Some(OccurrenceBody::Exchange { message_kind: MessageKind::Delete, receiver: Some(other), .. }) if other == lane)
|
||||
&& message.receive_y > finish.receive_y {
|
||||
return Err(PlanError::LifetimeInversionNotSupported { create, delete });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let old_footprints = footprints(baseline, &seed, &seed);
|
||||
let mut drawn = footprints(baseline, &anchors, &seed);
|
||||
let mut frames = BTreeMap::new();
|
||||
let mut containers: Vec<_> = seed.keys().copied()
|
||||
.filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. }))).collect();
|
||||
containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id)));
|
||||
for id in containers {
|
||||
let original = old_footprints[&id];
|
||||
let mut rect = original;
|
||||
if let Some(covered) = covered_bounds(p, id, &drawn) {
|
||||
// Keep seed extents where possible; 20px growth padding is an
|
||||
// explicit experimental presentation preference, not semantics.
|
||||
if covered.y < rect.y { rect = union(rect, Rectangle::new(Point::new(covered.x, covered.y - 20.), Size::new(covered.width, covered.height + 20.))); }
|
||||
if covered.y + covered.height > rect.y + rect.height {
|
||||
rect.height = covered.y + covered.height + 20. - rect.y;
|
||||
}
|
||||
}
|
||||
frames.insert(id, rect);
|
||||
drawn.insert(id, rect);
|
||||
}
|
||||
let mut scopes = baseline.sequence_bounds.clone();
|
||||
for (&scope, bounds) in &mut scopes {
|
||||
if let (Some(old), Some(new)) = (covered_bounds(p, scope, &old_footprints), covered_bounds(p, scope, &drawn)) {
|
||||
let top_padding = (old.y - bounds.0).max(0.);
|
||||
let bottom_padding = (bounds.1 - old.y - old.height).max(0.);
|
||||
if new.y < bounds.0 { bounds.0 = new.y - top_padding; }
|
||||
bounds.1 = bounds.1.max(new.y + new.height + bottom_padding);
|
||||
}
|
||||
}
|
||||
let tail = baseline.size.height - baseline.sequence_bounds[&owner].1;
|
||||
let height = baseline.size.height.max(scopes[&owner].1 + tail);
|
||||
let mut diagnostics = Vec::new();
|
||||
for element in p.elements.values().filter(|e| e.id == owner || inside(p, e.id, owner)) {
|
||||
if let Some(steps) = element.steps() {
|
||||
for pair in steps.windows(2) {
|
||||
if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) {
|
||||
if a.y > b.y { diagnostics.push(Diagnostic::SourceOrderCrossing { scope: element.id, before: pair[0], after: pair[1] }); }
|
||||
if let Some(frame) = frames.get(&pair[0]) {
|
||||
if frame.y + frame.height > b.y { diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor { frame: pair[0], following: pair[1] }); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self { anchors, messages, frames, scopes, height, diagnostics })
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static PLAN: RefCell<Option<Plan>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Scope(Option<Plan>);
|
||||
impl Scope {
|
||||
fn install(plan: &Plan) -> Self { Self(PLAN.with(|slot| slot.replace(Some(plan.clone())))) }
|
||||
}
|
||||
impl Drop for Scope {
|
||||
fn drop(&mut self) { PLAN.with(|slot| { slot.replace(self.0.take()); }); }
|
||||
}
|
||||
|
||||
pub(super) fn row_anchor(id: Id, y: &mut f32) {
|
||||
PLAN.with(|slot| {
|
||||
if let Some(plan) = slot.borrow().as_ref() {
|
||||
*y = plan.anchors.get(&id).expect("complete Scene anchor plan").y;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn frame_bounds(id: Id, fallback: Rectangle) -> Rectangle {
|
||||
PLAN.with(|slot| slot.borrow().as_ref().map_or(fallback, |plan| plan.frames[&id]))
|
||||
}
|
||||
|
||||
pub(super) fn scope_end(owner: Id, y: &mut f32, bounds: &mut BTreeMap<Id, (f32, f32)>) {
|
||||
PLAN.with(|slot| {
|
||||
if let Some(plan) = slot.borrow().as_ref() {
|
||||
bounds.insert(owner, plan.scopes[&owner]);
|
||||
*y = plan.scopes[&owner].1 + 42.; // Existing insertion-axis offset.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn scene_extent(scene: &mut Scene) {
|
||||
PLAN.with(|slot| {
|
||||
if let Some(plan) = slot.borrow().as_ref() { scene.size.height = plan.height; }
|
||||
});
|
||||
}
|
||||
|
||||
fn render(p: &Project, owner: Id, plan: &Plan) -> Scene {
|
||||
let _scope = Scope::install(plan);
|
||||
Scene::sequence(p, owner, owner)
|
||||
}
|
||||
|
||||
fn drag(original: &Candidate, frame: &Scene, target: Id, delta_y: f32) -> Candidate {
|
||||
assert!(delta_y.is_finite());
|
||||
if delta_y == 0. { return original.clone(); }
|
||||
let mut result = original.clone();
|
||||
result.insert(target, frame.rows[&target].1 + delta_y);
|
||||
result
|
||||
}
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{language, source_project::{ProjectLimits, SourceBundle}, validation};
|
||||
|
||||
fn id(p: &Project, alias: &str) -> Id {
|
||||
p.identities().into_iter().find(|(_, name)| *name == alias).unwrap().0
|
||||
}
|
||||
|
||||
fn fixture(lifetime: bool) -> (Document, Id) {
|
||||
let (name, input, owner) = if lifetime {
|
||||
("lifetime.arcadia", include_str!("../behavior-layout-scene-20260912/lifetime.arcadia"), "lifetime_conversation")
|
||||
} else {
|
||||
("fixture.arcadia", include_str!("../behavior-layout-scene-20260912/fixture.arcadia"), "conversation")
|
||||
};
|
||||
let p = language::parse(input).expect("existing real Arcadia fixture");
|
||||
let errors: Vec<_> = validation::validate(&p).into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error).collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let bundle = SourceBundle { entry: name.into(), files: BTreeMap::from([(name.into(), input.into())]) };
|
||||
let document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(document.project(), &p);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
let owner = id(&p, owner);
|
||||
(document, owner)
|
||||
}
|
||||
|
||||
fn passive(document: &Document, before: &Project, source: &SourceBundle) {
|
||||
assert_eq!(document.project(), before);
|
||||
assert_eq!(document.bundle(), source);
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_undo() && !document.can_redo());
|
||||
}
|
||||
|
||||
fn row(scene: &Scene, id: Id) -> f32 { scene.rows[&id].1 }
|
||||
fn line(scene: &Scene, id: Id) -> &Line {
|
||||
assert_eq!(scene.lines.iter().filter(|line| line.id == id).count(), 1);
|
||||
scene.lines.iter().find(|line| line.id == id).unwrap()
|
||||
}
|
||||
fn named_box(scene: &Scene, id: Id) -> &BoxItem {
|
||||
scene.boxes.iter().find(|item| item.id == id && !item.title.is_empty()).unwrap()
|
||||
}
|
||||
|
||||
fn same_scene(a: &Scene, b: &Scene) {
|
||||
assert_eq!(a.rows, b.rows);
|
||||
assert_eq!(a.sequence_bounds, b.sequence_bounds);
|
||||
assert_eq!(a.size, b.size);
|
||||
assert_eq!(a.lanes, b.lanes);
|
||||
assert_eq!(a.lifecycle, b.lifecycle);
|
||||
assert_eq!(a.open_calls, b.open_calls);
|
||||
let boxes = |s: &Scene| s.boxes.iter().map(|b| (b.id,b.rect,b.title.clone(),b.detail.clone(),b.marker)).collect::<Vec<_>>();
|
||||
let lines = |s: &Scene| s.lines.iter().map(|l| (l.id,l.points.clone(),l.label_at,l.label.clone(),l.dashed)).collect::<Vec<_>>();
|
||||
let slots = |s: &Scene| s.slots.iter().map(|slot| (slot.slot.parent,slot.slot.before,slot.y,slot.label.clone())).collect::<Vec<_>>();
|
||||
assert_eq!(boxes(a),boxes(b));
|
||||
assert_eq!(lines(a),lines(b));
|
||||
assert_eq!(slots(a),slots(b));
|
||||
}
|
||||
|
||||
fn others_fixed(before: &Scene, after: &Scene, changed: Id) {
|
||||
assert_eq!(before.rows.len(),after.rows.len());
|
||||
for (&id, original) in &before.rows {
|
||||
if id != changed { assert_eq!(&after.rows[&id],original,"unexpected movement of {id}"); }
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_seed_plan_preserves_both_actual_scene_and_document_baselines() {
|
||||
for lifetime in [false,true] {
|
||||
let (document,owner)=fixture(lifetime);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let plan=Plan::build(p,owner,&baseline,&Candidate::new()).unwrap();
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
for (&id,(_,y)) in &baseline.rows { assert_eq!(plan.anchors[&id].y,*y); }
|
||||
same_scene(&baseline,&render(p,owner,&plan));
|
||||
let reparsed=language::parse(&language::format(p)).unwrap();
|
||||
same_scene(&baseline,&render(&reparsed,owner,&plan));
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_second_delivery_reaches_200_and_all_other_occurrence_anchors_stay_fixed() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let first=id(p,"first"); let second=id(p,"second");
|
||||
assert_ne!(first,second);
|
||||
for occurrence_id in [first,second] {
|
||||
assert!(matches!(occurrence(p,occurrence_id),Some(OccurrenceBody::Exchange { exchange:Some(edge),.. }) if *edge==id(p,"channel")));
|
||||
}
|
||||
assert_eq!((row(&baseline,first),row(&baseline,second)),(148.,240.));
|
||||
let candidate=drag(&Candidate::new(),&baseline,second,-40.);
|
||||
assert_eq!(candidate,Candidate::from([(second,200.)]));
|
||||
let plan=Plan::build(p,owner,&baseline,&candidate).unwrap();
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!((row(&moved,first),row(&moved,second)),(148.,200.));
|
||||
others_fixed(&baseline,&moved,second);
|
||||
assert_eq!(line(&moved,first).points,line(&baseline,first).points);
|
||||
let left=moved.lanes[&id(p,"left")]; let right=moved.lanes[&id(p,"right")];
|
||||
assert_eq!(line(&moved,second).points,vec![Point::new(left,200.),Point::new(right,200.)]);
|
||||
assert_eq!(plan.messages[&second],MessageAnchors { send_y:200.,receive_y:200. });
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
same_scene(&moved,&render(p,owner,&plan));
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_upward_anchor_reaches_457_without_moving_parent_or_followers() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let nested=id(p,"nested"); let fragment=id(p,"fragment");
|
||||
assert_eq!(row(&baseline,nested),477.);
|
||||
let candidate=drag(&Candidate::new(),&baseline,nested,-20.);
|
||||
let plan=Plan::build(p,owner,&baseline,&candidate).unwrap();
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!(row(&moved,nested),457.);
|
||||
assert_eq!(named_box(&moved,nested).rect.y,437.);
|
||||
assert_eq!(named_box(&moved,nested).rect.height,70.);
|
||||
assert_eq!(named_box(&moved,fragment).rect,named_box(&baseline,fragment).rect);
|
||||
assert_eq!((named_box(&moved,fragment).rect.y,named_box(&moved,fragment).rect.height),(332.,240.));
|
||||
assert_eq!(moved.sequence_bounds[&id(p,"operand")],baseline.sequence_bounds[&id(p,"operand")]);
|
||||
others_fixed(&baseline,&moved,nested);
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_death_reaches_380_and_actual_lifetime_and_activation_clip_there() {
|
||||
let (document,owner)=fixture(true);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let create=id(p,"create_receiver"); let call=id(p,"service_call"); let delete=id(p,"delete_receiver");
|
||||
let provider=id(p,"provider");
|
||||
assert_eq!((row(&baseline,create),row(&baseline,call),row(&baseline,delete)),(243.,335.,427.));
|
||||
let candidate=drag(&Candidate::new(),&baseline,delete,-47.);
|
||||
let plan=Plan::build(p,owner,&baseline,&candidate).unwrap();
|
||||
assert_eq!(plan.anchors[&delete].role,AnchorRole::MessageReceive);
|
||||
assert_eq!(plan.messages[&delete],MessageAnchors { send_y:380.,receive_y:380. });
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!(row(&moved,delete),380.);
|
||||
others_fixed(&baseline,&moved,delete);
|
||||
let spans=moved.lifecycle.alive_spans(provider,owner,85.,moved.size.height-20.);
|
||||
assert_eq!(spans.iter().map(|s|(s.start,s.end)).collect::<Vec<_>>(),vec![(243.,380.)]);
|
||||
assert!(moved.lifecycle.alive_spans(provider,owner,380.,moved.size.height-20.).is_empty());
|
||||
let x=moved.lanes[&provider];
|
||||
assert_eq!(moved.lifecycle.marker_at(Point::new(x,380.)),Some(delete));
|
||||
assert_eq!(moved.lifecycle.marker_at(Point::new(x,427.)),None);
|
||||
assert_eq!(moved.hit(Point::new(x,380.)),Some(delete));
|
||||
let bars:Vec<_>=moved.boxes.iter().filter(|b|b.id==call && b.title.is_empty()).collect();
|
||||
assert_eq!(bars.len(),1);
|
||||
assert_eq!(bars[0].rect,Rectangle::new(Point::new(x-4.,335.),Size::new(8.,45.)));
|
||||
assert_eq!(moved.open_calls,vec![Point::new(x+12.,364.)]);
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_call_has_explicit_send610_receive634_and_reply_stays728() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let call=id(p,"self_call"); let reply=id(p,"self_reply");
|
||||
let candidate=drag(&Candidate::new(),&baseline,call,-26.);
|
||||
let plan=Plan::build(p,owner,&baseline,&candidate).unwrap();
|
||||
assert_eq!(plan.anchors[&call],Anchor { y:610.,role:AnchorRole::MessageSend });
|
||||
assert_eq!(plan.messages[&call],MessageAnchors { send_y:610.,receive_y:634. });
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!(row(&moved,call),610.);
|
||||
assert_eq!(row(&moved,reply),728.);
|
||||
assert_eq!(line(&moved,call).points.iter().map(|p|p.y).collect::<Vec<_>>(),vec![610.,610.,634.,634.]);
|
||||
assert_eq!(line(&moved,reply).points,line(&baseline,reply).points);
|
||||
others_fixed(&baseline,&moved,call);
|
||||
// Peer #21 owns the activation start/column contract. This assertion covers
|
||||
// actual message anchors, not the old row-based activation start defect.
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_zero_keeps_sparse_candidate_and_restoring_original_candidate_restores_actual_scene() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let second=id(p,"second");
|
||||
let original=Candidate::new();
|
||||
for zero in [0.,-0.] { assert_eq!(drag(&original,&baseline,second,zero),original); }
|
||||
let changed=drag(&original,&baseline,second,-40.);
|
||||
let plan=Plan::build(p,owner,&baseline,&changed).unwrap();
|
||||
let frame=render(p,owner,&plan);
|
||||
for zero in [0.,-0.] {
|
||||
let untouched=drag(&changed,&frame,second,zero);
|
||||
assert_eq!(untouched,changed);
|
||||
same_scene(&frame,&render(p,owner,&Plan::build(p,owner,&baseline,&untouched).unwrap()));
|
||||
}
|
||||
// This restores the exact sidecar candidate; it is not a production Undo
|
||||
// command or evidence of a layout source/REST persistence implementation.
|
||||
same_scene(&baseline,&render(p,owner,&Plan::build(p,owner,&baseline,&original).unwrap()));
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_is_rendered_exactly_and_diagnosed_with_extents_covering_the_earlier_far_event() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let first=id(p,"first"); let second=id(p,"second");
|
||||
let plan=Plan::build(p,owner,&baseline,&Candidate::from([(first,1000.)])).unwrap();
|
||||
assert!(plan.diagnostics.contains(&Diagnostic::SourceOrderCrossing { scope:owner,before:first,after:second }));
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!((row(&moved,first),row(&moved,second)),(1000.,240.));
|
||||
assert_eq!(row(&moved,id(p,"self_reply")),728.);
|
||||
others_fixed(&baseline,&moved,first);
|
||||
assert_eq!(moved.sequence_bounds[&owner].1,1026.);
|
||||
assert_eq!(moved.size.height,1113.);
|
||||
assert!(line(&moved,first).points.iter().all(|point| point.y<moved.size.height));
|
||||
assert_eq!(&p.elements[&owner].steps().unwrap()[..2],&[first,second]);
|
||||
// This is a policy falsifier: mechanically exact coordinates do not prove
|
||||
// that out-of-order vertical notation is understandable or acceptable.
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_outlier_expands_enclosing_geometry_without_using_final_traversal_row_as_extent() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let baseline=Scene::sequence(p,owner,owner);
|
||||
let nested=id(p,"nested"); let fragment=id(p,"fragment"); let following=id(p,"self_call");
|
||||
let plan=Plan::build(p,owner,&baseline,&Candidate::from([(nested,1000.)])).unwrap();
|
||||
assert!(plan.diagnostics.contains(&Diagnostic::FrameExtendsPastFollowingAnchor { frame:fragment,following }));
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!(row(&moved,nested),1000.);
|
||||
assert_eq!(named_box(&moved,nested).rect.y,980.);
|
||||
let enclosing=named_box(&moved,fragment).rect;
|
||||
assert_eq!(enclosing.y+enclosing.height,1070.);
|
||||
assert!(moved.sequence_bounds[&owner].1>=1070.);
|
||||
assert!(moved.size.height>1070.);
|
||||
assert_eq!(row(&moved,following),636.);
|
||||
others_fixed(&baseline,&moved,nested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_targets_and_lifetime_inversion_fail_explicitly_instead_of_silent_clamping() {
|
||||
let (document,owner)=fixture(true);
|
||||
let p=document.project(); let baseline=Scene::sequence(p,owner,owner);
|
||||
let create=id(p,"create_receiver"); let delete=id(p,"delete_receiver");
|
||||
assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(delete,200.)])).unwrap_err(),
|
||||
PlanError::LifetimeInversionNotSupported { create,delete });
|
||||
assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(delete,f32::NAN)])).unwrap_err(),PlanError::NonFinite(delete));
|
||||
let edge=id(p,"service");
|
||||
assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(edge,200.)])).unwrap_err(),PlanError::UnknownAnchor(edge));
|
||||
let (other,other_owner)=fixture(false);
|
||||
let fragment=id(other.project(),"fragment");
|
||||
let other_scene=Scene::sequence(other.project(),other_owner,other_owner);
|
||||
assert_eq!(Plan::build(other.project(),other_owner,&other_scene,&Candidate::from([(fragment,300.)])).unwrap_err(),
|
||||
PlanError::CompoundMoveNotImplemented(fragment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_plan_scope_is_inactive_in_other_threads_and_restored_after_deliberate_panic() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let baseline=Scene::sequence(p,owner,owner);
|
||||
let second=id(p,"second");
|
||||
let plan=Plan::build(p,owner,&baseline,&Candidate::from([(second,200.)])).unwrap();
|
||||
let caught=std::panic::catch_unwind(|| {
|
||||
let _scope=Scope::install(&plan);
|
||||
assert_eq!(row(&Scene::sequence(p,owner,owner),second),200.);
|
||||
std::thread::spawn(|| {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project();
|
||||
assert_eq!(row(&Scene::sequence(p,owner,owner),id(p,"second")),240.);
|
||||
}).join().unwrap();
|
||||
panic!("deliberate complete-anchor scope unwind");
|
||||
});
|
||||
let payload=caught.expect_err("deliberate panic is required");
|
||||
assert_eq!(payload.downcast_ref::<&str>().copied(),Some("deliberate complete-anchor scope unwind"));
|
||||
same_scene(&baseline,&Scene::sequence(p,owner,owner));
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs
|
||||
index 3f9fd97..f1ef5b7 100644
|
||||
--- a/crates/oa-desktop/src/behavior.rs
|
||||
+++ b/crates/oa-desktop/src/behavior.rs
|
||||
@@ -1121,6 +1121,10 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
|
||||
#[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"]
|
||||
mod behavior_layout_scene_experiment;
|
||||
|
||||
+#[cfg(test)]
|
||||
+#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"]
|
||||
+mod behavior_anchor_plan_experiment;
|
||||
+
|
||||
struct BoxItem {
|
||||
id: Id,
|
||||
rect: Rectangle,
|
||||
@@ -1332,6 +1336,8 @@ 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.);
|
||||
+ #[cfg(test)]
|
||||
+ behavior_anchor_plan_experiment::scene_extent(&mut scene);
|
||||
if scenario {
|
||||
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
|
||||
p,
|
||||
@@ -1441,6 +1447,8 @@ impl Scene {
|
||||
}
|
||||
#[cfg(test)]
|
||||
behavior_layout_scene_experiment::resolve_row_anchor(*id, y);
|
||||
+ #[cfg(test)]
|
||||
+ behavior_anchor_plan_experiment::row_anchor(*id, y);
|
||||
if ordered {
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
@@ -1465,6 +1473,8 @@ impl Scene {
|
||||
(*y - top).max(95.),
|
||||
),
|
||||
);
|
||||
+ #[cfg(test)]
|
||||
+ let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect);
|
||||
self.boxes.insert(
|
||||
0,
|
||||
BoxItem {
|
||||
@@ -1649,6 +1659,8 @@ impl Scene {
|
||||
}
|
||||
if ordered {
|
||||
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
|
||||
+ #[cfg(test)]
|
||||
+ behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds);
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"base": "66a1f1690f778c8814bfefff7f46fe7f6bbe520e",
|
||||
"status": "Static-review candidate; not compiled or executed",
|
||||
"files": [
|
||||
{
|
||||
"path": "crates/oa-desktop/src/behavior.rs",
|
||||
"bytes": 137409,
|
||||
"sha256": "5884BA35AD994EB5936CA29C071A5002EFC221DC42EF35E2265D50593BDF60EC"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/mod.rs",
|
||||
"bytes": 10863,
|
||||
"sha256": "CA7CFE5DD1FBE37D54E5E96F0C73825D0AA0B06733E19C62592BF2AAFD62EC2F"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/tests.rs",
|
||||
"bytes": 14475,
|
||||
"sha256": "477B6D271CCBC95CFFF415DDE3DAE5CF4B7F1DEE12D4D5FCE6B095A0DF35891C"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/README.md",
|
||||
"bytes": 10076,
|
||||
"sha256": "6B9AD8B34AFC1E62931865007F7AFE57D28DC54AB9C805F33D3D86D215588514"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/wiring.patch",
|
||||
"bytes": 2131,
|
||||
"sha256": "4CE4BC45FBC419EBA6A07B048B82EFB8D85525514E3D2E56421948E7733F40FD"
|
||||
}
|
||||
]
|
||||
}
|
||||
+3515
File diff suppressed because it is too large
Load Diff
+79
@@ -0,0 +1,79 @@
|
||||
# Complete anchor plan into actual Scene — pending execution
|
||||
|
||||
Prepared 2026-09-12 in isolated branch `codex/behavior-anchor-plan-ytr83r-20260912`, base `66a1f1690f778c8814bfefff7f46fe7f6bbe520e`. Parent owns compilation and publication. **Not compiled, executed, staged or committed.** Only test-only additions to `crates/oa-desktop/src/behavior.rs` and this new directory were authored. The existing floor experiment and its fixtures are unchanged. All new production-file statements are guarded by `cfg(test)`; normal production rendering is unchanged.
|
||||
|
||||
## Bounded question
|
||||
|
||||
Can a complete ephemeral anchor plan drive the actual Scene at exact requested y coordinates without the old running-cursor floor, preserve unspecified anchors and the entire Document, and maintain covered extents and real lifecycle derivation? This isolates the integration seam from the unresolved choice of a product movement policy.
|
||||
|
||||
The old floor candidate imposed existing seed increments on every requested row. This candidate seeds missing anchors from the actual baseline Scene, then replaces explicitly requested coordinates exactly. It does not impose sequence-order constraints or overlap spacing. Source-order crossings are returned as diagnostic data, not silently clamped or converted into semantic Reorder. This is a mechanical projection experiment, not evidence that crossed vertical ordering is acceptable UX.
|
||||
|
||||
`Candidate` is an external map keyed by occurrence UUID. `Plan` is a complete resolved input for the currently visible Scene, including operand headers missing from the ordinary rows map. It contains row roles, derived message send/receive y values, frame bounds, sequence extents and canvas height. It is not a model, parser or REST payload.
|
||||
|
||||
## Actual code boundary and wiring
|
||||
|
||||
`wiring.patch` records the exact production-file delta from the base commit. The five new cfg(test) integration points are:
|
||||
|
||||
1. Include this module as a private child of `behavior`, next to the unchanged floor module.
|
||||
2. Immediately after the old floor hook, consume the complete plan's exact occurrence anchor, before Scene records rows and constructs the occurrence's existing geometry.
|
||||
3. Before inserting an existing fragment BoxItem, use the plan's frame bounds. Existing fragment paint and label code remain in place.
|
||||
4. Immediately after recording a sequence's bounds, substitute planned bounds and update the existing final insertion axis. An earlier event placed lower than the last event cannot truncate the scope merely because traversal ends earlier.
|
||||
5. Before current lifecycle and activation construction, substitute the covered canvas height. Current `Rendering::new`, message alignment, activation generation and drawing remain responsible for their actual geometry.
|
||||
|
||||
The plan is scoped per thread, inactive by default, restored by Drop during unwinding, and has a deliberate-panic test requiring the exact sentinel payload. Other tests on other threads cannot consume its plan. The original floor hooks remain operational when their own scope is active; the new plan is otherwise absent.
|
||||
|
||||
Footprints are measured from the actual baseline named boxes, message polylines and label bounds, translated by requested row deltas. This avoids cloning the renderer's line or lifecycle algorithms. Empty activation bars are excluded because the real renderer derives those later. Frames conservatively preserve seed extents and can grow downward; growth adds an explicitly experimental 20px presentation margin. A move requiring upward frame growth returns `FixedFrameHeaderWouldMove`, because the candidate cannot move an unspecified painted header while retaining its FrameStart and insertion slot. Mere overlap inside the existing frame is not categorically rejected. Sequence bounds preserve seed extents and measured edge padding while expanding to cover outliers; canvas height uses the original scope-to-canvas margin. Neither these margins nor nonshrinking seed extents are adopted user requirements. This is not a general container-resize solver.
|
||||
|
||||
## Independently expected checks
|
||||
|
||||
Twelve new test functions use the existing hand-authored fixtures through the actual parser and Document bundle loader. Numeric expectations below are independently stated rather than generated from the new planner:
|
||||
|
||||
| Case | Expected actual Scene result |
|
||||
| --- | --- |
|
||||
| Empty complete plan, both fixtures | Same rows, named boxes and marker kinds, lines and labels, scopes, slots, lifecycle, open calls and size as the unmodified baseline Scene |
|
||||
| Second delivery240→200 | First148; second200; every other occurrence anchor unchanged; both second-message endpoints200; repeated exchange references retain different occurrence IDs |
|
||||
| Nested action477→457 | Box top437, height70; actual fragment metadata and active header332..370 retained, header hit still resolves the fragment, before-slot290 and label unchanged; operand bounds and all other anchors unchanged |
|
||||
| Nested action→300 | Proposed footprint280..350 would relocate unspecified frame top332 to260; reject with `FixedFrameHeaderWouldMove` before rendering. Nested353 remains admitted despite overlap inside the fixed frame |
|
||||
| Create243→50 or84 | Reject with `LifecycleAnchorOutsideRenderScope`; actual root lifecycle span starts85 even when insertion-scope planning expands higher. At85 the real birth marker and alive span85..427 remain present |
|
||||
| Death427→380 | Create243 and call335 stay fixed; real alive span243..380; actual bar335..380; death marker/hit moves to380; unresolved-call cue remains, no Reply is invented |
|
||||
| Self-call636→610 | Actual send610/receive634, using the current24 offset; reply728 unchanged; MessageSend role distinguished from the lifecycle MessageReceive role |
|
||||
| Signed zero and candidate restoration | Both0 and-0 return the exact original sparse candidate; no first-edit materialization; restoring the original candidate restores Scene geometry |
|
||||
| First event→1000, last remains728 | First1000/second240, SourceOrderCrossing diagnostic, source order unchanged; root scope end1026 and height1113 cover the earlier outlier rather than the traversal tail |
|
||||
| Nested action→1000 | Box top980/bottom1050; enclosing frame bottom1070; actual root scope/canvas cover it; following self-call636 remains fixed and overlap is diagnosed |
|
||||
| Invalid/unsupported input | Unknown anchor, nonfinite y, compound frame movement and same-scope inverted birth/death fail explicitly |
|
||||
| Scoped execution | Another thread sees baseline geometry; a deliberate panic restores the baseline scope |
|
||||
|
||||
The primary cases compare the entire Project and original SourceBundle and require the real Document to remain clean with no Undo/Redo entry. Canonical parser roundtrip is used, but it covers only the semantic Project: layout stays external. Candidate restoration is a reversible-sidecar check, **not** a production Undo implementation. No command has been added to Document.
|
||||
|
||||
## Explicit limitations and falsifiers
|
||||
|
||||
- Crossing source-ordered events is deliberately drawable with diagnostics. That proves removal of the mechanical floor, not adoption of unrestricted vertical notation. The crossing output remains an independent product-policy falsifier.
|
||||
- An expanded frame can overlap the next event. Its diagnostic preserves that evidence instead of shifting the next event implicitly. The experiment does not establish a frame/header constraint policy or all feasible layouts.
|
||||
- Upward growth that would relocate an unspecified frame header is explicitly unsupported. This does not ban arbitrary overlap gaps; the numeric353 example remains admitted. Full compound/header movement policy remains unresolved.
|
||||
- Lifecycle receive anchors must be within the actual effective Rendering scope: root85..height-20, or its recorded non-root scope with the renderer's existing empty-scope allowance. Outside coordinates are rejected before exposing a Scene whose row exists but whose marker/lifetime is missing. This bounds the inspected bridge; it does not establish general ancestor/operand ordering or cross-operand lifetime support.
|
||||
- The same-scope creation-after-deletion case returns `LifetimeInversionNotSupported` before rendering. Current lifecycle intervals cannot truthfully depict that inversion without another representation policy. This is an experimental bridge limitation, not permission to reject semantically valid models or a user-approved global movement rule. Cross-operand lifecycle combinations remain unproven.
|
||||
- Compound frame/operand movement, box resizing, horizontal layout, independent send/receive movement, route editing, drag transaction stamps, pointer capture, dirty-source recovery, persistence, REST and native UI are not implemented.
|
||||
- Self-call send and receive roles are explicit, but the current activation engine still owns its existing row-based behavior. Peer #21's typed activation integration must consume the actual receive anchor and retain the existing lifetime clipping. These tests do not silently bless the old activation-start discrepancy.
|
||||
- Scope/containment walking is deliberately straightforward and not a large-model performance implementation. There is no responsiveness claim.
|
||||
- Some self-message fixture exchanges are unassigned because the current global relation model forbids self edges. The existing fixture's known incomplete-model warnings remain; tests reject semantic errors.
|
||||
|
||||
No storage or parser design should be adopted from this adapter. Coordinate any eventual payload with prospective #7, whose required addressing must preserve diagram/owner/occurrence identity and explicit anchor roles. Coordinate inverse resize with #16 and typed activation/lifetime union with #21 before broad UI work.
|
||||
|
||||
Parent reports [peer #21 comment716](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/21#issuecomment-716), posted2026-09-12 23:06UTC: Codex-956atf owns an isolated typed-activation/lifecycle source union. Its intended order is finalized rows/height, lifecycle construction and Create/Delete alignment, then typed Call/Reply attachment, with receiver/scope clipping, logical reply timing distinct from clipped geometry, no resurrection after Delete, and unanswered-call cues preserved. That WIP is not integrated here. Retain the explicit send/receive seam and review the future union against these fixtures; this paragraph records a coordination dependency, not verified acceptance of peer code.
|
||||
|
||||
## Reproduction and handoff
|
||||
|
||||
At the parent's chosen compiler checkpoint, use the configured Windows Rust/linker environment:
|
||||
|
||||
```powershell
|
||||
cargo test -p oa-desktop behavior_anchor_plan_experiment --locked --offline -- --nocapture
|
||||
cargo test -p oa-desktop behavior_layout_scene_experiment --locked --offline -- --nocapture
|
||||
```
|
||||
|
||||
The first filter must execute twelve new tests; the second must retain the ten existing floor/lifetime tests. Preserve compile failures, parser failures, numeric mismatches and diagnostics as results. Do not claim success from a zero-test filtered run or from compiling only production targets. An intentional panic should be followed by its restoration test passing; another panic payload is a failure.
|
||||
|
||||
## Static review correction history
|
||||
|
||||
The frozen attempt1 candidate was preserved before these corrections. Parent's authoritative archive is `.local/behavior-anchor-plan-review-20260912/attempt1` with its five-file hash manifest; parent owns portable archival publication. The pre-review failures are static, unexecuted findings: nested300 silently moved the frame header to260 while row332/slot290 stayed fixed, and Create50 was omitted as MissingGeometry by the actual root lifetime span starting85. The corrected candidate adds named unsupported errors and independent numeric checks rather than recording either original behavior as acceptance. Production hooks and the original floor experiment remain unchanged.
|
||||
|
||||
Record exact compiled commit/uncommitted patch, commands and results before publication. Source hashes supplied with the handoff identify prepared bytes, not tested code. Root should publish the complete directory and exact behavior.rs delta so remote peers can reproduce it; workspace-local paths are not remote handoffs.
|
||||
+297
@@ -0,0 +1,297 @@
|
||||
//! Experimental complete anchor input for the actual Scene, cfg(test) only.
|
||||
//! No persistence schema and no replacement line/lifecycle/activation renderer.
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
mod tests;
|
||||
|
||||
type Candidate = BTreeMap<Id, f32>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum AnchorRole {
|
||||
MessageSend,
|
||||
MessageReceive,
|
||||
Observation,
|
||||
FrameStart,
|
||||
OperandStart,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct Anchor {
|
||||
y: f32,
|
||||
role: AnchorRole,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct MessageAnchors {
|
||||
send_y: f32,
|
||||
receive_y: f32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum Diagnostic {
|
||||
SourceOrderCrossing { scope: Id, before: Id, after: Id },
|
||||
FrameExtendsPastFollowingAnchor { frame: Id, following: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
enum PlanError {
|
||||
UnknownAnchor(Id),
|
||||
NonFinite(Id),
|
||||
CompoundMoveNotImplemented(Id),
|
||||
FixedFrameHeaderWouldMove { frame: Id },
|
||||
LifecycleAnchorOutsideRenderScope { occurrence: Id, scope: Id },
|
||||
LifetimeInversionNotSupported { create: Id, delete: Id },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct Plan {
|
||||
anchors: BTreeMap<Id, Anchor>,
|
||||
messages: BTreeMap<Id, MessageAnchors>,
|
||||
frames: BTreeMap<Id, Rectangle>,
|
||||
scopes: BTreeMap<Id, (f32, f32)>,
|
||||
height: f32,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
fn occurrence(p: &Project, id: Id) -> Option<&OccurrenceBody> {
|
||||
match p.elements.get(&id)?.shared()? {
|
||||
SharedBody::Occurrence { body, .. } => Some(body),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn role(body: &OccurrenceBody) -> AnchorRole {
|
||||
match body {
|
||||
OccurrenceBody::Exchange { message_kind: MessageKind::Create | MessageKind::Delete, .. } => AnchorRole::MessageReceive,
|
||||
OccurrenceBody::Exchange { .. } => AnchorRole::MessageSend,
|
||||
OccurrenceBody::Fragment { .. } => AnchorRole::FrameStart,
|
||||
OccurrenceBody::Branch { .. } => AnchorRole::OperandStart,
|
||||
_ => AnchorRole::Observation,
|
||||
}
|
||||
}
|
||||
|
||||
fn inside(p: &Project, id: Id, owner: Id) -> bool {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
for _ in 0..p.elements.len() {
|
||||
match current {
|
||||
Some(parent) if parent == owner => return true,
|
||||
Some(parent) => current = p.elements.get(&parent).and_then(|e| e.parent),
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn depth(p: &Project, id: Id) -> usize {
|
||||
let mut current = p.elements.get(&id).and_then(|e| e.parent);
|
||||
let mut result = 0;
|
||||
while let Some(parent) = current {
|
||||
result += 1;
|
||||
if result > p.elements.len() { break; }
|
||||
current = p.elements.get(&parent).and_then(|e| e.parent);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn union(a: Rectangle, b: Rectangle) -> Rectangle {
|
||||
let x = a.x.min(b.x);
|
||||
let y = a.y.min(b.y);
|
||||
Rectangle::new(Point::new(x, y), Size::new(
|
||||
(a.x + a.width).max(b.x + b.width) - x,
|
||||
(a.y + a.height).max(b.y + b.height) - y,
|
||||
))
|
||||
}
|
||||
|
||||
/// Actual named shapes/message paths supply footprint measurements; activation
|
||||
/// bars are intentionally excluded because current Scene derives them later.
|
||||
fn footprints(scene: &Scene, anchors: &BTreeMap<Id, Anchor>, seed: &BTreeMap<Id, Anchor>) -> BTreeMap<Id, Rectangle> {
|
||||
let mut out = BTreeMap::new();
|
||||
for item in scene.boxes.iter().filter(|item| !item.title.is_empty()) {
|
||||
let Some(anchor) = anchors.get(&item.id) else { continue; };
|
||||
let mut rect = item.rect;
|
||||
rect.y += anchor.y - seed[&item.id].y;
|
||||
out.insert(item.id, rect);
|
||||
}
|
||||
for line in &scene.lines {
|
||||
let Some(anchor) = anchors.get(&line.id) else { continue; };
|
||||
let delta = anchor.y - seed[&line.id].y;
|
||||
let mut rect = line.label_bounds();
|
||||
rect.y += delta;
|
||||
for point in &line.points {
|
||||
rect = union(rect, Rectangle::new(Point::new(point.x, point.y + delta), Size::ZERO));
|
||||
}
|
||||
out.entry(line.id).and_modify(|old| *old = union(*old, rect)).or_insert(rect);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn covered_bounds(p: &Project, owner: Id, footprints: &BTreeMap<Id, Rectangle>) -> Option<Rectangle> {
|
||||
footprints.iter().filter(|(id, _)| inside(p, **id, owner))
|
||||
.map(|(_, rect)| *rect).reduce(union)
|
||||
}
|
||||
|
||||
impl Plan {
|
||||
fn build(p: &Project, owner: Id, baseline: &Scene, desired: &Candidate) -> Result<Self, PlanError> {
|
||||
let mut seed: BTreeMap<_, _> = baseline.rows.iter().map(|(id, (_, y))| {
|
||||
(*id, Anchor { y: *y, role: role(occurrence(p, *id).expect("Scene occurrence")) })
|
||||
}).collect();
|
||||
// Operand headers are real Scene boxes, but are not entries in rows.
|
||||
for item in &baseline.boxes {
|
||||
if matches!(occurrence(p, item.id), Some(OccurrenceBody::Branch { .. })) {
|
||||
seed.insert(item.id, Anchor { y: item.rect.y, role: AnchorRole::OperandStart });
|
||||
}
|
||||
}
|
||||
let mut anchors = seed.clone();
|
||||
for (&id, &y) in desired {
|
||||
let anchor = anchors.get_mut(&id).ok_or(PlanError::UnknownAnchor(id))?;
|
||||
if !y.is_finite() { return Err(PlanError::NonFinite(id)); }
|
||||
if matches!(anchor.role, AnchorRole::FrameStart | AnchorRole::OperandStart) && y != anchor.y {
|
||||
return Err(PlanError::CompoundMoveNotImplemented(id));
|
||||
}
|
||||
anchor.y = y;
|
||||
}
|
||||
let mut messages = BTreeMap::new();
|
||||
for line in &baseline.lines {
|
||||
if let (Some(anchor), Some(first), Some(last)) = (anchors.get(&line.id), line.points.first(), line.points.last()) {
|
||||
let delta = anchor.y - seed[&line.id].y;
|
||||
messages.insert(line.id, MessageAnchors { send_y: first.y + delta, receive_y: last.y + delta });
|
||||
}
|
||||
}
|
||||
// The current lifecycle bridge needs positive birth/death intervals.
|
||||
// This is an explicit unsupported crossing, not a new semantic rule.
|
||||
for (&create, message) in &messages {
|
||||
if let Some(OccurrenceBody::Exchange { message_kind: MessageKind::Create, receiver: Some(lane), .. }) = occurrence(p, create) {
|
||||
for (&delete, finish) in &messages {
|
||||
if p.elements[&create].parent == p.elements[&delete].parent
|
||||
&& matches!(occurrence(p, delete), Some(OccurrenceBody::Exchange { message_kind: MessageKind::Delete, receiver: Some(other), .. }) if other == lane)
|
||||
&& message.receive_y > finish.receive_y {
|
||||
return Err(PlanError::LifetimeInversionNotSupported { create, delete });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let old_footprints = footprints(baseline, &seed, &seed);
|
||||
let mut drawn = footprints(baseline, &anchors, &seed);
|
||||
let mut frames = BTreeMap::new();
|
||||
let mut containers: Vec<_> = seed.keys().copied()
|
||||
.filter(|id| matches!(occurrence(p, *id), Some(OccurrenceBody::Fragment { .. }))).collect();
|
||||
containers.sort_by_key(|id| std::cmp::Reverse(depth(p, *id)));
|
||||
for id in containers {
|
||||
let original = old_footprints[&id];
|
||||
let mut rect = original;
|
||||
if let Some(covered) = covered_bounds(p, id, &drawn) {
|
||||
// Keep seed extents where possible; 20px growth padding is an
|
||||
// explicit experimental presentation preference, not semantics.
|
||||
// Upward envelope growth would move the painted frame header
|
||||
// while its fixed FrameStart/row/slot remained at the seed.
|
||||
// Reject that unsupported compound change, not mere overlap.
|
||||
if covered.y < rect.y {
|
||||
return Err(PlanError::FixedFrameHeaderWouldMove { frame: id });
|
||||
}
|
||||
if covered.y + covered.height > rect.y + rect.height {
|
||||
rect.height = covered.y + covered.height + 20. - rect.y;
|
||||
}
|
||||
}
|
||||
frames.insert(id, rect);
|
||||
drawn.insert(id, rect);
|
||||
}
|
||||
let mut scopes = baseline.sequence_bounds.clone();
|
||||
for (&scope, bounds) in &mut scopes {
|
||||
if let (Some(old), Some(new)) = (covered_bounds(p, scope, &old_footprints), covered_bounds(p, scope, &drawn)) {
|
||||
let top_padding = (old.y - bounds.0).max(0.);
|
||||
let bottom_padding = (bounds.1 - old.y - old.height).max(0.);
|
||||
if new.y < bounds.0 { bounds.0 = new.y - top_padding; }
|
||||
bounds.1 = bounds.1.max(new.y + new.height + bottom_padding);
|
||||
}
|
||||
}
|
||||
let tail = baseline.size.height - baseline.sequence_bounds[&owner].1;
|
||||
let height = baseline.size.height.max(scopes[&owner].1 + tail);
|
||||
for (&id, message) in &messages {
|
||||
if matches!(occurrence(p, id), Some(OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Create | MessageKind::Delete, ..
|
||||
})) {
|
||||
let scope = p.elements[&id].parent.expect("lifecycle sequence owner");
|
||||
// Match Rendering::new's effective scope, not the insertion
|
||||
// scope: it overrides the root span to 85..height-20 and gives
|
||||
// empty non-root scopes the existing 26px lifetime allowance.
|
||||
let (start, end) = if scope == owner {
|
||||
(85., height - 20.)
|
||||
} else if let Some(&(start, end)) = scopes.get(&scope) {
|
||||
(start, if start == end { start + 26. } else { end })
|
||||
} else {
|
||||
return Err(PlanError::LifecycleAnchorOutsideRenderScope { occurrence: id, scope });
|
||||
};
|
||||
if message.receive_y < start || message.receive_y > end {
|
||||
return Err(PlanError::LifecycleAnchorOutsideRenderScope { occurrence: id, scope });
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut diagnostics = Vec::new();
|
||||
for element in p.elements.values().filter(|e| e.id == owner || inside(p, e.id, owner)) {
|
||||
if let Some(steps) = element.steps() {
|
||||
for pair in steps.windows(2) {
|
||||
if let (Some(a), Some(b)) = (anchors.get(&pair[0]), anchors.get(&pair[1])) {
|
||||
if a.y > b.y { diagnostics.push(Diagnostic::SourceOrderCrossing { scope: element.id, before: pair[0], after: pair[1] }); }
|
||||
if let Some(frame) = frames.get(&pair[0]) {
|
||||
if frame.y + frame.height > b.y { diagnostics.push(Diagnostic::FrameExtendsPastFollowingAnchor { frame: pair[0], following: pair[1] }); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self { anchors, messages, frames, scopes, height, diagnostics })
|
||||
}
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static PLAN: RefCell<Option<Plan>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct Scope(Option<Plan>);
|
||||
impl Scope {
|
||||
fn install(plan: &Plan) -> Self { Self(PLAN.with(|slot| slot.replace(Some(plan.clone())))) }
|
||||
}
|
||||
impl Drop for Scope {
|
||||
fn drop(&mut self) { PLAN.with(|slot| { slot.replace(self.0.take()); }); }
|
||||
}
|
||||
|
||||
pub(super) fn row_anchor(id: Id, y: &mut f32) {
|
||||
PLAN.with(|slot| {
|
||||
if let Some(plan) = slot.borrow().as_ref() {
|
||||
*y = plan.anchors.get(&id).expect("complete Scene anchor plan").y;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn frame_bounds(id: Id, fallback: Rectangle) -> Rectangle {
|
||||
PLAN.with(|slot| slot.borrow().as_ref().map_or(fallback, |plan| plan.frames[&id]))
|
||||
}
|
||||
|
||||
pub(super) fn scope_end(owner: Id, y: &mut f32, bounds: &mut BTreeMap<Id, (f32, f32)>) {
|
||||
PLAN.with(|slot| {
|
||||
if let Some(plan) = slot.borrow().as_ref() {
|
||||
bounds.insert(owner, plan.scopes[&owner]);
|
||||
*y = plan.scopes[&owner].1 + 42.; // Existing insertion-axis offset.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn scene_extent(scene: &mut Scene) {
|
||||
PLAN.with(|slot| {
|
||||
if let Some(plan) = slot.borrow().as_ref() { scene.size.height = plan.height; }
|
||||
});
|
||||
}
|
||||
|
||||
fn render(p: &Project, owner: Id, plan: &Plan) -> Scene {
|
||||
let _scope = Scope::install(plan);
|
||||
Scene::sequence(p, owner, owner)
|
||||
}
|
||||
|
||||
fn drag(original: &Candidate, frame: &Scene, target: Id, delta_y: f32) -> Candidate {
|
||||
assert!(delta_y.is_finite());
|
||||
if delta_y == 0. { return original.clone(); }
|
||||
let mut result = original.clone();
|
||||
result.insert(target, frame.rows[&target].1 + delta_y);
|
||||
result
|
||||
}
|
||||
+348
@@ -0,0 +1,348 @@
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::{language, source_project::{ProjectLimits, SourceBundle}, validation};
|
||||
|
||||
fn id(p: &Project, alias: &str) -> Id {
|
||||
p.identities().into_iter().find(|(_, name)| *name == alias).unwrap().0
|
||||
}
|
||||
|
||||
fn fixture(lifetime: bool) -> (Document, Id) {
|
||||
let (name, input, owner) = if lifetime {
|
||||
("lifetime.arcadia", include_str!("../behavior-layout-scene-20260912/lifetime.arcadia"), "lifetime_conversation")
|
||||
} else {
|
||||
("fixture.arcadia", include_str!("../behavior-layout-scene-20260912/fixture.arcadia"), "conversation")
|
||||
};
|
||||
let p = language::parse(input).expect("existing real Arcadia fixture");
|
||||
let errors: Vec<_> = validation::validate(&p).into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error).collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
let bundle = SourceBundle { entry: name.into(), files: BTreeMap::from([(name.into(), input.into())]) };
|
||||
let document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(document.project(), &p);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
let owner = id(&p, owner);
|
||||
(document, owner)
|
||||
}
|
||||
|
||||
fn passive(document: &Document, before: &Project, source: &SourceBundle) {
|
||||
assert_eq!(document.project(), before);
|
||||
assert_eq!(document.bundle(), source);
|
||||
assert!(!document.dirty());
|
||||
assert!(!document.can_undo() && !document.can_redo());
|
||||
}
|
||||
|
||||
fn row(scene: &Scene, id: Id) -> f32 { scene.rows[&id].1 }
|
||||
fn line(scene: &Scene, id: Id) -> &Line {
|
||||
assert_eq!(scene.lines.iter().filter(|line| line.id == id).count(), 1);
|
||||
scene.lines.iter().find(|line| line.id == id).unwrap()
|
||||
}
|
||||
fn named_box(scene: &Scene, id: Id) -> &BoxItem {
|
||||
scene.boxes.iter().find(|item| item.id == id && !item.title.is_empty()).unwrap()
|
||||
}
|
||||
|
||||
fn same_scene(a: &Scene, b: &Scene) {
|
||||
assert_eq!(a.rows, b.rows);
|
||||
assert_eq!(a.sequence_bounds, b.sequence_bounds);
|
||||
assert_eq!(a.size, b.size);
|
||||
assert_eq!(a.lanes, b.lanes);
|
||||
assert_eq!(a.lifecycle, b.lifecycle);
|
||||
assert_eq!(a.open_calls, b.open_calls);
|
||||
let boxes = |s: &Scene| s.boxes.iter().map(|b| (b.id,b.rect,b.title.clone(),b.detail.clone(),b.marker)).collect::<Vec<_>>();
|
||||
let lines = |s: &Scene| s.lines.iter().map(|l| (l.id,l.points.clone(),l.label_at,l.label.clone(),l.dashed)).collect::<Vec<_>>();
|
||||
let slots = |s: &Scene| s.slots.iter().map(|slot| (slot.slot.parent,slot.slot.before,slot.y,slot.label.clone())).collect::<Vec<_>>();
|
||||
assert_eq!(boxes(a),boxes(b));
|
||||
assert_eq!(lines(a),lines(b));
|
||||
assert_eq!(slots(a),slots(b));
|
||||
}
|
||||
|
||||
fn others_fixed(before: &Scene, after: &Scene, changed: Id) {
|
||||
assert_eq!(before.rows.len(),after.rows.len());
|
||||
for (&id, original) in &before.rows {
|
||||
if id != changed { assert_eq!(&after.rows[&id],original,"unexpected movement of {id}"); }
|
||||
}
|
||||
}
|
||||
|
||||
fn fixed_frame_metadata(before: &Scene, after: &Scene, frame: Id) {
|
||||
let original=named_box(before,frame);
|
||||
let current=named_box(after,frame);
|
||||
assert_eq!((current.id,current.rect,¤t.title,¤t.detail,current.marker),
|
||||
(original.id,original.rect,&original.title,&original.detail,original.marker));
|
||||
assert_eq!(before.rows[&frame],after.rows[&frame]);
|
||||
assert_eq!(row(after,frame),332.);
|
||||
// Scene::hit uses the first38px as the frame's active painted header.
|
||||
let header=Rectangle::new(Point::new(original.rect.x,original.rect.y),Size::new(original.rect.width,38.));
|
||||
assert_eq!((header.y,header.y+header.height),(332.,370.));
|
||||
let probe=Point::new(header.x+8.,header.y+8.);
|
||||
assert_eq!(before.hit(probe),Some(frame));
|
||||
assert_eq!(after.hit(probe),Some(frame));
|
||||
let original_slot=before.slots.iter().find(|slot|slot.slot.before==Some(frame)).unwrap();
|
||||
let current_slot=after.slots.iter().find(|slot|slot.slot.before==Some(frame)).unwrap();
|
||||
assert_eq!((current_slot.slot.parent,current_slot.slot.before,current_slot.y,¤t_slot.label),
|
||||
(original_slot.slot.parent,original_slot.slot.before,original_slot.y,&original_slot.label));
|
||||
assert_eq!(current_slot.y,290.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_seed_plan_preserves_both_actual_scene_and_document_baselines() {
|
||||
for lifetime in [false,true] {
|
||||
let (document,owner)=fixture(lifetime);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let plan=Plan::build(p,owner,&baseline,&Candidate::new()).unwrap();
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
for (&id,(_,y)) in &baseline.rows { assert_eq!(plan.anchors[&id].y,*y); }
|
||||
same_scene(&baseline,&render(p,owner,&plan));
|
||||
let reparsed=language::parse(&language::format(p)).unwrap();
|
||||
same_scene(&baseline,&render(&reparsed,owner,&plan));
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upward_second_delivery_reaches_200_and_all_other_occurrence_anchors_stay_fixed() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let first=id(p,"first"); let second=id(p,"second");
|
||||
assert_ne!(first,second);
|
||||
for occurrence_id in [first,second] {
|
||||
assert!(matches!(occurrence(p,occurrence_id),Some(OccurrenceBody::Exchange { exchange:Some(edge),.. }) if *edge==id(p,"channel")));
|
||||
}
|
||||
assert_eq!((row(&baseline,first),row(&baseline,second)),(148.,240.));
|
||||
let candidate=drag(&Candidate::new(),&baseline,second,-40.);
|
||||
assert_eq!(candidate,Candidate::from([(second,200.)]));
|
||||
let plan=Plan::build(p,owner,&baseline,&candidate).unwrap();
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!((row(&moved,first),row(&moved,second)),(148.,200.));
|
||||
others_fixed(&baseline,&moved,second);
|
||||
assert_eq!(line(&moved,first).points,line(&baseline,first).points);
|
||||
let left=moved.lanes[&id(p,"left")]; let right=moved.lanes[&id(p,"right")];
|
||||
assert_eq!(line(&moved,second).points,vec![Point::new(left,200.),Point::new(right,200.)]);
|
||||
assert_eq!(plan.messages[&second],MessageAnchors { send_y:200.,receive_y:200. });
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
same_scene(&moved,&render(p,owner,&plan));
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_upward_anchor_reaches_457_without_moving_parent_or_followers() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let nested=id(p,"nested"); let fragment=id(p,"fragment");
|
||||
assert_eq!(row(&baseline,nested),477.);
|
||||
let candidate=drag(&Candidate::new(),&baseline,nested,-20.);
|
||||
let plan=Plan::build(p,owner,&baseline,&candidate).unwrap();
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!(row(&moved,nested),457.);
|
||||
assert_eq!(named_box(&moved,nested).rect.y,437.);
|
||||
assert_eq!(named_box(&moved,nested).rect.height,70.);
|
||||
assert_eq!(named_box(&moved,fragment).rect,named_box(&baseline,fragment).rect);
|
||||
assert_eq!((named_box(&moved,fragment).rect.y,named_box(&moved,fragment).rect.height),(332.,240.));
|
||||
fixed_frame_metadata(&baseline,&moved,fragment);
|
||||
assert_eq!(moved.sequence_bounds[&id(p,"operand")],baseline.sequence_bounds[&id(p,"operand")]);
|
||||
others_fixed(&baseline,&moved,nested);
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_move_that_would_relocate_fixed_header_is_rejected_but_mere_overlap_is_not_banned() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let nested=id(p,"nested"); let fragment=id(p,"fragment");
|
||||
let requested=Candidate::from([(nested,300.)]);
|
||||
// Actual70px action box has row-to-top offset-20: proposed footprint280..350.
|
||||
// The old envelope union would move frame/header top332 to260 while leaving
|
||||
// FrameStart332 and before-slot290. Reject before any such Scene is exposed.
|
||||
let original=named_box(&baseline,nested).rect;
|
||||
assert_eq!((row(&baseline,nested),original.y,original.height),(477.,457.,70.));
|
||||
let proposed_top=original.y+(300.-row(&baseline,nested));
|
||||
assert_eq!((proposed_top,proposed_top+original.height),(280.,350.));
|
||||
assert_eq!(Plan::build(p,owner,&baseline,&requested).unwrap_err(),
|
||||
PlanError::FixedFrameHeaderWouldMove { frame:fragment });
|
||||
fixed_frame_metadata(&baseline,&Scene::sequence(p,owner,owner),fragment);
|
||||
// Overlap inside the existing frame does not itself move its top. This
|
||||
// remains admitted mechanical geometry, not a claim of usable overlap UX.
|
||||
let overlap=Plan::build(p,owner,&baseline,&Candidate::from([(nested,353.)])).unwrap();
|
||||
let overlapping=render(p,owner,&overlap);
|
||||
assert_eq!(named_box(&overlapping,nested).rect.y,333.);
|
||||
fixed_frame_metadata(&baseline,&overlapping,fragment);
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lifecycle_anchor_below_actual_root_render_scope_is_rejected_before_scene_construction() {
|
||||
let (document,owner)=fixture(true);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let create=id(p,"create_receiver"); let provider=id(p,"provider");
|
||||
let requester=id(p,"requester");
|
||||
let root_life=baseline.lifecycle.alive_spans(requester,owner,0.,baseline.size.height);
|
||||
assert_eq!(root_life.iter().map(|span|(span.start,span.end)).collect::<Vec<_>>(),vec![(85.,544.)]);
|
||||
for requested in [50.,84.] {
|
||||
assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(create,requested)])).unwrap_err(),
|
||||
PlanError::LifecycleAnchorOutsideRenderScope { occurrence:create,scope:owner });
|
||||
}
|
||||
// At the renderer's actual85 boundary the marker/lifetime remains present.
|
||||
// Source-order crossing is still diagnosed separately, not silently banned.
|
||||
let boundary=Plan::build(p,owner,&baseline,&Candidate::from([(create,85.)])).unwrap();
|
||||
let moved=render(p,owner,&boundary);
|
||||
let x=moved.lanes[&provider];
|
||||
assert_eq!(row(&moved,create),85.);
|
||||
assert_eq!(moved.lifecycle.marker_at(Point::new(x,85.)),Some(create));
|
||||
let alive=moved.lifecycle.alive_spans(provider,owner,0.,moved.size.height);
|
||||
assert_eq!(alive.iter().map(|span|(span.start,span.end)).collect::<Vec<_>>(),vec![(85.,427.)]);
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn earlier_death_reaches_380_and_actual_lifetime_and_activation_clip_there() {
|
||||
let (document,owner)=fixture(true);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let create=id(p,"create_receiver"); let call=id(p,"service_call"); let delete=id(p,"delete_receiver");
|
||||
let provider=id(p,"provider");
|
||||
assert_eq!((row(&baseline,create),row(&baseline,call),row(&baseline,delete)),(243.,335.,427.));
|
||||
let candidate=drag(&Candidate::new(),&baseline,delete,-47.);
|
||||
let plan=Plan::build(p,owner,&baseline,&candidate).unwrap();
|
||||
assert_eq!(plan.anchors[&delete].role,AnchorRole::MessageReceive);
|
||||
assert_eq!(plan.messages[&delete],MessageAnchors { send_y:380.,receive_y:380. });
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!(row(&moved,delete),380.);
|
||||
others_fixed(&baseline,&moved,delete);
|
||||
let spans=moved.lifecycle.alive_spans(provider,owner,85.,moved.size.height-20.);
|
||||
assert_eq!(spans.iter().map(|s|(s.start,s.end)).collect::<Vec<_>>(),vec![(243.,380.)]);
|
||||
assert!(moved.lifecycle.alive_spans(provider,owner,380.,moved.size.height-20.).is_empty());
|
||||
let x=moved.lanes[&provider];
|
||||
assert_eq!(moved.lifecycle.marker_at(Point::new(x,380.)),Some(delete));
|
||||
assert_eq!(moved.lifecycle.marker_at(Point::new(x,427.)),None);
|
||||
assert_eq!(moved.hit(Point::new(x,380.)),Some(delete));
|
||||
let bars:Vec<_>=moved.boxes.iter().filter(|b|b.id==call && b.title.is_empty()).collect();
|
||||
assert_eq!(bars.len(),1);
|
||||
assert_eq!(bars[0].rect,Rectangle::new(Point::new(x-4.,335.),Size::new(8.,45.)));
|
||||
assert_eq!(moved.open_calls,vec![Point::new(x+12.,364.)]);
|
||||
assert!(plan.diagnostics.is_empty());
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_call_has_explicit_send610_receive634_and_reply_stays728() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let call=id(p,"self_call"); let reply=id(p,"self_reply");
|
||||
let candidate=drag(&Candidate::new(),&baseline,call,-26.);
|
||||
let plan=Plan::build(p,owner,&baseline,&candidate).unwrap();
|
||||
assert_eq!(plan.anchors[&call],Anchor { y:610.,role:AnchorRole::MessageSend });
|
||||
assert_eq!(plan.messages[&call],MessageAnchors { send_y:610.,receive_y:634. });
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!(row(&moved,call),610.);
|
||||
assert_eq!(row(&moved,reply),728.);
|
||||
assert_eq!(line(&moved,call).points.iter().map(|p|p.y).collect::<Vec<_>>(),vec![610.,610.,634.,634.]);
|
||||
assert_eq!(line(&moved,reply).points,line(&baseline,reply).points);
|
||||
others_fixed(&baseline,&moved,call);
|
||||
// Peer #21 owns the activation start/column contract. This assertion covers
|
||||
// actual message anchors, not the old row-based activation start defect.
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signed_zero_keeps_sparse_candidate_and_restoring_original_candidate_restores_actual_scene() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let second=id(p,"second");
|
||||
let original=Candidate::new();
|
||||
for zero in [0.,-0.] { assert_eq!(drag(&original,&baseline,second,zero),original); }
|
||||
let changed=drag(&original,&baseline,second,-40.);
|
||||
let plan=Plan::build(p,owner,&baseline,&changed).unwrap();
|
||||
let frame=render(p,owner,&plan);
|
||||
for zero in [0.,-0.] {
|
||||
let untouched=drag(&changed,&frame,second,zero);
|
||||
assert_eq!(untouched,changed);
|
||||
same_scene(&frame,&render(p,owner,&Plan::build(p,owner,&baseline,&untouched).unwrap()));
|
||||
}
|
||||
// This restores the exact sidecar candidate; it is not a production Undo
|
||||
// command or evidence of a layout source/REST persistence implementation.
|
||||
same_scene(&baseline,&render(p,owner,&Plan::build(p,owner,&baseline,&original).unwrap()));
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crossing_is_rendered_exactly_and_diagnosed_with_extents_covering_the_earlier_far_event() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let before=p.clone(); let source=document.bundle().clone();
|
||||
let baseline=Scene::sequence(p,owner,owner);
|
||||
let first=id(p,"first"); let second=id(p,"second");
|
||||
let plan=Plan::build(p,owner,&baseline,&Candidate::from([(first,1000.)])).unwrap();
|
||||
assert!(plan.diagnostics.contains(&Diagnostic::SourceOrderCrossing { scope:owner,before:first,after:second }));
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!((row(&moved,first),row(&moved,second)),(1000.,240.));
|
||||
assert_eq!(row(&moved,id(p,"self_reply")),728.);
|
||||
others_fixed(&baseline,&moved,first);
|
||||
assert_eq!(moved.sequence_bounds[&owner].1,1026.);
|
||||
assert_eq!(moved.size.height,1113.);
|
||||
assert!(line(&moved,first).points.iter().all(|point| point.y<moved.size.height));
|
||||
assert_eq!(&p.elements[&owner].steps().unwrap()[..2],&[first,second]);
|
||||
// This is a policy falsifier: mechanically exact coordinates do not prove
|
||||
// that out-of-order vertical notation is understandable or acceptable.
|
||||
passive(&document,&before,&source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_outlier_expands_enclosing_geometry_without_using_final_traversal_row_as_extent() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let baseline=Scene::sequence(p,owner,owner);
|
||||
let nested=id(p,"nested"); let fragment=id(p,"fragment"); let following=id(p,"self_call");
|
||||
let plan=Plan::build(p,owner,&baseline,&Candidate::from([(nested,1000.)])).unwrap();
|
||||
assert!(plan.diagnostics.contains(&Diagnostic::FrameExtendsPastFollowingAnchor { frame:fragment,following }));
|
||||
let moved=render(p,owner,&plan);
|
||||
assert_eq!(row(&moved,nested),1000.);
|
||||
assert_eq!(named_box(&moved,nested).rect.y,980.);
|
||||
let enclosing=named_box(&moved,fragment).rect;
|
||||
assert_eq!(enclosing.y+enclosing.height,1070.);
|
||||
assert!(moved.sequence_bounds[&owner].1>=1070.);
|
||||
assert!(moved.size.height>1070.);
|
||||
assert_eq!(row(&moved,following),636.);
|
||||
others_fixed(&baseline,&moved,nested);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_targets_and_lifetime_inversion_fail_explicitly_instead_of_silent_clamping() {
|
||||
let (document,owner)=fixture(true);
|
||||
let p=document.project(); let baseline=Scene::sequence(p,owner,owner);
|
||||
let create=id(p,"create_receiver"); let delete=id(p,"delete_receiver");
|
||||
assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(delete,200.)])).unwrap_err(),
|
||||
PlanError::LifetimeInversionNotSupported { create,delete });
|
||||
assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(delete,f32::NAN)])).unwrap_err(),PlanError::NonFinite(delete));
|
||||
let edge=id(p,"service");
|
||||
assert_eq!(Plan::build(p,owner,&baseline,&Candidate::from([(edge,200.)])).unwrap_err(),PlanError::UnknownAnchor(edge));
|
||||
let (other,other_owner)=fixture(false);
|
||||
let fragment=id(other.project(),"fragment");
|
||||
let other_scene=Scene::sequence(other.project(),other_owner,other_owner);
|
||||
assert_eq!(Plan::build(other.project(),other_owner,&other_scene,&Candidate::from([(fragment,300.)])).unwrap_err(),
|
||||
PlanError::CompoundMoveNotImplemented(fragment));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_plan_scope_is_inactive_in_other_threads_and_restored_after_deliberate_panic() {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project(); let baseline=Scene::sequence(p,owner,owner);
|
||||
let second=id(p,"second");
|
||||
let plan=Plan::build(p,owner,&baseline,&Candidate::from([(second,200.)])).unwrap();
|
||||
let caught=std::panic::catch_unwind(|| {
|
||||
let _scope=Scope::install(&plan);
|
||||
assert_eq!(row(&Scene::sequence(p,owner,owner),second),200.);
|
||||
std::thread::spawn(|| {
|
||||
let (document,owner)=fixture(false);
|
||||
let p=document.project();
|
||||
assert_eq!(row(&Scene::sequence(p,owner,owner),id(p,"second")),240.);
|
||||
}).join().unwrap();
|
||||
panic!("deliberate complete-anchor scope unwind");
|
||||
});
|
||||
let payload=caught.expect_err("deliberate panic is required");
|
||||
assert_eq!(payload.downcast_ref::<&str>().copied(),Some("deliberate complete-anchor scope unwind"));
|
||||
same_scene(&baseline,&Scene::sequence(p,owner,owner));
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs
|
||||
index 3f9fd97..f1ef5b7 100644
|
||||
--- a/crates/oa-desktop/src/behavior.rs
|
||||
+++ b/crates/oa-desktop/src/behavior.rs
|
||||
@@ -1121,6 +1121,10 @@ fn in_machine(p: &Project, id: Id, machine: Id) -> bool {
|
||||
#[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"]
|
||||
mod behavior_layout_scene_experiment;
|
||||
|
||||
+#[cfg(test)]
|
||||
+#[path = "../../../harness/experiments/behavior-anchor-plan-20260912/mod.rs"]
|
||||
+mod behavior_anchor_plan_experiment;
|
||||
+
|
||||
struct BoxItem {
|
||||
id: Id,
|
||||
rect: Rectangle,
|
||||
@@ -1332,6 +1336,8 @@ 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.);
|
||||
+ #[cfg(test)]
|
||||
+ behavior_anchor_plan_experiment::scene_extent(&mut scene);
|
||||
if scenario {
|
||||
scene.lifecycle = scenario_lifecycle::render::Rendering::new(
|
||||
p,
|
||||
@@ -1441,6 +1447,8 @@ impl Scene {
|
||||
}
|
||||
#[cfg(test)]
|
||||
behavior_layout_scene_experiment::resolve_row_anchor(*id, y);
|
||||
+ #[cfg(test)]
|
||||
+ behavior_anchor_plan_experiment::row_anchor(*id, y);
|
||||
if ordered {
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
@@ -1465,6 +1473,8 @@ impl Scene {
|
||||
(*y - top).max(95.),
|
||||
),
|
||||
);
|
||||
+ #[cfg(test)]
|
||||
+ let rect = behavior_anchor_plan_experiment::frame_bounds(*id, rect);
|
||||
self.boxes.insert(
|
||||
0,
|
||||
BoxItem {
|
||||
@@ -1649,6 +1659,8 @@ impl Scene {
|
||||
}
|
||||
if ordered {
|
||||
self.sequence_bounds.insert(owner, (sequence_top, *y - 42.));
|
||||
+ #[cfg(test)]
|
||||
+ behavior_anchor_plan_experiment::scope_end(owner, y, &mut self.sequence_bounds);
|
||||
let slot = scenario::Slot {
|
||||
parent: owner,
|
||||
before: None,
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"status": "Two-fix static candidate; uncompiled, with newly discovered operand-scope gap",
|
||||
"files": [
|
||||
{
|
||||
"path": "crates/oa-desktop/src/behavior.rs",
|
||||
"bytes": 137409,
|
||||
"sha256": "5884BA35AD994EB5936CA29C071A5002EFC221DC42EF35E2265D50593BDF60EC"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/mod.rs",
|
||||
"bytes": 12330,
|
||||
"sha256": "2F139B39D067E8ACB39E9239F06E6AE3B560BE0541F7478B3EADA81D758CEDA9"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/tests.rs",
|
||||
"bytes": 18836,
|
||||
"sha256": "12CBC8FFCF2CDA54694C265C31F18CA47FB795BE34E28C2F09129027274D8E22"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/README.md",
|
||||
"bytes": 12234,
|
||||
"sha256": "080C7027CBFF51FC5D0A51E550F13C9F66AD3E3453C105355E7B78AF32CA9BC6"
|
||||
},
|
||||
{
|
||||
"path": "harness/experiments/behavior-anchor-plan-20260912/wiring.patch",
|
||||
"bytes": 2131,
|
||||
"sha256": "4CE4BC45FBC419EBA6A07B048B82EFB8D85525514E3D2E56421948E7733F40FD"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
# Scenario layout policy experiment — bounded evidence
|
||||
|
||||
Question: can absolute desired coordinates and gap-before records express the same small feasible layouts, and what semantics must a movement planner preserve? This is an isolated JavaScript experiment, not production Rust or a substitute for actual Arcadia Scene, parser, REST, history, or native acceptance. The ten-unit separation and rectangle metrics are illustrative.
|
||||
|
||||
[Source](experiment.mjs) executed through Node REPL and wrote [results](results.json): 3,555 feasible three-row layouts and 53,325 single-move cases passed. Handwritten numeric cases expose different naive reorder/delete/resize behavior. Both encodings can express the enumerated feasible outputs. Changed-coordinate counts compare only the specific single-edit forward policy, not equally expressive retained intent across histories. Elapsed time includes assertions and this machine’s load; it is not a renderer performance result.
|
||||
|
||||
[Independent review](independent-review.md) found an omitted conflict-then-zero-motion history. [The negative probe](negative-zero-motion.mjs) reuses the same experiment with that additional assertion; root executed it and [reproduced the expected assertion failure](negative-zero-motion-result.json): a zero-screen-motion operation changes B.desired from200 to280. The original source/results remain preserved. No production failure is implied.
|
||||
|
||||
The next experiment must distinguish a pointer gesture (original frame and actual delta; zero motion preserves authored input) from an explicit SetDesiredY/Normalize operation, and test move-away/back and resize histories. Equal visible geometry does not imply equal latent layout intent. Before schema adoption, exercise one parsed real scenario through the actual Scene/attachment adapter, including self-message and nested/lifetime cases. The [design proposal and critique](../behavior-navigation-review-20260912/README.md) remain conditional.
|
||||
|
||||
To reproduce, copy experiment.mjs and negative-zero-motion.mjs into an empty directory and run `node experiment.mjs`, then `node negative-zero-motion.mjs`. The first writes results.json; the second must fail its zero-distance assertion. Existing output is never overwritten. Node built-ins only; no package installation or network is required. [Hashes](hashes.json) bind the exact executed source/result artifacts.
|
||||
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
// Isolated policy comparison; these illustrative metrics are not Studio rules.
|
||||
const separation = 10;
|
||||
function absolute(rows) {
|
||||
let end = -separation;
|
||||
return rows.map(r => { const y = Math.max(r.y, end + separation); end=y+r.height; return {...r,y}; });
|
||||
}
|
||||
function gaps(rows) {
|
||||
let end=0;
|
||||
return rows.map((r,i) => { assert(r.gap >= (i?separation:0)); const y=end+r.gap; end=y+r.height; return {...r,y}; });
|
||||
}
|
||||
function encodeGaps(rows) {
|
||||
return rows.map((r,i)=>({...r,gap:r.y-(i?rows[i-1].y+rows[i-1].height:0)}));
|
||||
}
|
||||
const positions = rows=>rows.map(r=>r.y);
|
||||
function moveAbsolute(rows,index,requested) {
|
||||
const current=absolute(rows);
|
||||
const floor=index?current[index-1].y+current[index-1].height+separation:0;
|
||||
const candidate=rows.map((r,i)=>({...r,y:i===index?Math.max(requested,floor):r.y}));
|
||||
return {candidate,preview:absolute(candidate)};
|
||||
}
|
||||
function changeCount(before,after,key) {return before.reduce((n,r,i)=>n+(r[key]!==after[i][key]),0);}
|
||||
const initial=[{id:'A',y:100,height:20},{id:'B',y:200,height:20}];
|
||||
const initialG=encodeGaps(initial);
|
||||
const cases=[];
|
||||
function named(name,a,g,expectedA,expectedG) {
|
||||
assert.deepEqual(positions(a),expectedA,name+' absolute');
|
||||
assert.deepEqual(positions(g),expectedG,name+' gaps');
|
||||
cases.push({name,absolute:positions(a),gapsWithoutInverse:positions(g)});
|
||||
}
|
||||
named('move A to50',absolute([{...initial[0],y:50},initial[1]]),gaps([{...initialG[0],gap:50},initialG[1]]),[50,200],[50,150]);
|
||||
named('move A to250',absolute([{...initial[0],y:250},initial[1]]),gaps([{...initialG[0],gap:250},initialG[1]]),[250,280],[250,350]);
|
||||
named('resize A to80',absolute([{...initial[0],height:80},initial[1]]),gaps([{...initialG[0],height:80},initialG[1]]),[100,200],[100,260]);
|
||||
named('resize A to120',absolute([{...initial[0],height:120},initial[1]]),gaps([{...initialG[0],height:120},initialG[1]]),[100,230],[100,300]);
|
||||
named('source reorder B A',absolute([initial[1],initial[0]]),gaps([initialG[1],initialG[0]]),[200,230],[80,200]);
|
||||
named('delete A',absolute([initial[1]]),gaps([initialG[1]]),[200],[80]);
|
||||
assert.deepEqual(positions(moveAbsolute(initial,1,110).preview),[100,130]);
|
||||
let feasible=0,moves=0,absoluteChanges=0,gapChanges=0;
|
||||
const started=performance.now();
|
||||
for(const ha of [10,20,30])for(const hb of [10,20,30])for(const hc of [10,20,30])
|
||||
for(let a=0;a<=40;a+=10)for(let b=a+ha+10;b<=90;b+=10)for(let c=b+hb+10;c<=140;c+=10) {
|
||||
const rows=[{id:'A',height:ha,y:a},{id:'B',height:hb,y:b},{id:'C',height:hc,y:c}];
|
||||
assert.deepEqual(positions(absolute(rows)),[a,b,c]);
|
||||
assert.deepEqual(positions(gaps(encodeGaps(rows))),[a,b,c]);
|
||||
feasible++;
|
||||
for(let i=0;i<3;i++)for(const delta of [-50,-20,0,10,40]) {
|
||||
const requested=rows[i].y+delta, {candidate,preview}=moveAbsolute(rows,i,requested);
|
||||
const independent=[];
|
||||
for(let j=0;j<3;j++) {
|
||||
const floor=j?independent[j-1]+rows[j-1].height+10:0;
|
||||
independent.push(Math.max(j===i?requested:rows[j].y,floor));
|
||||
}
|
||||
assert.deepEqual(positions(preview),independent);
|
||||
assert.deepEqual(positions(absolute(preview)),independent);
|
||||
assert.deepEqual(positions(absolute(JSON.parse(JSON.stringify(candidate)))),independent);
|
||||
const encoded=encodeGaps(preview);
|
||||
assert.deepEqual(positions(gaps(encoded)),independent);
|
||||
assert.deepEqual(candidate.map(r=>r.id),['A','B','C']);
|
||||
for(let j=0;j<i;j++) assert.deepEqual(candidate[j],rows[j]);
|
||||
absoluteChanges+=changeCount(rows,candidate,'y');
|
||||
gapChanges+=changeCount(encodeGaps(rows),encoded,'gap');
|
||||
moves++;
|
||||
}
|
||||
}
|
||||
// Independent event/frame attachment probes: no actual Scene/activation engine.
|
||||
const selfCall=absolute([{id:'call',y:250,height:24},{id:'reply',y:320,height:0}]);
|
||||
assert.equal(selfCall[0].y+24,274);
|
||||
assert.equal(selfCall[1].y,320);
|
||||
const children=[{y:160,height:20},{y:260,height:20}];
|
||||
const requiredFrameBottom=Math.max(...children.map(r=>r.y+r.height))+20;
|
||||
assert.equal(requiredFrameBottom,300);
|
||||
assert.equal(Math.max(300,requiredFrameBottom),300);
|
||||
assert.equal(Math.max(220,requiredFrameBottom),300);
|
||||
assert.deepEqual(children,[{y:160,height:20},{y:260,height:20}]);
|
||||
assert.equal(220-90,130); // delayed header top from moved create receive and header height
|
||||
const result={
|
||||
scope:'Illustrative linear policy and anchor experiment; no production parser, REST, Scene, GUI or full fragment/lifecycle acceptance.',
|
||||
separation, cases, feasibleLayouts:feasible, moveCases:moves,
|
||||
changedAuthoredCoordinateRecords:{absolute:absoluteChanges,gapsWithInverse:gapChanges},
|
||||
elapsedMs:performance.now()-started,
|
||||
assertions:'passed',
|
||||
interpretation:'Both policies express all enumerated feasible layouts. Absolute coordinates preserve unaffected positions with fewer authored coordinate changes under these fixtures. Choose no production schema from this alone; full scope/anchor/inverse and source lifecycle validation remain required.'
|
||||
};
|
||||
await writeFile(new URL('./results.json',import.meta.url),JSON.stringify(result,null,2)+'\n',{flag:'wx'});
|
||||
export default result;
|
||||
@@ -0,0 +1,22 @@
|
||||
[
|
||||
{
|
||||
"file": "experiment.mjs",
|
||||
"bytes": 5123,
|
||||
"sha256": "8a2b5adc35a25173ec2d37a46572cebac12d73bc8c9440f3cfdff784858c52c1"
|
||||
},
|
||||
{
|
||||
"file": "results.json",
|
||||
"bytes": 1633,
|
||||
"sha256": "866e4c81c42a922033669aa6c591857029303627db28c5307b0126503a6791b8"
|
||||
},
|
||||
{
|
||||
"file": "negative-zero-motion.mjs",
|
||||
"bytes": 5383,
|
||||
"sha256": "b76b1aa1c5d19043540381fc31224ecb86298796fb3adf2cdc8847c412b034ce"
|
||||
},
|
||||
{
|
||||
"file": "negative-zero-motion-result.json",
|
||||
"bytes": 707,
|
||||
"sha256": "1dab9972fdef1586e48c89f5afe8dd6a3cfa5d58adf68088e74440dbf4eb70a9"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
# Review of the isolated layout-policy experiment
|
||||
|
||||
2026-09-12. Independent reviewer `/root/diagram_usage_workflows`; read [experiment.mjs](experiment.mjs) and [results.json](results.json). Root reports executing the experiment. This review inspected code/results without rerunning them; no production edits, builds, UI, or Gitea operations. Local links are for this shared agent workspace; root will publish portable artifacts before any external peer handoff.
|
||||
|
||||
## Findings and justified conclusion
|
||||
|
||||
**Useful result:** both encodings reproduce all 3,555 enumerated feasible three-row layouts. Under the particular forward-only, minimum-gap movement policy, a gap inverse can reproduce the absolute planner's 53,325 single-move previews. This rebuts the idea that gap storage inherently prevents precise placement. Absolute coordinates are a reasonable candidate for the next experiment.
|
||||
|
||||
**Not established:** the best production policy, equivalent edit-history behavior, source/API parity, real scenario anchors, fragments/lifetimes, minimal record changes in general, responsiveness, or schema adoption. The result file correctly disclaims most of these. Its “fewer authored coordinate changes” claim should be scoped to single edits from initially feasible layouts, using this forward resolver and this gap encoding. It is not yet a fair comparison of equally expressive persistent intent across histories.
|
||||
|
||||
### 1. A concrete omitted history violates the proposed zero-movement contract
|
||||
|
||||
Starting authored A100/B200, height20 each:
|
||||
|
||||
1. `moveAbsolute(rows, A, 250)` stores A250/B200 and displays A250/B280.
|
||||
2. Select B and release at unchanged displayed y280.
|
||||
3. `moveAbsolute(candidate, B, 280)` stores B280, changing its previous desired200 despite no screen movement.
|
||||
|
||||
The experiment starts each move from a feasible layout, so its delta=0 cases cannot find this. A pointer gesture should carry its original frame, original authored candidate and actual pointer delta, returning that original candidate for zero delta before inverse mapping. That is an additional required contract, not something tested here. An explicit text/API `SetDesiredY` may legitimately change latent authored intent even when the current projected coordinate is equal; explicitly applying the displayed coordinate through Properties could likewise be a meaningful normalization operation. The toy function has no operation-intent input and is not a complete gesture planner. An untouched pointer gesture must remain a no-op.
|
||||
|
||||
Another important history is moving A back to100. Retaining absolute B.desired=200 restores B200, whereas the first gap inverse encoded the displaced output B280 and did not preserve B's original desired position. A gap planner can preserve the current visible B280 or use extra latent intent to restore200; choose the intended behavior explicitly. Neither result is intrinsically correct, but the current counters compare representations containing different information after the first conflict.
|
||||
|
||||
### 2. Oracle independence is partial
|
||||
|
||||
- The six named example outputs are hand-authored numerical expectations. They are useful independent checks of the proposed policy examples, though they do not exercise Studio.
|
||||
- The loop called `independent` repeats the same left-to-right `max(requested-or-old, predecessor-bottom+10)` recurrence as `moveAbsolute` plus `absolute`. It can catch implementation inconsistencies but does not independently establish that the chosen movement policy matches user intent or is the least disruptive feasible solution.
|
||||
- `absolute(preview)` checks idempotence on already resolved geometry. It does not prove that authored unresolved intent survives a sequence of commands or that the inverse preserves inactive fields.
|
||||
- JSON stringify/parse of plain toy records demonstrates only that these small numeric values survive JavaScript JSON. There is no Arcadia grammar, source comment ownership, schema version, f32 precision, REST conditional transaction, or Document history exercised.
|
||||
- Checking the three IDs remain A/B/C and earlier rows are unchanged is useful but does not exercise steps, sender/receiver, reply_to, branch membership, or lifecycle semantics.
|
||||
- “Independent event/frame attachment probes” is too strong a label. `250+24=274`, `max(child bottoms)+20=300`, and `220-90=130` are arithmetic expectations. They do not invoke a self-message, frame resize or lifetime implementation. The unchanged-children assertion observes an array no code attempted to change. Treat these as fixture seeds for future tests.
|
||||
|
||||
### 3. Fairness and coverage of the comparison
|
||||
|
||||
Encoding the same final preview with `encodeGaps` is a fair test of gap expressiveness and a narrowly useful coordinate-update count. It is not an independent gap-policy optimizer: the desired final layout comes from the absolute policy. The named gap cases intentionally omit an inverse and should illustrate naive behavior only, not count as evidence against gap-based direct manipulation.
|
||||
|
||||
The recorded totals, 37,134 absolute-coordinate changes versus 61,371 gap changes, exclude latent-intent equivalence, bytes, comments, identities, transactions, secondary geometry and histories. An absolute edit leaves displaced followers' authored y unchanged; the gap inverse rewrites them to encode resolved positions. Count logical preserved intent, visible displacement and serialized changes separately. Also remove unused y fields from the conceptual gap representation when estimating storage; `encodeGaps` currently retains them through object spread even though `gaps` ignores/overwrites them.
|
||||
|
||||
The enumerator covers nonnegative integer tops on a ten-unit grid, three rows, fixed minimum separation, and five deltas. The third height varies but constrains no successor, so the count contains geometrically equivalent positioning cases. This does not invalidate the result; it limits the diversity implied by the headline number. Negative/fractional/extreme coordinates, variable per-kind clearances, mixed resize/move sequences, preexisting conflicts, simultaneous branches, nested frames and route geometry are absent. The elapsed time includes assertions and allocations; it is not a renderer performance measurement.
|
||||
|
||||
### 4. Important absent lifecycle/source cases
|
||||
|
||||
The isolated source-reorder and delete examples change array order/membership but test no cleanup or diagnostics. Missing are: repeated source reorder; deleting a displaced predecessor; insert between authored neighbors; rename/content growth; reparent between operands; reorder across call/reply or creation/deletion constraints; hidden records; reset one override; two saved views; first-edit identity capture; undo/redo; and comments keyed by typed addresses. Frame anchors need header/operand containment, while self calls need both send and receive geometry consumed by activation logic. These are specification gates, not reasons to expand the toy to thousands more similar rows.
|
||||
|
||||
## Bounded next experiment
|
||||
|
||||
First add a small history table to the isolated experiment: conflict then zero-distance drag, move away/back, resize away/back, delete predecessor, and source reorder/reverse. Assert authored records and displayed positions separately. Use a tiny exhaustive feasible-layout oracle with a declared lexicographic objective (selected target error, predecessor changes, other displacement, then record changes) to check a few cases without duplicating the forward recurrence. The objective itself remains a policy proposal to review. Run both candidate representations through their own repeated histories rather than deriving every gap state from an absolute preview.
|
||||
|
||||
Then stop scaling the toy and make **one real-model renderer experiment**, isolated from the production schema:
|
||||
|
||||
1. Parse a real minimal Arcadia scenario through the existing parser: two lifelines, separate call/reply occurrences using one exchange, and an action/state box between them. Use an experimental presentation adapter in a test boundary consumed by the actual Scene geometry; do not relocate the entire renderer or invent production syntax yet.
|
||||
2. Exercise call movement that displaces the box/reply, zero-motion on the displaced box, movement back, and box-height changes. Record independent numeric anchor expectations and compare actual rendered bounds/line endpoints. Execute a valid source/SetSteps reorder of the independent box and confirm the semantic order, model validation and presentation diagnostic. The semantic Project must remain unchanged during cosmetic previews.
|
||||
3. Extend that same bounded fixture with one nested operand and a self-message, then a delayed-created lifeline case. Assert send/receive anchors, header attachment, frame containment and child authored-coordinate preservation against the actual Scene/lifecycle outputs. Coordinate with #16 and #21 owners; do not duplicate or replace their implementation.
|
||||
4. Compare absolute and gap adapters on those identical requested layouts and histories. Capture unmet constraints explicitly; do not auto-normalize source or silently reorder behavior. Select the candidate only after defining its conflict and inverse behavior.
|
||||
|
||||
This next experiment can validate real semantic/geometry integration without pretending to prove persistence. Once it passes, the next separate slice is the smallest versioned typed-layout record through parser/formatter, Document transaction, JSON/REST and reopen. Native editing and usability remain subsequent required evidence. Publish the exact experiment source, inputs and results together with this review so remote peers can reproduce the bounded claims.
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"status": "expected assertion failure reproduced",
|
||||
"assertion": "zero-distance pointer operation must preserve authored intent\n+ actual - expected\n... Skipped lines\n\n [\n {\n height: 20,\n id: 'A',\n y: 250\n...\n id: 'B',\n+ y: 280\n- y: 200\n }\n ]\n",
|
||||
"actual": [
|
||||
{
|
||||
"id": "A",
|
||||
"y": 250,
|
||||
"height": 20
|
||||
},
|
||||
{
|
||||
"id": "B",
|
||||
"y": 280,
|
||||
"height": 20
|
||||
}
|
||||
],
|
||||
"expected": [
|
||||
{
|
||||
"id": "A",
|
||||
"y": 250,
|
||||
"height": 20
|
||||
},
|
||||
{
|
||||
"id": "B",
|
||||
"y": 200,
|
||||
"height": 20
|
||||
}
|
||||
],
|
||||
"scope": "Same toy planner, additional conflict-then-zero-motion history; no production failure claim."
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
// Isolated policy comparison; these illustrative metrics are not Studio rules.
|
||||
const separation = 10;
|
||||
function absolute(rows) {
|
||||
let end = -separation;
|
||||
return rows.map(r => { const y = Math.max(r.y, end + separation); end=y+r.height; return {...r,y}; });
|
||||
}
|
||||
function gaps(rows) {
|
||||
let end=0;
|
||||
return rows.map((r,i) => { assert(r.gap >= (i?separation:0)); const y=end+r.gap; end=y+r.height; return {...r,y}; });
|
||||
}
|
||||
function encodeGaps(rows) {
|
||||
return rows.map((r,i)=>({...r,gap:r.y-(i?rows[i-1].y+rows[i-1].height:0)}));
|
||||
}
|
||||
const positions = rows=>rows.map(r=>r.y);
|
||||
function moveAbsolute(rows,index,requested) {
|
||||
const current=absolute(rows);
|
||||
const floor=index?current[index-1].y+current[index-1].height+separation:0;
|
||||
const candidate=rows.map((r,i)=>({...r,y:i===index?Math.max(requested,floor):r.y}));
|
||||
return {candidate,preview:absolute(candidate)};
|
||||
}
|
||||
function changeCount(before,after,key) {return before.reduce((n,r,i)=>n+(r[key]!==after[i][key]),0);}
|
||||
const initial=[{id:'A',y:100,height:20},{id:'B',y:200,height:20}];
|
||||
const initialG=encodeGaps(initial);
|
||||
const cases=[];
|
||||
function named(name,a,g,expectedA,expectedG) {
|
||||
assert.deepEqual(positions(a),expectedA,name+' absolute');
|
||||
assert.deepEqual(positions(g),expectedG,name+' gaps');
|
||||
cases.push({name,absolute:positions(a),gapsWithoutInverse:positions(g)});
|
||||
}
|
||||
named('move A to50',absolute([{...initial[0],y:50},initial[1]]),gaps([{...initialG[0],gap:50},initialG[1]]),[50,200],[50,150]);
|
||||
named('move A to250',absolute([{...initial[0],y:250},initial[1]]),gaps([{...initialG[0],gap:250},initialG[1]]),[250,280],[250,350]);
|
||||
named('resize A to80',absolute([{...initial[0],height:80},initial[1]]),gaps([{...initialG[0],height:80},initialG[1]]),[100,200],[100,260]);
|
||||
named('resize A to120',absolute([{...initial[0],height:120},initial[1]]),gaps([{...initialG[0],height:120},initialG[1]]),[100,230],[100,300]);
|
||||
named('source reorder B A',absolute([initial[1],initial[0]]),gaps([initialG[1],initialG[0]]),[200,230],[80,200]);
|
||||
named('delete A',absolute([initial[1]]),gaps([initialG[1]]),[200],[80]);
|
||||
assert.deepEqual(positions(moveAbsolute(initial,1,110).preview),[100,130]);
|
||||
let feasible=0,moves=0,absoluteChanges=0,gapChanges=0;
|
||||
const started=performance.now();
|
||||
for(const ha of [10,20,30])for(const hb of [10,20,30])for(const hc of [10,20,30])
|
||||
for(let a=0;a<=40;a+=10)for(let b=a+ha+10;b<=90;b+=10)for(let c=b+hb+10;c<=140;c+=10) {
|
||||
const rows=[{id:'A',height:ha,y:a},{id:'B',height:hb,y:b},{id:'C',height:hc,y:c}];
|
||||
assert.deepEqual(positions(absolute(rows)),[a,b,c]);
|
||||
assert.deepEqual(positions(gaps(encodeGaps(rows))),[a,b,c]);
|
||||
feasible++;
|
||||
for(let i=0;i<3;i++)for(const delta of [-50,-20,0,10,40]) {
|
||||
const requested=rows[i].y+delta, {candidate,preview}=moveAbsolute(rows,i,requested);
|
||||
const independent=[];
|
||||
for(let j=0;j<3;j++) {
|
||||
const floor=j?independent[j-1]+rows[j-1].height+10:0;
|
||||
independent.push(Math.max(j===i?requested:rows[j].y,floor));
|
||||
}
|
||||
assert.deepEqual(positions(preview),independent);
|
||||
assert.deepEqual(positions(absolute(preview)),independent);
|
||||
assert.deepEqual(positions(absolute(JSON.parse(JSON.stringify(candidate)))),independent);
|
||||
const encoded=encodeGaps(preview);
|
||||
assert.deepEqual(positions(gaps(encoded)),independent);
|
||||
assert.deepEqual(candidate.map(r=>r.id),['A','B','C']);
|
||||
for(let j=0;j<i;j++) assert.deepEqual(candidate[j],rows[j]);
|
||||
absoluteChanges+=changeCount(rows,candidate,'y');
|
||||
gapChanges+=changeCount(encodeGaps(rows),encoded,'gap');
|
||||
moves++;
|
||||
}
|
||||
}
|
||||
// Independent event/frame attachment probes: no actual Scene/activation engine.
|
||||
const selfCall=absolute([{id:'call',y:250,height:24},{id:'reply',y:320,height:0}]);
|
||||
assert.equal(selfCall[0].y+24,274);
|
||||
assert.equal(selfCall[1].y,320);
|
||||
const children=[{y:160,height:20},{y:260,height:20}];
|
||||
const requiredFrameBottom=Math.max(...children.map(r=>r.y+r.height))+20;
|
||||
assert.equal(requiredFrameBottom,300);
|
||||
assert.equal(Math.max(300,requiredFrameBottom),300);
|
||||
assert.equal(Math.max(220,requiredFrameBottom),300);
|
||||
assert.deepEqual(children,[{y:160,height:20},{y:260,height:20}]);
|
||||
assert.equal(220-90,130); // delayed header top from moved create receive and header height
|
||||
const conflicting = moveAbsolute(initial,0,250);
|
||||
const zeroMotion = moveAbsolute(conflicting.candidate,1,conflicting.preview[1].y);
|
||||
assert.deepEqual(zeroMotion.candidate, conflicting.candidate, 'zero-distance pointer operation must preserve authored intent');
|
||||
const result={
|
||||
scope:'Illustrative linear policy and anchor experiment; no production parser, REST, Scene, GUI or full fragment/lifecycle acceptance.',
|
||||
separation, cases, feasibleLayouts:feasible, moveCases:moves,
|
||||
changedAuthoredCoordinateRecords:{absolute:absoluteChanges,gapsWithInverse:gapChanges},
|
||||
elapsedMs:performance.now()-started,
|
||||
assertions:'passed',
|
||||
interpretation:'Both policies express all enumerated feasible layouts. Absolute coordinates preserve unaffected positions with fewer authored coordinate changes under these fixtures. Choose no production schema from this alone; full scope/anchor/inverse and source lifecycle validation remain required.'
|
||||
};
|
||||
await writeFile(new URL('./results.json',import.meta.url),JSON.stringify(result,null,2)+'\n',{flag:'wx'});
|
||||
export default result;
|
||||
@@ -0,0 +1,79 @@
|
||||
{
|
||||
"scope": "Illustrative linear policy and anchor experiment; no production parser, REST, Scene, GUI or full fragment/lifecycle acceptance.",
|
||||
"separation": 10,
|
||||
"cases": [
|
||||
{
|
||||
"name": "move A to50",
|
||||
"absolute": [
|
||||
50,
|
||||
200
|
||||
],
|
||||
"gapsWithoutInverse": [
|
||||
50,
|
||||
150
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "move A to250",
|
||||
"absolute": [
|
||||
250,
|
||||
280
|
||||
],
|
||||
"gapsWithoutInverse": [
|
||||
250,
|
||||
350
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resize A to80",
|
||||
"absolute": [
|
||||
100,
|
||||
200
|
||||
],
|
||||
"gapsWithoutInverse": [
|
||||
100,
|
||||
260
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "resize A to120",
|
||||
"absolute": [
|
||||
100,
|
||||
230
|
||||
],
|
||||
"gapsWithoutInverse": [
|
||||
100,
|
||||
300
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "source reorder B A",
|
||||
"absolute": [
|
||||
200,
|
||||
230
|
||||
],
|
||||
"gapsWithoutInverse": [
|
||||
80,
|
||||
200
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "delete A",
|
||||
"absolute": [
|
||||
200
|
||||
],
|
||||
"gapsWithoutInverse": [
|
||||
80
|
||||
]
|
||||
}
|
||||
],
|
||||
"feasibleLayouts": 3555,
|
||||
"moveCases": 53325,
|
||||
"changedAuthoredCoordinateRecords": {
|
||||
"absolute": 37134,
|
||||
"gapsWithInverse": 61371
|
||||
},
|
||||
"elapsedMs": 24777.856600000057,
|
||||
"assertions": "passed",
|
||||
"interpretation": "Both policies express all enumerated feasible layouts. Absolute coordinates preserve unaffected positions with fewer authored coordinate changes under these fixtures. Choose no production schema from this alone; full scope/anchor/inverse and source lifecycle validation remain required."
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
# Actual Scene anchor experiment — pending execution
|
||||
|
||||
2026-09-12. Prepared by delegated reviewer `/root/diagram_usage_workflows` in branch `codex/diagram-coverage-fixes-ytr83r-20260912`, inspected base `f23615ffebdc3ca6872f98b6889ec49bb4e8abda` plus root's ongoing correction work. Only this experiment directory was authored. **Root applied the test-only wiring on isolated branch `codex/behavior-layout-scene-ytr83r-20260912`, based on `46b4f3c0d0f04050ad6d705051ee0199138e47a2`. Not compiled or executed.** Root owns the build checkpoint. No production schema choice, native acceptance or fix is claimed.
|
||||
|
||||
## Question and candidates
|
||||
|
||||
Can a minimal presentation input feed the actual current desktop Scene before it constructs rows, message polylines, nested frames and lifecycle geometry, while preserving the semantic Project and occurrence identities? Does explicit pointer intent preserve latent authored values on zero motion?
|
||||
|
||||
The candidate is an occurrence-ID-to-desired-row map. A tiny test-only hook raises the current Scene cursor to a requested row before recording an occurrence. This deliberately retains the existing renderer's next-row floor: it is a **downward spacing adapter**, not a general absolute-coordinate projector. The competing gap policy remains meaningful but is not implemented here: this experiment first tests the proposed real-Scene integration boundary. It cannot select a production storage schema.
|
||||
|
||||
Rejected experimental shortcuts: a standalone rectangle model would not test Scene; translating final polylines after rendering would bypass dependent frames/lifecycle; extracting the whole renderer would exceed the uncertainty being tested. This adapter uses the existing code that actually generates all those representations.
|
||||
|
||||
## Files and exact root wiring
|
||||
|
||||
- `fixture.arcadia`: hand-authored current source with explicit UUIDs, two occurrences sharing one communication mean, a fragment containing an operand/action box, and a self-call/reply. The self messages intentionally have unassigned global exchanges: current global relation validation rejects a self edge. Their explicit lifelines permit rendering; incomplete-exchange warnings are expected, semantic errors are not.
|
||||
- `mod.rs`: private child tests of `behavior`, a scoped thread-local anchor adapter, pointer-delta candidate operation, and independent numerical checks against actual Scene outputs.
|
||||
- `wiring.patch`: proposed two-site test-only wiring in `crates/oa-desktop/src/behavior.rs`. It adds the child module and calls `resolve_row_anchor(*id, y)` immediately before the `if ordered` block that records rows/slots in `Scene::occurrences`, **after** any self lifecycle clearance. The relative module path is from `crates/oa-desktop/src`. The line numbers are informational; verify the context against the exact integration version.
|
||||
|
||||
On the isolated wired branch, run:
|
||||
|
||||
```powershell
|
||||
cargo test -p oa-desktop behavior_layout_scene_experiment -- --nocapture
|
||||
```
|
||||
|
||||
Use the project's configured Windows compiler/linker environment. No dependency, Cargo target or main-function changes are needed. The hook is compiled only under cfg(test), inactive unless the current thread installs an AnchorScope, and restored by Drop after normal return or unwind. A test checks that another thread does not inherit the anchor map and that an intentional panic restores the baseline. Do not catch or reinterpret that expected panic output as a product crash; its test must still pass.
|
||||
|
||||
The isolated branch already contains this wiring; `wiring.patch` records its reviewable delta and must not be applied twice. If a compiler/parser assertion fails, preserve the failure, identify the exact reviewed source version, and correct the fixture or adapter at this boundary. Do not weaken independent geometric assertions solely to make the test green. Never claim a passing test from the existence of these files.
|
||||
|
||||
## Independent evaluators
|
||||
|
||||
The baseline is the actual `Scene::sequence` with no installed anchors. The hand-derived expected row coordinates are first148, second240, fragment332, nested action477, self-call636 and self-reply728. These follow the current seed metrics (message92, fragment header55, operand90, action95, trailing fragment64) and are observable implementation oracles, not proposed permanent UX rules. The nested box top is457; fragment height240; self-call endpoint y values are636 and660. Line identities and endpoints are asserted independently, not just compared between two calls to the same projector.
|
||||
|
||||
Tests cover:
|
||||
|
||||
1. Actual `oa_core::language::parse` and canonical format/reparse Project equality; baseline actual Scene equality after semantic source round trip; repeated exchange references retain distinct line UUIDs.
|
||||
2. Move only second delivery by40: its row becomes280, first stays148, nested action becomes517, fragment translates with unchanged height240, and self-call/reply geometry moves by40 with unchanged x and identity. Whole Project and canonical semantic source remain equal.
|
||||
3. Move the nested action by30: actual fragment grows from240 to270 while its top remains332; later self-call shifts. Move that self-call by18 and assert actual send/receive y684/708 and reply776; the nested box remains unchanged.
|
||||
4. Move first by200: second is visibly displaced to440 while its authored desired value stays240. Zero pointer delta returns the exact original candidate. Moving first back restores second240. Explicitly assigning second.desired=440 before moving first back deliberately preserves440 instead, exposing the distinction between pointer no-op and explicit latent-intent change.
|
||||
5. Reorder the repeated occurrences in actual source and parse it; the override stays attached to the second occurrence UUID. Its row is280 and the first follows at372. Projection does not write back any semantic model change.
|
||||
6. Scope restoration on panic and isolation from other test threads.
|
||||
7. Explicit upward-drag counterexample: the requested y200 remains rendered at240 under the current floor.
|
||||
|
||||
Model equality compares the entire Project. Scene equality checks rows, scopes, size, lanes, lifecycle, open calls, lines/labels, boxes and insertion slots; it does not invoke GPU painting. Explicit UUIDs are necessary because parsing declarations without IDs allocates new identities, which would make separate source parses unsuitable for occurrence-address comparison.
|
||||
|
||||
## Limits and next decision
|
||||
|
||||
This is real parser/Scene construction evidence **only after it runs**. Layout maps remain external experimental data; source round trips cover the semantic Project, not persisted layout. No new parser syntax, Document transaction, REST operation, undo stack, dirty-source workflow, route bend, text measurement, native gesture, inspector, resize, or performance result is established.
|
||||
|
||||
The current floor retains automatic whitespace and cannot establish all feasible free positions, upward drag, independent frame resize, gap-policy superiority or minimal displacement. Its limitation is useful evidence: if feeding requested anchors into the existing cursor still feels rigid, a real constraint/inverse policy is required, not more seed spacing. The next bounded choice is a declared anchor/constraint policy, then one typed layout record through source/Document/REST; broad renderer extraction is not justified by this probe.
|
||||
|
||||
Self-call send/receive assertions intentionally follow the actual current polyline contract. They do **not** assert activation correctness or substitute for peer #21's work. Activation boxes are generated by the real existing Scene/lifecycle path, and source roundtrip equality includes them, but that is not an independent oracle for nested activation start/depth/paint order. The fixture does not cover delayed creation/deletion, parallel operands, nested calls, cross-operand semantic moves, hidden views, copy/delete/reidentify lifecycle, or large models. Coordinate any later anchor contract with #16/#21 owners and retain these gaps in published evidence.
|
||||
|
||||
Root should publish this directory and the exact wired code/test result together for remote peer review. Independent machines cannot consume workspace-local links.
|
||||
|
||||
## Root source review before execution
|
||||
|
||||
The panic-restoration evaluator originally accepted any panic inside its scope, so an earlier failed geometry/thread assertion could have looked like successful deliberate unwinding. Root corrected it to require the exact deliberate panic payload. Moved message polylines now also require equal point counts before zipped endpoint comparisons. These are evaluator corrections; execution remains pending.
|
||||
|
||||
Independent bounded review confirmed hook placement, fixture assumptions and the hand-derived coordinates without executing them. Its coverage caveat prompted adding box marker metadata and insertion-slot labels to equality comparisons. Transient selection, camera and interaction state are not part of this geometry comparison. No material source finding remains from that review; compiler/runtime evidence is still required.
|
||||
|
||||
## Peer activation integration boundary
|
||||
|
||||
A read-only check of peer source `8112dea7a371755c7373f4ecc09a6488a73ac136` found that the pre-row hook remains upstream of typed activation construction and final message anchoring. That anchoring preserves generated y coordinates. Its column-zero self-call changes x from the old lane/lane+75/lane+75/lane path to lane/lane+66/lane+66/lane+6; receive y remains660 in this fixture, and the activation begins there rather than at send y636.
|
||||
|
||||
The peer branch and this branch have different lifetime integration states. This branch has production lifetime rendering, message alignment, self-lifecycle clearance and alive_spans clipping. In the peer checkpoint, scenario_lifecycle is cfg(test)-only and typed activation_segment emits an unclipped span. A future union must retain the lifetime behavior and reconcile duplicate activation methods; copying either whole Scene implementation is insufficient. Compare both lifecycle geometry and typed segment identity/rectangle/column/finish, and verify delayed Create/Delete inside and outside operands. Do not remove the existing lifecycle comparison to make the peer version compile. A segment clipped before its semantic reply must not falsely claim a visible reply termination. No peer code has been merged by this experiment.
|
||||
|
||||
[Shared integration finding](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/issues/21#issuecomment-698) and [peer activation source](http://217.177.34.108:3000/sorokin.oleg.edu/ARCADIA-Studio/src/commit/8112dea7a371755c7373f4ecc09a6488a73ac136/crates/oa-desktop/src/behavior/scenario_activation.rs). This is source compatibility evidence, not runtime acceptance.
|
||||
@@ -0,0 +1,60 @@
|
||||
arcadia 7;
|
||||
project layout_probe "Behavior layout Scene probe" id "10000000-0000-4000-8000-000000000001" {
|
||||
oa_entity sender "Sender" id "10000000-0000-4000-8000-000000000002" {}
|
||||
oa_entity receiver "Receiver" id "10000000-0000-4000-8000-000000000003" {}
|
||||
oa_activity inspect "Inspect" id "10000000-0000-4000-8000-000000000004" {}
|
||||
allocation inspect_assignment "Receiver performs inspection" id "10000000-0000-4000-8000-000000000005" {
|
||||
source @receiver; target @inspect;
|
||||
}
|
||||
communication_mean channel "Shared channel" id "10000000-0000-4000-8000-000000000006" {
|
||||
source @sender; target @receiver; oriented false;
|
||||
}
|
||||
oa_scenario conversation "Conversation" id "10000000-0000-4000-8000-000000000007" {
|
||||
viewpoint ParticipantInteraction;
|
||||
steps [@first, @second, @fragment, @self_call, @self_reply];
|
||||
}
|
||||
oa_lifeline left "Sender role" id "10000000-0000-4000-8000-000000000008" { parent @conversation; represented @sender; }
|
||||
oa_lifeline right "Receiver role" id "10000000-0000-4000-8000-000000000009" { parent @conversation; represented @receiver; }
|
||||
oa_occurrence first "First delivery" id "10000000-0000-4000-8000-00000000000a" {
|
||||
parent @conversation;
|
||||
body { Exchange {
|
||||
exchange @channel; sender @left; receiver @right;
|
||||
message_kind Asynchronous;
|
||||
}; };
|
||||
}
|
||||
oa_occurrence second "Second delivery" id "10000000-0000-4000-8000-00000000000b" {
|
||||
parent @conversation;
|
||||
body { Exchange {
|
||||
exchange @channel; sender @left; receiver @right;
|
||||
message_kind Asynchronous;
|
||||
}; };
|
||||
}
|
||||
oa_occurrence fragment "Optional inspection" id "10000000-0000-4000-8000-00000000000c" {
|
||||
parent @conversation;
|
||||
body { Fragment { operator Optional; branches [@operand]; }; };
|
||||
}
|
||||
oa_occurrence operand "When requested" id "10000000-0000-4000-8000-00000000000d" {
|
||||
parent @fragment;
|
||||
body { Branch { guard "requested"; occurrences [@nested]; }; };
|
||||
}
|
||||
oa_occurrence nested "Inspection occurrence" id "10000000-0000-4000-8000-00000000000e" {
|
||||
parent @operand;
|
||||
body { Action { action @inspect; lifeline @right; }; };
|
||||
}
|
||||
// Self calls intentionally have no global self-edge: such a relation is
|
||||
// invalid in the current metamodel. Their unassigned exchange is a known
|
||||
// incomplete-model warning, while explicit lifelines make Scene drawable.
|
||||
oa_occurrence self_call "Local call" id "10000000-0000-4000-8000-00000000000f" {
|
||||
parent @conversation;
|
||||
body { Exchange {
|
||||
exchange none; sender @right; receiver @right; message_kind Call;
|
||||
}; };
|
||||
}
|
||||
oa_occurrence self_reply "Local reply" id "10000000-0000-4000-8000-000000000010" {
|
||||
parent @conversation;
|
||||
body { Exchange {
|
||||
exchange none; sender @right; receiver @right;
|
||||
message_kind Reply; reply_to @self_call;
|
||||
}; };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
arcadia 7;
|
||||
// Separate lifetime probe: preserve this authored note and all stable IDs.
|
||||
project lifetime_probe "Delayed receiver lifetime" id "20000000-0000-4000-8000-000000000001" {
|
||||
oa_activity prepare "Prepare request" id "20000000-0000-4000-8000-000000000002" {}
|
||||
oa_activity serve "Serve request" id "20000000-0000-4000-8000-000000000003" {}
|
||||
interaction service "Service request" id "20000000-0000-4000-8000-000000000004" {
|
||||
source @prepare; target @serve;
|
||||
}
|
||||
oa_scenario lifetime_conversation "Receiver created on demand" id "20000000-0000-4000-8000-000000000005" {
|
||||
viewpoint OperationalActivity;
|
||||
steps [@before_birth, @create_receiver, @service_call, @delete_receiver];
|
||||
}
|
||||
oa_lifeline requester "Requester" id "20000000-0000-4000-8000-000000000006" {
|
||||
parent @lifetime_conversation; represented @prepare;
|
||||
}
|
||||
oa_lifeline provider "Provider" id "20000000-0000-4000-8000-000000000007" {
|
||||
parent @lifetime_conversation; represented @serve;
|
||||
}
|
||||
oa_occurrence before_birth "Prepare before receiver exists" id "20000000-0000-4000-8000-000000000008" {
|
||||
parent @lifetime_conversation;
|
||||
body { Action { action @prepare; lifeline @requester; }; };
|
||||
}
|
||||
oa_occurrence create_receiver "Create receiver" id "20000000-0000-4000-8000-000000000009" {
|
||||
parent @lifetime_conversation;
|
||||
body { Exchange {
|
||||
exchange @service; sender @requester; receiver @provider;
|
||||
message_kind Create;
|
||||
}; };
|
||||
}
|
||||
oa_occurrence service_call "Call receiver" id "20000000-0000-4000-8000-00000000000a" {
|
||||
parent @lifetime_conversation;
|
||||
body { Exchange {
|
||||
exchange @service; sender @requester; receiver @provider;
|
||||
message_kind Call;
|
||||
}; };
|
||||
}
|
||||
// Death ends the visible activation but deliberately does not invent a Reply.
|
||||
oa_occurrence delete_receiver "Delete receiver" id "20000000-0000-4000-8000-00000000000b" {
|
||||
parent @lifetime_conversation;
|
||||
body { Exchange {
|
||||
exchange @service; sender @requester; receiver @provider;
|
||||
message_kind Delete;
|
||||
}; };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
# Lifetime extension of the actual Scene experiment — not executed
|
||||
|
||||
Prepared 2026-09-12 in `codex/behavior-layout-scene-ytr83r-20260912`, inspected
|
||||
HEAD `911f63e`. This extension owns only `lifetime.arcadia`, `lifetime.rs`, this
|
||||
note, and the `mod lifetime;` declaration in the existing experiment module.
|
||||
The original fixture and its seven baseline tests are unchanged. Root owns the
|
||||
README compatibility note, compiler resource, source integration and publication.
|
||||
No production renderer, parser or model code was edited here.
|
||||
|
||||
## Question and method
|
||||
|
||||
Does the current test-only row-anchor hook propagate a changed Create/Delete
|
||||
position into the actual Scene's local lifetime, marker hit identity, aligned
|
||||
message endpoints and activation clipping, while leaving semantic/source state
|
||||
and occurrence IDs intact?
|
||||
|
||||
The separate source fixture declares stable UUIDs, a sender action before receiver
|
||||
birth, then Create, Call and receiver Delete. All three messages reference one
|
||||
valid Interaction; explicit sender/receiver lifelines represent its Activity
|
||||
endpoints. A Reply is deliberately absent: receiver death must not manufacture a
|
||||
reply or erase the unanswered-call presentation cue. The test requires real
|
||||
language parsing, no semantic errors, ordinary `Document::from_bundle` admission,
|
||||
canonical format/reparse Project equality and exact original source retention.
|
||||
Warnings are allowed and are not promoted into semantic acceptance requirements.
|
||||
|
||||
The evaluator calls the existing `Scene::sequence` and scoped `render`/`drag`
|
||||
helpers. It reads `Rendering::alive_spans` and `marker_at`, real Scene line
|
||||
polylines and actual activation `BoxItem` records. It does not construct an
|
||||
alternative activation/lifetime implementation or edit final geometry afterward.
|
||||
|
||||
## Independent expectations
|
||||
|
||||
Current Scene metrics give sender action148, Create243, Call335 and Delete427:
|
||||
the action consumes95 and each following message92. The scene height is564 and
|
||||
its root insertion bounds are106–477. These numbers are regression oracles for
|
||||
the inspected implementation, not permanent product spacing requirements.
|
||||
|
||||
The receiver is alive exactly243–427; the sender remains alive throughout the
|
||||
visible root span. Create ends at the 140px birth capsule's near edge, 70px before
|
||||
the receiver lane, while Delete ends at the death cross's center. Marker and
|
||||
Scene hit queries return the original Create/Delete occurrence IDs. The actual
|
||||
8px activation starts at Call335 and ends at death427, with no minimum-height tail
|
||||
after death. The unresolved-call cue stays at receiver-x+12, death-y−16.
|
||||
|
||||
Three tests establish these expectations if they pass:
|
||||
|
||||
1. Baseline parsed Scene has those independent rows, line endpoints, lifetime
|
||||
extents, hit identities, activation bounds and unanswered-call cue. Its real
|
||||
geometry remains equal after source round trip and an empty hook scope.
|
||||
2. Moving Create down60 changes its row to303 and propagates Call395/Delete487;
|
||||
the actual receiver lifetime, birth/death markers and activation follow. The
|
||||
preceding action's box stays fixed, line/row UUIDs stay stable, and the old
|
||||
birth point is no longer a lifecycle marker.
|
||||
3. Moving only Delete down48 changes death to475 and extends the same real
|
||||
activation to475, while Create and Call polylines stay fixed. Moving Create
|
||||
with an authored Delete request still at427 forces displayed death487; either
|
||||
signed-zero pointer delta preserves the entire latent map and Scene. Moving
|
||||
Create back restores baseline geometry and the original authored map.
|
||||
|
||||
Every test compares the entire accepted Project and source bundle against the
|
||||
original snapshot, and requires no dirty state or undo/redo history. No test
|
||||
weakens equality to just the occurrence list.
|
||||
|
||||
## Contract reused and limits
|
||||
|
||||
The inspected implementation builds lifecycle geometry after recording occurrence
|
||||
rows (`behavior.rs:1332–1344`), aligns Create/Delete messages through
|
||||
`scenario_lifecycle::render::Rendering::align_messages`, and constructs actual
|
||||
activation boxes through `activation_sequence`/`activation_segment`
|
||||
(`behavior.rs:1663–1734`). The latter intersects segments with `alive_spans` and
|
||||
keeps a cue when Delete terminates an unanswered call. Existing
|
||||
`scenario_lifecycle/render_tests.rs` tests real root birth/death alignment and
|
||||
branch-local activation clipping. This extension exercises those current
|
||||
contracts through the layout hook; it does not replace them.
|
||||
|
||||
The peer `8112dea` typed activation correction has a different future integration
|
||||
boundary and, as reported to this task, has no production lifecycle clipping yet.
|
||||
These tests are not evidence that that unmerged source is compatible. A future
|
||||
typed activation union needs its own tests for clipping, typed call/segment IDs,
|
||||
self/nested activation receive geometry, branch behavior, depth and paint order.
|
||||
Do not preserve a legacy width/classification assertion blindly when deliberately
|
||||
changing the activation representation; retain independent lifetime endpoints,
|
||||
identity and no-after-death expectations on the new actual records.
|
||||
|
||||
This extension remains uncompiled and unexecuted. It does not establish persisted
|
||||
layout syntax, a production drag transaction, undoable geometry edits, native
|
||||
pointer behavior, GPU painting, performance, or general upward/free positioning.
|
||||
The existing hook still applies a next-row floor. Root should run the existing
|
||||
`cargo test -p oa-desktop behavior_layout_scene_experiment -- --nocapture` gate
|
||||
on the exact identified wired source, then publish fixture, evaluator and result
|
||||
together. Remote peer machines cannot use workspace-local links.
|
||||
|
||||
Independent bounded source review confirmed the fixture ownership/endpoint contract and the numerical expectations above. Formatting and whitespace checks passed. Compilation and execution remain pending; these findings do not establish an editing transaction or native behavior.
|
||||
@@ -0,0 +1,259 @@
|
||||
//! Real current Scene/lifecycle evaluator, independent of the original fixture.
|
||||
//! No replacement activation geometry or future peer implementation is used.
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::{ProjectLimits, SourceBundle};
|
||||
|
||||
fn lifetime_document() -> (Document, Id) {
|
||||
let source = include_str!("lifetime.arcadia");
|
||||
let parsed = language::parse(source).expect("separate lifetime source parses");
|
||||
let errors: Vec<_> = validation::validate(&parsed)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(
|
||||
errors.is_empty(),
|
||||
"lifetime fixture semantic errors: {errors:?}"
|
||||
);
|
||||
let owner = id(&parsed, "lifetime_conversation");
|
||||
let bundle = SourceBundle {
|
||||
entry: "lifetime.arcadia".into(),
|
||||
files: BTreeMap::from([("lifetime.arcadia".into(), source.into())]),
|
||||
};
|
||||
let document = Document::from_bundle(bundle.clone(), ProjectLimits::default())
|
||||
.expect("lifetime fixture is ordinarily admitted");
|
||||
assert_eq!(document.project(), &parsed);
|
||||
assert_eq!(document.bundle(), &bundle);
|
||||
assert_eq!(language::parse(&language::format(&parsed)).unwrap(), parsed);
|
||||
(document, owner)
|
||||
}
|
||||
|
||||
/// Numeric expectations are hand-derived current-Scene metrics, not proposed
|
||||
/// permanent spacing rules: Action95, message92, initial occurrence148.
|
||||
fn assert_lifetime_geometry(
|
||||
scene: &Scene,
|
||||
project: &Project,
|
||||
owner: Id,
|
||||
birth: f32,
|
||||
call_y: f32,
|
||||
death: f32,
|
||||
) {
|
||||
let requester = id(project, "requester");
|
||||
let provider = id(project, "provider");
|
||||
let create = id(project, "create_receiver");
|
||||
let call = id(project, "service_call");
|
||||
let delete = id(project, "delete_receiver");
|
||||
let left = scene.lanes[&requester];
|
||||
let right = scene.lanes[&provider];
|
||||
assert_eq!((left, right), (160., 400.));
|
||||
assert_eq!(row(scene, id(project, "before_birth")), 148.);
|
||||
assert_eq!(row(scene, create), birth);
|
||||
assert_eq!(row(scene, call), call_y);
|
||||
assert_eq!(row(scene, delete), death);
|
||||
assert!(
|
||||
birth > 148.,
|
||||
"receiver creation is delayed beyond the opening action"
|
||||
);
|
||||
assert_eq!(scene.rows[&create].0, owner);
|
||||
assert_eq!(scene.rows[&delete].0, owner);
|
||||
|
||||
// Exercise the actual Rendering bridge, including marker hit identity and
|
||||
// its clipping contract, rather than rebuilding lifetimes from our rows.
|
||||
let alive = scene
|
||||
.lifecycle
|
||||
.alive_spans(provider, owner, 85., scene.size.height - 20.);
|
||||
assert_eq!(
|
||||
alive
|
||||
.iter()
|
||||
.map(|span| (span.start, span.end))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(birth, death)]
|
||||
);
|
||||
assert!(
|
||||
scene
|
||||
.lifecycle
|
||||
.alive_spans(provider, owner, 85., birth)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
scene
|
||||
.lifecycle
|
||||
.alive_spans(provider, owner, death, scene.size.height - 20.)
|
||||
.is_empty()
|
||||
);
|
||||
let sender_alive = scene
|
||||
.lifecycle
|
||||
.alive_spans(requester, owner, 85., scene.size.height - 20.);
|
||||
assert_eq!(
|
||||
sender_alive
|
||||
.iter()
|
||||
.map(|span| (span.start, span.end))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(85., scene.size.height - 20.)]
|
||||
);
|
||||
assert_eq!(
|
||||
scene.lifecycle.marker_at(Point::new(right, birth)),
|
||||
Some(create)
|
||||
);
|
||||
assert_eq!(
|
||||
scene.lifecycle.marker_at(Point::new(right, death)),
|
||||
Some(delete)
|
||||
);
|
||||
assert_eq!(scene.hit(Point::new(right, birth)), Some(create));
|
||||
assert_eq!(scene.hit(Point::new(right, death)), Some(delete));
|
||||
assert_eq!(
|
||||
line(scene, create).points,
|
||||
vec![Point::new(left, birth), Point::new(right - 70., birth)]
|
||||
);
|
||||
assert_eq!(
|
||||
line(scene, call).points,
|
||||
vec![Point::new(left, call_y), Point::new(right, call_y)]
|
||||
);
|
||||
assert_eq!(
|
||||
line(scene, delete).points,
|
||||
vec![Point::new(left, death), Point::new(right, death)]
|
||||
);
|
||||
assert!(line(scene, create).label_at.y + 22. < birth - 14.);
|
||||
|
||||
// Inspect actual activation boxes produced by Scene::activation_sequence.
|
||||
// A correlated Reply is absent, so death must clip the bar, not close the
|
||||
// semantic call. Do not accept a new generic minimum-height tail after death.
|
||||
let bars: Vec<_> = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|item| item.id == call && item.title.is_empty())
|
||||
.collect();
|
||||
assert_eq!(bars.len(), 1);
|
||||
assert_eq!(
|
||||
bars[0].rect,
|
||||
Rectangle::new(
|
||||
Point::new(right - 4., call_y),
|
||||
Size::new(8., death - call_y)
|
||||
)
|
||||
);
|
||||
assert_eq!(bars[0].rect.y + bars[0].rect.height, death);
|
||||
assert!(
|
||||
bars.iter()
|
||||
.all(|item| item.rect.y >= birth && item.rect.y + item.rect.height <= death)
|
||||
);
|
||||
assert_eq!(scene.open_calls, vec![Point::new(right + 12., death - 16.)]);
|
||||
assert!(!project.elements.values().any(|element| matches!(
|
||||
element.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
message_kind: MessageKind::Reply,
|
||||
..
|
||||
},
|
||||
..
|
||||
})
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delayed_create_call_and_delete_have_real_lifetime_markers_and_exact_clipped_activation() {
|
||||
let (document, owner) = lifetime_document();
|
||||
let before = document.snapshot();
|
||||
let source = document.bundle().clone();
|
||||
let scene = Scene::sequence(document.project(), owner, owner);
|
||||
assert_lifetime_geometry(&scene, document.project(), owner, 243., 335., 427.);
|
||||
assert_eq!(scene.size.height, 564.);
|
||||
assert_eq!(scene.sequence_bounds[&owner], (106., 477.));
|
||||
same_geometry(&scene, &render(document.project(), owner, &Layout::new()));
|
||||
let reparsed = language::parse(&language::format(document.project())).unwrap();
|
||||
same_geometry(&scene, &Scene::sequence(&reparsed, owner, owner));
|
||||
assert_eq!(document.project(), &before.decoded.project);
|
||||
assert_eq!(document.bundle(), &source);
|
||||
assert!(!document.dirty() && !document.can_undo() && !document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_create_moves_real_lifetime_and_followers_without_changing_semantics_or_source() {
|
||||
let (document, owner) = lifetime_document();
|
||||
let before = document.snapshot();
|
||||
let source = document.bundle().clone();
|
||||
let p = document.project();
|
||||
let create = id(p, "create_receiver");
|
||||
let provider = id(p, "provider");
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let layout = drag(&Layout::new(), &baseline, create, 60.);
|
||||
assert_eq!(layout, Layout::from([(create, 303.)]));
|
||||
let moved = render(p, owner, &layout);
|
||||
assert_lifetime_geometry(&moved, p, owner, 303., 395., 487.);
|
||||
assert_eq!(moved.size.height, 624.);
|
||||
assert_eq!(
|
||||
moved
|
||||
.lifecycle
|
||||
.marker_at(Point::new(moved.lanes[&provider], 243.)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
named_box(&moved, id(p, "before_birth")).rect,
|
||||
named_box(&baseline, id(p, "before_birth")).rect
|
||||
);
|
||||
assert_eq!(
|
||||
moved.rows.keys().collect::<Vec<_>>(),
|
||||
baseline.rows.keys().collect::<Vec<_>>()
|
||||
);
|
||||
assert_eq!(
|
||||
moved.lines.iter().map(|line| line.id).collect::<Vec<_>>(),
|
||||
baseline
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| line.id)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let reparsed = language::parse(&language::format(p)).unwrap();
|
||||
same_geometry(&moved, &render(&reparsed, owner, &layout));
|
||||
assert_eq!(p, &before.decoded.project);
|
||||
assert_eq!(document.bundle(), &source);
|
||||
assert!(!document.dirty() && !document.can_undo() && !document.can_redo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_delete_extends_only_to_actual_death_and_zero_motion_preserves_latent_lifetime_intent() {
|
||||
let (document, owner) = lifetime_document();
|
||||
let before = document.snapshot();
|
||||
let source = document.bundle().clone();
|
||||
let p = document.project();
|
||||
let create = id(p, "create_receiver");
|
||||
let delete = id(p, "delete_receiver");
|
||||
let provider = id(p, "provider");
|
||||
let baseline = Scene::sequence(p, owner, owner);
|
||||
let later_delete = drag(&Layout::new(), &baseline, delete, 48.);
|
||||
let extended = render(p, owner, &later_delete);
|
||||
assert_lifetime_geometry(&extended, p, owner, 243., 335., 475.);
|
||||
assert_eq!(extended.size.height, 612.);
|
||||
assert_eq!(
|
||||
extended
|
||||
.lifecycle
|
||||
.marker_at(Point::new(extended.lanes[&provider], 427.)),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
line(&extended, create).points,
|
||||
line(&baseline, create).points
|
||||
);
|
||||
assert_eq!(
|
||||
line(&extended, id(p, "service_call")).points,
|
||||
line(&baseline, id(p, "service_call")).points
|
||||
);
|
||||
|
||||
// Create displacement forces the displayed death to487 while its authored
|
||||
// request stays427. Both signed zero deltas must preserve that latent value.
|
||||
let original = Layout::from([(create, 243.), (delete, 427.)]);
|
||||
let displaced = drag(&original, &baseline, create, 60.);
|
||||
let frame = render(p, owner, &displaced);
|
||||
assert_lifetime_geometry(&frame, p, owner, 303., 395., 487.);
|
||||
assert_eq!(displaced[&delete], 427.);
|
||||
for delta in [0., -0.] {
|
||||
let untouched = drag(&displaced, &frame, delete, delta);
|
||||
assert_eq!(untouched, displaced);
|
||||
same_geometry(&frame, &render(p, owner, &untouched));
|
||||
let restored = drag(&untouched, &frame, create, -60.);
|
||||
assert_eq!(restored, original);
|
||||
same_geometry(&baseline, &render(p, owner, &restored));
|
||||
}
|
||||
assert_eq!(p, &before.decoded.project);
|
||||
assert_eq!(document.bundle(), &source);
|
||||
assert!(!document.dirty() && !document.can_undo() && !document.can_redo());
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Isolated test-only adapter. See README.md and wiring.patch before running.
|
||||
//! It drives the actual parent Scene; it contains no replacement geometry model.
|
||||
use super::*;
|
||||
use oa_core::{language, validation};
|
||||
use std::cell::RefCell;
|
||||
|
||||
mod lifetime;
|
||||
|
||||
type Layout = BTreeMap<Id, f32>;
|
||||
|
||||
thread_local! {
|
||||
static ANCHORS: RefCell<Option<Layout>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
struct AnchorScope(Option<Layout>);
|
||||
impl AnchorScope {
|
||||
fn install(layout: &Layout) -> Self {
|
||||
Self(ANCHORS.with(|slot| slot.replace(Some(layout.clone()))))
|
||||
}
|
||||
}
|
||||
impl Drop for AnchorScope {
|
||||
fn drop(&mut self) {
|
||||
ANCHORS.with(|slot| {
|
||||
slot.replace(self.0.take());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Called only by the proposed cfg(test) row hook, before Scene records rows.
|
||||
/// Retains the CURRENT renderer's next-row floor; this is not general free y.
|
||||
pub(super) fn resolve_row_anchor(id: Id, y: &mut f32) {
|
||||
ANCHORS.with(|slot| {
|
||||
if let Some(desired) = slot.borrow().as_ref().and_then(|map| map.get(&id)) {
|
||||
assert!(desired.is_finite());
|
||||
*y = (*y).max(*desired);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn render(project: &Project, owner: Id, layout: &Layout) -> Scene {
|
||||
let _scope = AnchorScope::install(layout);
|
||||
Scene::sequence(project, owner, owner)
|
||||
}
|
||||
|
||||
/// Pointer intent differs from explicit SetDesiredY: zero displacement preserves
|
||||
/// the complete original authored candidate even when desired != displayed.
|
||||
fn drag(original: &Layout, frame: &Scene, target: Id, delta_y: f32) -> Layout {
|
||||
assert!(delta_y.is_finite());
|
||||
if delta_y == 0. {
|
||||
return original.clone();
|
||||
}
|
||||
let mut next = original.clone();
|
||||
next.insert(target, frame.rows[&target].1 + delta_y);
|
||||
next
|
||||
}
|
||||
|
||||
fn id(p: &Project, alias: &str) -> Id {
|
||||
p.identities()
|
||||
.into_iter()
|
||||
.find(|(_, name)| *name == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn fixture() -> (Project, Id) {
|
||||
let p = language::parse(include_str!("fixture.arcadia")).expect("real Arcadia fixture parses");
|
||||
let errors: Vec<_> = validation::validate(&p)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "fixture semantic errors: {errors:?}");
|
||||
let owner = id(&p, "conversation");
|
||||
(p, owner)
|
||||
}
|
||||
|
||||
fn row(scene: &Scene, id: Id) -> f32 {
|
||||
scene.rows[&id].1
|
||||
}
|
||||
fn line(scene: &Scene, id: Id) -> &Line {
|
||||
assert_eq!(scene.lines.iter().filter(|line| line.id == id).count(), 1);
|
||||
scene.lines.iter().find(|line| line.id == id).unwrap()
|
||||
}
|
||||
fn named_box(scene: &Scene, id: Id) -> &BoxItem {
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|item| item.id == id && !item.title.is_empty())
|
||||
.unwrap()
|
||||
}
|
||||
fn same_geometry(a: &Scene, b: &Scene) {
|
||||
assert_eq!(a.rows, b.rows);
|
||||
assert_eq!(a.sequence_bounds, b.sequence_bounds);
|
||||
assert_eq!(a.size, b.size);
|
||||
assert_eq!(a.lanes, b.lanes);
|
||||
assert_eq!(a.lifecycle, b.lifecycle);
|
||||
assert_eq!(a.open_calls, b.open_calls);
|
||||
let lines = |s: &Scene| {
|
||||
s.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
(
|
||||
l.id,
|
||||
l.points.clone(),
|
||||
l.label_at,
|
||||
l.label.clone(),
|
||||
l.dashed,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let boxes = |s: &Scene| {
|
||||
s.boxes
|
||||
.iter()
|
||||
.map(|b| (b.id, b.rect, b.title.clone(), b.detail.clone(), b.marker))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(lines(a), lines(b));
|
||||
assert_eq!(boxes(a), boxes(b));
|
||||
let slots = |s: &Scene| {
|
||||
s.slots
|
||||
.iter()
|
||||
.map(|s| (s.slot.parent, s.slot.before, s.y, s.label.clone()))
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(slots(a), slots(b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_parser_roundtrip_and_baseline_scene_have_independent_expected_anchors() {
|
||||
let (p, owner) = fixture();
|
||||
let before = p.clone();
|
||||
let source = language::format(&p);
|
||||
let reparsed = language::parse(&source).unwrap();
|
||||
assert_eq!(reparsed, p);
|
||||
let baseline = Scene::sequence(&p, owner, owner);
|
||||
same_geometry(&baseline, &render(&p, owner, &Layout::new()));
|
||||
same_geometry(&baseline, &Scene::sequence(&reparsed, owner, owner));
|
||||
let first = id(&p, "first");
|
||||
let second = id(&p, "second");
|
||||
assert_ne!(first, second);
|
||||
for occurrence in [first, second] {
|
||||
assert!(matches!(p.elements[&occurrence].shared(),
|
||||
Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange: Some(edge), .. }, .. })
|
||||
if *edge == id(&p, "channel")));
|
||||
}
|
||||
// Hand-derived current metrics: start148, each message92, fragment55,
|
||||
// operand90, action95, fragment trailing64. These are regression oracles,
|
||||
// NOT a claim that those fixed spacings should remain product constraints.
|
||||
for (alias, expected) in [
|
||||
("first", 148.),
|
||||
("second", 240.),
|
||||
("fragment", 332.),
|
||||
("nested", 477.),
|
||||
("self_call", 636.),
|
||||
("self_reply", 728.),
|
||||
] {
|
||||
assert_eq!(row(&baseline, id(&p, alias)), expected, "{alias}");
|
||||
}
|
||||
assert_eq!(baseline.rows[&id(&p, "nested")].0, id(&p, "operand"));
|
||||
assert_eq!(named_box(&baseline, id(&p, "nested")).rect.y, 457.);
|
||||
let frame = named_box(&baseline, id(&p, "fragment")).rect;
|
||||
assert_eq!((frame.y, frame.height), (332., 240.));
|
||||
let left = baseline.lanes[&id(&p, "left")];
|
||||
let right = baseline.lanes[&id(&p, "right")];
|
||||
assert_eq!(
|
||||
line(&baseline, first).points,
|
||||
vec![Point::new(left, 148.), Point::new(right, 148.)]
|
||||
);
|
||||
assert_eq!(
|
||||
line(&baseline, second).points,
|
||||
vec![Point::new(left, 240.), Point::new(right, 240.)]
|
||||
);
|
||||
let local = line(&baseline, id(&p, "self_call"));
|
||||
assert_eq!(
|
||||
local.points,
|
||||
vec![
|
||||
Point::new(right, 636.),
|
||||
Point::new(right + 75., 636.),
|
||||
Point::new(right + 75., 660.),
|
||||
Point::new(right, 660.)
|
||||
]
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
assert_eq!(language::format(&p), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moved_repeat_drives_actual_scene_followers_frames_and_self_message_without_semantic_edits() {
|
||||
let (p, owner) = fixture();
|
||||
let before = p.clone();
|
||||
let source = language::format(&p);
|
||||
let baseline = Scene::sequence(&p, owner, owner);
|
||||
let second = id(&p, "second");
|
||||
let candidate = drag(&Layout::new(), &baseline, second, 40.);
|
||||
assert_eq!(candidate, Layout::from([(second, 280.)]));
|
||||
let moved = render(&p, owner, &candidate);
|
||||
assert_eq!(row(&moved, id(&p, "first")), 148.);
|
||||
assert_eq!(row(&moved, second), 280.);
|
||||
assert_eq!(row(&moved, id(&p, "nested")), 517.);
|
||||
assert_eq!(row(&moved, id(&p, "self_call")), 676.);
|
||||
assert_eq!(row(&moved, id(&p, "self_reply")), 768.);
|
||||
assert_eq!(
|
||||
line(&moved, id(&p, "first")).points,
|
||||
line(&baseline, id(&p, "first")).points
|
||||
);
|
||||
for alias in ["second", "self_call", "self_reply"] {
|
||||
assert_eq!(
|
||||
line(&moved, id(&p, alias)).points.len(),
|
||||
line(&baseline, id(&p, alias)).points.len()
|
||||
);
|
||||
for (old, new) in line(&baseline, id(&p, alias))
|
||||
.points
|
||||
.iter()
|
||||
.zip(&line(&moved, id(&p, alias)).points)
|
||||
{
|
||||
assert_eq!(new.x, old.x);
|
||||
assert_eq!(new.y - old.y, 40.);
|
||||
}
|
||||
}
|
||||
let frame = named_box(&moved, id(&p, "fragment")).rect;
|
||||
assert_eq!((frame.y, frame.height), (372., 240.));
|
||||
assert_eq!(named_box(&moved, id(&p, "nested")).rect.y, 497.);
|
||||
same_geometry(&moved, &render(&p, owner, &candidate));
|
||||
same_geometry(
|
||||
&moved,
|
||||
&render(&language::parse(&source).unwrap(), owner, &candidate),
|
||||
);
|
||||
assert_eq!(p, before); // Entire Project, not only the steps vector.
|
||||
assert_eq!(language::format(&p), source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_anchor_grows_actual_fragment_and_self_call_keeps_its_receive_offset() {
|
||||
let (p, owner) = fixture();
|
||||
let before = p.clone();
|
||||
let baseline = Scene::sequence(&p, owner, owner);
|
||||
let nested = id(&p, "nested");
|
||||
let candidate = drag(&Layout::new(), &baseline, nested, 30.);
|
||||
let moved = render(&p, owner, &candidate);
|
||||
let frame = named_box(&moved, id(&p, "fragment")).rect;
|
||||
assert_eq!((frame.y, frame.height), (332., 270.));
|
||||
assert_eq!(named_box(&moved, nested).rect.y, 487.);
|
||||
assert_eq!(row(&moved, id(&p, "self_call")), 666.);
|
||||
let call = id(&p, "self_call");
|
||||
let next = drag(&candidate, &moved, call, 18.);
|
||||
let local = render(&p, owner, &next);
|
||||
assert_eq!(row(&local, call), 684.);
|
||||
assert_eq!(line(&local, call).points.first().unwrap().y, 684.);
|
||||
assert_eq!(line(&local, call).points.last().unwrap().y, 708.);
|
||||
assert_eq!(row(&local, id(&p, "self_reply")), 776.);
|
||||
assert_eq!(
|
||||
named_box(&local, nested).rect,
|
||||
named_box(&moved, nested).rect
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_pointer_delta_preserves_latent_authored_intent_but_explicit_assignment_can_change_it() {
|
||||
let (p, owner) = fixture();
|
||||
let baseline = Scene::sequence(&p, owner, owner);
|
||||
let first = id(&p, "first");
|
||||
let second = id(&p, "second");
|
||||
let original = Layout::from([(first, 148.), (second, 240.)]);
|
||||
let displaced = drag(&original, &baseline, first, 200.);
|
||||
let frame = render(&p, owner, &displaced);
|
||||
assert_eq!(row(&frame, second), 440.);
|
||||
assert_eq!(displaced[&second], 240.);
|
||||
let untouched = drag(&displaced, &frame, second, 0.);
|
||||
assert_eq!(untouched, displaced);
|
||||
let back = drag(&untouched, &frame, first, -200.);
|
||||
assert_eq!(back, original);
|
||||
assert_eq!(row(&render(&p, owner, &back), second), 240.);
|
||||
let mut explicit = displaced.clone();
|
||||
explicit.insert(second, 440.); // Explicit SetDesiredY, not a pointer gesture.
|
||||
let explicit_back = drag(&explicit, &frame, first, -200.);
|
||||
assert_eq!(row(&render(&p, owner, &explicit_back), second), 440.);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_reorder_keeps_override_bound_to_occurrence_identity_and_does_not_write_back_projection() {
|
||||
let (p, owner) = fixture();
|
||||
let input = include_str!("fixture.arcadia");
|
||||
let reordered_source =
|
||||
input.replace("[@first, @second, @fragment", "[@second, @first, @fragment");
|
||||
assert_ne!(input, reordered_source);
|
||||
let reordered = language::parse(&reordered_source).unwrap();
|
||||
let before = reordered.clone();
|
||||
let second = id(&p, "second");
|
||||
let first = id(&p, "first");
|
||||
assert_eq!(id(&reordered, "second"), second);
|
||||
let scene = render(&reordered, owner, &Layout::from([(second, 280.)]));
|
||||
assert_eq!(row(&scene, second), 280.);
|
||||
assert_eq!(row(&scene, first), 372.);
|
||||
assert_eq!(
|
||||
&reordered.elements[&owner].steps().unwrap()[..2],
|
||||
&[second, first]
|
||||
);
|
||||
assert_eq!(reordered, before);
|
||||
assert_eq!(
|
||||
language::parse(&language::format(&reordered)).unwrap(),
|
||||
before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_floor_is_an_explicit_counterexample_to_general_free_positioning() {
|
||||
let (p, owner) = fixture();
|
||||
let baseline = Scene::sequence(&p, owner, owner);
|
||||
let second = id(&p, "second");
|
||||
let requested = drag(&Layout::new(), &baseline, second, -40.);
|
||||
assert_eq!(requested[&second], 200.);
|
||||
assert_eq!(row(&render(&p, owner, &requested), second), 240.);
|
||||
// This expected mismatch records a limitation; it is not acceptance of
|
||||
// silently clamped product gestures or proof that y200 is semantically bad.
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_hook_is_inactive_by_default_thread_local_and_restored_after_panic() {
|
||||
let (p, owner) = fixture();
|
||||
let first = id(&p, "first");
|
||||
let baseline = Scene::sequence(&p, owner, owner);
|
||||
let caught = std::panic::catch_unwind(|| {
|
||||
let _scope = AnchorScope::install(&Layout::from([(first, 900.)]));
|
||||
assert_eq!(row(&Scene::sequence(&p, owner, owner), first), 900.);
|
||||
// Another test thread has no access to this thread's temporary anchors.
|
||||
std::thread::spawn(|| {
|
||||
let (p, owner) = fixture();
|
||||
assert_eq!(
|
||||
row(&Scene::sequence(&p, owner, owner), id(&p, "first")),
|
||||
148.
|
||||
);
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
panic!("exercise scoped restoration");
|
||||
});
|
||||
let payload = caught.expect_err("the deliberate scope-restoration panic must occur");
|
||||
assert_eq!(
|
||||
payload.downcast_ref::<&str>().copied(),
|
||||
Some("exercise scoped restoration"),
|
||||
"an earlier failed assertion must not masquerade as the expected panic"
|
||||
);
|
||||
same_geometry(&baseline, &Scene::sequence(&p, owner, owner));
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
diff --git a/crates/oa-desktop/src/behavior.rs b/crates/oa-desktop/src/behavior.rs
|
||||
--- a/crates/oa-desktop/src/behavior.rs
|
||||
+++ b/crates/oa-desktop/src/behavior.rs
|
||||
@@ -1120,1 +1120,5 @@
|
||||
+#[cfg(test)]
|
||||
+#[path = "../../../harness/experiments/behavior-layout-scene-20260912/mod.rs"]
|
||||
+mod behavior_layout_scene_experiment;
|
||||
+
|
||||
struct BoxItem {
|
||||
@@ -1446,2 +1450,4 @@
|
||||
+ #[cfg(test)]
|
||||
+ behavior_layout_scene_experiment::resolve_row_anchor(*id, y);
|
||||
if ordered {
|
||||
let slot = scenario::Slot {
|
||||
@@ -0,0 +1,13 @@
|
||||
# In-progress navigation source and behavior editing reviews
|
||||
|
||||
This commit makes the reviewed source and reports available to sessions on other machines. Local `.local` paths in prior issue comments were inaccessible handoffs; use these versioned files and their relative source links.
|
||||
|
||||
The source was copied from the 561-input frozen Windows test snapshot on branch `codex/diagram-usages-ytr83r-20260912`, based on `7aa007b0777aa182a99f46e90eeb9b2558f3ad2a`. Every copied input was SHA-256 verified against the pre-gate capture. This is unaccepted work in progress: the desktop test binary is still compiling at publication preparation, no final tests/lint/native pass is claimed, and known navigation selection/coverage corrections remain. The reports are proposals and independent source reviews, not acceptance results. Linux/WSL remains deferred.
|
||||
|
||||
- [Nested activation review for issue21](issue21-activation-analysis-20260912.md): peer956 retains implementation ownership.
|
||||
- [Free behavior canvas proposal](free-behavior-canvas-design-20260912.md).
|
||||
- [Independent canvas design critique](free-behavior-canvas-critique-20260912.md).
|
||||
- [Remaining navigation coverage and selection review](diagram-navigation-remaining-coverage-20260912.md).
|
||||
- [Frozen source input manifest](frozen-inputs.json).
|
||||
|
||||
The experiment, reviews, source paths, and runtime evidence from this machine must be published before they are used as cross-machine handoffs. In-thread delegated agents share the local workspace; independently running Gitea peers do not. Live ownership, plans, and results remain in Gitea issues.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user