Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00a83c10eb | ||
|
|
694efd6e59 | ||
|
|
aed07176ab | ||
|
|
9245f8d23e | ||
|
|
a4d1efc5f7 | ||
|
|
ecc49e0417 | ||
|
|
e409107a63 |
@@ -35,26 +35,26 @@ 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.
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ fn fixture() -> (Project, Id, Vec<Id>, Vec<Id>, Vec<Id>) {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, routes: vec![] });
|
||||
p.diagrams.insert(diagram, Diagram { id: diagram, alias: "forty_exchange_view".into(), name: "Forty service exchanges · all concerns".into(), description: "Start with All. Compare first category, overlapping concerns, global coverage and chosen processes. No hand-routed edges.".into(), layer: Layer::Oa, projection: ProjectionKind::FunctionalDataflow, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
|
||||
(p, diagram, exchanges, categories, contexts)
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,7 @@ fn diagram(
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1823,7 +1823,7 @@ pub(crate) fn resolve_declarations(
|
||||
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
|
||||
}
|
||||
let defaults =
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
|
||||
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default(),"layout":null});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, true);
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod ports;
|
||||
pub mod projection;
|
||||
pub mod references;
|
||||
pub mod routing;
|
||||
pub mod scenario_layout;
|
||||
pub mod semantic;
|
||||
pub mod session;
|
||||
pub mod source_files;
|
||||
@@ -18,6 +19,9 @@ pub mod validation;
|
||||
pub mod view_options;
|
||||
pub use model::*;
|
||||
pub use ports::*;
|
||||
pub use scenario_layout::{
|
||||
DiagramLayout, ScenarioLanePosition, ScenarioLayout, ScenarioMessagePosition,
|
||||
};
|
||||
pub use semantic::*;
|
||||
pub use session::*;
|
||||
mod demo;
|
||||
|
||||
@@ -1436,6 +1436,7 @@ pub enum ProjectionKind {
|
||||
FunctionalDataflow,
|
||||
Capabilities,
|
||||
StateMachine { machine: Id },
|
||||
Scenario { scenario: Id },
|
||||
}
|
||||
impl ProjectionKind {
|
||||
pub fn state_machine(self) -> Option<Id> {
|
||||
@@ -1445,8 +1446,30 @@ impl ProjectionKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// The semantic owner of a saved Scenario representation.
|
||||
pub fn scenario(self) -> Option<Id> {
|
||||
match self {
|
||||
Self::Scenario { scenario } => Some(scenario),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicitly classify architecture views; absence of a state machine is insufficient.
|
||||
pub fn is_architecture(self) -> bool {
|
||||
match self {
|
||||
Self::Overview
|
||||
| Self::Decomposition
|
||||
| Self::Allocation
|
||||
| Self::Traceability
|
||||
| Self::FunctionalDataflow
|
||||
| Self::Capabilities => true,
|
||||
Self::StateMachine { .. } | Self::Scenario { .. } => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn includes(self, kind: Kind) -> bool {
|
||||
match self {
|
||||
Self::Scenario { .. } => false,
|
||||
Self::StateMachine { .. } => {
|
||||
matches!(kind, Kind::StateMachine | Kind::Region) || kind.is_state()
|
||||
}
|
||||
@@ -1471,6 +1494,7 @@ impl std::fmt::Display for ProjectionKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::StateMachine { .. } => "State machine",
|
||||
Self::Scenario { .. } => "Scenario",
|
||||
Self::Allocation => "Architecture · allocated work",
|
||||
Self::FunctionalDataflow => "Functional dataflow",
|
||||
Self::Capabilities => "Missions & capabilities",
|
||||
@@ -1495,6 +1519,8 @@ pub struct Diagram {
|
||||
pub nodes: BTreeMap<Id, NodePlacement>,
|
||||
#[serde(default)]
|
||||
pub routes: Vec<EdgeRoute>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub layout: Option<crate::scenario_layout::DiagramLayout>,
|
||||
}
|
||||
/// Authored presentation for one existing relation or trace in one diagram.
|
||||
/// Computed endpoints and automatic routing elbows are never persisted here.
|
||||
@@ -1921,6 +1947,7 @@ impl Project {
|
||||
view_options: crate::view_options::ViewOptions::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
id
|
||||
|
||||
@@ -278,6 +278,11 @@ fn project_diagram_impl(
|
||||
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
|
||||
index: Option<&crate::view_options::ViewIndex>,
|
||||
) -> ProjectedView {
|
||||
// Saved Scenario layout is not an architecture/state projection. Its dedicated
|
||||
// renderer is not wired yet; never interpret its payload through this fallback.
|
||||
if d.projection.scenario().is_some() {
|
||||
return ProjectedView::default();
|
||||
}
|
||||
let parents = parents(p, d);
|
||||
let mut nodes = Vec::new();
|
||||
for n in d.nodes.values() {
|
||||
@@ -394,6 +399,7 @@ fn project_diagram_impl(
|
||||
continue;
|
||||
}
|
||||
let included = match d.projection {
|
||||
ProjectionKind::Scenario { .. } => false,
|
||||
ProjectionKind::StateMachine { machine } => matches!(&r.body,
|
||||
RelationBody::StateTransition { transition_kind, .. }
|
||||
if *transition_kind != TransitionKind::Internal
|
||||
|
||||
@@ -203,7 +203,81 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
/// Every semantic/presentation reference in a diagram, excluding its own identities.
|
||||
pub fn diagram_references(d: &Diagram) -> Vec<Id> {
|
||||
let mut copy = d.clone();
|
||||
let mut refs = Vec::new();
|
||||
visit_diagram_refs(&mut copy, |id| refs.push(*id));
|
||||
refs
|
||||
}
|
||||
|
||||
pub fn visit_diagram_refs(d: &mut Diagram, mut visit: impl FnMut(&mut Id)) {
|
||||
d.view_options.visit_references(&mut visit);
|
||||
match &mut d.projection {
|
||||
ProjectionKind::StateMachine { machine } => visit(machine),
|
||||
ProjectionKind::Scenario { scenario } => visit(scenario),
|
||||
_ => {}
|
||||
}
|
||||
if let Some(layout) = &mut d.layout {
|
||||
layout.visit_references(&mut visit);
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
visit(&mut route.edge);
|
||||
}
|
||||
for node in d.nodes.values_mut() {
|
||||
for anchor in &mut node.port_anchors {
|
||||
visit(&mut anchor.port);
|
||||
}
|
||||
visit(&mut node.element);
|
||||
}
|
||||
}
|
||||
|
||||
/// Preflight reference-key collisions before the caller changes object identities.
|
||||
/// Fresh targets are allowed. This is not whole-project admission: unrelated
|
||||
/// invalid records remain repairable within a transaction. Future typed reference
|
||||
/// domains must compose their own preflight here before any remap mutation.
|
||||
pub fn check_remap(p: &Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
if old == new {
|
||||
return Ok(());
|
||||
}
|
||||
let remap = |id: Id| if id == old { new } else { id };
|
||||
for diagram in p.diagrams.values() {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
continue;
|
||||
};
|
||||
let mut lanes = std::collections::BTreeMap::new();
|
||||
for lane in &layout.lanes {
|
||||
let affected = lane.lifeline == old;
|
||||
if let Some(previous_affected) = lanes.insert(remap(lane.lifeline), affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario lane position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let mut messages = std::collections::BTreeMap::new();
|
||||
for message in &layout.messages {
|
||||
let mut address = message.address.clone();
|
||||
let affected = address.occurrence == old || address.references.contains(&old);
|
||||
address.occurrence = remap(address.occurrence);
|
||||
for reference in &mut address.references {
|
||||
*reference = remap(*reference);
|
||||
}
|
||||
if let Some(previous_affected) = messages.insert(address, affected)
|
||||
&& (affected || previous_affected)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Identity remap would collide with a Scenario message position.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remap_references(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
check_remap(p, old, new)?;
|
||||
for e in p.elements.values_mut() {
|
||||
visit_element_refs(e, |id| {
|
||||
if *id == old {
|
||||
@@ -219,31 +293,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.visit_references(|id| {
|
||||
visit_diagram_refs(d, |id| {
|
||||
if *id == old {
|
||||
*id = new;
|
||||
}
|
||||
});
|
||||
if let ProjectionKind::StateMachine { machine } = &mut d.projection
|
||||
&& *machine == old
|
||||
{
|
||||
*machine = new;
|
||||
}
|
||||
for route in &mut d.routes {
|
||||
if route.edge == old {
|
||||
route.edge = new;
|
||||
}
|
||||
}
|
||||
for n in d.nodes.values_mut() {
|
||||
for anchor in &mut n.port_anchors {
|
||||
if anchor.port == old {
|
||||
anchor.port = new;
|
||||
}
|
||||
}
|
||||
if n.element == old {
|
||||
n.element = new;
|
||||
}
|
||||
}
|
||||
}
|
||||
for a in p.artifact_links.values_mut() {
|
||||
if a.owner == old {
|
||||
@@ -265,6 +319,7 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
|
||||
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
|
||||
@@ -339,7 +394,8 @@ fn deletion_impact_inner(p: &Project, id: Id, defer_exchange_allocations: bool)
|
||||
for d in p.diagrams.values() {
|
||||
if d.projection
|
||||
.state_machine()
|
||||
.is_some_and(|machine| removed.contains(&machine))
|
||||
.or_else(|| d.projection.scenario())
|
||||
.is_some_and(|owner| removed.contains(&owner))
|
||||
{
|
||||
removed.insert(d.id);
|
||||
}
|
||||
@@ -412,6 +468,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
p.artifact_links.retain(|id, _| !removed.contains(id));
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.view_options.retain_references(|id| !removed.contains(id));
|
||||
crate::scenario_layout::prune_deleted(d, &removed);
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
@@ -589,3 +646,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "references/scenario_layout_tests.rs"]
|
||||
mod scenario_layout_tests;
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
lanes: [Id; 2],
|
||||
messages: [Id; 2],
|
||||
views: [Id; 2],
|
||||
}
|
||||
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
|
||||
fn layout(project: &Project, view: Id) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &project.diagrams[&view].layout else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn layout_mut(project: &mut Project, view: Id) -> &mut ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) =
|
||||
&mut project.diagrams.get_mut(&view).unwrap().layout
|
||||
else {
|
||||
panic!("expected authored Scenario positions")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
fn admitted(project: &Project) {
|
||||
let errors: Vec<_> = crate::validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == crate::validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Delivery", None)
|
||||
.unwrap();
|
||||
let lanes = ["Sender", "Receiver"].map(|name| {
|
||||
project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, name, Some(owner))
|
||||
.unwrap()
|
||||
});
|
||||
let messages = ["First message", "Second message"].map(|name| {
|
||||
let id = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, name, Some(owner))
|
||||
.unwrap();
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: Some(lanes[0]),
|
||||
receiver: Some(lanes[1]),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
id
|
||||
});
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = messages.to_vec();
|
||||
let views = ["delivery_view", "review_view"].map(|alias| {
|
||||
let id = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
id,
|
||||
Diagram {
|
||||
id,
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[0],
|
||||
x: 120.,
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: lanes[1],
|
||||
x: 420.,
|
||||
},
|
||||
],
|
||||
messages: vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[0]),
|
||||
send_y: 220.,
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(messages[1]),
|
||||
send_y: 360.,
|
||||
},
|
||||
],
|
||||
})),
|
||||
},
|
||||
);
|
||||
id
|
||||
});
|
||||
admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
lanes,
|
||||
messages,
|
||||
views,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_identity_swap_preserves_coordinates_and_semantic_sequence() {
|
||||
let mut f = fixture();
|
||||
let before = f.project.clone();
|
||||
let temporary = Id::new_v4();
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
admitted(&f.project);
|
||||
assert_eq!(
|
||||
f.project.elements[&f.owner].steps().unwrap(),
|
||||
&vec![f.messages[1], f.messages[0]]
|
||||
);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(
|
||||
rows.lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[1],
|
||||
x: 120.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lanes[0],
|
||||
x: 420.
|
||||
}
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 220.
|
||||
},
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.messages[0]),
|
||||
send_y: 360.
|
||||
}
|
||||
]
|
||||
);
|
||||
}
|
||||
// Reverse the swaps: no values or identities other than those requested drift.
|
||||
for ids in [f.lanes, f.messages] {
|
||||
f.project = crate::execute_batch(
|
||||
&f.project,
|
||||
&[
|
||||
Command::Reidentify(ids[0], temporary),
|
||||
Command::Reidentify(ids[1], ids[0]),
|
||||
Command::Reidentify(temporary, ids[1]),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn colliding_lane_or_message_remap_rejects_before_any_reference_changes() {
|
||||
for message in [false, true] {
|
||||
let mut f = fixture();
|
||||
let [old, new] = if message { f.messages } else { f.lanes };
|
||||
let before = f.project.clone();
|
||||
assert!(check_remap(&f.project, old, new).is_err());
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentify_preflight_precedes_object_map_mutation_for_fresh_colliding_target() {
|
||||
let mut f = fixture();
|
||||
let fresh = Id::new_v4();
|
||||
// A repair candidate can already have a dangling coordinate key. It must not
|
||||
// silently merge with the live lane, even though the destination ID is vacant.
|
||||
layout_mut(&mut f.project, f.views[0]).lanes[1].lifeline = fresh;
|
||||
let before = f.project.clone();
|
||||
assert!(crate::session::reidentify(&mut f.project, f.lanes[0], fresh).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_address_paths_are_walked_and_collision_checked_without_admitting_expansion() {
|
||||
let mut f = fixture();
|
||||
let [old, new] = [Id::new_v4(), Id::new_v4()];
|
||||
let rows = layout_mut(&mut f.project, f.views[0]);
|
||||
// Future/invalid expanded candidates still participate in reference safety.
|
||||
rows.messages[0].address.references = vec![old];
|
||||
rows.messages[1].address = OccurrenceEndpoint {
|
||||
references: vec![new],
|
||||
occurrence: f.messages[0],
|
||||
};
|
||||
let refs = diagram_references(&f.project.diagrams[&f.views[0]]);
|
||||
assert!(refs.contains(&f.owner) && refs.contains(&old) && refs.contains(&new));
|
||||
let before = f.project.clone();
|
||||
assert!(remap_references(&mut f.project, old, new).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
let fresh = Id::new_v4();
|
||||
remap_references(&mut f.project, old, fresh).unwrap();
|
||||
let rows = layout(&f.project, f.views[0]);
|
||||
assert_eq!(rows.messages[0].address.references, vec![fresh]);
|
||||
assert_eq!(rows.messages[0].address.occurrence, f.messages[0]);
|
||||
assert_eq!(rows.messages[1].address.references, vec![new]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_reidentify_and_delete_cover_all_saved_views_without_deleting_other_owners() {
|
||||
let mut f = fixture();
|
||||
let other_owner = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Independent", None)
|
||||
.unwrap();
|
||||
let mut other_view = f.project.diagrams[&f.views[0]].clone();
|
||||
other_view.id = Id::new_v4();
|
||||
other_view.alias = "independent_view".into();
|
||||
other_view.projection = ProjectionKind::Scenario {
|
||||
scenario: other_owner,
|
||||
};
|
||||
other_view.layout = None;
|
||||
f.project.diagrams.insert(other_view.id, other_view.clone());
|
||||
admitted(&f.project);
|
||||
let fresh_owner = Id::new_v4();
|
||||
f.project = crate::prepare_command(&f.project, &Command::Reidentify(f.owner, fresh_owner))
|
||||
.unwrap()
|
||||
.project;
|
||||
for view in f.views {
|
||||
assert_eq!(
|
||||
f.project.diagrams[&view].projection.scenario(),
|
||||
Some(fresh_owner)
|
||||
);
|
||||
}
|
||||
let impact = deletion_impact(&f.project, fresh_owner);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.chain(&f.lanes)
|
||||
.chain(&f.messages)
|
||||
.all(|id| impact.contains(id))
|
||||
);
|
||||
assert!(!impact.contains(&other_owner) && !impact.contains(&other_view.id));
|
||||
delete_cascade(&mut f.project, fresh_owner);
|
||||
assert_eq!(f.project.diagrams[&other_view.id], other_view);
|
||||
assert!(
|
||||
f.views
|
||||
.iter()
|
||||
.all(|id| !f.project.diagrams.contains_key(id))
|
||||
);
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_then_recreation_never_resurrects_lane_or_message_coordinates() {
|
||||
let mut f = fixture();
|
||||
let lane = f.project.elements[&f.lanes[0]].clone();
|
||||
let message = f.project.elements[&f.messages[0]].clone();
|
||||
delete_cascade(&mut f.project, lane.id);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(rows.lanes[0].lifeline, f.lanes[1]);
|
||||
// Missing endpoint assignment does not erase a still-live message's spacing.
|
||||
assert_eq!(rows.messages.len(), 2);
|
||||
}
|
||||
let mut pending = BTreeSet::new();
|
||||
delete_cascade_deferred(&mut f.project, message.id, &mut pending);
|
||||
// Pruning happens during deletion, before later batch commands can recreate IDs.
|
||||
assert!(
|
||||
layout(&f.project, f.views[0])
|
||||
.messages
|
||||
.iter()
|
||||
.all(|row| row.address.occurrence != message.id)
|
||||
);
|
||||
f.project.elements.insert(lane.id, lane);
|
||||
f.project.elements.insert(message.id, message);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.insert(0, f.messages[0]);
|
||||
finish_deletions(&mut f.project, &pending);
|
||||
for view in f.views {
|
||||
let rows = layout(&f.project, view);
|
||||
assert_eq!(rows.lanes.len(), 1);
|
||||
assert_eq!(
|
||||
rows.messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
}
|
||||
admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrelated_invalid_records_do_not_block_scoped_remap_or_get_repaired_implicitly() {
|
||||
let mut f = fixture();
|
||||
let unrelated = Id::new_v4();
|
||||
let rows = layout_mut(&mut f.project, f.views[1]);
|
||||
let invalid = ScenarioLanePosition {
|
||||
lifeline: unrelated,
|
||||
x: MAX_COORDINATE * 2.,
|
||||
};
|
||||
rows.lanes.extend([invalid.clone(), invalid]);
|
||||
let fresh = Id::new_v4();
|
||||
let untouched = f.project.diagrams[&f.views[1]].clone();
|
||||
let before = f.project.clone();
|
||||
remap_references(&mut f.project, f.owner, f.owner).unwrap();
|
||||
assert_eq!(f.project, before);
|
||||
// The duplicate lane records do not involve the remapped message identity.
|
||||
remap_references(&mut f.project, f.messages[0], fresh).unwrap();
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).lanes,
|
||||
layout_from(&untouched).lanes
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[1]).messages[0]
|
||||
.address
|
||||
.occurrence,
|
||||
fresh
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages[0].send_y, 220.);
|
||||
}
|
||||
|
||||
fn layout_from(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("missing layout")
|
||||
};
|
||||
layout
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_prunes_an_explicit_address_path_without_pruning_other_message_rows() {
|
||||
let mut f = fixture();
|
||||
// Expanded addresses are not admitted in this slice, but deleting their
|
||||
// explicit references must still make an intermediate repair candidate safe.
|
||||
layout_mut(&mut f.project, f.views[0]).messages[0]
|
||||
.address
|
||||
.references = vec![f.lanes[0]];
|
||||
delete_cascade(&mut f.project, f.lanes[0]);
|
||||
assert_eq!(
|
||||
layout(&f.project, f.views[0]).messages,
|
||||
vec![ScenarioMessagePosition {
|
||||
address: address(f.messages[1]),
|
||||
send_y: 360.
|
||||
}]
|
||||
);
|
||||
assert_eq!(layout(&f.project, f.views[1]).messages.len(), 2);
|
||||
assert!(f.project.elements.contains_key(&f.messages[0]));
|
||||
admitted(&f.project);
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
//! Authored Scenario presentation. Structural admission never changes event order.
|
||||
use crate::{
|
||||
Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint,
|
||||
Project, SharedBody, validation,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DiagramLayout {
|
||||
Scenario(ScenarioLayout),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLayout {
|
||||
#[serde(default)]
|
||||
pub lanes: Vec<ScenarioLanePosition>,
|
||||
#[serde(default)]
|
||||
pub messages: Vec<ScenarioMessagePosition>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioLanePosition {
|
||||
pub lifeline: Id,
|
||||
pub x: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScenarioMessagePosition {
|
||||
pub address: OccurrenceEndpoint,
|
||||
/// Actual send endpoint, not the renderer's receive-oriented lifecycle row.
|
||||
pub send_y: f32,
|
||||
}
|
||||
|
||||
impl DiagramLayout {
|
||||
/// Visit every explicit reference, including unsupported paths in a candidate.
|
||||
/// The caller owns remap preflight and final atomic validation.
|
||||
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
|
||||
match self {
|
||||
Self::Scenario(layout) => {
|
||||
for lane in &mut layout.lanes {
|
||||
visit(&mut lane.lifeline);
|
||||
}
|
||||
for message in &mut layout.messages {
|
||||
for reference in &mut message.address.references {
|
||||
visit(reference);
|
||||
}
|
||||
visit(&mut message.address.occurrence);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Proposed aggregate accounting contribution; the shared admission owner applies it.
|
||||
/// One payload plus one record per lane/message. No graph objects are introduced.
|
||||
pub fn record_count(diagram: &Diagram) -> usize {
|
||||
match &diagram.layout {
|
||||
None => 0,
|
||||
Some(DiagramLayout::Scenario(layout)) => 1usize
|
||||
.saturating_add(layout.lanes.len())
|
||||
.saturating_add(layout.messages.len()),
|
||||
}
|
||||
}
|
||||
|
||||
fn coordinate(value: f32) -> bool {
|
||||
value.is_finite() && value.abs() <= MAX_COORDINATE
|
||||
}
|
||||
|
||||
fn owner(p: &Project, diagram: &Diagram) -> Result<Id, ModelError> {
|
||||
let scenario = diagram
|
||||
.projection
|
||||
.scenario()
|
||||
.ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?;
|
||||
if !p
|
||||
.elements
|
||||
.get(&scenario)
|
||||
.is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer)
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Scenario projection requires an existing Scenario in the diagram layer.".into(),
|
||||
));
|
||||
}
|
||||
Ok(scenario)
|
||||
}
|
||||
|
||||
fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool {
|
||||
p.elements.get(&lifeline).is_some_and(|element| {
|
||||
element.kind() == Kind::Lifeline
|
||||
&& element.parent == Some(owner)
|
||||
&& element.layer() == layer
|
||||
})
|
||||
}
|
||||
|
||||
/// Only actual steps of this Scenario and its own Fragment/Branch sequences.
|
||||
/// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph
|
||||
/// cycles terminate, and malformed parent/step links do not manufacture membership.
|
||||
fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet<Id> {
|
||||
let mut messages = BTreeSet::new();
|
||||
let mut visited = BTreeSet::new();
|
||||
let mut pending = vec![owner];
|
||||
while let Some(scope) = pending.pop() {
|
||||
if !visited.insert(scope) {
|
||||
continue;
|
||||
}
|
||||
let Some(element) = p.elements.get(&scope) else {
|
||||
continue;
|
||||
};
|
||||
for id in element.steps().into_iter().flatten() {
|
||||
let Some(item) = p.elements.get(id) else {
|
||||
continue;
|
||||
};
|
||||
if item.parent != Some(scope) || item.layer() != layer {
|
||||
continue;
|
||||
}
|
||||
match item.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { .. },
|
||||
..
|
||||
}) => {
|
||||
messages.insert(*id);
|
||||
}
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Fragment { branches, .. },
|
||||
..
|
||||
}) => {
|
||||
for branch in branches {
|
||||
if p.elements.get(branch).is_some_and(|operand| {
|
||||
operand.parent == Some(*id)
|
||||
&& operand.layer() == layer
|
||||
&& matches!(
|
||||
operand.shared(),
|
||||
Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Branch { .. },
|
||||
..
|
||||
})
|
||||
)
|
||||
}) {
|
||||
pending.push(*branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
messages
|
||||
}
|
||||
|
||||
pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec<validation::Issue>) {
|
||||
if diagram.projection.scenario().is_none() {
|
||||
if diagram.layout.is_some() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_projection",
|
||||
"layout",
|
||||
"Only a Scenario projection accepts a Scenario layout, including an empty payload.",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let scenario = match owner(p, diagram) {
|
||||
Ok(owner) => owner,
|
||||
Err(error) => {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_context",
|
||||
"projection",
|
||||
error.0,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_diagram_content",
|
||||
"layout",
|
||||
"Scenario views do not accept architecture node placements or edge routes.",
|
||||
);
|
||||
}
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
return;
|
||||
};
|
||||
let mut lanes = BTreeSet::new();
|
||||
for lane in &layout.lanes {
|
||||
if !lanes.insert(lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_lane",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per lifeline.",
|
||||
);
|
||||
}
|
||||
if !lane_member(p, scenario, diagram.layer, lane.lifeline) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_lifeline",
|
||||
"layout",
|
||||
"Lane positions reference a Lifeline directly owned by the projected Scenario.",
|
||||
);
|
||||
}
|
||||
if !coordinate(lane.x) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
let members = direct_messages(p, scenario, diagram.layer);
|
||||
let mut addresses = BTreeSet::new();
|
||||
for message in &layout.messages {
|
||||
// Reject expanded paths without cloning or indexing their potentially large contents.
|
||||
if !message.address.references.is_empty() {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_address",
|
||||
"layout",
|
||||
"Expanded-reference message positions are not supported; references must be empty.",
|
||||
);
|
||||
} else if !addresses.insert(message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"duplicate_scenario_message",
|
||||
"layout",
|
||||
"A Scenario layout has at most one position per message address.",
|
||||
);
|
||||
}
|
||||
if !members.contains(&message.address.occurrence) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_message",
|
||||
"layout",
|
||||
"Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.",
|
||||
);
|
||||
}
|
||||
if !coordinate(message.send_y) {
|
||||
validation::error(
|
||||
out,
|
||||
diagram.id,
|
||||
"scenario_layout_coordinate",
|
||||
"layout",
|
||||
"Scenario positions must be finite and within the shared coordinate bounds.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> {
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?;
|
||||
let scenario = owner(p, diagram)?;
|
||||
if !diagram.nodes.is_empty() || !diagram.routes.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Scenario views do not accept architecture node placements or edge routes.".into(),
|
||||
));
|
||||
}
|
||||
Ok((diagram, scenario))
|
||||
}
|
||||
|
||||
fn check_message(
|
||||
p: &Project,
|
||||
diagram: &Diagram,
|
||||
owner: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<(), ModelError> {
|
||||
if !address.references.is_empty() {
|
||||
return Err(ModelError(
|
||||
"Expanded-reference message positions are not supported; references must be empty."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) {
|
||||
return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout {
|
||||
let DiagramLayout::Scenario(layout) = diagram
|
||||
.layout
|
||||
.get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
layout
|
||||
}
|
||||
|
||||
/// Target-scoped preparation, not whole-project or whole-layout validation.
|
||||
/// Unrelated intermediate errors remain repairable by later commands in a batch.
|
||||
pub fn set_lane(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) {
|
||||
return Err(ModelError(
|
||||
"Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.lanes
|
||||
.iter()
|
||||
.filter(|row| row.lifeline == position.lifeline)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate lane position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.lanes
|
||||
.iter_mut()
|
||||
.find(|row| row.lifeline == position.lifeline)
|
||||
{
|
||||
if existing.x != position.x {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.lanes.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
if !lane_member(p, owner, current.layer, lifeline) {
|
||||
return Err(ModelError(
|
||||
"Removing a lane position requires an owned Lifeline.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.lanes.retain(|row| row.lifeline != lifeline);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn set_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, &position.address)?;
|
||||
if !coordinate(position.send_y) {
|
||||
return Err(ModelError(
|
||||
"Message position requires a finite bounded send_y coordinate.".into(),
|
||||
));
|
||||
}
|
||||
let mut result = current.clone();
|
||||
let layout = payload(&mut result);
|
||||
if layout
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|row| row.address == position.address)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Cannot set an ambiguous duplicate message position.".into(),
|
||||
));
|
||||
}
|
||||
if let Some(existing) = layout
|
||||
.messages
|
||||
.iter_mut()
|
||||
.find(|row| row.address == position.address)
|
||||
{
|
||||
if existing.send_y != position.send_y {
|
||||
*existing = position;
|
||||
}
|
||||
} else {
|
||||
layout.messages.push(position);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn remove_message(
|
||||
p: &Project,
|
||||
diagram: Id,
|
||||
address: &OccurrenceEndpoint,
|
||||
) -> Result<Diagram, ModelError> {
|
||||
let (current, owner) = editable(p, diagram)?;
|
||||
check_message(p, current, owner, address)?;
|
||||
let mut result = current.clone();
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout {
|
||||
layout.messages.retain(|row| &row.address != address);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Prune only identities in the caller's actual deletion closure, never visibility
|
||||
/// or missing endpoint assignments. Owner-view cascade remains the caller's job.
|
||||
pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet<Id>) {
|
||||
if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout {
|
||||
layout
|
||||
.lanes
|
||||
.retain(|lane| !deleted.contains(&lane.lifeline));
|
||||
layout.messages.retain(|message| {
|
||||
!deleted.contains(&message.address.occurrence)
|
||||
&& !message
|
||||
.address
|
||||
.references
|
||||
.iter()
|
||||
.any(|id| deleted.contains(id))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,800 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
Element, FragmentOperator, MessageKind, ProjectionKind, Relation, RelationBody, RelationKind,
|
||||
ScenarioViewpoint,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
struct Fixture {
|
||||
project: Project,
|
||||
owner: Id,
|
||||
performer: Id,
|
||||
lane: Id,
|
||||
repeated_lane: Id,
|
||||
first: Id,
|
||||
second: Id,
|
||||
nested: Id,
|
||||
fragment: Id,
|
||||
branch: Id,
|
||||
view: Id,
|
||||
other_view: Id,
|
||||
exchange: Id,
|
||||
}
|
||||
|
||||
fn set_body(element: &mut Element, body: OccurrenceBody) {
|
||||
*element.shared_mut().unwrap() = SharedBody::Occurrence {
|
||||
body,
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
fn address(occurrence: Id) -> OccurrenceEndpoint {
|
||||
OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence,
|
||||
}
|
||||
}
|
||||
fn diagram(owner: Id, alias: &str) -> Diagram {
|
||||
Diagram {
|
||||
id: Id::new_v4(),
|
||||
alias: alias.into(),
|
||||
name: alias.into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
fn assert_admitted(project: &Project) {
|
||||
let errors: Vec<_> = validation::validate(project)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.severity == validation::Severity::Error)
|
||||
.collect();
|
||||
assert!(errors.is_empty(), "{errors:?}");
|
||||
}
|
||||
fn has_code(project: &Project, code: &str) -> bool {
|
||||
validation::validate(project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == code)
|
||||
}
|
||||
fn layout(diagram: &Diagram) -> &ScenarioLayout {
|
||||
let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else {
|
||||
panic!("expected saved payload")
|
||||
};
|
||||
layout
|
||||
}
|
||||
fn save(project: &mut Project, replacement: Diagram) {
|
||||
project.diagrams.insert(replacement.id, replacement);
|
||||
}
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Nominal", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
project.elements.get_mut(&owner).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let performer = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Controller", None)
|
||||
.unwrap();
|
||||
let receiver = project
|
||||
.add_element(Kind::Entity, Layer::Oa, "Provider", None)
|
||||
.unwrap();
|
||||
let lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Controller lane", Some(owner))
|
||||
.unwrap();
|
||||
let repeated_lane = project
|
||||
.add_element(
|
||||
Kind::Lifeline,
|
||||
Layer::Oa,
|
||||
"Controller review lane",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let receiver_lane = project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Provider lane", Some(owner))
|
||||
.unwrap();
|
||||
for (id, represented) in [
|
||||
(lane, performer),
|
||||
(repeated_lane, performer),
|
||||
(receiver_lane, receiver),
|
||||
] {
|
||||
*project.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let exchange = Id::new_v4();
|
||||
project.relations.insert(
|
||||
exchange,
|
||||
Relation {
|
||||
id: exchange,
|
||||
alias: "delivery_channel".into(),
|
||||
name: "Delivery".into(),
|
||||
description: String::new(),
|
||||
body: RelationBody::new(RelationKind::CommunicationMean, performer, receiver),
|
||||
},
|
||||
);
|
||||
let first = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "First delivery", Some(owner))
|
||||
.unwrap();
|
||||
let second = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Second delivery", Some(owner))
|
||||
.unwrap();
|
||||
for (id, sender) in [(first, lane), (second, repeated_lane)] {
|
||||
set_body(
|
||||
project.elements.get_mut(&id).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: Some(sender),
|
||||
receiver: Some(receiver_lane),
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
let fragment = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Optional delivery",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![],
|
||||
},
|
||||
);
|
||||
let branch = project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Option body", Some(fragment))
|
||||
.unwrap();
|
||||
let nested = project
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Oa,
|
||||
"Unfinished nested delivery",
|
||||
Some(branch),
|
||||
)
|
||||
.unwrap();
|
||||
set_body(
|
||||
project.elements.get_mut(&nested).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&branch).unwrap(),
|
||||
OccurrenceBody::Branch {
|
||||
guard: "requested".into(),
|
||||
occurrences: vec![nested],
|
||||
},
|
||||
);
|
||||
set_body(
|
||||
project.elements.get_mut(&fragment).unwrap(),
|
||||
OccurrenceBody::Fragment {
|
||||
operator: FragmentOperator::Optional,
|
||||
branches: vec![branch],
|
||||
},
|
||||
);
|
||||
*project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![first, second, fragment];
|
||||
let view_record = diagram(owner, "nominal_view");
|
||||
let other_record = diagram(owner, "review_view");
|
||||
let view = view_record.id;
|
||||
let other_view = other_record.id;
|
||||
save(&mut project, view_record);
|
||||
save(&mut project, other_record);
|
||||
assert_admitted(&project);
|
||||
Fixture {
|
||||
project,
|
||||
owner,
|
||||
performer,
|
||||
lane,
|
||||
repeated_lane,
|
||||
first,
|
||||
second,
|
||||
nested,
|
||||
fragment,
|
||||
branch,
|
||||
view,
|
||||
other_view,
|
||||
exchange,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_views_and_repeated_semantic_definitions_keep_independent_overrides() {
|
||||
let mut f = fixture();
|
||||
let semantics = (f.project.elements.clone(), f.project.relations.clone());
|
||||
for (view, line, x) in [
|
||||
(f.view, f.lane, 420.),
|
||||
(f.view, f.repeated_lane, 680.),
|
||||
(f.other_view, f.lane, 900.),
|
||||
] {
|
||||
let result =
|
||||
set_lane(&f.project, view, ScenarioLanePosition { lifeline: line, x }).unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
for (view, id, y) in [
|
||||
(f.view, f.first, 200.),
|
||||
(f.view, f.second, 350.),
|
||||
(f.other_view, f.first, 260.),
|
||||
] {
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: y,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
}
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view]).lanes,
|
||||
vec![
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.
|
||||
},
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.repeated_lane,
|
||||
x: 680.
|
||||
},
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.view])
|
||||
.messages
|
||||
.iter()
|
||||
.map(|r| (r.address.occurrence, r.send_y))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![(f.first, 200.), (f.second, 350.)]
|
||||
);
|
||||
assert_eq!(
|
||||
layout(&f.project.diagrams[&f.other_view]).messages[0].send_y,
|
||||
260.
|
||||
);
|
||||
assert_eq!(layout(&f.project.diagrams[&f.other_view]).lanes[0].x, 900.);
|
||||
assert_eq!(
|
||||
(f.project.elements.clone(), f.project.relations.clone()),
|
||||
semantics
|
||||
);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn own_nested_unfinished_message_is_admitted_but_expansion_is_not() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.nested),
|
||||
send_y: -80.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
let before = f.project.clone();
|
||||
let expanded = OccurrenceEndpoint {
|
||||
references: vec![f.fragment],
|
||||
occurrence: f.nested,
|
||||
};
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: expanded.clone(),
|
||||
send_y: 400.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(remove_message(&f.project, f.view, &expanded).is_err());
|
||||
assert_eq!(f.project, before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].address = expanded;
|
||||
assert!(has_code(&f.project, "scenario_layout_address"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_ordered_message_appearances_and_owned_lifelines_are_targets() {
|
||||
let mut f = fixture();
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.performer,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
for id in [f.exchange, f.fragment, f.branch, f.owner, Id::new_v4()] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(id),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let foreign = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Foreign", None)
|
||||
.unwrap();
|
||||
let foreign_lane = f
|
||||
.project
|
||||
.add_element(Kind::Lifeline, Layer::Oa, "Foreign lane", Some(foreign))
|
||||
.unwrap();
|
||||
assert!(remove_lane(&f.project, f.view, foreign_lane).is_err());
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(foreign);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
f.project.elements.get_mut(&f.first).unwrap().parent = Some(f.owner);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.retain(|id| *id != f.first);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_positions_reject_even_when_values_match() {
|
||||
let mut f = fixture();
|
||||
let lane = ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.,
|
||||
};
|
||||
let message = ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
};
|
||||
let entries = payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
entries.lanes = vec![lane.clone(), lane.clone()];
|
||||
entries.messages = vec![message.clone(), message.clone()];
|
||||
assert!(has_code(&f.project, "duplicate_scenario_lane"));
|
||||
assert!(has_code(&f.project, "duplicate_scenario_message"));
|
||||
assert!(set_lane(&f.project, f.view, lane).is_err());
|
||||
assert!(set_message(&f.project, f.view, message).is_err());
|
||||
// Explicit removal can repair all duplicates of the targeted identity.
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sparse_absence_empty_payload_and_signed_zero_are_preserved() {
|
||||
let mut f = fixture();
|
||||
let absent = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(remove_lane(&f.project, f.view, f.lane).unwrap(), absent);
|
||||
assert_eq!(
|
||||
remove_message(&f.project, f.view, &address(f.first)).unwrap(),
|
||||
absent
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_value(&absent)
|
||||
.unwrap()
|
||||
.get("layout")
|
||||
.is_none()
|
||||
);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: -0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(layout(&result).lanes[0].x.to_bits(), (-0.0_f32).to_bits());
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 0.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
layout(&result).messages[0].send_y.to_bits(),
|
||||
(-0.0_f32).to_bits()
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_string(&result).unwrap(),
|
||||
serde_json::to_string(&f.project.diagrams[&f.view]).unwrap()
|
||||
);
|
||||
let result = remove_lane(&f.project, f.view, f.lane).unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = remove_message(&f.project, f.view, &address(f.first)).unwrap();
|
||||
assert_eq!(
|
||||
result.layout,
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()))
|
||||
);
|
||||
assert_ne!(result, absent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_are_structural_and_rejected_helpers_do_not_mutate_project() {
|
||||
let mut f = fixture();
|
||||
for value in [-MAX_COORDINATE, MAX_COORDINATE, -7., 0.] {
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
let before = serde_json::to_string(&f.project).unwrap();
|
||||
for value in [
|
||||
f32::NAN,
|
||||
f32::INFINITY,
|
||||
f32::NEG_INFINITY,
|
||||
MAX_COORDINATE + 1.,
|
||||
] {
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: value
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert_eq!(serde_json::to_string(&f.project).unwrap(), before);
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap()).messages[0].send_y = f32::NAN;
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_do_not_validate_unrelated_transient_records_in_repair_batches() {
|
||||
let mut f = fixture();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.push(ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: f32::INFINITY,
|
||||
});
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert!(has_code(&f.project, "scenario_layout_coordinate"));
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 300.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
assert_admitted(&f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_context_and_architecture_payloads_are_explicitly_separate() {
|
||||
let mut f = fixture();
|
||||
let scenario = f.project.diagrams[&f.view].projection;
|
||||
assert_eq!(scenario.scenario(), Some(f.owner));
|
||||
assert!(!scenario.is_architecture());
|
||||
assert!(!scenario.includes(Kind::Lifeline));
|
||||
assert!(ProjectionKind::Overview.is_architecture());
|
||||
assert!(!ProjectionKind::StateMachine { machine: f.owner }.is_architecture());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Sa;
|
||||
assert!(has_code(&f.project, "scenario_diagram_context"));
|
||||
assert!(remove_lane(&f.project, f.view, f.lane).is_err());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().layer = Layer::Oa;
|
||||
f.project
|
||||
.diagrams
|
||||
.get_mut(&f.view)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(crate::EdgeRoute {
|
||||
edge: f.exchange,
|
||||
waypoints: vec![],
|
||||
label: None,
|
||||
});
|
||||
assert!(has_code(&f.project, "scenario_diagram_content"));
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().routes.clear();
|
||||
payload(f.project.diagrams.get_mut(&f.view).unwrap());
|
||||
f.project.diagrams.get_mut(&f.view).unwrap().projection = ProjectionKind::Overview;
|
||||
assert!(has_code(&f.project, "scenario_layout_projection"));
|
||||
assert!(
|
||||
set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 10.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_json_records_round_trip_and_reject_unknown_or_missing_fields() {
|
||||
let mut f = fixture();
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let encoded = serde_json::to_value(&f.project.diagrams[&f.view]).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Diagram>(encoded.clone()).unwrap(),
|
||||
f.project.diagrams[&f.view]
|
||||
);
|
||||
assert_eq!(
|
||||
encoded["layout"]["Scenario"]["messages"][0]["address"]["occurrence"],
|
||||
serde_json::json!(f.first)
|
||||
);
|
||||
let mut unknown = encoded.clone();
|
||||
unknown["layout"]["Scenario"]["messages"][0]["extra"] = serde_json::json!(1);
|
||||
assert!(serde_json::from_value::<Diagram>(unknown).is_err());
|
||||
let mut missing = encoded;
|
||||
missing["layout"]["Scenario"]["messages"][0]["address"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("references");
|
||||
assert!(serde_json::from_value::<Diagram>(missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_only_actual_ids_preserves_latent_message_positions_and_other_views() {
|
||||
let mut f = fixture();
|
||||
let result = set_lane(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 420.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let result = set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 200.,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
save(&mut f.project, result);
|
||||
let mut copied = f.project.diagrams[&f.view].clone();
|
||||
copied.id = f.other_view;
|
||||
copied.alias = "review_view".into();
|
||||
copied.name = "Review view".into();
|
||||
save(&mut f.project, copied.clone());
|
||||
assert_admitted(&f.project);
|
||||
assert_eq!(layout(&copied).messages[0].address.occurrence, f.first);
|
||||
let mut candidate = f.project.diagrams[&f.view].clone();
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.lane, f.exchange]));
|
||||
assert!(layout(&candidate).lanes.is_empty());
|
||||
assert_eq!(layout(&candidate).messages.len(), 1);
|
||||
prune_deleted(&mut candidate, &BTreeSet::from([f.first]));
|
||||
assert!(layout(&candidate).messages.is_empty());
|
||||
assert!(candidate.layout.is_some());
|
||||
assert_eq!(f.project.diagrams[&f.other_view], copied);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_visitor_and_record_count_cover_explicit_rows() {
|
||||
let f = fixture();
|
||||
let mut d = f.project.diagrams[&f.view].clone();
|
||||
assert_eq!(record_count(&d), 0);
|
||||
let rows = payload(&mut d);
|
||||
rows.lanes.push(ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 50.,
|
||||
});
|
||||
rows.messages.push(ScenarioMessagePosition {
|
||||
address: address(f.first),
|
||||
send_y: 100.,
|
||||
});
|
||||
assert_eq!(record_count(&d), 3);
|
||||
let replacement = Id::new_v4();
|
||||
d.layout.as_mut().unwrap().visit_references(|id| {
|
||||
if *id == f.first {
|
||||
*id = replacement;
|
||||
}
|
||||
});
|
||||
assert_eq!(layout(&d).messages[0].address.occurrence, replacement);
|
||||
assert_eq!(layout(&d).messages[0].send_y, 100.);
|
||||
assert_eq!(layout(&d).lanes[0].lifeline, f.lane);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_real_scenario_use_never_imports_its_called_message_as_a_direct_target() {
|
||||
let mut f = fixture();
|
||||
let called = f
|
||||
.project
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Called sequence", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
f.project.elements.get_mut(&called).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("Called Scenario fixture body");
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::ParticipantInteraction;
|
||||
let called_message = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Called delivery", Some(called))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&called_message).unwrap(),
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: None,
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Asynchronous,
|
||||
reply_to: None,
|
||||
values: vec![],
|
||||
operation: None,
|
||||
},
|
||||
);
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&called)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap() = vec![called_message];
|
||||
let usage = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Call sequence", Some(f.owner))
|
||||
.unwrap();
|
||||
set_body(
|
||||
f.project.elements.get_mut(&usage).unwrap(),
|
||||
OccurrenceBody::ScenarioUse {
|
||||
scenario: Some(called),
|
||||
bindings: vec![],
|
||||
},
|
||||
);
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.owner)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(usage);
|
||||
assert_admitted(&f.project);
|
||||
for target in [
|
||||
address(called_message),
|
||||
address(usage),
|
||||
OccurrenceEndpoint {
|
||||
references: vec![usage],
|
||||
occurrence: called_message,
|
||||
},
|
||||
] {
|
||||
assert!(
|
||||
set_message(
|
||||
&f.project,
|
||||
f.view,
|
||||
ScenarioMessagePosition {
|
||||
address: target,
|
||||
send_y: 200.
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
mod source_lifecycle;
|
||||
@@ -0,0 +1,245 @@
|
||||
//! Source/command lifecycle checks for the candidate payload. Public version
|
||||
//! assignment remains the coordinated codec integration gate.
|
||||
use super::*;
|
||||
use crate::{Command, Session, language, prepare_command, source_project::*};
|
||||
|
||||
fn authored(f: &Fixture) -> CompiledSourceProject {
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let initial = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &initial.index.declarations[&f.view];
|
||||
let source = bundle.files.get_mut(&location.path).unwrap();
|
||||
let text = format!(
|
||||
r#"diagram nominal_view "Nominal review" id "{}" {{
|
||||
projection {{ Scenario {{ scenario @{}; }}; }};
|
||||
layout {{ Scenario {{
|
||||
lanes [
|
||||
// Controller spacing rationale.
|
||||
{{ lifeline @ // first lane identity
|
||||
{}; x 4.20e2; }},
|
||||
// Repeated performer has its own lane.
|
||||
{{ lifeline @{}; x 680; }}
|
||||
];
|
||||
messages [
|
||||
// First delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 2.00e2; }},
|
||||
// Second delivery timing rationale.
|
||||
{{ address {{ references []; occurrence @{}; }}; send_y 350; }}
|
||||
];
|
||||
}}; }};
|
||||
}}
|
||||
"#,
|
||||
f.view,
|
||||
f.project.elements[&f.owner].alias,
|
||||
f.project.elements[&f.lane].alias,
|
||||
f.project.elements[&f.repeated_lane].alias,
|
||||
f.project.elements[&f.first].alias,
|
||||
f.project.elements[&f.second].alias
|
||||
);
|
||||
source.replace_range(location.span.start..location.span.end, &text);
|
||||
compile_bundle(&bundle, ProjectLimits::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_rows_follow_identity_order_and_keep_comments_numeric_spelling_and_file_ownership() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut changed = original.decoded.project.clone();
|
||||
let rows = payload(changed.diagrams.get_mut(&f.view).unwrap());
|
||||
rows.lanes.swap(0, 1);
|
||||
rows.messages.swap(0, 1);
|
||||
rows.messages[0].send_y = 390.;
|
||||
changed.elements.get_mut(&f.lane).unwrap().alias = "renamed_controller_lane".into();
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
assert_eq!(&updated.index.declarations[&f.view].path, path);
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Repeated performer").unwrap() < text.find("// Controller spacing").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains(
|
||||
"lifeline @ // first lane identity\n renamed_controller_lane; x 4.20e2;"
|
||||
),
|
||||
"{text}"
|
||||
);
|
||||
assert!(text.contains("send_y 2.00e2"), "{text}");
|
||||
assert!(text.contains("send_y 390"), "{text}");
|
||||
for (other, bytes) in &original.bundle.files {
|
||||
if other != path && !bytes.contains(&f.project.elements[&f.lane].alias) {
|
||||
assert_eq!(&updated.bundle.files[other], bytes);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
compile_bundle(&updated.bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
changed
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_identity_swap_keeps_each_coordinate_comment_with_its_original_object() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let temporary = Id::new_v4();
|
||||
let command = Command::Batch(vec![
|
||||
Command::Reidentify(f.first, temporary),
|
||||
Command::Reidentify(f.second, f.first),
|
||||
Command::Reidentify(temporary, f.second),
|
||||
]);
|
||||
let prepared = prepare_command(&original.decoded.project, &command).unwrap();
|
||||
let mut changed = prepared.project;
|
||||
payload(changed.diagrams.get_mut(&f.view).unwrap())
|
||||
.messages
|
||||
.swap(0, 1);
|
||||
let updated = update_bundle_with_identity_map(
|
||||
&original,
|
||||
&changed,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(updated.decoded.project, changed);
|
||||
let path = &original.index.declarations[&f.view].path;
|
||||
let text = &updated.bundle.files[path];
|
||||
assert!(
|
||||
text.find("// Second delivery").unwrap() < text.find("// First delivery").unwrap(),
|
||||
"{text}"
|
||||
);
|
||||
let first_comment = text.find("// First delivery").unwrap();
|
||||
assert!(text[first_comment..].contains(&format!(
|
||||
"occurrence @{}",
|
||||
changed.elements[&f.second].alias
|
||||
)));
|
||||
assert!(text[first_comment..].contains("send_y 2.00e2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinate_batch_is_one_undo_and_numeric_noop_keeps_source_exact() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let mut session = Session::new(original.decoded.project.clone());
|
||||
session
|
||||
.execute(Command::Batch(vec![
|
||||
Command::SetScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioLanePosition {
|
||||
lifeline: f.lane,
|
||||
x: 510.,
|
||||
},
|
||||
},
|
||||
Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
},
|
||||
]))
|
||||
.unwrap();
|
||||
let changed = session.project().clone();
|
||||
assert_eq!(session.revision(), 1);
|
||||
assert_eq!(changed.elements, original.decoded.project.elements);
|
||||
assert_eq!(changed.relations, original.decoded.project.relations);
|
||||
let updated = update_bundle(&original, &changed, ProjectLimits::default()).unwrap();
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &original.decoded.project);
|
||||
assert!(!session.can_undo());
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &changed);
|
||||
session
|
||||
.execute(Command::SetScenarioMessagePosition {
|
||||
diagram: f.view,
|
||||
position: ScenarioMessagePosition {
|
||||
address: address(f.second),
|
||||
send_y: 430.,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), 3);
|
||||
let noop = update_bundle(&updated, session.project(), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(noop.bundle, updated.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absent_and_empty_payload_round_trip_distinctly_and_remove_does_not_materialize_absence() {
|
||||
let f = fixture();
|
||||
let before = language::format(&f.project);
|
||||
let mut session = Session::new(f.project.clone());
|
||||
session
|
||||
.execute(Command::RemoveScenarioLanePosition {
|
||||
diagram: f.view,
|
||||
lifeline: f.lane,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.project(), &f.project);
|
||||
assert!(!session.can_undo());
|
||||
assert_eq!(language::format(session.project()), before);
|
||||
let mut present = f.project.clone();
|
||||
present.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let text = language::format(&present);
|
||||
assert_ne!(text, before);
|
||||
assert_eq!(language::parse(&text).unwrap(), present);
|
||||
assert_eq!(language::parse(&before).unwrap(), f.project);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_layout_source_rejects_without_changing_last_compiled_workspace() {
|
||||
let f = fixture();
|
||||
let original = authored(&f);
|
||||
let before = original.bundle.clone();
|
||||
let path = original.index.declarations[&f.view].path.clone();
|
||||
for replacement in ["x 1e30", "x not_a_number"] {
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace("x 4.20e2", replacement);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
let mut candidate = before.clone();
|
||||
let source = candidate.files.get_mut(&path).unwrap();
|
||||
*source = source.replace(
|
||||
&format!("occurrence @{}", f.project.elements[&f.first].alias),
|
||||
&format!("occurrence @{}", f.project.elements[&f.performer].alias),
|
||||
);
|
||||
assert!(compile_bundle(&candidate, ProjectLimits::default()).is_err());
|
||||
assert_eq!(original.bundle, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_none_survives_other_edits_while_real_payload_clear_removes_values() {
|
||||
let f = fixture();
|
||||
let mut bundle = format_bundle(&f.project);
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let location = &compiled.index.declarations[&f.view];
|
||||
let path = location.path.clone();
|
||||
bundle.files.get_mut(&path).unwrap().insert_str(
|
||||
location.span.end - 1,
|
||||
"\n layout // explicitly automatic\n none;\n",
|
||||
);
|
||||
let original = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let mut described = original.decoded.project.clone();
|
||||
described.diagrams.get_mut(&f.view).unwrap().description =
|
||||
"Review spacing independently".into();
|
||||
let updated = update_bundle(&original, &described, ProjectLimits::default()).unwrap();
|
||||
assert!(updated.bundle.files[&path].contains("layout // explicitly automatic\n none;"));
|
||||
assert_eq!(updated.decoded.project, described);
|
||||
let mut empty = described.clone();
|
||||
empty.diagrams.get_mut(&f.view).unwrap().layout =
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout::default()));
|
||||
let present = update_bundle(&updated, &empty, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(present.decoded.project, empty);
|
||||
assert!(present.bundle.files[&path].contains("// explicitly automatic"));
|
||||
let cleared = update_bundle(&present, &described, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(cleared.decoded.project, described);
|
||||
assert!(!cleared.bundle.files[&path].contains("layout"));
|
||||
assert!(cleared.bundle.files[&path].contains("// explicitly automatic"));
|
||||
}
|
||||
@@ -23,6 +23,22 @@ pub enum Command {
|
||||
EditRelation(Id, Relation),
|
||||
CreateDiagram(Diagram),
|
||||
EditDiagram(Id, Diagram),
|
||||
SetScenarioLanePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioLanePosition,
|
||||
},
|
||||
RemoveScenarioLanePosition {
|
||||
diagram: Id,
|
||||
lifeline: Id,
|
||||
},
|
||||
SetScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
position: ScenarioMessagePosition,
|
||||
},
|
||||
RemoveScenarioMessagePosition {
|
||||
diagram: Id,
|
||||
address: OccurrenceEndpoint,
|
||||
},
|
||||
CreateArtifact(ArtifactLink),
|
||||
EditArtifact(Id, ArtifactLink),
|
||||
CreateTrace(Trace),
|
||||
@@ -488,6 +504,26 @@ fn apply_command(
|
||||
reidentify_tracked(p, *old, value.id, provenance)?;
|
||||
Some(value.id)
|
||||
}
|
||||
Command::SetScenarioLanePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_lane(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioLanePosition { diagram, lifeline } => {
|
||||
let replacement = crate::scenario_layout::remove_lane(p, *diagram, *lifeline)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::SetScenarioMessagePosition { diagram, position } => {
|
||||
let replacement = crate::scenario_layout::set_message(p, *diagram, position.clone())?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::RemoveScenarioMessagePosition { diagram, address } => {
|
||||
let replacement = crate::scenario_layout::remove_message(p, *diagram, address)?;
|
||||
p.diagrams.insert(*diagram, replacement);
|
||||
Some(*diagram)
|
||||
}
|
||||
Command::CreateArtifact(value) => {
|
||||
vacant(p, value.id)?;
|
||||
p.artifact_links.insert(value.id, value.clone());
|
||||
@@ -878,6 +914,7 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
return Ok(());
|
||||
}
|
||||
vacant(p, new)?;
|
||||
crate::references::check_remap(p, old, new)?;
|
||||
if p.id == old {
|
||||
p.id = new;
|
||||
} else if let Some(mut v) = p.elements.remove(&old) {
|
||||
@@ -904,12 +941,17 @@ pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::references::remap_references(p, old, new);
|
||||
crate::references::remap_references(p, old, new)?;
|
||||
Ok(())
|
||||
}
|
||||
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
|
||||
if d.projection.scenario().is_some() {
|
||||
return Err(ModelError(
|
||||
"Scenario layout requires the Scenario geometry operation.".into(),
|
||||
));
|
||||
}
|
||||
let parents = crate::projection::parents(p, d);
|
||||
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
|
||||
for (id, parent) in &parents {
|
||||
|
||||
@@ -930,7 +930,18 @@ pub fn update_bundle_with_identity_map(
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
update.value(&actual.fields, Some(&previous.fields), &next.fields, local);
|
||||
let context = if old.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
Some(&previous.fields),
|
||||
&next.fields,
|
||||
context,
|
||||
local,
|
||||
);
|
||||
}
|
||||
}
|
||||
if old.id != project.id || old.alias != project.alias || old.name != project.name {
|
||||
@@ -1119,6 +1130,29 @@ fn inserted_value(text: &str, indent: &str, source: &str) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
// Schema context is explicit: similarly named fields in arbitrary records must
|
||||
// never acquire Scenario row identity or move their authored trivia.
|
||||
#[derive(Clone, Copy)]
|
||||
enum EditContext {
|
||||
Other,
|
||||
Diagram,
|
||||
Layout,
|
||||
Scenario,
|
||||
Lanes,
|
||||
Messages,
|
||||
}
|
||||
impl EditContext {
|
||||
fn field(self, name: &str) -> Self {
|
||||
match (self, name) {
|
||||
(Self::Diagram, "layout") => Self::Layout,
|
||||
(Self::Layout, "Scenario") => Self::Scenario,
|
||||
(Self::Scenario, "lanes") => Self::Lanes,
|
||||
(Self::Scenario, "messages") => Self::Messages,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SyntaxUpdate<'a> {
|
||||
source: &'a str,
|
||||
before: &'a str,
|
||||
@@ -1185,6 +1219,7 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &language::EditValue,
|
||||
before: Option<&language::EditValue>,
|
||||
after: &language::EditValue,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
use language::EditKind;
|
||||
@@ -1200,7 +1235,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.record(actual.span, fields, previous, next, edits);
|
||||
self.record(actual.span, fields, previous, next, context, edits);
|
||||
}
|
||||
(EditKind::List(items), EditKind::List(next)) => {
|
||||
let previous = before.and_then(|old| {
|
||||
@@ -1210,7 +1245,7 @@ impl SyntaxUpdate<'_> {
|
||||
None
|
||||
}
|
||||
});
|
||||
self.list(actual.span, items, previous, next, edits);
|
||||
self.list(actual.span, items, previous, next, context, edits);
|
||||
}
|
||||
(EditKind::Reference(_), EditKind::Reference(alias)) => {
|
||||
let token = significant(&self.source[actual.span.start..actual.span.end])
|
||||
@@ -1263,6 +1298,7 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &BTreeMap<String, language::EditField>,
|
||||
before: Option<&BTreeMap<String, language::EditField>>,
|
||||
after: &BTreeMap<String, language::EditField>,
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
// A typed constructor tag change retains shared fields and their trivia.
|
||||
@@ -1286,7 +1322,13 @@ impl SyntaxUpdate<'_> {
|
||||
},
|
||||
new_key.clone(),
|
||||
));
|
||||
self.value(&field.value, Some(&old.value), &new.value, edits);
|
||||
self.value(
|
||||
&field.value,
|
||||
Some(&old.value),
|
||||
&new.value,
|
||||
context.field(new_key),
|
||||
edits,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1298,9 +1340,16 @@ impl SyntaxUpdate<'_> {
|
||||
.and_then(|fields| fields.get(key))
|
||||
.map(|field| &field.value),
|
||||
&next.value,
|
||||
context.field(key),
|
||||
edits,
|
||||
);
|
||||
} else {
|
||||
} else if !(matches!(context, EditContext::Diagram)
|
||||
&& key == "layout"
|
||||
&& before.is_some_and(|fields| !fields.contains_key(key)))
|
||||
{
|
||||
// Canonical None omits layout. Preserve an authored explicit
|
||||
// none while another field changes; a real Some->None clear
|
||||
// still removes the former payload and retains its trivia.
|
||||
erase_syntax(self.source, field.span, edits);
|
||||
}
|
||||
}
|
||||
@@ -1355,6 +1404,7 @@ impl SyntaxUpdate<'_> {
|
||||
actual: &[language::EditValue],
|
||||
before: Option<&[language::EditValue]>,
|
||||
after: &[language::EditValue],
|
||||
context: EditContext,
|
||||
edits: &mut LocalEdits,
|
||||
) {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
@@ -1367,6 +1417,7 @@ impl SyntaxUpdate<'_> {
|
||||
self.old_aliases,
|
||||
self.legacy_value_paths,
|
||||
None,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1379,6 +1430,7 @@ impl SyntaxUpdate<'_> {
|
||||
self.new_aliases,
|
||||
self.legacy_value_paths,
|
||||
self.identity_origins,
|
||||
context,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -1407,7 +1459,7 @@ impl SyntaxUpdate<'_> {
|
||||
.all(|(i, source)| *source == Some(i))
|
||||
{
|
||||
for (i, (current, next)) in actual.iter().zip(after).enumerate() {
|
||||
self.value(current, previous.get(i), next, edits);
|
||||
self.value(current, previous.get(i), next, EditContext::Other, edits);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1440,7 +1492,13 @@ impl SyntaxUpdate<'_> {
|
||||
let chunk = chunks[index];
|
||||
text.push_str(&self.source[chunk.start..current.span.start]);
|
||||
let mut local = Vec::new();
|
||||
self.value(current, previous.get(index), next, &mut local);
|
||||
self.value(
|
||||
current,
|
||||
previous.get(index),
|
||||
next,
|
||||
EditContext::Other,
|
||||
&mut local,
|
||||
);
|
||||
text.push_str(&changed_text(self.source, current.span, local));
|
||||
suffix = &self.source[current.span.end..chunk.end];
|
||||
} else {
|
||||
@@ -1483,14 +1541,58 @@ impl SyntaxUpdate<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn scenario_item_key(
|
||||
value: &language::EditValue,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
let EditKind::Record(fields) = &value.kind else {
|
||||
return None;
|
||||
};
|
||||
let reference = |value: &language::EditValue| {
|
||||
if let EditKind::Reference(alias) = &value.kind {
|
||||
aliases.get(alias).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
match context {
|
||||
EditContext::Lanes => {
|
||||
reference(&fields.get("lifeline")?.value).map(|id| format!("scenario-lane:{id}"))
|
||||
}
|
||||
EditContext::Messages => {
|
||||
let EditKind::Record(address) = &fields.get("address")?.value.kind else {
|
||||
return None;
|
||||
};
|
||||
let occurrence = reference(&address.get("occurrence")?.value)?;
|
||||
let path: Vec<_> = match address.get("references") {
|
||||
None => Vec::new(),
|
||||
Some(field) => {
|
||||
let EditKind::List(path) = &field.value.kind else {
|
||||
return None;
|
||||
};
|
||||
path.iter().map(reference).collect::<Option<Vec<_>>>()?
|
||||
}
|
||||
};
|
||||
Some(format!("scenario-message:{}:{occurrence}", path.join("/")))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
origins: Option<&BTreeMap<Id, Id>>,
|
||||
context: EditContext,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
if matches!(context, EditContext::Lanes | EditContext::Messages) {
|
||||
return scenario_item_key(value, aliases, context);
|
||||
}
|
||||
if matches!(value.kind, EditKind::Record(_))
|
||||
&& let Some(key) = language::semantic_row_key(
|
||||
&source[value.span.start..value.span.end],
|
||||
|
||||
@@ -68,6 +68,11 @@ pub(super) fn upgrade(
|
||||
&actual.fields,
|
||||
Some(&actual.fields),
|
||||
&next.fields,
|
||||
if project.diagrams.contains_key(&id) {
|
||||
EditContext::Diagram
|
||||
} else {
|
||||
EditContext::Other
|
||||
},
|
||||
edits.entry(path.clone()).or_default(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -619,6 +619,7 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
text(&mut out, d.id, "name", &d.name);
|
||||
text(&mut out, d.id, "description", &d.description);
|
||||
crate::scenario_layout::validate_diagram(p, d, &mut out);
|
||||
let option_issues = if d.projection == ProjectionKind::Traceability {
|
||||
crate::view_options::validate_options_cross_layer(p, &d.view_options)
|
||||
} else {
|
||||
|
||||
@@ -41,6 +41,7 @@ fn fixture() -> Fixture {
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -261,7 +261,7 @@ fn source7_context_lifecycle_source_history_and_closed_admission() {
|
||||
assert_eq!(session.project(), &after);
|
||||
let fresh = Id::new_v4();
|
||||
let mut reidentified = after.clone();
|
||||
references::remap_references(&mut reidentified, f.r1, fresh);
|
||||
references::remap_references(&mut reidentified, f.r1, fresh).unwrap();
|
||||
let mut e = reidentified.elements.remove(&f.r1).unwrap();
|
||||
e.id = fresh;
|
||||
reidentified.elements.insert(fresh, e);
|
||||
|
||||
@@ -20,6 +20,7 @@ fn view(p: &mut Project, machine: Id, alias: &str) -> Id {
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
+329
-12
@@ -16,6 +16,7 @@ mod source_symbols;
|
||||
mod transition_context_tests;
|
||||
#[cfg(test)]
|
||||
mod view_copy_tests;
|
||||
pub(crate) const SAVED_SCENARIO_UNAVAILABLE: &str = "Saved Scenario layout rendering and canvas editing are not available yet. Select the Scenario in Behavior to use its existing semantic preview.";
|
||||
use crate::forms::Draft;
|
||||
use crate::{
|
||||
behavior,
|
||||
@@ -577,7 +578,7 @@ impl App {
|
||||
let active_diagram = project
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
let draft = Draft::from_project(&project, selected);
|
||||
@@ -798,7 +799,7 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == self.layer)
|
||||
.filter(|d| d.layer == self.layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
}
|
||||
@@ -1050,7 +1051,7 @@ impl App {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(id)
|
||||
.is_some_and(|d| d.layer == layer)
|
||||
.is_some_and(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
})
|
||||
.or(retained)
|
||||
.or_else(|| self.diagram_for(layer, target));
|
||||
@@ -1746,7 +1747,49 @@ impl App {
|
||||
}
|
||||
self.behavior.scenario.focus_stamp(self.scenario_context()?)
|
||||
}
|
||||
fn saved_scenario_diagram(&self, id: Id) -> bool {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.is_some_and(|d| d.projection.scenario().is_some())
|
||||
}
|
||||
|
||||
fn unsupported_saved_scenario_message(&self, message: &Message) -> bool {
|
||||
let active = self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id));
|
||||
match message {
|
||||
Message::Diagram(choice) => self.saved_scenario_diagram(choice.id),
|
||||
Message::RevealInDiagram(diagram, _) | Message::Command(Command::Layout(diagram)) => {
|
||||
self.saved_scenario_diagram(*diagram)
|
||||
}
|
||||
Message::Projection(projection) => active || projection.scenario().is_some(),
|
||||
Message::SelectNode(_)
|
||||
| Message::PlaceSelected
|
||||
| Message::HideSelected
|
||||
| Message::ToggleCollapse
|
||||
| Message::MoveNode(..)
|
||||
| Message::Route(_)
|
||||
| Message::Connection(_)
|
||||
| Message::PortAnchor(_)
|
||||
| Message::Export
|
||||
| Message::Fit
|
||||
| Message::Zoom(_)
|
||||
| Message::Pan(_) => active,
|
||||
Message::Add(_) | Message::CreateContextElement(..) => {
|
||||
active && self.page == Page::Diagram
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(&mut self, message: Message) -> Task<Message> {
|
||||
// Refuse unsupported saved-view actions before they can Apply unrelated drafts.
|
||||
// Generic model/source edits and the semantic Scenario workspace remain available.
|
||||
if self.unsupported_saved_scenario_message(&message) {
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
match message {
|
||||
Message::BehaviorControl(intent) => return self.behavior_control(intent),
|
||||
Message::Architecture(event) => return self.architecture_event(event),
|
||||
@@ -2394,7 +2437,7 @@ impl App {
|
||||
self.project()
|
||||
.diagrams
|
||||
.get(&id)
|
||||
.is_some_and(|d| d.projection.state_machine().is_some())
|
||||
.is_some_and(|d| !d.projection.is_architecture())
|
||||
})
|
||||
{
|
||||
// Architecture remains reachable from a state workspace.
|
||||
@@ -2403,9 +2446,7 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| {
|
||||
d.layer == self.layer && d.projection.state_machine().is_none()
|
||||
})
|
||||
.filter(|d| d.layer == self.layer && d.projection.is_architecture())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
self.fit();
|
||||
@@ -2663,7 +2704,7 @@ impl App {
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| (d.projection != ProjectionKind::Allocation, d.id))
|
||||
.map(|d| d.id);
|
||||
self.selected = self.project().id;
|
||||
@@ -2836,6 +2877,10 @@ impl App {
|
||||
let Some(view) = self.project().diagrams.get(&diagram) else {
|
||||
return Task::none();
|
||||
};
|
||||
if view.projection.scenario().is_some() {
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
let layer = view.layer;
|
||||
let endpoints = self
|
||||
.project()
|
||||
@@ -2903,6 +2948,7 @@ impl App {
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
projection: ProjectionKind::Allocation,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
@@ -3079,6 +3125,10 @@ impl App {
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.cloned()
|
||||
{
|
||||
if d.projection.scenario().is_some() {
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
d.projection = projection;
|
||||
self.execute(Command::EditDiagram(d.id, d));
|
||||
self.fit();
|
||||
@@ -3643,6 +3693,14 @@ impl App {
|
||||
}
|
||||
}
|
||||
Message::Export => {
|
||||
let Some(diagram) = self.active_diagram else {
|
||||
self.status = "Create a diagram first.".into();
|
||||
return Task::none();
|
||||
};
|
||||
if !self.project().diagrams.contains_key(&diagram) {
|
||||
self.status = crate::diagram::ExportError::MissingDiagram(diagram).to_string();
|
||||
return Task::none();
|
||||
}
|
||||
if self.apply() {
|
||||
return self.choose_file(FileOperation::Export);
|
||||
}
|
||||
@@ -3705,7 +3763,9 @@ impl App {
|
||||
};
|
||||
let id = e.id;
|
||||
let mut commands = vec![Command::CreateElement(e)];
|
||||
let mut diagram_target = self.active_diagram;
|
||||
let mut diagram_target = self
|
||||
.active_diagram
|
||||
.filter(|id| !self.saved_scenario_diagram(*id));
|
||||
let preferred = if self.create_kind.is_work() || self.create_kind.is_performer() {
|
||||
Some(ProjectionKind::Allocation)
|
||||
} else if ProjectionKind::Capabilities.includes(self.create_kind) {
|
||||
@@ -3735,6 +3795,7 @@ impl App {
|
||||
description: String::new(),
|
||||
layer: self.layer,
|
||||
projection: preferred,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
@@ -4074,11 +4135,24 @@ impl App {
|
||||
)
|
||||
}
|
||||
fn export_to(&mut self, path: PathBuf) -> Task<Message> {
|
||||
if self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id))
|
||||
{
|
||||
self.status = SAVED_SCENARIO_UNAVAILABLE.into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(diagram) = self.active_diagram else {
|
||||
self.status = "Create a diagram first.".into();
|
||||
return Task::none();
|
||||
};
|
||||
let svg = crate::diagram::export_svg(self.project(), diagram);
|
||||
let svg = match crate::diagram::export_svg(self.project(), diagram) {
|
||||
Ok(svg) => svg,
|
||||
Err(error) => {
|
||||
self.status = error.to_string();
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
self.busy = true;
|
||||
Task::perform(
|
||||
async move {
|
||||
@@ -4301,6 +4375,9 @@ impl App {
|
||||
)
|
||||
}
|
||||
pub fn route_context(&self) -> Option<crate::route_edit::Context> {
|
||||
if self.saved_scenario_diagram(self.active_diagram?) {
|
||||
return None;
|
||||
}
|
||||
Some(crate::route_edit::Context {
|
||||
document: self.canvas_document,
|
||||
diagram: self.active_diagram?,
|
||||
@@ -4525,6 +4602,12 @@ impl App {
|
||||
}
|
||||
}
|
||||
pub fn fit(&mut self) {
|
||||
if self
|
||||
.active_diagram
|
||||
.is_some_and(|id| self.saved_scenario_diagram(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.fit_to_view = true;
|
||||
let view = self
|
||||
.active_diagram
|
||||
@@ -4572,7 +4655,7 @@ fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id)
|
||||
return p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| {
|
||||
let view = project_view(p, d.id);
|
||||
(
|
||||
@@ -4586,7 +4669,7 @@ fn preferred_diagram(p: &Project, active: Option<Id>, layer: Layer, element: Id)
|
||||
}
|
||||
p.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == layer)
|
||||
.filter(|d| d.layer == layer && d.projection.scenario().is_none())
|
||||
.min_by_key(|d| {
|
||||
(
|
||||
!p.elements
|
||||
@@ -5389,6 +5472,7 @@ mod port_view_tests {
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Capabilities,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
@@ -5555,3 +5639,236 @@ mod data_paging_lifecycle_tests {
|
||||
assert_eq!(app.data_state, crate::data_view::State::default());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_scenario_dispatch_tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let f = behavior::scenario::direct_tests::fixture();
|
||||
let mut project = f.project;
|
||||
let architecture = project.ensure_diagram(Layer::Oa);
|
||||
let saved = Id::new_v4();
|
||||
project.diagrams.insert(
|
||||
saved,
|
||||
Diagram {
|
||||
id: saved,
|
||||
alias: "saved_scenario_dispatch".into(),
|
||||
name: "Saved Scenario".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: f.owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
},
|
||||
);
|
||||
let mut app = App::with_project(project);
|
||||
app.page = Page::Diagram;
|
||||
app.active_diagram = Some(architecture);
|
||||
app.select(f.owner);
|
||||
(app, architecture, saved, f.owner)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_export_schedules_no_write_and_preserves_draft_history_and_busy_state() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
let _ = app.update(Message::Field("/name".into(), "Keep export draft".into()));
|
||||
assert!(app.draft_dirty());
|
||||
let path =
|
||||
std::env::temp_dir().join(format!("arcadia-rejected-export-{}.svg", Id::new_v4()));
|
||||
std::fs::write(&path, b"existing export").unwrap();
|
||||
for target in [Some(saved), Some(Id::new_v4()), None] {
|
||||
for busy in [false, true] {
|
||||
app.active_diagram = target;
|
||||
app.busy = busy;
|
||||
let before = app.document.bundle().clone();
|
||||
let history = (
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo(),
|
||||
);
|
||||
let view = (
|
||||
app.page,
|
||||
app.selected,
|
||||
app.active_diagram,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
);
|
||||
let task = app.export_to(path.clone());
|
||||
assert_eq!(task.units(), 0, "rejection must not schedule a write");
|
||||
assert_eq!(app.busy, busy);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(
|
||||
(
|
||||
app.document.revision(),
|
||||
app.document.can_undo(),
|
||||
app.document.can_redo()
|
||||
),
|
||||
history
|
||||
);
|
||||
assert_eq!(
|
||||
(
|
||||
app.page,
|
||||
app.selected,
|
||||
app.active_diagram,
|
||||
app.zoom,
|
||||
app.pan
|
||||
),
|
||||
view
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"existing export");
|
||||
}
|
||||
}
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_export_target_does_not_apply_pending_properties_or_open_dialog() {
|
||||
for target in [None, Some(Id::new_v4())] {
|
||||
let (mut app, _, _, _) = fixture();
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied export draft".into(),
|
||||
));
|
||||
app.active_diagram = target;
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let task = app.handle(Message::Export);
|
||||
assert_eq!(task.units(), 0);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(!app.busy);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_saved_picker_and_reveal_do_not_apply_pending_properties() {
|
||||
for reveal in [false, true] {
|
||||
let (mut app, architecture, saved, owner) = fixture();
|
||||
let _ = app.update(Message::Field(
|
||||
"/name".into(),
|
||||
"Unapplied scenario name".into(),
|
||||
));
|
||||
assert!(app.draft_dirty());
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let selected = app.selected;
|
||||
let message = if reveal {
|
||||
Message::RevealInDiagram(saved, owner)
|
||||
} else {
|
||||
Message::Diagram(Choice {
|
||||
id: saved,
|
||||
label: "Saved Scenario".into(),
|
||||
})
|
||||
};
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert!(app.draft_dirty());
|
||||
assert_eq!(app.status, SAVED_SCENARIO_UNAVAILABLE);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_cannot_use_architecture_controls_or_change_projection_by_toolbar() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
app.active_diagram = Some(saved);
|
||||
let _ = app.update(Message::Field("/name".into(), "Keep pending".into()));
|
||||
let before = app.document.bundle().clone();
|
||||
assert!(app.route_context().is_none());
|
||||
assert!(architecture_authoring::kinds(&app).is_empty());
|
||||
for message in [
|
||||
Message::Projection(ProjectionKind::Overview),
|
||||
Message::PlaceSelected,
|
||||
Message::Command(Command::Layout(saved)),
|
||||
Message::Export,
|
||||
] {
|
||||
let _ = app.update(message);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(app.draft_dirty());
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert!(
|
||||
app.project().diagrams[&saved]
|
||||
.projection
|
||||
.scenario()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_scenario_does_not_start_the_existing_architecture_palette() {
|
||||
let (mut app, _, saved, _) = fixture();
|
||||
app.active_diagram = Some(saved);
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Architecture(
|
||||
architecture_authoring::Event::Existing(None),
|
||||
));
|
||||
assert!(app.architecture.is_none());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
assert!(app.status.contains("architecture view"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forged_saved_usage_is_refused_without_selection_or_camera_changes() {
|
||||
let (mut app, architecture, saved, _) = fixture();
|
||||
let before = (
|
||||
app.page,
|
||||
app.selected,
|
||||
app.layer,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
app.canvas_epoch,
|
||||
);
|
||||
let source = app.document.bundle().clone();
|
||||
let usage = crate::diagram_usage::Usage {
|
||||
subject: saved,
|
||||
surface: crate::diagram_usage::Surface::Saved(saved),
|
||||
target: crate::diagram_usage::Target::Whole,
|
||||
role: "diagram".into(),
|
||||
bounds: iced::Rectangle::new(iced::Point::new(500., 600.), iced::Size::new(300., 200.)),
|
||||
};
|
||||
assert!(!app.reveal_diagram_usage(&usage));
|
||||
assert_eq!(
|
||||
(
|
||||
app.page,
|
||||
app.selected,
|
||||
app.layer,
|
||||
app.zoom,
|
||||
app.pan,
|
||||
app.canvas_epoch
|
||||
),
|
||||
before
|
||||
);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert!(app.pending_diagram_reveal.is_none());
|
||||
assert_eq!(app.document.bundle(), &source);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_scenario_preview_remains_available_and_saved_views_are_not_default_architecture() {
|
||||
let (mut app, architecture, saved, owner) = fixture();
|
||||
assert_eq!(
|
||||
preferred_diagram(app.project(), Some(saved), Layer::Oa, owner),
|
||||
Some(architecture)
|
||||
);
|
||||
app.active_diagram = Some(saved);
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Select(owner));
|
||||
let _ = app.update(Message::Page(Page::Behavior));
|
||||
assert_eq!(app.scenario_context().unwrap().scenario, owner);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let mut only_saved = app.project().clone();
|
||||
only_saved.diagrams.retain(|id, _| *id == saved);
|
||||
assert!(preferred_diagram(&only_saved, None, Layer::Oa, owner).is_none());
|
||||
assert!(App::with_project(only_saved).active_diagram.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
.diagrams
|
||||
.get(&s.diagram)
|
||||
.ok_or("The creation draft's diagram is missing.")?;
|
||||
if matches!(d.projection, ProjectionKind::StateMachine { .. })
|
||||
if !d.projection.is_architecture()
|
||||
|| s.query.len() > 16384
|
||||
|| s.page > 1_000_000
|
||||
|| s.point
|
||||
@@ -180,7 +180,7 @@ pub fn validate_saved(p: &Project, s: &Saved) -> Result<(), String> {
|
||||
}
|
||||
fn context_kinds(p: &Project, d: &Diagram) -> Vec<Kind> {
|
||||
let mut values = match (d.layer, d.projection) {
|
||||
(_, ProjectionKind::StateMachine { .. }) => vec![],
|
||||
(_, ProjectionKind::StateMachine { .. } | ProjectionKind::Scenario { .. }) => vec![],
|
||||
(Layer::Oa, ProjectionKind::FunctionalDataflow) => vec![Kind::Activity],
|
||||
(Layer::Sa, ProjectionKind::FunctionalDataflow) => vec![Kind::Function],
|
||||
(Layer::Oa, ProjectionKind::Capabilities) => vec![
|
||||
@@ -208,11 +208,12 @@ pub fn kinds(app: &App) -> Vec<Kind> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn compatible(p: &Project, d: &Diagram, id: Id) -> bool {
|
||||
p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
d.projection.is_architecture()
|
||||
&& p.elements.get(&id).is_some_and(|e| {
|
||||
e.kind() != Kind::Port
|
||||
&& d.projection.includes(e.kind())
|
||||
&& (e.layer() == d.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
}
|
||||
fn suggested(p: &Project, d: &Diagram, kind: Kind) -> bool {
|
||||
// Existing System remains a useful choice even when the singleton exists.
|
||||
@@ -257,6 +258,17 @@ pub fn candidates(app: &App) -> Vec<(Id, String, bool)> {
|
||||
}
|
||||
impl App {
|
||||
pub(super) fn architecture_event(&mut self, event: Event) -> Task<Message> {
|
||||
if matches!(
|
||||
&event,
|
||||
Event::Start(..) | Event::Allocate(..) | Event::Existing(..)
|
||||
) && !self
|
||||
.active_diagram
|
||||
.and_then(|id| self.project().diagrams.get(&id))
|
||||
.is_some_and(|d| d.projection.is_architecture())
|
||||
{
|
||||
self.status = "Choose an architecture view before adding architecture content.".into();
|
||||
return Task::none();
|
||||
}
|
||||
match event {
|
||||
Event::Start(_, point) | Event::Allocate(_, _, point) | Event::Existing(point) => {
|
||||
if self.architecture.is_some() {
|
||||
|
||||
@@ -98,6 +98,7 @@ fn fixture(canvas: Canvas) -> (App, Usage) {
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
|
||||
@@ -58,6 +58,10 @@ impl App {
|
||||
let process = presentation.into_query_state();
|
||||
let scenario = behavior::scenario::State::default();
|
||||
usage::collect(&project, context.subject, &process, &scenario)
|
||||
.into_iter()
|
||||
.filter(|row| !matches!(row.surface, Surface::Saved(id)
|
||||
if project.diagrams.get(&id).is_some_and(|d| d.projection.scenario().is_some())))
|
||||
.collect()
|
||||
})
|
||||
.await
|
||||
.map_err(|error| format!("Diagram lookup failed: {error}. Close and try again."));
|
||||
@@ -290,6 +294,9 @@ impl App {
|
||||
if !self.usage_navigation_available() {
|
||||
return false;
|
||||
}
|
||||
if matches!(usage.surface, Surface::Saved(id) if self.saved_scenario_diagram(id)) {
|
||||
return false;
|
||||
}
|
||||
let previous_layer = self.layer;
|
||||
let p = self.document.project();
|
||||
let (selected, placement) = match &usage.target {
|
||||
@@ -430,6 +437,7 @@ impl App {
|
||||
let current_surface = match surface {
|
||||
Surface::Saved(id) => {
|
||||
self.active_diagram == Some(id)
|
||||
&& !self.saved_scenario_diagram(id)
|
||||
&& matches!(self.page, Page::Diagram | Page::Behavior)
|
||||
}
|
||||
Surface::Process(owner) | Surface::Scenario(owner) => {
|
||||
|
||||
@@ -440,7 +440,7 @@ fn rename_then_exact_caret_diagram_navigation_preserves_complete_model_undo_redo
|
||||
Arc::ptr_eq(&expected, &app.document.snapshot()),
|
||||
"exact history entry restored"
|
||||
);
|
||||
assert_eq!(app.project(), &expected.project);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_eq!(app.source_workspace.bundle(), expected.bundle);
|
||||
assert!(!app.source_dirty());
|
||||
|
||||
@@ -1712,6 +1712,7 @@ mod tests {
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
|
||||
@@ -292,6 +292,7 @@ pub fn create_view(project: &Project, machine: Id) -> Result<Plan, ModelError> {
|
||||
layer: owner.layer(),
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
nodes: BTreeMap::new(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
},
|
||||
|
||||
@@ -1594,11 +1594,32 @@ fn hex(c: Color) -> String {
|
||||
(c.b * 255.) as u8
|
||||
)
|
||||
}
|
||||
pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExportError {
|
||||
MissingDiagram(Id),
|
||||
UnsupportedScenario(Id),
|
||||
}
|
||||
impl std::fmt::Display for ExportError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingDiagram(id) => write!(f, "Cannot export: diagram {id} no longer exists."),
|
||||
Self::UnsupportedScenario(_) => {
|
||||
f.write_str("SVG export of saved Scenario views is not available yet.")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ExportError {}
|
||||
|
||||
pub fn export_svg(p: &Project, diagram: Id) -> Result<String, ExportError> {
|
||||
let d = p
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.ok_or(ExportError::MissingDiagram(diagram))?;
|
||||
if d.projection.scenario().is_some() {
|
||||
return Err(ExportError::UnsupportedScenario(diagram));
|
||||
}
|
||||
let view = project_view(p, diagram);
|
||||
let Some(d) = p.diagrams.get(&diagram) else {
|
||||
return String::new();
|
||||
};
|
||||
let routed = route_diagram(p, d, &view);
|
||||
let left = routed.bounds.position.x - 30.;
|
||||
let top = routed.bounds.position.y - 30.;
|
||||
@@ -1737,7 +1758,7 @@ pub fn export_svg(p: &Project, diagram: Id) -> String {
|
||||
svg.push_str("</g>");
|
||||
}
|
||||
svg.push_str("</svg>");
|
||||
svg
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1747,6 +1768,83 @@ mod pointer_tests;
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn svg_rejects_saved_scenario_with_absent_or_authored_layout_without_mutation() {
|
||||
let f = crate::behavior::scenario::direct_tests::fixture();
|
||||
let mut p = f.project;
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let d = p.diagrams.get_mut(&diagram).unwrap();
|
||||
d.projection = ProjectionKind::Scenario { scenario: f.owner };
|
||||
d.nodes.clear();
|
||||
d.routes.clear();
|
||||
for layout in [
|
||||
None,
|
||||
Some(DiagramLayout::Scenario(ScenarioLayout {
|
||||
lanes: vec![ScenarioLanePosition {
|
||||
lifeline: f.sender,
|
||||
x: 240.,
|
||||
}],
|
||||
messages: vec![ScenarioMessagePosition {
|
||||
address: OccurrenceEndpoint {
|
||||
references: vec![],
|
||||
occurrence: f.call,
|
||||
},
|
||||
send_y: 180.,
|
||||
}],
|
||||
})),
|
||||
] {
|
||||
p.diagrams.get_mut(&diagram).unwrap().layout = layout;
|
||||
let admitted = execute_batch(&p, &[]).expect("valid saved Scenario fixture");
|
||||
assert_eq!(admitted, p);
|
||||
let before = p.clone();
|
||||
assert_eq!(
|
||||
export_svg(&p, diagram),
|
||||
Err(ExportError::UnsupportedScenario(diagram))
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_rejects_missing_diagram_without_mutation() {
|
||||
let p = Project::demo();
|
||||
let missing = Id::new_v4();
|
||||
let before = p.clone();
|
||||
assert_eq!(
|
||||
export_svg(&p, missing),
|
||||
Err(ExportError::MissingDiagram(missing))
|
||||
);
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn svg_accepts_populated_and_empty_architecture_views() {
|
||||
let mut p = Project::demo();
|
||||
let diagram = p
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.projection.is_architecture() && !d.nodes.is_empty())
|
||||
.unwrap()
|
||||
.id;
|
||||
let before = p.clone();
|
||||
let svg = export_svg(&p, diagram).expect("populated architecture");
|
||||
assert!(svg.starts_with("<svg "));
|
||||
assert!(svg.contains("<text "));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
assert_eq!(p, before);
|
||||
let d = p.diagrams.get_mut(&diagram).unwrap();
|
||||
d.projection = ProjectionKind::Overview;
|
||||
d.nodes.clear();
|
||||
d.routes.clear();
|
||||
assert_eq!(execute_batch(&p, &[]).expect("valid empty architecture"), p);
|
||||
let before = p.clone();
|
||||
let svg = export_svg(&p, diagram).expect("empty architecture is supported");
|
||||
assert!(svg.contains("viewBox=\"-30 -30 60 60\""));
|
||||
assert!(svg.ends_with("</svg>"));
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typed_function_glyphs_and_unoriented_standard_connections_have_distinct_svg() {
|
||||
let p = Project::demo();
|
||||
@@ -1780,7 +1878,7 @@ mod tests {
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let title = format!("<title>{}</title>", escape(&boundary.name));
|
||||
let edge_path = svg
|
||||
.split(&title)
|
||||
@@ -1854,7 +1952,7 @@ mod tests {
|
||||
p.place(diagram, id, Position { x: 60., y: 60. });
|
||||
}
|
||||
let p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap();
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let group = svg
|
||||
.split("<g>")
|
||||
.find(|g| g.contains(">Grouped work</text>"))
|
||||
@@ -1944,7 +2042,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.id;
|
||||
let view = project_view(&p, diagram);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
for edge in &view.edges {
|
||||
assert!(svg.contains(&format!(">{}</text>", escape(&short(p.named(edge.id), 32)))));
|
||||
}
|
||||
@@ -1959,7 +2057,7 @@ mod tests {
|
||||
p.place(d, a, Position { x: 0., y: 0. });
|
||||
p.place(d, b, Position { x: 400., y: 0. });
|
||||
p.relate(RelationKind::CapabilityInclude, "Uses", a, b);
|
||||
let svg = export_svg(&p, d);
|
||||
let svg = export_svg(&p, d).expect("supported diagram");
|
||||
assert!(svg.contains("«include»</text>"));
|
||||
assert!(svg.contains("stroke-dasharray=\"6 4\""));
|
||||
}
|
||||
@@ -2046,7 +2144,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(moved.position.x, port.position.x + 100.);
|
||||
assert_eq!(moved.position.y, port.position.y + 70.);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
for port in &view.ports {
|
||||
assert!(svg.contains(&format!("<title>{}</title>", escape(p.named(port.element)))));
|
||||
}
|
||||
@@ -2503,7 +2601,7 @@ mod tests {
|
||||
});
|
||||
let view = project_view(&p, diagram);
|
||||
let geometry = route_diagram(&p, &p.diagrams[&diagram], &view);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let route = &geometry.edges[&edge];
|
||||
let path = route
|
||||
.points
|
||||
@@ -2563,7 +2661,7 @@ mod tests {
|
||||
let routed = route_diagram(&p, &p.diagrams[&diagram], &projected);
|
||||
let ends = routing::association_end_geometry(&p.relations[&edge], &routed.edges[&edge]);
|
||||
assert_eq!(ends.len(), 2);
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
assert!(svg.contains("parent [0..1]</text>"));
|
||||
assert!(svg.contains("children [0..*]</text>"));
|
||||
assert!(!svg.contains("data-navigation=\"source\""));
|
||||
|
||||
@@ -27,7 +27,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
validate_candidate(&p).unwrap();
|
||||
let initial = p.clone();
|
||||
let path_key = format!("<path data-edge=\"{edge}\"");
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
let edge_path = svg
|
||||
.split(&path_key)
|
||||
.nth(1)
|
||||
@@ -46,7 +46,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
.unwrap()
|
||||
.view_options
|
||||
.exchange_labels = ExchangeLabels::Items;
|
||||
let items_svg = export_svg(&p, diagram);
|
||||
let items_svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
assert!(items_svg.contains("Data & status"));
|
||||
let RelationBody::CommunicationMean { oriented, .. } =
|
||||
&mut p.relations.get_mut(&edge).unwrap().body
|
||||
@@ -54,7 +54,7 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
unreachable!()
|
||||
};
|
||||
*oriented = true;
|
||||
let svg = export_svg(&p, diagram);
|
||||
let svg = export_svg(&p, diagram).expect("supported diagram");
|
||||
assert!(
|
||||
svg.split(&path_key)
|
||||
.nth(1)
|
||||
@@ -71,7 +71,11 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
.view_options
|
||||
.hidden_edges
|
||||
.push(edge);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
assert!(
|
||||
!export_svg(&p, diagram)
|
||||
.expect("supported diagram")
|
||||
.contains(&path_key)
|
||||
);
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
@@ -83,7 +87,11 @@ fn communication_svg_uses_saved_visibility_labels_and_direction() {
|
||||
.unwrap()
|
||||
.view_options
|
||||
.relation_kinds = RelationKinds::Only(vec![]);
|
||||
assert!(!export_svg(&p, diagram).contains(&path_key));
|
||||
assert!(
|
||||
!export_svg(&p, diagram)
|
||||
.expect("supported diagram")
|
||||
.contains(&path_key)
|
||||
);
|
||||
assert_eq!(p.diagrams[&diagram].nodes, placements);
|
||||
assert_eq!(language::parse(&language::format(&p)).unwrap(), p);
|
||||
}
|
||||
|
||||
@@ -1535,7 +1535,8 @@ fn group_label(path: &str) -> Option<&'static str> {
|
||||
}
|
||||
|
||||
fn placement_allowed(project: &Project, diagram: &Diagram, element: &Element, current: Id) -> bool {
|
||||
(diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
|
||||
diagram.projection.scenario().is_none()
|
||||
&& (diagram.projection == ProjectionKind::Traceability || diagram.layer == element.layer())
|
||||
&& diagram
|
||||
.projection
|
||||
.state_machine()
|
||||
@@ -2683,6 +2684,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Overview,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
|
||||
@@ -813,6 +813,7 @@ mod tests {
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::Overview,
|
||||
layout: None,
|
||||
routes: Vec::new(),
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
|
||||
@@ -389,11 +389,24 @@ fn modal_width<'a>(
|
||||
.into()
|
||||
}
|
||||
fn diagram(app: &App) -> View<'_, Message> {
|
||||
if app
|
||||
.active_diagram
|
||||
.and_then(|id| app.project().diagrams.get(&id))
|
||||
.is_some_and(|d| d.projection.scenario().is_some())
|
||||
{
|
||||
return heading(
|
||||
"Saved Scenario view",
|
||||
crate::app::SAVED_SCENARIO_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
let mut diagrams = app
|
||||
.project()
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.layer == app.layer || d.projection == ProjectionKind::Traceability)
|
||||
.filter(|d| {
|
||||
d.projection.scenario().is_none()
|
||||
&& (d.layer == app.layer || d.projection == ProjectionKind::Traceability)
|
||||
})
|
||||
.map(|d| Choice {
|
||||
id: d.id,
|
||||
label: d.name.clone(),
|
||||
|
||||
@@ -352,7 +352,8 @@ impl Transition {
|
||||
}
|
||||
|
||||
fn result_view_compatible(p: &Project, diagram: &Diagram, targets: &[Id], layer: Layer) -> bool {
|
||||
(diagram.layer == layer || diagram.projection == ProjectionKind::Traceability)
|
||||
(diagram.projection.is_architecture() || diagram.projection.state_machine().is_some())
|
||||
&& (diagram.layer == layer || diagram.projection == ProjectionKind::Traceability)
|
||||
&& (diagram.projection.state_machine().is_none() || !targets.is_empty())
|
||||
&& targets.iter().all(|id| {
|
||||
p.elements.get(id).is_some_and(|e| {
|
||||
@@ -1972,3 +1973,37 @@ mod port_authoring_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod saved_scenario_destination_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_transition_results_do_not_make_a_scenario_an_architecture_destination() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Scenario", None)
|
||||
.unwrap();
|
||||
let mut diagram = Diagram {
|
||||
id: Id::new_v4(),
|
||||
alias: "target_view".into(),
|
||||
name: "Target".into(),
|
||||
description: String::new(),
|
||||
layer: Layer::Oa,
|
||||
projection: ProjectionKind::Scenario { scenario: owner },
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
routes: vec![],
|
||||
layout: None,
|
||||
};
|
||||
assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa));
|
||||
let machine = p
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Machine", None)
|
||||
.unwrap();
|
||||
diagram.projection = ProjectionKind::StateMachine { machine };
|
||||
assert!(result_view_compatible(&p, &diagram, &[machine], Layer::Oa));
|
||||
assert!(!result_view_compatible(&p, &diagram, &[], Layer::Oa));
|
||||
diagram.projection = ProjectionKind::Overview;
|
||||
assert!(result_view_compatible(&p, &diagram, &[], Layer::Oa));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ fn view(p: &mut Project, number: u128, projection: ProjectionKind) -> Id {
|
||||
projection,
|
||||
view_options: Default::default(),
|
||||
nodes: Default::default(),
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -151,7 +151,7 @@ fn diagram(
|
||||
(node.id, node)
|
||||
})
|
||||
.collect();
|
||||
p.diagrams.insert(diagram_id, Diagram { id: diagram_id, alias: alias.into(), name: name.into(), description: "Teaching view: positions and filters do not change ownership or exchange identities.".into(), layer, projection, view_options: ViewOptions::default(), nodes, routes: vec![] });
|
||||
p.diagrams.insert(diagram_id, Diagram { id: diagram_id, alias: alias.into(), name: name.into(), description: "Teaching view: positions and filters do not change ownership or exchange identities.".into(), layer, projection, view_options: ViewOptions::default(), nodes, layout: None, routes: vec![] });
|
||||
diagram_id
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ fn fixture() -> Project {
|
||||
description: String::new(),
|
||||
layer,
|
||||
projection,
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::new(),
|
||||
|
||||
@@ -315,6 +315,7 @@ fn predict(base: &CompiledSourceProject, stage: Stage, carrier: &Project) -> Com
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::from([(
|
||||
@@ -401,6 +402,7 @@ fn false_carrier(base: &Project) -> Project {
|
||||
projection: ProjectionKind::StateMachine {
|
||||
machine: test_id(3),
|
||||
},
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::from([(
|
||||
|
||||
@@ -23,7 +23,7 @@ fn fixture(layer: Layer) -> Project {
|
||||
let key=id(50+index as u128);
|
||||
p.artifact_links.insert(key, ArtifactLink { id:key, alias:format!("evidence_{index}"), name:format!("Responsibility evidence {index}"), description:"Keep owner identity and evidence metadata".into(), owner, relation:ArtifactRelation::References, uri:format!("https://example.invalid/evidence/{index}"), revision:Some("review-7".into()), fragment:Some("assignment".into()) });
|
||||
}
|
||||
p.diagrams.insert(id(2), Diagram { id:id(2), alias:"allocation_view".into(), name:format!("{layer:?} allocated work"), description:"Native child-work fixture".into(), layer, projection:ProjectionKind::Allocation, routes:vec![], view_options:Default::default(), nodes:BTreeMap::new() });
|
||||
p.diagrams.insert(id(2), Diagram { id:id(2), alias:"allocation_view".into(), name:format!("{layer:?} allocated work"), description:"Native child-work fixture".into(), layer, projection:ProjectionKind::Allocation, layout:None, routes:vec![], view_options:Default::default(), nodes:BTreeMap::new() });
|
||||
for (n,element,x,y,collapsed) in [(40,id(10),60.,60.,false),(41,id(11),84.,124.,true)] {
|
||||
p.diagrams.get_mut(&id(2)).unwrap().nodes.insert(id(n), NodePlacement { id:id(n), alias:format!("seed_view_{n}"), element, position:Position{x,y}, size:Size{width:224.,height:94.}, collapsed, port_anchors:vec![] });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# Actual WGPU label-ink evaluator — 2026-09-13
|
||||
|
||||
Status: **second caller policy authored, not compiled or executed**. The first policy completed an actual Windows WGPU run with eight corridor findings; its raw evidence remains untouched. This revision changes only this harness's caller positioning/width policy and adds backplate-containment checks. The measured-label module, corridor helper and production renderer remain untouched.
|
||||
|
||||
## Preserved first-policy result and second-policy question
|
||||
|
||||
The parent gate `.local/scenario-label-wgpu-gates-20260913` completed 32 cases with zero invalid controls, eight findings (all italic) and 12 advance-spill cases. Every finding was `outside_corridor_x_plus_one_pixel`; every case had `outside_plate=0` and `blocker_changed_pixels=0`. The actual resolved italic face was Segoe UI Italic. The first-policy source hash was `48236A9A6FD0156A9DB4CAF7520D48F00FE8697202E036B697A03C27632CED95`; its stable executable hash was `BE5B044A974AF61E471D0A887C8DF1DCD73C5CC582AC5E8CCEBCD343D77752E2`. The gate's receipts/manifests bind the exact module and dependency inputs. Parent owns publication; these local paths describe inspected evidence, not a cross-machine handoff.
|
||||
|
||||
Independent readback of narrow italic `text.rgba` confirmed the left-side violations: at zoom0.65/DPI1 the true corridor began at physical x60.8125, with one violating pixel in column59; at1/1 it began73.5, with two in column71; at1.75/1 it began100.6875, with seven across96..99; at1/1.5 it began110.25, with five across106..108. All four counts apply the unchanged channel threshold and one-physical-pixel halo. This supports testing reserved internal space; it does not prove a universal three-unit glyph overhang bound.
|
||||
|
||||
**Policy 2:** keep the true world corridor at x36.25 with width160 or760, and keep camera, viewport and blocker positions unchanged. Reserve 3 world units on each horizontal side: resolve the label against width154 or754, and paint at x39.25. Grow its backplate by the existing3 horizontal/2 vertical world units. Never grow or shift the true corridor to make the result pass. All 32 strings/styles/zoom/DPI/control combinations remain; narrower displayed prefixes may legitimately change because the available advance budget decreased.
|
||||
|
||||
New oracles require the constructed backplate's exact world left/right edges to remain within the true corridor, with no epsilon or clamp, and measure its actual painted yellow pixels against that corridor (strict and one-pixel-halo counts). A world containment failure or painted spill beyond the halo is a candidate finding. The first policy did not test backplate-within-corridor containment; the additional requirement is recorded explicitly instead of rewriting its historical result. Widths at or below the six-unit reserve are outside these 32 probes; no empty-corridor product behavior is selected here.
|
||||
|
||||
## Question and competing expectations
|
||||
|
||||
Does a single-line label measured by the pinned Iced text allocator fit its proposed painted backplate and horizontal free interval after the actual canvas camera transform? Layout advances are not glyph-ink bounds. Italic bearings, combining marks, fallback fonts, antialiasing and device scaling can falsify containment despite a successful width measurement.
|
||||
|
||||
The experiment distinguishes three claims:
|
||||
|
||||
1. **Advance-only hypothesis:** every painted pixel lies in the returned layout rectangle. Count strict violations and violations after a one-physical-pixel halo. Violations are evidence, not automatic evaluator failure: the module explicitly does not promise ink containment.
|
||||
2. **Candidate padding hypothesis:** adding 3 horizontal / 2 vertical world units to that rectangle contains ink, with a separately reported one-physical-pixel halo. This is an unaccepted probe policy. A violation beyond the halo is a finding, not grounds to silently increase the constant.
|
||||
3. **Corridor/blocker hypothesis:** ink does not extend horizontally beyond the fixed true interval plus one physical pixel, the backplate's world edges stay within that interval, its painted pixels remain within the reported halo, and text does not change painted blocker interiors. These are independent probes, not the `widest_clear` algorithm or real Scene membership.
|
||||
|
||||
Strict counts remain visible even when the one-pixel halo passes; the halo is an explicit experimental allowance, not proof of zero overlap. Pixel difference threshold is greater than 3 in any 8-bit RGBA channel. We retain raw pixels so weaker ink and alternative tolerances can be investigated without rerendering.
|
||||
|
||||
## Actual path and independent controls
|
||||
|
||||
`main.rs` imports the existing `scenario_label.rs` using `#[path]`, without copying it and without enabling its test module. It creates an actual `iced::Renderer` through `Headless::new(..., Some("wgpu"))`, creates `canvas::Frame`, applies translation then positive uniform scale, calls the label's `canvas_text` followed by `Frame::fill_text`, records `draw_geometry`, and reads actual RGBA using `Headless::screenshot`. No alternate text renderer, native window, screenshot mock, or hand-drawn glyph is involved.
|
||||
|
||||
All 32 cases are evaluated in one dedicated executable process. Global fonts initialize before a fresh renderer and `RenderSession`; no font mutation is allowed until process exit. Each frame resets renderer geometry while retaining its genuine WGPU caches. Cases cover:
|
||||
|
||||
- A realistic existing scenario-style call label, CJK, decomposed combining marks, and an italic label beginning with `jfj`.
|
||||
- True corridor widths160 and760 world units, giving policy2 text budgets154 and754. The wide case must preserve the explicit full source; the narrow case must show an ellipsis and retain a nonempty proper source prefix.
|
||||
- `(zoom, DPI)` of `(0.65,1)`, `(1,1)`, `(1.75,1)`, `(1,1.5)`. Fractional camera translation and text origins exercise subpixel positioning.
|
||||
|
||||
The independently constructed `canvas::Text` control must paint the same pixels as the guarded payload. A second actual Frame shifts camera translation by exactly 16 physical pixels; its pixels must equal the original shifted by 16. These checks can reveal wrong coordinate assumptions before any ink conclusion. The viewport has generous right/bottom margin; any observed ink touching its boundary invalidates the probe because clipping may have hidden overhang.
|
||||
|
||||
Text-only pixels against white provide the ink mask. A separate decoration render contains yellow backplate then blue blocker rectangles. A composite adds actual text. Actual opaque blue interior pixels must remain unchanged. Because the blockers are painted after the backplate, this specifically evaluates foreground text interference; it does not infer that every antialiased blocker border remains untouched. Policy2 separately tests backplate world containment and actual yellow plate pixels, including attenuated antialiased edges. Its fixed blockers begin4 world units outside the true corridor; the maximum backplate may reach the corridor boundary but cannot consume that clearance.
|
||||
|
||||
Actual shaped glyph IDs and resolved font faces are recorded as a **coverage gate only**. Glyph ID zero, no glyphs, or no resolved italic/oblique face for the italic case invalidates that case. CJK missing-font boxes cannot count as CJK acceptance. The inspected shaping buffer does not supply the ink bounds. System fonts may differ across machines; face identities/families/styles are recorded, but this is not a portable pixel-golden suite or a complete font-file provenance recorder. Pin and hash relevant system font files for a cross-machine comparison.
|
||||
|
||||
## Build and run: integration owner only
|
||||
|
||||
No Cargo or new crate dependencies are required. Compile a normal executable, **not `rustc --test`**, against the exact already-built Iced 0.14.0 and unicode-segmentation artifacts, plus their matching transitive dependency directory. The active Iced artifact must have WGPU, canvas and advanced enabled; tiny-skia fallback is not accepted by this harness. This imports the real candidate but does not wire it into desktop production.
|
||||
|
||||
Equivalent raw invocation, substituting actual absolute paths:
|
||||
|
||||
```powershell
|
||||
& $rustc --edition=2024 --crate-name scenario_label_wgpu --crate-type bin `
|
||||
-C debuginfo=0 -C "linker=$clang" -L "dependency=$deps" -L "native=$nativeLibs" `
|
||||
--extern "iced=$icedRlib" --extern "unicode_segmentation=$unicodeRlib" `
|
||||
$mainRs -o $newExecutable
|
||||
& $newExecutable $newPixelDirectory
|
||||
```
|
||||
|
||||
`build-and-run.ps1` accepts those paths explicitly and refuses an existing run directory. It hashes the resolved compiler, linker, direct libraries and source inputs, and passes the resolved linker path through `-C linker=...`. For Windows gnullvm, initialize the parent's existing `.local/toolchain/env.ps1`, resolve `$clang = (Get-Command clang.exe).Source`, and set `$nativeLibs` to the repository's `.local/toolchain/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/windows_x86_64_gnullvm-0.52.6/lib`. This exact native search directory and `linker=clang.exe` appear in the successful parent `.local/scenario-label-gates-attempt4-20260913/compile-receipt.json` (rustc 1.98.1, x86_64-pc-windows-gnullvm). That prior module gate is linkage evidence, not an executed result for this WGPU harness.
|
||||
|
||||
The runner records the complete recursively enumerated Rust link/metadata artifact set (`.rlib`, `.rmeta`, `.dll`, `.lib`, `.a`, `.o`, `.obj`) in the supplied dependency directory and every file in the explicit native directory. It rehashes and compares both manifests after linking, detecting additions, removals or changed content. It separately rechecks source/direct-library/compiler/linker inputs before running. These are artifact supersets, not assertions that every file was linked. It hashes the executable before and after its fresh-process run and preserves an output manifest of generated logs, receipts, executable, metrics and images, including nonzero evaluator outcomes.
|
||||
|
||||
The successful attempt4 used Iced `ee73abd722c72024` and unicode-segmentation `85698043f7780858`. Another Iced artifact `a4e48b220c5d9711` also exists; use the matching tested dependency set rather than treating them as interchangeable. Required runtime DLL paths and a working WGPU adapter remain prerequisites. Adapter/device creation failure is an infrastructure failure, never an ignored pass.
|
||||
|
||||
```powershell
|
||||
& ./build-and-run.ps1 -Rustc $rustc -IcedRlib $icedRlib `
|
||||
-UnicodeSegmentationRlib $unicodeRlib -DependencyDirectory $deps `
|
||||
-Linker $clang -NativeLibraryDirectory $nativeLibs `
|
||||
-NewRunDirectory $freshRun
|
||||
```
|
||||
|
||||
The script completed the first-policy build/run described above and requires PowerShell7 (`Path.GetRelativePath`). The second-policy Rust revision remains uncompiled/unexecuted. Select a NEW run directory; never reuse the first raw gate. Compile/run only after acquiring the parent's sole compiler/GPU resource. Before/after hashes do not detect a file temporarily changed and restored during linking; exclusive input ownership remains required. This is not a hermetic toolchain capture: implicitly searched system SDK libraries, clang's auxiliary executables/DLLs, runtime dependencies, system fonts and GPU drivers outside the explicit directories are not all hashed. Preserve that environment separately. The renderer exposes the backend name `wgpu`, not complete adapter identity through `Headless`; retain driver/backend logs and hardware identity separately. `WGPU_BACKEND` is recorded if set. Do not infer native acceptance from this offscreen run.
|
||||
|
||||
## Artifacts and interpretation
|
||||
|
||||
Each case writes `metrics.txt` with source/display/prefix, font faces, coordinates, ink pixel bounding box/counts, control differences and findings. It writes `text`, `decoration` and `composite` as both tightly packed, top-to-bottom RGBA8 `.rgba` and standard P6 `.ppm`. Width/height are in `metrics.txt` and the PPM header. Failed controls also preserve their rendered pixels. No image conversion participates in the oracle. These are real WGPU readbacks; inspect the PPMs or convert the preserved pixels for review after the numeric run.
|
||||
|
||||
Exit 0 means exactly 32 valid probes without a candidate-padding/corridor finding. Exit 2 means a candidate finding. Exit 3 means a coverage or control failure makes at least one probe invalid. Panic, missing summary or fewer than 32 cases is nonacceptance. Every completed run writes `summary.txt`; preserve failures before changing source. Report exact source, executable, artifact and environment provenance alongside any finding. A positive result supports only the specified strings/fonts/scales/padding; no universal raster containment claim follows.
|
||||
|
||||
This is intentionally smaller than Scene integration: no parsed model, occurrence/navigation hit testing, old renderer comparison, real activation/frame paint ordering, self-call or reversed-message corridor selection, clipping near viewport edges, rotation, wrapping, row-height policy, gesture performance, dynamic font replacement, or native user workflow is accepted. Those remain integration evaluators. A finding here should refine the shared measured-label/ink-clearance contract before production callers are added.
|
||||
|
||||
## Pinned primary API evidence
|
||||
|
||||
Registry source root is the repository-local `.local/toolchain/cargo/registry/src/index.crates.io-1949cf8c6b5b557f/` (outside this worktree):
|
||||
|
||||
- `iced_core-0.14.0/src/renderer.rs`: `Headless::new`, `name`, `screenshot` explicitly return RGBA; `Renderer::reset` resets the frame.
|
||||
- `iced_wgpu-0.14.0/src/lib.rs:912`: headless creation uses WGPU's actual adapter/device, RGBA render target and MSAAx4; screenshot uses a physical viewport and supplied DPI.
|
||||
- `iced_wgpu-0.14.0/src/geometry.rs:306`: positive uniform-scale `fill_text` transforms the position, scales font size/absolute line height, and emits `Text::Cached`. Nonuniform/rotated vector fallback is outside this experiment.
|
||||
- `iced_widget-0.14.2/src/canvas.rs`: canvas draws generated geometry through `geometry::Renderer::draw_geometry`.
|
||||
- `iced_graphics-0.14.0/src/text/cache.rs`: glyph coverage introspection uses the actual allocator's buffer and resolved fonts. Its metrics are not substituted for screenshots.
|
||||
- Current desktop `behavior.rs:2158` translates the camera then scales its actual Frame. This evaluator preserves that order without claiming to import the whole Scene.
|
||||
@@ -0,0 +1,109 @@
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Rustc,
|
||||
[Parameter(Mandatory = $true)][string]$IcedRlib,
|
||||
[Parameter(Mandatory = $true)][string]$UnicodeSegmentationRlib,
|
||||
[Parameter(Mandatory = $true)][string]$DependencyDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$Linker,
|
||||
[Parameter(Mandatory = $true)][string]$NativeLibraryDirectory,
|
||||
[Parameter(Mandatory = $true)][string]$NewRunDirectory
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# Only the integration owner may invoke this while holding the compiler/GPU gate.
|
||||
# No Cargo, dependency fetching, source mutation or existing output replacement.
|
||||
$labelRustc = (Resolve-Path -LiteralPath $Rustc).Path
|
||||
$labelIced = (Resolve-Path -LiteralPath $IcedRlib).Path
|
||||
$labelUnicode = (Resolve-Path -LiteralPath $UnicodeSegmentationRlib).Path
|
||||
$labelDeps = (Resolve-Path -LiteralPath $DependencyDirectory).Path
|
||||
$labelLinker = (Resolve-Path -LiteralPath $Linker).Path
|
||||
$labelNative = (Resolve-Path -LiteralPath $NativeLibraryDirectory).Path
|
||||
$labelRun = [IO.Path]::GetFullPath($NewRunDirectory)
|
||||
if (Test-Path -LiteralPath $labelRun) { throw 'Run directory already exists; preserve it and select a new path.' }
|
||||
$labelMain = Join-Path $PSScriptRoot 'main.rs'
|
||||
$labelModule = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../../../crates/oa-desktop/src/behavior/scenario_label.rs'))
|
||||
$labelScript = Join-Path $PSScriptRoot 'build-and-run.ps1'
|
||||
New-Item -ItemType Directory -Path $labelRun | Out-Null
|
||||
$labelExe = Join-Path $labelRun 'scenario-label-wgpu.exe'
|
||||
$labelArguments = @(
|
||||
'--edition=2024', '--crate-name', 'scenario_label_wgpu', '--crate-type', 'bin',
|
||||
'-C', 'debuginfo=0', '-L', ('dependency=' + $labelDeps),
|
||||
'-C', ('linker=' + $labelLinker), '-L', ('native=' + $labelNative),
|
||||
'--extern', ('iced=' + $labelIced),
|
||||
'--extern', ('unicode_segmentation=' + $labelUnicode),
|
||||
$labelMain, '-o', $labelExe
|
||||
)
|
||||
$labelInputs = @($labelRustc, $labelLinker, $labelIced, $labelUnicode, $labelMain, $labelModule, $labelScript) | ForEach-Object {
|
||||
$labelHash = Get-FileHash -LiteralPath $_ -Algorithm SHA256
|
||||
[ordered]@{ path = $_; sha256 = $labelHash.Hash }
|
||||
}
|
||||
function Get-LabelArtifactManifest([string]$Directory, [switch]$AllFiles) {
|
||||
Get-ChildItem -LiteralPath $Directory -File -Recurse | Where-Object {
|
||||
$AllFiles -or $_.Extension -in @('.rlib', '.rmeta', '.dll', '.lib', '.a', '.o', '.obj')
|
||||
} | Sort-Object FullName | ForEach-Object {
|
||||
[ordered]@{
|
||||
path = [IO.Path]::GetRelativePath($Directory, $_.FullName)
|
||||
bytes = $_.Length
|
||||
sha256 = (Get-FileHash -LiteralPath $_.FullName -Algorithm SHA256).Hash
|
||||
}
|
||||
}
|
||||
}
|
||||
# Supersets of Rust link/metadata artifacts and all explicit native-dir inputs,
|
||||
# not a claim that every inventoried artifact is actually linked.
|
||||
$labelDependencyManifest = @(Get-LabelArtifactManifest $labelDeps)
|
||||
$labelNativeManifest = @(Get-LabelArtifactManifest $labelNative -AllFiles)
|
||||
if ($labelDependencyManifest.Count -eq 0 -or $labelNativeManifest.Count -eq 0) { throw 'Empty dependency or native-library directory.' }
|
||||
ConvertTo-Json -InputObject $labelDependencyManifest -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'dependency-artifacts-before.json') -Encoding utf8
|
||||
ConvertTo-Json -InputObject $labelNativeManifest -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'native-artifacts-before.json') -Encoding utf8
|
||||
$labelRecord = [ordered]@{
|
||||
scope = 'Standalone actual WGPU canvas label evaluator; not desktop Scene/native acceptance'
|
||||
startedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
os = [Environment]::OSVersion.ToString()
|
||||
rustc = (& $labelRustc --version --verbose | Out-String)
|
||||
arguments = $labelArguments
|
||||
inputs = $labelInputs
|
||||
dependencyDirectory = $labelDeps
|
||||
nativeLibraryDirectory = $labelNative
|
||||
WGPU_BACKEND = $env:WGPU_BACKEND
|
||||
WGPU_ADAPTER_NAME = $env:WGPU_ADAPTER_NAME
|
||||
}
|
||||
$labelRecord | ConvertTo-Json -Depth 6 | Set-Content -LiteralPath (Join-Path $labelRun 'inputs.json') -Encoding utf8
|
||||
& $labelRustc @labelArguments 2>&1 | Tee-Object -FilePath (Join-Path $labelRun 'build.log')
|
||||
$labelBuildExit = $LASTEXITCODE
|
||||
$labelDependenciesAfter = @(Get-LabelArtifactManifest $labelDeps)
|
||||
$labelNativeAfter = @(Get-LabelArtifactManifest $labelNative -AllFiles)
|
||||
ConvertTo-Json -InputObject $labelDependenciesAfter -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'dependency-artifacts-after.json') -Encoding utf8
|
||||
ConvertTo-Json -InputObject $labelNativeAfter -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'native-artifacts-after.json') -Encoding utf8
|
||||
$labelDependenciesStable = (ConvertTo-Json -InputObject $labelDependencyManifest -Depth 4 -Compress) -ceq (ConvertTo-Json -InputObject $labelDependenciesAfter -Depth 4 -Compress)
|
||||
$labelNativeStable = (ConvertTo-Json -InputObject $labelNativeManifest -Depth 4 -Compress) -ceq (ConvertTo-Json -InputObject $labelNativeAfter -Depth 4 -Compress)
|
||||
[ordered]@{
|
||||
exit = $labelBuildExit; dependencyManifestStable = $labelDependenciesStable
|
||||
nativeManifestStable = $labelNativeStable; endedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
} | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $labelRun 'compile-receipt.json') -Encoding utf8
|
||||
if ($labelBuildExit -ne 0) { throw "Compilation failed with exit $labelBuildExit; this run is nonacceptance." }
|
||||
if (!$labelDependenciesStable -or !$labelNativeStable) { throw 'Dependency/native inputs changed during linking; preserve this run as nonacceptance.' }
|
||||
foreach ($labelInput in $labelInputs) {
|
||||
if ((Get-FileHash -LiteralPath $labelInput.path -Algorithm SHA256).Hash -ne $labelInput.sha256) {
|
||||
throw ('Input changed during compilation: ' + $labelInput.path)
|
||||
}
|
||||
}
|
||||
$labelExeHash = (Get-FileHash -LiteralPath $labelExe -Algorithm SHA256).Hash
|
||||
$labelOutput = Join-Path $labelRun 'pixels'
|
||||
# A single NEW process owns initialization, settled fonts, renderer and all cases.
|
||||
& $labelExe $labelOutput 2>&1 | Tee-Object -FilePath (Join-Path $labelRun 'run.log')
|
||||
$labelRunExit = $LASTEXITCODE
|
||||
$labelExeAfterHash = (Get-FileHash -LiteralPath $labelExe -Algorithm SHA256).Hash
|
||||
$labelReceipt = [ordered]@{
|
||||
executable = $labelExe; sha256Before = $labelExeHash; sha256After = $labelExeAfterHash
|
||||
binaryStable = ($labelExeHash -ceq $labelExeAfterHash); exit = $labelRunExit
|
||||
endedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
interpretation = '0=32 valid probes without candidate findings; 2=padding/corridor finding; 3=invalid coverage/contract probe; other=infrastructure/panic'
|
||||
}
|
||||
$labelReceipt | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'receipt.json') -Encoding utf8
|
||||
# Capture all produced evidence, even for exit 2/3 or a missing summary. The
|
||||
# manifest itself is excluded naturally because it does not exist until written.
|
||||
$labelOutputManifest = @(Get-LabelArtifactManifest $labelRun -AllFiles)
|
||||
ConvertTo-Json -InputObject $labelOutputManifest -Depth 4 | Set-Content -LiteralPath (Join-Path $labelRun 'output-artifacts.json') -Encoding utf8
|
||||
if ($labelExeHash -cne $labelExeAfterHash) { throw 'Executable changed during execution; this run is nonacceptance.' }
|
||||
$labelSummary = Join-Path $labelOutput 'summary.txt'
|
||||
if (!(Test-Path -LiteralPath $labelSummary)) { throw 'No complete summary: infrastructure failure or panic, not acceptance.' }
|
||||
if ((Get-Content -LiteralPath $labelSummary -Raw) -notmatch '(?m)^cases=32\r?$') { throw 'Expected exactly 32 completed cases.' }
|
||||
if ($labelRunExit -ne 0) { throw "Evaluator exit $labelRunExit. Preserve metrics/images; diagnose before changing the candidate." }
|
||||
@@ -0,0 +1,565 @@
|
||||
//! Standalone offscreen evaluator; deliberately compiled WITHOUT --test.
|
||||
//! Imports the actual candidate, never a copy of its shaping or truncation code.
|
||||
#[path = "../../../crates/oa-desktop/src/behavior/scenario_label.rs"]
|
||||
mod scenario_label;
|
||||
|
||||
use iced::advanced::{
|
||||
graphics::{geometry::Renderer as _, text as graphics_text},
|
||||
renderer::{Headless, Renderer as _},
|
||||
text::{Alignment, LineHeight, Shaping},
|
||||
};
|
||||
use iced::{Color, Font, Pixels, Point, Rectangle, Size, Vector, widget::canvas};
|
||||
use scenario_label::{Cache, Label, RenderSession, Style};
|
||||
use std::{collections::BTreeSet, fmt::Write as _, fs, future::Future, path::Path, sync::Arc};
|
||||
|
||||
const PAN: Vector = Vector::new(37.25, 23.5);
|
||||
// POSITION remains the TRUE corridor origin, unchanged from empirical policy 1.
|
||||
const POSITION: Point = Point::new(36.25, 40.5);
|
||||
const PIXEL_DELTA: u8 = 3;
|
||||
const HORIZONTAL_INSET: f32 = 3.;
|
||||
const POLICY: &str = "2-fixed-corridor-inset-3-world-units";
|
||||
|
||||
struct Case {
|
||||
name: &'static str,
|
||||
source: &'static str,
|
||||
style: Style,
|
||||
require_italic: bool,
|
||||
}
|
||||
|
||||
fn cases() -> [Case; 4] {
|
||||
let advanced = Style {
|
||||
shaping: Shaping::Advanced,
|
||||
..Style::default()
|
||||
};
|
||||
[
|
||||
Case {
|
||||
name: "realistic-default",
|
||||
source: "1. call · Request valve repair · Technician service conversation",
|
||||
style: Style::default(),
|
||||
require_italic: false,
|
||||
},
|
||||
Case {
|
||||
name: "cjk-advanced",
|
||||
source: "2. call · 请求阀门维修并确认压力传感器状态 · 技术人员服务会话",
|
||||
style: advanced,
|
||||
require_italic: false,
|
||||
},
|
||||
Case {
|
||||
name: "combining-advanced",
|
||||
source: "3. call · A\u{0301}\u{0323}larm e\u{0301}valuation · verify technician's pressure-report acknowledgement",
|
||||
style: advanced,
|
||||
require_italic: false,
|
||||
},
|
||||
Case {
|
||||
name: "italic-advanced",
|
||||
source: "jfj · Verify valve qualification, pressure relief and technician signoff",
|
||||
style: Style {
|
||||
font: Font {
|
||||
style: iced::font::Style::Italic,
|
||||
..Font::DEFAULT
|
||||
},
|
||||
..advanced
|
||||
},
|
||||
require_italic: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
// No runtime or test-suite global executor: this executable owns the GPU/font
|
||||
// process. WGPU's adapter/device futures provide their normal wake notifications.
|
||||
fn block_on<T>(future: impl Future<Output = T>) -> T {
|
||||
struct WakeThread(std::thread::Thread);
|
||||
impl std::task::Wake for WakeThread {
|
||||
fn wake(self: Arc<Self>) {
|
||||
self.0.unpark();
|
||||
}
|
||||
fn wake_by_ref(self: &Arc<Self>) {
|
||||
self.0.unpark();
|
||||
}
|
||||
}
|
||||
let waker = std::task::Waker::from(Arc::new(WakeThread(std::thread::current())));
|
||||
let mut context = std::task::Context::from_waker(&waker);
|
||||
let mut future = std::pin::pin!(future);
|
||||
loop {
|
||||
match future.as_mut().poll(&mut context) {
|
||||
std::task::Poll::Ready(value) => return value,
|
||||
std::task::Poll::Pending => std::thread::park(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Pass {
|
||||
Text,
|
||||
ManualText,
|
||||
Decoration,
|
||||
Composite,
|
||||
}
|
||||
|
||||
struct Surface {
|
||||
logical: Size,
|
||||
physical: Size<u32>,
|
||||
dpi: f32,
|
||||
zoom: f32,
|
||||
text_position: Point,
|
||||
advance: Rectangle,
|
||||
plate: Rectangle,
|
||||
corridor: Rectangle,
|
||||
blockers: [Rectangle; 2],
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
fn new(label: &Label, session: &RenderSession, zoom: f32, dpi: f32, width: f32) -> Self {
|
||||
let text_position = Point::new(POSITION.x + HORIZONTAL_INSET, POSITION.y);
|
||||
let advance = label.bounds_at(session, zoom, text_position).unwrap();
|
||||
// Explicit EXPERIMENT candidate: 3 horizontal / 2 vertical world units.
|
||||
// It is not an accepted production padding or an assertion about ink.
|
||||
let plate = grow(advance, HORIZONTAL_INSET, 2.);
|
||||
// Do not derive corridor.x from the shifted text or widen the corridor
|
||||
// to excuse overhang. Its original origin/width and blockers stay fixed.
|
||||
let corridor = Rectangle {
|
||||
x: POSITION.x,
|
||||
width,
|
||||
..advance
|
||||
};
|
||||
let blockers = [
|
||||
Rectangle {
|
||||
x: POSITION.x - 12.,
|
||||
y: POSITION.y - 12.,
|
||||
width: 8.,
|
||||
height: 52.,
|
||||
},
|
||||
Rectangle {
|
||||
x: POSITION.x + width + 4.,
|
||||
y: POSITION.y - 12.,
|
||||
width: 8.,
|
||||
height: 52.,
|
||||
},
|
||||
];
|
||||
let physical = Size::new(
|
||||
((PAN.x + (POSITION.x + width + 70.) * zoom) * dpi).ceil() as u32,
|
||||
((PAN.y + (POSITION.y + 90.) * zoom) * dpi).ceil() as u32,
|
||||
);
|
||||
let logical = Size::new(physical.width as f32 / dpi, physical.height as f32 / dpi);
|
||||
Self {
|
||||
logical,
|
||||
physical,
|
||||
dpi,
|
||||
zoom,
|
||||
text_position,
|
||||
advance,
|
||||
plate,
|
||||
corridor,
|
||||
blockers,
|
||||
}
|
||||
}
|
||||
|
||||
fn pixels(&self, rect: Rectangle) -> Rectangle {
|
||||
Rectangle {
|
||||
x: (PAN.x + rect.x * self.zoom) * self.dpi,
|
||||
y: (PAN.y + rect.y * self.zoom) * self.dpi,
|
||||
width: rect.width * self.zoom * self.dpi,
|
||||
height: rect.height * self.zoom * self.dpi,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn grow(rect: Rectangle, x: f32, y: f32) -> Rectangle {
|
||||
Rectangle {
|
||||
x: rect.x - x,
|
||||
y: rect.y - y,
|
||||
width: rect.width + 2. * x,
|
||||
height: rect.height + 2. * y,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(
|
||||
renderer: &mut iced::Renderer,
|
||||
surface: &Surface,
|
||||
label: &Label,
|
||||
session: &RenderSession,
|
||||
style: Style,
|
||||
pass: Pass,
|
||||
pixel_shift: f32,
|
||||
) -> Vec<u8> {
|
||||
renderer.reset(Rectangle::with_size(surface.logical));
|
||||
let mut frame = canvas::Frame::new(renderer, surface.logical);
|
||||
// Same order as actual scenario Frame: camera translation, then uniform scale.
|
||||
// The extra translation is only the independent integer-pixel shift oracle.
|
||||
frame.translate(Vector::new(PAN.x + pixel_shift / surface.dpi, PAN.y));
|
||||
frame.scale(surface.zoom);
|
||||
if matches!(pass, Pass::Decoration | Pass::Composite) {
|
||||
frame.fill_rectangle(
|
||||
surface.plate.position(),
|
||||
surface.plate.size(),
|
||||
Color::from_rgb8(255, 230, 120),
|
||||
);
|
||||
// Backplate precedes activation-like blockers; text remains foreground.
|
||||
// These are probes, not imported Scene activations or a corridor planner.
|
||||
for blocker in surface.blockers {
|
||||
frame.fill_rectangle(
|
||||
blocker.position(),
|
||||
blocker.size(),
|
||||
Color::from_rgb8(0, 40, 255),
|
||||
);
|
||||
}
|
||||
}
|
||||
if !matches!(pass, Pass::Decoration) {
|
||||
let text = if matches!(pass, Pass::ManualText) {
|
||||
// Independently construct the documented paint payload. Both paths
|
||||
// use actual Frame::fill_text; no alternate renderer is involved.
|
||||
canvas::Text {
|
||||
content: label.displayed(session, surface.zoom).unwrap().to_owned(),
|
||||
position: surface.text_position,
|
||||
color: Color::BLACK,
|
||||
size: style.size,
|
||||
line_height: style.line_height,
|
||||
font: style.font,
|
||||
shaping: style.shaping,
|
||||
max_width: f32::INFINITY,
|
||||
..canvas::Text::default()
|
||||
}
|
||||
} else {
|
||||
label
|
||||
.canvas_text(session, surface.zoom, surface.text_position, Color::BLACK)
|
||||
.unwrap()
|
||||
};
|
||||
frame.fill_text(text);
|
||||
}
|
||||
renderer.draw_geometry(frame.into_geometry());
|
||||
let pixels = Headless::screenshot(renderer, surface.physical, surface.dpi, Color::WHITE);
|
||||
assert_eq!(
|
||||
pixels.len(),
|
||||
surface.physical.width as usize * surface.physical.height as usize * 4
|
||||
);
|
||||
pixels
|
||||
}
|
||||
|
||||
fn differs(a: &[u8], b: &[u8]) -> bool {
|
||||
a.iter().zip(b).any(|(&a, &b)| a.abs_diff(b) > PIXEL_DELTA)
|
||||
}
|
||||
|
||||
fn inside(rect: Rectangle, x: usize, y: usize) -> bool {
|
||||
let x = x as f32 + 0.5;
|
||||
let y = y as f32 + 0.5;
|
||||
x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height
|
||||
}
|
||||
|
||||
#[derive(Default, Debug)]
|
||||
struct Ink {
|
||||
count: usize,
|
||||
bounds: Option<(usize, usize, usize, usize)>,
|
||||
outside_advance: usize,
|
||||
outside_advance_plus_one_pixel: usize,
|
||||
outside_plate: usize,
|
||||
outside_plate_plus_one_pixel: usize,
|
||||
outside_corridor_x_plus_one_pixel: usize,
|
||||
touches_viewport: usize,
|
||||
}
|
||||
|
||||
fn ink(pixels: &[u8], surface: &Surface) -> Ink {
|
||||
let advance = surface.pixels(surface.advance);
|
||||
let plate = surface.pixels(surface.plate);
|
||||
let corridor = surface.pixels(surface.corridor);
|
||||
let mut out = Ink::default();
|
||||
for (i, rgba) in pixels.chunks_exact(4).enumerate() {
|
||||
if !differs(rgba, &[255, 255, 255, 255]) {
|
||||
continue;
|
||||
}
|
||||
let (x, y) = (
|
||||
i % surface.physical.width as usize,
|
||||
i / surface.physical.width as usize,
|
||||
);
|
||||
out.count += 1;
|
||||
out.bounds = Some(match out.bounds {
|
||||
None => (x, y, x + 1, y + 1),
|
||||
Some((l, t, r, b)) => (l.min(x), t.min(y), r.max(x + 1), b.max(y + 1)),
|
||||
});
|
||||
out.outside_advance += usize::from(!inside(advance, x, y));
|
||||
out.outside_advance_plus_one_pixel += usize::from(!inside(grow(advance, 1., 1.), x, y));
|
||||
out.outside_plate += usize::from(!inside(plate, x, y));
|
||||
out.outside_plate_plus_one_pixel += usize::from(!inside(grow(plate, 1., 1.), x, y));
|
||||
let px = x as f32 + 0.5;
|
||||
out.outside_corridor_x_plus_one_pixel +=
|
||||
usize::from(px < corridor.x - 1. || px >= corridor.x + corridor.width + 1.);
|
||||
out.touches_viewport += usize::from(
|
||||
x == 0
|
||||
|| y == 0
|
||||
|| x + 1 == surface.physical.width as usize
|
||||
|| y + 1 == surface.physical.height as usize,
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// Independent inspection of the actual shaped glyphs is ONLY a coverage gate:
|
||||
// metrics from this buffer do not serve as the painted-ink oracle.
|
||||
fn font_coverage(
|
||||
displayed: &str,
|
||||
style: Style,
|
||||
zoom: f32,
|
||||
) -> (usize, usize, bool, BTreeSet<String>) {
|
||||
let size = Pixels(style.size.0 * zoom);
|
||||
let line_height = match style.line_height {
|
||||
LineHeight::Absolute(value) => value.0 * zoom,
|
||||
LineHeight::Relative(value) => value * size.0,
|
||||
};
|
||||
let mut fonts = graphics_text::font_system().write().unwrap();
|
||||
let mut shapes = graphics_text::Cache::new();
|
||||
let (_, entry) = shapes.allocate(
|
||||
fonts.raw(),
|
||||
graphics_text::cache::Key {
|
||||
content: displayed,
|
||||
size: size.0,
|
||||
line_height,
|
||||
font: style.font,
|
||||
bounds: Size::INFINITE,
|
||||
shaping: style.shaping,
|
||||
align_x: Alignment::Default,
|
||||
},
|
||||
);
|
||||
let (mut glyphs, mut missing, mut italic) = (0, 0, false);
|
||||
let mut faces = BTreeSet::new();
|
||||
for run in entry.buffer.layout_runs() {
|
||||
for glyph in run.glyphs {
|
||||
glyphs += 1;
|
||||
missing += usize::from(glyph.glyph_id == 0);
|
||||
if let Some(face) = fonts.raw().db().face(glyph.font_id) {
|
||||
italic |= face.style != graphics_text::cosmic_text::fontdb::Style::Normal;
|
||||
faces.insert(format!(
|
||||
"{:?} {:?} {:?}",
|
||||
glyph.font_id, face.families, face.style
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
(glyphs, missing, italic, faces)
|
||||
}
|
||||
|
||||
fn save_pixels(dir: &Path, name: &str, pixels: &[u8], size: Size<u32>) {
|
||||
fs::write(dir.join(format!("{name}.rgba")), pixels).unwrap();
|
||||
// Standard portable pixmap for immediate image inspection, no image crate.
|
||||
let mut ppm = format!("P6\n{} {}\n255\n", size.width, size.height).into_bytes();
|
||||
for pixel in pixels.chunks_exact(4) {
|
||||
ppm.extend_from_slice(&pixel[..3]);
|
||||
}
|
||||
fs::write(dir.join(format!("{name}.ppm")), ppm).unwrap();
|
||||
}
|
||||
|
||||
fn evaluate(
|
||||
renderer: &mut iced::Renderer,
|
||||
session: &RenderSession,
|
||||
case: &Case,
|
||||
zoom: f32,
|
||||
dpi: f32,
|
||||
width: f32,
|
||||
dir: &Path,
|
||||
) -> (usize, usize, bool) {
|
||||
fs::create_dir(dir).unwrap();
|
||||
let mut cache = Cache::default();
|
||||
let text_budget = width - 2. * HORIZONTAL_INSET;
|
||||
assert!(
|
||||
text_budget > 0.,
|
||||
"these 32 probes require a positive inset text budget"
|
||||
);
|
||||
let label = cache
|
||||
.resolve(session, case.source, text_budget, zoom, case.style)
|
||||
.unwrap();
|
||||
let displayed = label.displayed(session, zoom).unwrap();
|
||||
let retained = label.retained_source(session, zoom).unwrap();
|
||||
let surface = Surface::new(&label, session, zoom, dpi, width);
|
||||
let text = render(
|
||||
renderer,
|
||||
&surface,
|
||||
&label,
|
||||
session,
|
||||
case.style,
|
||||
Pass::Text,
|
||||
0.,
|
||||
);
|
||||
let manual = render(
|
||||
renderer,
|
||||
&surface,
|
||||
&label,
|
||||
session,
|
||||
case.style,
|
||||
Pass::ManualText,
|
||||
0.,
|
||||
);
|
||||
let moved = render(
|
||||
renderer,
|
||||
&surface,
|
||||
&label,
|
||||
session,
|
||||
case.style,
|
||||
Pass::Text,
|
||||
16.,
|
||||
);
|
||||
let decoration = render(
|
||||
renderer,
|
||||
&surface,
|
||||
&label,
|
||||
session,
|
||||
case.style,
|
||||
Pass::Decoration,
|
||||
0.,
|
||||
);
|
||||
let composite = render(
|
||||
renderer,
|
||||
&surface,
|
||||
&label,
|
||||
session,
|
||||
case.style,
|
||||
Pass::Composite,
|
||||
0.,
|
||||
);
|
||||
let measured_ink = ink(&text, &surface);
|
||||
let (glyphs, missing, italic, faces) = font_coverage(displayed, case.style, zoom);
|
||||
let payload_mismatch = text
|
||||
.chunks_exact(4)
|
||||
.zip(manual.chunks_exact(4))
|
||||
.filter(|(a, b)| differs(a, b))
|
||||
.count();
|
||||
let mut translation_mismatch = 0;
|
||||
let mut blocker_changed = 0;
|
||||
let mut blocker_samples = 0;
|
||||
let plate_inside_corridor_x = surface.plate.x >= surface.corridor.x
|
||||
&& surface.plate.x + surface.plate.width <= surface.corridor.x + surface.corridor.width;
|
||||
let corridor_pixels = surface.pixels(surface.corridor);
|
||||
let mut painted_plate_samples = 0;
|
||||
let mut painted_plate_outside_corridor_x = 0;
|
||||
let mut painted_plate_outside_corridor_x_plus_one_pixel = 0;
|
||||
for (i, rgba) in moved.chunks_exact(4).enumerate() {
|
||||
let x = i % surface.physical.width as usize;
|
||||
let expected = if x >= 16 {
|
||||
&text[(i - 16) * 4..(i - 16) * 4 + 4]
|
||||
} else {
|
||||
&[255, 255, 255, 255]
|
||||
};
|
||||
translation_mismatch += usize::from(differs(rgba, expected));
|
||||
}
|
||||
for (i, (before, after)) in decoration
|
||||
.chunks_exact(4)
|
||||
.zip(composite.chunks_exact(4))
|
||||
.enumerate()
|
||||
{
|
||||
// Sample actual opaque blue blocker interiors, not a guessed screen box.
|
||||
if before[0] < 8 && before[1] < 60 && before[2] > 245 {
|
||||
blocker_samples += 1;
|
||||
blocker_changed += usize::from(differs(before, after));
|
||||
}
|
||||
// Yellow plate over white has full red and attenuated blue. This mask
|
||||
// also includes weak antialiased plate edges; blue blockers cannot match.
|
||||
if before[0] > 247 && before[2] < 252 {
|
||||
painted_plate_samples += 1;
|
||||
let x = (i % surface.physical.width as usize) as f32 + 0.5;
|
||||
painted_plate_outside_corridor_x += usize::from(
|
||||
x < corridor_pixels.x || x >= corridor_pixels.x + corridor_pixels.width,
|
||||
);
|
||||
painted_plate_outside_corridor_x_plus_one_pixel += usize::from(
|
||||
x < corridor_pixels.x - 1. || x >= corridor_pixels.x + corridor_pixels.width + 1.,
|
||||
);
|
||||
}
|
||||
}
|
||||
let source_expectation = if width > 500. {
|
||||
displayed == case.source && retained == (0..case.source.len())
|
||||
} else {
|
||||
displayed.ends_with('…') && retained.end < case.source.len() && retained.end > 0
|
||||
};
|
||||
// Infrastructure/contract failures differ from geometric candidate findings.
|
||||
let invalid = usize::from(!source_expectation)
|
||||
+ usize::from(glyphs == 0 || missing > 0 || (case.require_italic && !italic))
|
||||
+ usize::from(measured_ink.count == 0 || measured_ink.touches_viewport > 0)
|
||||
+ usize::from(
|
||||
payload_mismatch > 0
|
||||
|| translation_mismatch > 0
|
||||
|| blocker_samples == 0
|
||||
|| painted_plate_samples == 0,
|
||||
);
|
||||
let finding = usize::from(measured_ink.outside_plate_plus_one_pixel > 0)
|
||||
+ usize::from(measured_ink.outside_corridor_x_plus_one_pixel > 0)
|
||||
+ usize::from(blocker_changed > 0)
|
||||
+ usize::from(!plate_inside_corridor_x)
|
||||
+ usize::from(painted_plate_outside_corridor_x_plus_one_pixel > 0);
|
||||
let mut report = format!(
|
||||
"policy={POLICY}\nsource={:?}\ndisplayed={displayed:?}\nretained={retained:?}\nstyle={:?}\nzoom={zoom}\ndpi={dpi}\nwidth={width}\ntext_budget={text_budget}\nhorizontal_inset={HORIZONTAL_INSET}\nphysical={:?}\nadvance_world={:?}\nplate_world={:?}\nblockers_world={:?}\nink={measured_ink:#?}\nglyphs={glyphs}\nmissing_glyphs={missing}\nresolved_italic={italic}\npayload_mismatch_pixels={payload_mismatch}\ntranslation_mismatch_pixels={translation_mismatch}\nblocker_samples={blocker_samples}\nblocker_changed_pixels={blocker_changed}\nplate_inside_corridor_x={plate_inside_corridor_x}\npainted_plate_samples={painted_plate_samples}\npainted_plate_outside_corridor_x={painted_plate_outside_corridor_x}\npainted_plate_outside_corridor_x_plus_one_pixel={painted_plate_outside_corridor_x_plus_one_pixel}\ninvalid_checks={invalid}\ncandidate_findings={finding}\n",
|
||||
case.source, case.style, surface.physical, surface.advance, surface.plate, surface.blockers,
|
||||
);
|
||||
writeln!(
|
||||
&mut report,
|
||||
"corridor_world={:?}\ntext_position={:?}",
|
||||
surface.corridor, surface.text_position
|
||||
)
|
||||
.unwrap();
|
||||
for face in faces {
|
||||
writeln!(&mut report, "resolved_face={face}").unwrap();
|
||||
}
|
||||
fs::write(dir.join("metrics.txt"), report).unwrap();
|
||||
for (name, pixels) in [
|
||||
("text", &text),
|
||||
("decoration", &decoration),
|
||||
("composite", &composite),
|
||||
] {
|
||||
save_pixels(dir, name, pixels, surface.physical);
|
||||
}
|
||||
if payload_mismatch > 0 {
|
||||
save_pixels(dir, "manual-control", &manual, surface.physical);
|
||||
}
|
||||
if translation_mismatch > 0 {
|
||||
save_pixels(dir, "shift-control", &moved, surface.physical);
|
||||
}
|
||||
(invalid, finding, measured_ink.outside_advance > 0)
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let args: Vec<_> = std::env::args_os().collect();
|
||||
assert_eq!(
|
||||
args.len(),
|
||||
2,
|
||||
"usage: scenario-label-wgpu.exe NEW_OUTPUT_DIRECTORY"
|
||||
);
|
||||
let out = Path::new(&args[1]);
|
||||
fs::create_dir(out).expect("output directory must be new; preserve prior runs");
|
||||
// Initialize the system font database before the new renderer. No fonts are
|
||||
// loaded after this point, through all screenshots and coverage inspection.
|
||||
let version = graphics_text::font_system().read().unwrap().version();
|
||||
let mut renderer = block_on(<iced::Renderer as Headless>::new(
|
||||
Font::DEFAULT,
|
||||
Pixels(16.),
|
||||
Some("wgpu"),
|
||||
))
|
||||
.expect("WGPU adapter/device unavailable: infrastructure failure, never a skipped pass");
|
||||
assert_eq!(Headless::name(&renderer), "wgpu");
|
||||
let session = RenderSession::after_fonts_settled_and_renderer_reset().unwrap();
|
||||
let mut summary = format!(
|
||||
"policy={POLICY}\nhorizontal_inset={HORIZONTAL_INSET}\nfont_version={version:?}\nrenderer=wgpu\nchannel_delta_threshold={PIXEL_DELTA}\nimported_module=crates/oa-desktop/src/behavior/scenario_label.rs\n"
|
||||
);
|
||||
let (mut cases_run, mut invalid, mut findings, mut advance_spill_cases) = (0, 0, 0, 0);
|
||||
for case in cases() {
|
||||
for (zoom, dpi) in [(0.65, 1.), (1., 1.), (1.75, 1.), (1., 1.5)] {
|
||||
for width in [160., 760.] {
|
||||
let name = format!("{}-z{zoom}-dpi{dpi}-w{width}", case.name);
|
||||
let dir = out.join(&name);
|
||||
let (bad, found, advance_spill) =
|
||||
evaluate(&mut renderer, &session, &case, zoom, dpi, width, &dir);
|
||||
// Summary is only an index; full numeric ink fields are per case.
|
||||
advance_spill_cases += usize::from(advance_spill);
|
||||
writeln!(&mut summary, "{name}: invalid={bad} findings={found}").unwrap();
|
||||
invalid += bad;
|
||||
findings += found;
|
||||
cases_run += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(cases_run, 32);
|
||||
writeln!(&mut summary, "cases={cases_run}\ninvalid_checks={invalid}\ncandidate_findings={findings}\nadvance_spill_cases={advance_spill_cases}\n").unwrap();
|
||||
summary.push_str("Advance spill is measured evidence, not automatically a failure. Candidate findings reject this probe's padding/corridor assumptions. This is not Scene/native acceptance.\n");
|
||||
fs::write(out.join("summary.txt"), &summary).unwrap();
|
||||
print!("{summary}");
|
||||
if invalid > 0 {
|
||||
std::process::exit(3);
|
||||
}
|
||||
if findings > 0 {
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
# Scenario movement: source evidence and discriminating workflows
|
||||
|
||||
This extends the earlier [anchor-plan experiment](../behavior-anchor-plan-20260912/README.md) and the [layout contract candidate](../../scenario-layout-contract-candidate.md). It informs the user's #20 request for ordinary diagram manipulation and the application specification's requirement to learn from Capella. It does not select a final gesture policy or claim native Capella evaluation.
|
||||
|
||||
## Verified primary-source behavior
|
||||
|
||||
Capella7.1.0, commit3153b930a4284f6c82952658f1e8d8d6d8c8f340, connects movement of messages, Create/Delete, returns and executions to DoReorder. Its service moves sending and receiving interaction fragments separately, then moves the existing message in ownedMessages, retaining identities. See [tool mapping](https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/core/plugins/org.polarsys.capella.core.sirius.analysis/description/common.odesign#L15777) and [implementation](https://github.com/eclipse-capella/capella/blob/3153b930a4284f6c82952658f1e8d8d6d8c8f340/core/plugins/org.polarsys.capella.core.sirius.analysis/src/org/polarsys/capella/core/sirius/analysis/SequenceDiagramServices.java#L257).
|
||||
|
||||
Sirius documents separate blank-space insertion/removal using Ctrl-drag on the vertical ruler or Ctrl+Shift-drag on the canvas. Arrange All preserves order while reducing spacing. Create moves a lifeline's birth/header and Delete its death/bottom. The [user guide](https://eclipse.dev/sirius/doc/user/sequences/Sequence%20Diagrams.html) describes these operations. Sirius7.6.1, commit0a7288dda7c0069659b54091ba811abab0d247c2, [spacing assertions](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/SequenceSpaceTest.java#L75) demonstrate that inserting space within an execution leaves the call fixed but moves the return and extends the lifeline; inserting below leaves both fixed.
|
||||
|
||||
Sirius [destruction movement tests](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/SequenceDestroyMessageMoveTest.java#L61) permit ordinary drag from y400 to230 across an unrelated message at300, preserving that message's coordinates, but reject death before earlier use of the receiver. Crossing is constrained by affected lifelines, not universally forbidden.
|
||||
|
||||
Call/execution/return movement can be coupled, including self calls. Sirius has distinct predecessor values for starting and finishing event ends ([reorder tool contract](https://eclipse.dev/sirius/doc/specifier/sequences/Sequence%20Diagrams.html#EventReorderTool)); [synchronous call assertions](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/SyncCallMoveTest.java#L330) check coordinated movement and birth/death limits.
|
||||
|
||||
Messages and executions must keep their endpoints within the same operand. [Frame coverage tests](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/CombinedFragmentsMoveTests.java#L103) reject enclosing a fragment within one covering fewer required participants. [Whole-frame movement assertions](https://github.com/eclipse-sirius/sirius-desktop/blob/0a7288dda7c0069659b54091ba811abab0d247c2/plugins/org.eclipse.sirius.tests.swtbot/src/org/eclipse/sirius/tests/swtbot/sequence/SyncCallInOperandReorderTest.java#L75) translate enclosed calls and can enlarge a containing execution. This does not establish unrestricted semantic reparenting.
|
||||
|
||||
## Interpretation and alternatives still to evaluate
|
||||
|
||||
The evidence rules out assuming every sequence drag is cosmetic. It does not justify copying Capella event-end reorder directly: ARCADIA Studio currently orders complete occurrences in Scenario/Branch sequences, and validates reply dominance, lifetime and operand relations at that granularity. Independent Capella event-end ordering is a material semantic difference.
|
||||
|
||||
Candidate A uses direct dragging for presentation spacing and a separately discoverable semantic reorder action. It reduces accidental semantic changes but may make the common order-correction task less discoverable.
|
||||
|
||||
Candidate B uses one drag with explicit spacing or semantic-reorder preview according to the prospective position, preflighted against actual chronology/operand constraints. It may better support direct manipulation but must make a semantic transition clear before commit and prevent accidental changes near a boundary. No coordinate-only finite check authorizes that semantic operation.
|
||||
|
||||
Both candidates need actual geometry feasibility, clear rejected-move feedback, cancellation, one transaction for model and presentation, exact Undo and saved/reopened geometry. Existing floor-clamp counterexamples remain rejected; silently snapping to a different chronology does not satisfy an exact authored coordinate request.
|
||||
|
||||
| Engineer workflow | Observable discriminator |
|
||||
| --- | --- |
|
||||
| Make room for a commissioning review note without changing behavior | Predictable spacing; no unintended semantic order mutation near another row |
|
||||
| Correct vent-then-isolate to isolate-then-vent within an operand | Discoverable reorder, comprehensible semantic preview, one coherent Undo |
|
||||
| Extend service duration around a self call | Coupled reply/activation effects visible before acceptance; start and duration operations distinguishable |
|
||||
| Move shutdown earlier across unrelated telemetry | Valid crossing allowed; death before last receiver use rejected clearly; exact lifecycle anchors retained |
|
||||
| Adjust a fault-handling message near an alt divider, then move its frame | No accidental operand change; whole-frame translation distinguishable from semantic reparenting |
|
||||
|
||||
Record task completion, unintended semantic mutations, preview comprehension, cancellation, Undo and reopen behavior. Pointer distance alone is not an adequate evaluator. The user request includes resizing and ordinary controls beyond this initial sparse lane/message storage slice.
|
||||
|
||||
## Evidence limits
|
||||
|
||||
This was source inspection, not executed native Capella/Sirius validation. Exact modifiers, feedback and persistence/Undo behavior have not been observed locally. Capella and Sirius revisions were inspected separately; their precise bundled dependency relationship was not established. Findings follow assertions rather than misleading test names. Real GUI acceptance remains required for the chosen product behavior.
|
||||
@@ -104,6 +104,7 @@ fn diagram(p: &mut Project, n: u32, layer: Layer, name: &str, rows: &[(Id, f32,
|
||||
projection: ProjectionKind::Overview,
|
||||
view_options: ViewOptions::default(),
|
||||
nodes,
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
},
|
||||
);
|
||||
|
||||
@@ -298,6 +298,7 @@ fn stage_commands(before: &Project, stage: Stage, roles: &mut Roles<'_>) -> Vec<
|
||||
description: String::new(),
|
||||
layer: Layer::Sa,
|
||||
projection: ProjectionKind::StateMachine { machine },
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::from([(
|
||||
@@ -422,6 +423,7 @@ fn false_carrier(base: &Project) -> Project {
|
||||
projection: ProjectionKind::StateMachine {
|
||||
machine: test_id(6),
|
||||
},
|
||||
layout: None,
|
||||
routes: vec![],
|
||||
view_options: Default::default(),
|
||||
nodes: BTreeMap::from([(
|
||||
|
||||
Reference in New Issue
Block a user