Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a63654f782 |
@@ -1,73 +0,0 @@
|
||||
# Agent working agreement
|
||||
|
||||
## Working method
|
||||
|
||||
- Follow `PROJECT_GOAL.md` and the user's current instructions. Work autonomously within scope. Resolve discoverable questions yourself; record, test, and revise reversible assumptions. Ask only when a consequential decision needs information or authorization not already available. Continue independent useful work while blocked elsewhere.
|
||||
- Map unfamiliar domains through primary sources, comparable systems, and real user workflows. Identify underlying needs, valuable concepts, and avoidable friction; evaluate improvements rather than merely reproducing features. Synthesize findings into decisions or experiments, and stop research when another action offers greater value.
|
||||
- Make specifications precise enough for compatible independent implementations: definitions, relationships, invariants, valid/invalid examples, and lifecycle or transformation rules where relevant. Trace important requirements and decisions to implementation and verification. Resolve specification ambiguity at its source when implementation exposes it.
|
||||
- For consequential uncertain choices, compare materially different candidates against explicit user-derived criteria. Ask independent critics to challenge assumptions and attempt to falsify proposals; refine or replace candidates as evidence warrants. Record the choice, evidence, tradeoffs, rejected approaches, and reasons to revisit it. Use proportionate effort for reversible local choices.
|
||||
- Plan milestones that resolve uncertainty or deliver useful capability, with explicit dependencies. Test foundational assumptions before substantial downstream work. At checkpoints, assess progress toward the actual objective and replan when evidence invalidates the strategy or progress stalls.
|
||||
- Define observable success and suitable evaluators before repeated improvement: invariants, examples, round trips, transformations, performance checks, realistic user tasks, or explicit review rubrics. Ensure the evaluator measures the intended outcome. For experiments, record the question, expectations, method, result, and implications; isolate exploratory prototypes from production architecture.
|
||||
- Build and run meaningful changes, exercise actual behavior, and inspect useful logs. Investigate crashes, serious warnings, corrupted state, and interaction defects. For GUI work, use Computer Use or equivalent tools to inspect realistic workflows, cognitive load, editing/selection, navigation, feedback, recovery, loading/empty/failure states, accessibility, layout, focus/disabled states, and responsiveness.
|
||||
- Match independent reviews to the artifact and risks. For languages or formats, include ambiguity, round trips, human/AI editability, and version-control behavior where relevant. Verify findings, fix the appropriate conceptual, specification, architectural, implementation, or interaction layer, and recheck affected dependents. Reuse valid evidence; repeat checks for concrete risks, changes, findings, or required gates. Compilation, screenshots, and worker reports alone do not establish correctness or usability.
|
||||
- Favor the simplest maintainable design satisfying known requirements and justified extensibility. Avoid speculative abstraction, unrelated refactoring, and repeating approaches already invalidated by evidence.
|
||||
|
||||
## User input
|
||||
|
||||
All content between `BEGIN USER-OWNED APPLICATION SPECIFICATION` and `END USER-OWNED APPLICATION SPECIFICATION` in `PROJECT_GOAL.md` belongs to the user. Its headings and structure are unrestricted. Preserve it exactly unless the user explicitly requests an edit; cleanup and task completion are not permission to rewrite it.
|
||||
|
||||
Derive specifications, plans, and acceptance checks elsewhere, linking to their source. Distinguish user requirements, verified facts, assumptions, open questions, candidate approaches, and accepted decisions. Interpretations must not silently replace the user's intent. When input changes, reconcile affected work. Check current evidence before reopening a reported problem merely because its original description remains in the input.
|
||||
|
||||
## Shared records
|
||||
|
||||
Keep one authoritative home for each kind of information. Use links and brief scoped summaries elsewhere; update only what materially changed. Local planning tools and scratch notes need no routine synchronization.
|
||||
|
||||
| Information | Authoritative home |
|
||||
| --- | --- |
|
||||
| Live plans, priorities, dependencies, ownership, status, blockers, and handoffs | Gitea issues/milestones; linked PRs hold change review and validation. |
|
||||
| Specifications, architecture, interfaces, and build/run documentation | Established version-controlled project documents. |
|
||||
| Durable decisions and reusable evidence | `harness/DECISIONS.md` and relevant topic notes, or existing equivalents. |
|
||||
| Navigation and integrated-state checkpoint | `harness/STATE.md`: links, verified code commit/release, capabilities, and material limitations. No live task ledger. |
|
||||
| Shared entrypoint | Gitea Wiki: overview and links, without duplicating versioned documentation. |
|
||||
| Published deliverables | Gitea releases, tied to source commits and verification. |
|
||||
|
||||
Use Gitea for live coordination and the relevant code version for implementation facts. Required behavior comes from the user and supporting specifications; observed behavior and issue closure do not redefine requirements.
|
||||
|
||||
## 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, 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. 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.
|
||||
|
||||
Allocate by task difficulty and consequences, not role or length alone. Start higher when a clearly difficult decision has expensive downstream consequences. Escalate for concrete reasoning difficulties; resolve missing evidence or unclear requirements at their source. Set subagent effort explicitly through supported controls to avoid unintended inheritance. Do not claim an unapplied change or alter global configuration merely to enforce these preferences.
|
||||
|
||||
## Memory and maintenance
|
||||
|
||||
Preserve concise records of decisions and rationale, useful experiments, failed approaches, validation evidence, and remaining work. Link substantial evidence rather than copying logs into indexes. Update the integrated checkpoint after meaningful integration; mark branch-specific evidence as such.
|
||||
|
||||
Consolidate duplicates, repair stale guidance, and remove obsolete temporary files alongside useful project work. Before removing material, check references, active ownership, and ongoing use; preserve unique knowledge and update affected links. Prefer closing, superseding, or archiving history over deletion. User input is excluded from autonomous cleanup.
|
||||
|
||||
If legacy local task ledgers or plans duplicate Gitea, migrate their live content once into existing or appropriately scoped issues, preserve useful decisions/evidence, and mark the old records superseded. Record the migration so later sessions do not repeat it. Do not create empty documents or maintain records for ceremony.
|
||||
|
||||
## Delivery
|
||||
|
||||
Publish versioned Gitea releases at meaningful working milestones; mark unfinished versions as prereleases. Check existing releases/tags and coordinate one owner and version before publishing. Build from an identified integrated commit and verify the packaged result and launch steps.
|
||||
|
||||
Include runnable builds or appropriate artifacts, concise release notes, build/run/deployment instructions as applicable, known limitations, tested platforms, and verification evidence. Confirm publication and artifact availability. Use existing authorized API access or project tooling when MCP lacks a needed release/upload operation. Do not silently replace published versions with different contents.
|
||||
-111
@@ -1,111 +0,0 @@
|
||||
# Project goal
|
||||
|
||||
## Objective
|
||||
|
||||
Take the existing project to the complete, coherent, usable, and verified outcome defined by the user input below. Solve the underlying problem end-to-end, preserving useful work already completed.
|
||||
|
||||
Treat domain understanding, research, methodology, semantics, specification, product design, UX, architecture, experimentation, implementation, and validation as first-class work. Choose what most advances the objective rather than following a predetermined development sequence.
|
||||
|
||||
Follow `AGENTS.md` for execution, coordination, record ownership, and delivery. Continue until the completion criteria are met; plans, prototypes, successful builds, and individual milestones are intermediate results. Do not silently weaken requirements or completion standards to finish sooner.
|
||||
|
||||
## Completion criteria
|
||||
|
||||
Apply these criteria to the scope requested by the user:
|
||||
|
||||
- Required capabilities address the intended problem and support realistic user workflows.
|
||||
- Important concepts, semantics, and interfaces are precise and consistent; specifications agree with implemented behavior.
|
||||
- Consequential decisions have evidence and rationale. Major assumptions have been validated, explicitly accepted, or isolated without compromising required behavior.
|
||||
- The result builds, runs, and operates on the required platforms. Relevant tests and evaluators pass; integrations, edge cases, and failure states have been exercised against the actual result.
|
||||
- User-facing behavior and usability have been exercised directly, using representative data and workflows.
|
||||
- A fresh independent review covers requirements, domain/semantic consistency, usability, architecture, correctness, regressions, runtime behavior, documentation, and remaining risks. Valid material findings are addressed, blocking and high-impact issues resolved, and affected behavior reverified; no known blocker or high-severity defect remains.
|
||||
- Deliverables are maintainable, appropriately extensible, documented, and published as required by `AGENTS.md`. Obsolete scaffolding is removed; documentation and shared state accurately describe the result. Another engineer or agent can reproduce the verification and continue the work.
|
||||
|
||||
If required work or verification is blocked, record the specific gap and continue independent useful work without claiming completion.
|
||||
|
||||
## Final handoff
|
||||
|
||||
Link the delivered result and summarize capabilities, discoveries, architecture and decisions, relevant rejected approaches, verification and independent review, remaining limitations, and exact build/run/deployment/verification instructions. Refer to authoritative records instead of repeating the project history.
|
||||
|
||||
## USER-OWNED APPLICATION SPECIFICATION
|
||||
|
||||
The block below accepts all project-specific requirements, context, priorities, and feedback, with any headings or organization the user chooses.
|
||||
|
||||
<!-- BEGIN USER-OWNED APPLICATION SPECIFICATION -->
|
||||
## Application specification
|
||||
|
||||
### General description
|
||||
|
||||
Create a modern clone of ARCADIA Capella using Rust Iced framework. It should be cross platform (supporting Windows and Linux at least). Use Rust exclusively (wherever possible & practical).
|
||||
|
||||
Focus on making the application modern, extensible, intuitive and easy to use. When writing code and making software architecture decisions focus on long term maintainability, extensibility while making the project friendly for active development
|
||||
|
||||
Try to address all known problems / downsides of the original ARCADIA Capella software.
|
||||
|
||||
Version 1 MVP in the form of working Operational Analysis layer is done.
|
||||
Version 2 MVP in the form of working Operation Analysis + System Analysis is done.
|
||||
You should make the 1st release version of the project with fully feature complete Operation Analysis + System Architecture, according to this application specification.
|
||||
Remaining ARCADIA layers will be added in future releases.
|
||||
|
||||
Transitioning of model elements between layers (in both directions) should be easy and intuitive.
|
||||
|
||||
Keep in mind that the target audience for the application are system engineers. And you should satisfy their needs and use cases
|
||||
|
||||
The application should be a general systems modeling tool, like ARCADIA Capella or SysML v2
|
||||
|
||||
You should implement most of the ARCADIA semantics, while adressing original ARCADIA Capella problems / downsides
|
||||
|
||||
The original ARCADIA Capella UI is very bloated, clunky, unintuitive and hard to use. In addition, it lacks a number of quality of life features. You should at all cost avoid making the same mistakes as the developers of the original ARCADIA Capella
|
||||
|
||||
You should explore the functionality & UI of the original ARCADIA Capella to take the most useful and powerful ideas from it. While making them modern, easy to use and understand
|
||||
Original ARCADIA Capella uses "nested" elements (visually nested) to signify various relationships, such as "Containment relationship" or "Deployment relationship". Instead of using visually noisy arrows / graph edges. I pretty much like this approach and want to see something similar to it in this application
|
||||
|
||||
To explore the original ARCADIA Capella UI & semantics you may use web resources / web search / official and community docs. Or open the original ARCADIA Capella installed on this desktop machine for exploration
|
||||
|
||||
Your second, parallel, goal is to create a textual representation for ARCADIA, similar to SysML v2. So a user can either edit a system model using UI (graphical representation) or text. Both graphical and textual representations should be fully equivalent and have full parity. You may take inspiration from SysML v2. Both graphical and textual representations should have one overarching, concrete, unambiguous spec in reST format, similar to SysML v2 strictness
|
||||
The application should support both forms of editing
|
||||
The textual representation should be easily editable and understandble for both humans and AI
|
||||
The textual representation should be easily handled by version control tools such as Git
|
||||
The underlying semantics of the textual / graphical representation should support traceability of various development artifacts (for example - code, CAD files, electrical schematics, Docx files and anything in between)
|
||||
The textual representation editor should have syntax highlighting and linting
|
||||
|
||||
The project should also have a backend server / service that you can deploy. The backend server purpose is to give programmatic access to stored system models. So, there also should be a clear REST API for querying, editing and working with models. The purpose of the REST API is to allow automation, scripting and AI-driven workflows
|
||||
An example could be - writing custom plugins for various domain specific software (version control software, CAD software etc...) to establish traceability between development artifacts and a system model
|
||||
|
||||
The desktop application should contain built-in guide / help / tutorial with clear and concise info on modeling concepts, underlying semantics, common modeling scenarios and application features / how to use the app. It should be easy to navigate and explore
|
||||
|
||||
@Computer
|
||||
|
||||
### Semantic model / metamodel
|
||||
|
||||
Requirements:
|
||||
- Textual notation.
|
||||
- Graphical notation.
|
||||
- Constraints and calculations.
|
||||
- Quantities and units.
|
||||
- State and behavior modeling.
|
||||
- Reusable model libraries.
|
||||
- Strong, standardized specification.
|
||||
- Strong, standardized API.
|
||||
|
||||
#### Textual notation
|
||||
|
||||
Requirements:
|
||||
- Should not have visual noise and clutter.
|
||||
- Should have means to reduce cognitive load when developing system models.
|
||||
- Should be easily editable by humans. And AI agents.
|
||||
- System engineers should be able to easily develop system models (both big and small) using textual notation only.
|
||||
|
||||
### UI
|
||||
|
||||
The focus should be on reducing system engineer's cognitive load when creating / working with a system model. Regardless of the model size (big or small).
|
||||
|
||||
Long, homogeneous lists in the UI that require user to scroll far or search for a desired option for too long - is a bad design.
|
||||
|
||||
## Active issues / current guidance
|
||||
|
||||
Right now, the behavior & scenarios UI is hard to use. It contains static / uneditable diagrams, which is highly dubious. In addition, user workflow with the behavior & scenarios UI differ significantly from the other UIs - you can't edit diagrams and instead suppose to click on menus for model development
|
||||
|
||||
---
|
||||
|
||||
The current project state is nowhere near the usability of the original ARCADIA Capella (despite its downfalls) or its functionality / semantics. You should focus on that by studying original ARCADIA Capella very closely and implementing its features in a modern, ergonomic and easy to use way
|
||||
<!-- END USER-OWNED APPLICATION SPECIFICATION -->
|
||||
@@ -45,34 +45,26 @@ The example opens in an architecture view with work nested inside its performers
|
||||
|
||||
1. In **Operational Analysis**, create entities, actors, activities and capabilities. Use **In selected** for semantic decomposition and **Allocated** for new leaf work performed by a participant. Activities can decompose independently of their performer; roles capture shared operational responsibility.
|
||||
2. Switch to **System Analysis**. Define one System of Interest and external Actors, then functions, missions, capabilities and functional chains. The system boundary has no internal LA/PA components in this version.
|
||||
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Use **Boundary exchange** in the diagram palette, or **Draw boundary exchange** on a selected visible source port, to connect compatible component ports directly. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions; the **Functional exchange** palette tool connects them. **Actions** exposes other engineering relationships and off-diagram configuration; **Data & interfaces** holds reusable payloads and contracts.
|
||||
3. Select a System or System Actor and choose **Add port** in the browser or Properties. Choose Standard or Flow, then **Create port**. Select the port and choose **Connect boundary exchange**; the picker identifies compatible ports by owner, name and alias. Create the other participant's matching port first if none exists. Functions expose separate input/output port actions. Use **Create relationship** for allocations, functional exchanges, capability involvement, mission links, state transitions and interface relationships; **Data & interfaces** holds reusable payloads and contracts.
|
||||
4. Create focused views. **Containment** nests semantic children; **Architecture · allocated work** nests work within performers; **Functional dataflow** shows work decomposition and exchanges; **Missions & capabilities** shows objectives and participation. Dashed, italic parent work denotes an allocation summary inferred from its leaves. Ports appear on their owner's boundary. Placement dragging changes geometry. **Hide** removes a placement; **Delete from model** removes semantics and dependent references. Use **Placement** and **View properties** for exact values.
|
||||
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. Select a State machine and choose **Create diagram** to place its existing regions and states/modes in a saved view. State machines also support availability and state-action links. Conditions are descriptive, not executable simulation code. Behavior and Data authoring preserve your other diagram layouts.
|
||||
5. In **Behavior & scenarios**, author processes/chains, scenario lifelines and ordered occurrences, including repeated uses of the same exchange and explicit fragments/branches. State machines contain regions and states/modes with guarded transitions, availability and state-action links. Conditions are descriptive, not executable simulation code. Creating records in Behavior or Data preserves your architecture layouts; **Show in this diagram** adds an explicit placement when desired.
|
||||
6. Select an OA or SA element and choose **Create / update counterpart**. Review dependency scope and counterpart choices, then apply one undoable transaction. Existing counterparts retain their structural choices; reviewed updates compare names/descriptions. Manual traces link other engineering correspondences. **Traceability** also edits artifact URI, revision and fragment metadata.
|
||||
7. Review **Model checks**, then save `.arcadia` source or `.json`. Warnings allow incomplete authoring; structural errors preserve the committed model and rejected draft.
|
||||
|
||||
**Model explorer** groups roots by engineering purpose and opens one container's direct children. Breadcrumbs navigate ownership; **Allocated work** is listed separately. Search names, kinds or `@aliases` to find elements with ancestor context. Results are paged in groups of 100. **Collapse all** folds the outline; **Locate** clears the filter and reveals the selected element's ancestry.
|
||||
|
||||
**Data & interfaces** separates exchange nature from mechanism, supports bounded reusable types and typed defaults, and gives each interface item allocation its own protocol and artifact identity. Operation parameters have provider-side directions; qualified message paths distinguish repeated Record uses. Record/item/interface specialization preserves inherited identities. See the [data contract](docs/language.rst) for exact semantics and compatibility.
|
||||
|
||||
The inspector exposes typed properties, references, ordered lists, aliases and UUID identity. Applying or navigating away attempts to commit the draft; invalid values remain editable. **Escaped strings (advanced)** uses quoted JSON escapes for exact tabs, line breaks and carriage returns, for example `"First\nSecond"`. UUID changes atomically update dependent references. Accepted model/source history retains up to 100 edits within an estimated 32 MiB history budget.
|
||||
|
||||
Choose **Interaction**, **Functional exchange** or **Boundary exchange** in the diagram palette, then click the source and a highlighted compatible target, or drag between them. Functional exchanges use output/input ports; function cards create or reuse real ports atomically. Boundary exchanges use compatible component ports. Middle-button drag pans without losing the chosen source; arrow keys also pan while drawing with the canvas focused. Click empty space to restart, or Escape to cancel. Each completed connection is one model/source history operation; edit its name and carried items in Properties.
|
||||
Drag cards to move on a 10-unit grid, drag empty space to pan, and scroll to zoom. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
|
||||
|
||||
In **Select / move**, drag cards on a 10-unit grid, drag empty space to pan, and scroll to zoom. Drag ports along their owner's border or onto any of its four sides. Automatic sides account for direction and visible connections. Manual positions are per diagram and remain relative to the owner through movement or resize; right-click **Reset automatic position** restores automatic placement. **Arrange** changes the current view layout; **Fit** frames its projected bounds. SVG exports the current architecture diagram as a view, not an editable model file.
|
||||
|
||||
Connections start with orthogonal routes that avoid cards and container headers. In Select mode, select a connection and drag a segment to add a bend, or right-click for **Add bend here**. Drag its circles to adjust the route; the menu offers **Remove bend** and **Reset routing**. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Shift+F10 opens the selected item's menu; **Actions** in Properties provides the same commands. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment; automatic routing is bounded and does not guarantee an overlap-free result.
|
||||
|
||||
For state diagrams, use **+ Region** inside a highlighted machine or State/Mode, then choose **+ State / symbol** and click its owning region. **Transition** supports dragging between compatible vertices or clicking the source and then the target. Select a drawn transition and drag its endpoint handle, or right-click **Reconnect source/target**. Right-click a state or the background for **New transition with details…** to create Local, Internal or timed transitions; an existing transition's **Edit transition** form edits its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local.
|
||||
|
||||
In the state view, **Select / move** drags vertices and containers without reparenting; container movement includes its descendants. Middle-button drag pans, scroll or **−/+** zooms, and **Fit** frames the view. Right-click for **Fold contents**, **Unfold contents**, **Placement properties**, or **Hide from this diagram**. The **Show an element not yet in this view…** selector restores missing placements. Escape cancels a gesture; **Undo** restores a completed edit. **+ View** creates an independent layout. The persisted diagram's machine reference and placements are the same records in source, JSON and REST; opening the page alone creates no diagram.
|
||||
Connections start with orthogonal routes that avoid cards and container headers. Select a connection and drag a line segment to add a bend, or choose **Add bend**. Drag its circles to adjust the route; **Remove bend** removes the selected point, and **Reset routing** restores automatic routing. Drag the connection label independently. Each completed gesture is one undoable edit; Escape cancels a drag. Routes belong to the current diagram and persist in source, JSON, REST and SVG export. Typed ports remain attached to their owning elements. Folding an endpoint container temporarily uses an automatic route; expanding it restores your authored route. Dense diagrams can still need manual adjustment—automatic routing is bounded and does not guarantee an overlap-free result.
|
||||
|
||||
## Edit the same model as text
|
||||
|
||||
**Textual model** has a source-file folder tree, per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Collapse/Locate navigate the hierarchy; collapsed folders retain draft and diagnostic indicators. Right-click a file for contextual actions. Projects can use relative includes to organize source into files. For example:
|
||||
**Textual model** has per-file drafts, syntax highlighting, live linting and explicit **Apply all**. Projects can use relative includes to organize source into files. For example:
|
||||
|
||||
```text
|
||||
arcadia 4;
|
||||
arcadia 3;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity respond "Respond to requests" {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+11
-24
@@ -34,7 +34,10 @@ fn occurrence(
|
||||
id
|
||||
}
|
||||
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
|
||||
let items = crate::data::interface_items(p, interface);
|
||||
let items = match p.elements[&interface].shared() {
|
||||
Some(SharedBody::Interface { items }) => items.clone(),
|
||||
_ => vec![],
|
||||
};
|
||||
let contract = if p.elements[&owner].kind() == Kind::Function {
|
||||
if direction == PortDirection::Out {
|
||||
PortContract::FunctionOutput {
|
||||
@@ -165,11 +168,6 @@ pub fn coastal_rescue() -> Project {
|
||||
"Distress report",
|
||||
None,
|
||||
);
|
||||
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
|
||||
p.elements.get_mut(&oa_item).unwrap().shared_mut()
|
||||
{
|
||||
*mechanism = ExchangeMechanism::Event;
|
||||
}
|
||||
p.relate(RelationKind::Allocation, "Reports distress", vessel, report);
|
||||
p.relate(
|
||||
RelationKind::Allocation,
|
||||
@@ -310,11 +308,6 @@ pub fn coastal_rescue() -> Project {
|
||||
"Digital distress report",
|
||||
None,
|
||||
);
|
||||
if let Some(SharedBody::ExchangeItem { mechanism, .. }) =
|
||||
p.elements.get_mut(&information).unwrap().shared_mut()
|
||||
{
|
||||
*mechanism = ExchangeMechanism::Event;
|
||||
}
|
||||
let material = add(
|
||||
&mut p,
|
||||
Kind::ExchangeItem,
|
||||
@@ -324,8 +317,6 @@ pub fn coastal_rescue() -> Project {
|
||||
);
|
||||
*p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
};
|
||||
let energy = add(
|
||||
&mut p,
|
||||
@@ -336,8 +327,6 @@ pub fn coastal_rescue() -> Project {
|
||||
);
|
||||
*p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
};
|
||||
let field = add(
|
||||
&mut p,
|
||||
@@ -351,9 +340,6 @@ pub fn coastal_rescue() -> Project {
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: "degrees north".into(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
let interface = add(
|
||||
&mut p,
|
||||
@@ -362,12 +348,13 @@ pub fn coastal_rescue() -> Project {
|
||||
"Distress reporting interface",
|
||||
None,
|
||||
);
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Distress payload",
|
||||
interface,
|
||||
information,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface {
|
||||
items: vec![information],
|
||||
};
|
||||
let vessel_port = port(
|
||||
&mut p,
|
||||
actor,
|
||||
|
||||
+15
-103
@@ -1,4 +1,4 @@
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2/V3 migration.
|
||||
//! Typed OA/SA language, tolerant source analysis, and explicit V1/V2 migration.
|
||||
#[path = "migration_v2/source.rs"]
|
||||
mod v2_source;
|
||||
use crate::*;
|
||||
@@ -66,14 +66,7 @@ impl ParseLimits {
|
||||
project
|
||||
.diagrams
|
||||
.values()
|
||||
.map(|d| {
|
||||
d.nodes.len().saturating_add(d.routes.len()).saturating_add(
|
||||
d.nodes
|
||||
.values()
|
||||
.map(|n| n.port_anchors.len())
|
||||
.sum::<usize>(),
|
||||
)
|
||||
})
|
||||
.map(|d| d.nodes.len().saturating_add(d.routes.len()))
|
||||
.sum::<usize>(),
|
||||
)
|
||||
.saturating_add(project.artifact_links.len())
|
||||
@@ -340,8 +333,6 @@ fn reference_field(key: &str) -> bool {
|
||||
| "referenced"
|
||||
| "local"
|
||||
| "field"
|
||||
| "path"
|
||||
| "supertype"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
| "action"
|
||||
@@ -358,7 +349,6 @@ fn reference_field(key: &str) -> bool {
|
||||
| "sa"
|
||||
| "diagram"
|
||||
| "edge"
|
||||
| "port"
|
||||
| "machine"
|
||||
)
|
||||
}
|
||||
@@ -379,15 +369,10 @@ fn enum_field(key: &str) -> bool {
|
||||
| "viewpoint"
|
||||
| "message_kind"
|
||||
| "transition_kind"
|
||||
| "side"
|
||||
| "mechanism"
|
||||
| "storage"
|
||||
| "transmission"
|
||||
| "acquisition"
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse V4, or explicitly migrate a valid V1/V2/V3 source candidate. Parsing never
|
||||
/// Parse V3, or explicitly migrate a valid V1/V2 source candidate. Parsing never
|
||||
/// adopts a model and never overwrites an editor draft.
|
||||
pub fn parse(source: &str) -> Result<Project, Vec<Diagnostic>> {
|
||||
parse_limited(source, ParseLimits::UNLIMITED)
|
||||
@@ -552,7 +537,7 @@ fn offset_for(source: &str, line: usize, column: usize) -> usize {
|
||||
/// nested records are ordinary language fields; JSON documents are not embedded.
|
||||
pub fn format(project: &Project) -> String {
|
||||
let mut out = std::format!(
|
||||
"arcadia 4;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
"arcadia 3;\nproject {} {} id \"{}\" {{\n description {};\n\n",
|
||||
project.alias,
|
||||
quote(&project.name),
|
||||
project.id,
|
||||
@@ -1343,12 +1328,12 @@ impl Parser<'_> {
|
||||
.map(|(key, span)| ((d.id, key.clone()), span.start)),
|
||||
);
|
||||
}
|
||||
let decoded =
|
||||
let project =
|
||||
resolve_declarations(header.project, &unit.declarations, &aliases, self.limits)
|
||||
.map_err(|(_, error)| error)?;
|
||||
Ok(ParsedDocument {
|
||||
project: decoded.project,
|
||||
migration: decoded.migration,
|
||||
project,
|
||||
migration: None,
|
||||
locations,
|
||||
fields,
|
||||
})
|
||||
@@ -1356,11 +1341,9 @@ impl Parser<'_> {
|
||||
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
|
||||
let mut header = if entry {
|
||||
self.take("arcadia")?;
|
||||
if !matches!(self.text(), "3" | "4") {
|
||||
return Err(self
|
||||
.error("Unsupported schema version: expected 'arcadia 3;' or 'arcadia 4;'."));
|
||||
if self.text() != "3" {
|
||||
return Err(self.error("Unsupported schema version: expected 'arcadia 3;'."));
|
||||
}
|
||||
let schema_version = if self.text() == "3" { 3 } else { 4 };
|
||||
self.cursor += 1;
|
||||
self.take(";")?;
|
||||
let start = self.current().span.start;
|
||||
@@ -1373,7 +1356,7 @@ impl Parser<'_> {
|
||||
self.take("{")?;
|
||||
Some(SourceHeader {
|
||||
project: Project {
|
||||
schema_version,
|
||||
schema_version: SCHEMA_VERSION,
|
||||
id,
|
||||
alias,
|
||||
name,
|
||||
@@ -1548,9 +1531,7 @@ pub(crate) fn resolve_declarations(
|
||||
declarations: &[Declaration],
|
||||
aliases: &BTreeMap<String, Id>,
|
||||
limits: ParseLimits,
|
||||
) -> Result<crate::persistence::DecodeResult, (usize, Error)> {
|
||||
let legacy = project.schema_version == 3;
|
||||
let mut legacy_elements = serde_json::Map::new();
|
||||
) -> Result<Project, (usize, Error)> {
|
||||
let mut nodes = Vec::new();
|
||||
for (index, declaration) in declarations.iter().enumerate() {
|
||||
(|| -> Result<(), Error> {
|
||||
@@ -1571,9 +1552,6 @@ pub(crate) fn resolve_declarations(
|
||||
};
|
||||
match d.kind {
|
||||
DeclKind::Element(kind, layer) => {
|
||||
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Element constructor was not part of schema3.".into()));
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let explicit_parent = d.fields.contains_key("parent");
|
||||
@@ -1614,17 +1592,9 @@ pub(crate) fn resolve_declarations(
|
||||
*viewpoint = ScenarioViewpoint::MixedLegacy;
|
||||
}
|
||||
let mut body = serde_json::to_value(initial).unwrap();
|
||||
let defaults = if legacy {
|
||||
crate::persistence::v3::legacy_defaults(kind)
|
||||
.and_then(|value| value.as_object().cloned())
|
||||
.unwrap_or_else(|| payload(&body))
|
||||
} else { payload(&body) };
|
||||
let body_fields = merge_fields(defaults, fields).map_err(&conversion_error)?;
|
||||
let body_fields =
|
||||
merge_fields(payload(&body), fields).map_err(&conversion_error)?;
|
||||
replace_payload(&mut body, body_fields);
|
||||
if legacy {
|
||||
legacy_elements.insert(d.id.to_string(), serde_json::json!({"id":d.id,"alias":d.alias,"name":d.name,"description":description,"parent":parent,"body":body}));
|
||||
return Ok(());
|
||||
}
|
||||
let body: ElementBody = serde_json::from_value(body)
|
||||
.map_err(|e| conversion_error(e.to_string()))?;
|
||||
if body.kind() != kind || body.layer() != layer {
|
||||
@@ -1645,9 +1615,6 @@ pub(crate) fn resolve_declarations(
|
||||
);
|
||||
}
|
||||
DeclKind::Relation(kind) => {
|
||||
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
|
||||
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
|
||||
}
|
||||
let description =
|
||||
take_string(&mut fields, "description", "").map_err(&conversion_error)?;
|
||||
let required: &[&str] = if kind == RelationKind::FunctionalExchange {
|
||||
@@ -1697,7 +1664,7 @@ pub(crate) fn resolve_declarations(
|
||||
if !fields.contains_key("element") {
|
||||
return Err(conversion_error("Missing required field 'element'.".into()));
|
||||
}
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false,"port_anchors":[]});
|
||||
let defaults = serde_json::json!({"element":null,"position":{"x":0.0,"y":0.0},"size":{"width":224.0,"height":94.0},"collapsed":false});
|
||||
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
|
||||
.map_err(&conversion_error)?;
|
||||
header(&mut record, d, false);
|
||||
@@ -1749,58 +1716,6 @@ pub(crate) fn resolve_declarations(
|
||||
.map_err(|error| (index, error))?;
|
||||
target.nodes.insert(node.id, node);
|
||||
}
|
||||
if legacy {
|
||||
let mut value = serde_json::to_value(&project).expect("project serialization");
|
||||
value["elements"] = Value::Object(legacy_elements);
|
||||
let migrated = serde_json::from_value(value)
|
||||
.map_err(crate::persistence::FileError::Json)
|
||||
.and_then(|old| crate::persistence::v3::migrate(old, limits))
|
||||
.map_err(|error| {
|
||||
let issue = if let crate::persistence::FileError::Model { issues, .. } = &error {
|
||||
issues.first()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let index = issue
|
||||
.and_then(|issue| issue.element)
|
||||
.and_then(|id| declarations.iter().position(|d| d.id == id))
|
||||
.unwrap_or(usize::MAX);
|
||||
let offset = declarations.get(index).map_or(0, |d| {
|
||||
issue
|
||||
.and_then(|issue| issue.field.as_ref())
|
||||
.and_then(|field| d.field_spans.get(field))
|
||||
.map_or(d.offset, |span| span.start)
|
||||
});
|
||||
(
|
||||
index,
|
||||
Error {
|
||||
code: if matches!(error, crate::persistence::FileError::Limit(_)) {
|
||||
"model_limit"
|
||||
} else {
|
||||
"migration_error"
|
||||
},
|
||||
offset,
|
||||
message: error.to_string(),
|
||||
},
|
||||
)
|
||||
})?;
|
||||
for (index, d) in declarations.iter().enumerate() {
|
||||
if let DeclKind::Element(kind, layer) = d.kind {
|
||||
let body = &migrated.project.elements[&d.id].body;
|
||||
if body.kind() != kind || body.layer() != layer {
|
||||
return Err((
|
||||
index,
|
||||
Error {
|
||||
code: "invalid_source",
|
||||
offset: d.offset,
|
||||
message: "Declaration kind disagrees with its typed body.".into(),
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Ok(migrated);
|
||||
}
|
||||
limits.check_model(&project).map_err(|message| {
|
||||
(
|
||||
usize::MAX,
|
||||
@@ -1811,10 +1726,7 @@ pub(crate) fn resolve_declarations(
|
||||
},
|
||||
)
|
||||
})?;
|
||||
Ok(crate::persistence::DecodeResult {
|
||||
project,
|
||||
migration: None,
|
||||
})
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
fn header(fields: &mut Map<String, Value>, d: &Declaration, named: bool) {
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::source_project::{ProjectLimits, SourceBundle, compile_bundle, lint_bu
|
||||
|
||||
fn project_source(body: &str) -> String {
|
||||
format!(
|
||||
"arcadia 4; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
"arcadia 3; project model \"Model\" id \"00000000-0000-0000-0000-000000000001\" {{\n{body}\n}}"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ fn nested_fragment_id_stabilization_preserves_comments_and_indexes_real_bytes()
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 4; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
"arcadia 3; project model \"Model\" { include \"children.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"children.arcadia".into(),
|
||||
@@ -300,7 +300,7 @@ fn deep_declaration_parsing_and_positions_use_a_bounded_rust_stack() {
|
||||
|
||||
#[test]
|
||||
fn tolerant_nested_analysis_retains_closed_children_before_an_unfinished_sibling() {
|
||||
let source = "arcadia 4; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let source = "arcadia 3; project model \"航海\" {\noa_activity outer_one \"Outer\" {\n oa_activity ready \"Ready\" { description \"} is text\"; }\n oa_activity unfinished \"Unfinished\" { description \"unterminated";
|
||||
let analysis = analyze(source);
|
||||
let ready = analysis
|
||||
.declarations
|
||||
@@ -406,34 +406,12 @@ fn checked_in_source_project() -> SourceBundle {
|
||||
fn checked_in_nested_example_has_stable_ids_typed_exchanges_and_three_clear_views() {
|
||||
let input = checked_in_source_project();
|
||||
let compiled = compile_bundle(&input, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.migration.as_ref().unwrap().from_schema, 3);
|
||||
for (path, source) in &input.files {
|
||||
let unit = parse_unit(source, path == &input.entry, ParseLimits::UNLIMITED).unwrap();
|
||||
assert!(
|
||||
unit.header
|
||||
.as_ref()
|
||||
.is_none_or(|header| header.id_insertion.is_none())
|
||||
);
|
||||
for declaration in unit.declarations {
|
||||
assert!(
|
||||
declaration.id_insertion.is_none(),
|
||||
"every example identity is already explicit"
|
||||
);
|
||||
assert_eq!(
|
||||
identity(&compiled.decoded.project, &declaration.alias),
|
||||
declaration.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
assert!(reopened.decoded.migration.is_none());
|
||||
assert_eq!(compiled.diagnostics.len(), 2);
|
||||
assert_eq!(
|
||||
compiled.bundle, input,
|
||||
"every example identity is already explicit"
|
||||
);
|
||||
assert!(
|
||||
compiled
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
|
||||
compiled.diagnostics.is_empty(),
|
||||
"{:?}",
|
||||
compiled.diagnostics
|
||||
);
|
||||
@@ -495,15 +473,7 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.diagnostics.len(), 2);
|
||||
assert!(
|
||||
result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.all(|issue| issue.diagnostic.code == "unspecified_exchange_mechanism"),
|
||||
"{:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
|
||||
assert_eq!(
|
||||
result.index.declarations[&assess].path,
|
||||
"sa/assurance.arcadia"
|
||||
@@ -530,6 +500,6 @@ fn checked_in_example_supports_cross_file_reparent_without_changing_allocations(
|
||||
"sa/participants.arcadia",
|
||||
"views.arcadia",
|
||||
] {
|
||||
assert_eq!(result.bundle.files[path], compiled.bundle.files[path]);
|
||||
assert_eq!(result.bundle.files[path], input.files[path]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
//! Framework-independent OA/SA model, transactional commands and editing services.
|
||||
pub mod data;
|
||||
pub mod language;
|
||||
pub mod model;
|
||||
pub mod persistence;
|
||||
|
||||
@@ -11,12 +11,10 @@ but constructs and validates the frozen DTO, preserves located old-contract
|
||||
errors, then calls `adapter.rs`. Original staged scenario/state defaults and
|
||||
generic port/optional FE endpoint contracts therefore remain explicit.
|
||||
|
||||
`adapter.rs` budgets generated data, validates V2 and transforms only the changed
|
||||
port/endpoint payloads into an explicit schema 3 candidate. The schema 3 reader
|
||||
then performs the data-contract upgrade and validates schema 4. Serialization
|
||||
between compatibility DTOs copies unchanged fields; original input bytes are
|
||||
never deserialized into the new `Project`. V1 normalization also constructs this
|
||||
V2 DTO before the shared stages. Combined generation stays within one budget.
|
||||
`adapter.rs` budgets generated data, validates V2, transforms only the changed
|
||||
payloads, and validates schema 3. Serialization between the two typed DTOs copies
|
||||
unchanged fields; original input bytes are never deserialized into the new
|
||||
`Project`. V1 normalization also constructs this V2 DTO before the shared stage.
|
||||
|
||||
Do not update these frozen shapes when extending the current model. Add another
|
||||
versioned migration when the persistent contract changes again. Golden V2
|
||||
|
||||
@@ -62,9 +62,16 @@ pub(crate) fn migrate(
|
||||
}],
|
||||
});
|
||||
}
|
||||
let mut report = MigrationReport::data_upgrade(2);
|
||||
let mut report = MigrationReport {
|
||||
from_schema: 2,
|
||||
to_schema: 3,
|
||||
preserved_ports: vec![],
|
||||
generated_endpoints: vec![],
|
||||
orientation_defaults: vec![],
|
||||
required_resolutions: vec![],
|
||||
};
|
||||
let mut value = serde_json::to_value(&old)?;
|
||||
value["schema_version"] = Value::from(3);
|
||||
value["schema_version"] = Value::from(SCHEMA_VERSION);
|
||||
for element in old.elements.values() {
|
||||
if let Some(super::model::SharedBody::Port {
|
||||
direction,
|
||||
@@ -197,23 +204,11 @@ pub(crate) fn migrate(
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// Carry the endpoint-generation reference charge into the next migration;
|
||||
// independent per-stage checks would undercount the combined generated graph.
|
||||
let data_limits = ParseLimits {
|
||||
max_step_references: limits
|
||||
.max_step_references
|
||||
.saturating_sub(additional_references),
|
||||
..limits
|
||||
};
|
||||
let migrated = super::super::v3::migrate(serde_json::from_value(value)?, data_limits)?;
|
||||
let data = migrated
|
||||
.migration
|
||||
.expect("the schema3 adapter reports its defaults");
|
||||
report.semantic_defaults = data.semantic_defaults;
|
||||
report.generated_interface_allocations = data.generated_interface_allocations;
|
||||
report.unresolved_message_values = data.unresolved_message_values;
|
||||
let project: Project = serde_json::from_value(value)?;
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
super::super::validate_project(&project)?;
|
||||
Ok(DecodeResult {
|
||||
project: migrated.project,
|
||||
project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Schema 3 to 4 reader
|
||||
|
||||
Schema 3 is a real compatibility input. Its shapes were frozen at `5ce3eef`.
|
||||
The small DTOs in `mod.rs` own the changed data payloads and old message values;
|
||||
closed element/relation constructor lists prevent new vocabulary from entering
|
||||
the old reader. Unchanged ports, diagram placements/routes, artifacts and traces
|
||||
reuse their typed records. The intermediate JSON bridge rejects duplicate keys.
|
||||
|
||||
Nature, bounded types, field defaults/direction/storage, and single supertypes
|
||||
receive explicit documented defaults. Interface lists become identified ordered
|
||||
allocations with UUID v5 namespace equal to the project UUID and name
|
||||
`v2-interface-item:<interface-uuid>:<item-uuid>`. The name remains the same for
|
||||
V2 and V3 inputs so the migration route cannot change the generated identity.
|
||||
Existing IDs never move; deterministic collisions fail with a located diagnostic.
|
||||
Aliases use the normal deterministic collision suffix. Unique legacy message
|
||||
paths are qualified; ambiguous annotations retain their expression and warning.
|
||||
|
||||
V1 first normalizes to the frozen V2 DTO. V2 port/endpoint normalization emits
|
||||
an explicit schema3 candidate and then runs this reader before current decoding.
|
||||
Generated object, record and reference budgets cover the combined stages.
|
||||
|
||||
`language::resolve_declarations` shares lexical/AST parsing, including nesting,
|
||||
but chooses frozen source3 payload defaults and rejects new constructors. Both
|
||||
single-file parsing and `source_project::compile_bundle` take this explicit path.
|
||||
The bundle compiler first stabilizes omitted IDs, then performs targeted token
|
||||
edits in `source_project/source_migration.rs`. Original files, includes, lexical
|
||||
ownership and comments survive. New allocation declarations stay in the source
|
||||
interface's module. The returned source and index describe schema4 bytes; loading
|
||||
does not rewrite original disk files. Canonical output and second-save stability
|
||||
are schema4. No whole-project formatting fallback handles authored source3.
|
||||
|
||||
Frozen source/JSON fixtures live under `tests/fixtures/v3-data-contract.*`; the
|
||||
original V2 golden files remain unchanged.
|
||||
@@ -1,630 +0,0 @@
|
||||
//! Explicit schema-3 compatibility. Only changed payloads are frozen here;
|
||||
//! unchanged typed records are shared after a closed old-constructor check.
|
||||
use crate::language::ParseLimits;
|
||||
use crate::persistence::{DecodeResult, FileError, MigrationReport};
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Project3 {
|
||||
pub schema_version: u32,
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub elements: BTreeMap<Id, Element3>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub relations: BTreeMap<Id, Relation3>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub diagrams: BTreeMap<Id, Diagram>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub artifact_links: BTreeMap<Id, ArtifactLink>,
|
||||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||||
pub traces: BTreeMap<Id, Trace>,
|
||||
}
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Element3 {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub parent: Option<Id>,
|
||||
#[serde(deserialize_with = "strict_value")]
|
||||
pub body: Value,
|
||||
}
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(crate) struct Relation3 {
|
||||
pub id: Id,
|
||||
pub alias: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
#[serde(deserialize_with = "strict_value")]
|
||||
pub body: Value,
|
||||
}
|
||||
|
||||
// A Value bridge must retain the strict duplicate-field behavior of typed DTOs.
|
||||
fn strict_value<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Value, D::Error> {
|
||||
struct Strict;
|
||||
impl<'de> serde::de::Visitor<'de> for Strict {
|
||||
type Value = Value;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("a value with unique object fields")
|
||||
}
|
||||
fn visit_bool<E: serde::de::Error>(self, v: bool) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_f64<E: serde::de::Error>(self, v: f64) -> Result<Value, E> {
|
||||
serde_json::Number::from_f64(v)
|
||||
.map(Value::Number)
|
||||
.ok_or_else(|| E::custom("Non-finite number"))
|
||||
}
|
||||
fn visit_str<E: serde::de::Error>(self, v: &str) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_string<E: serde::de::Error>(self, v: String) -> Result<Value, E> {
|
||||
Ok(v.into())
|
||||
}
|
||||
fn visit_unit<E: serde::de::Error>(self) -> Result<Value, E> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
fn visit_none<E: serde::de::Error>(self) -> Result<Value, E> {
|
||||
Ok(Value::Null)
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
|
||||
let mut out = Vec::new();
|
||||
while let Some(v) = a.next_element::<StrictValue>()? {
|
||||
out.push(v.0);
|
||||
}
|
||||
Ok(Value::Array(out))
|
||||
}
|
||||
fn visit_map<A: serde::de::MapAccess<'de>>(self, mut a: A) -> Result<Value, A::Error> {
|
||||
let mut out = serde_json::Map::new();
|
||||
while let Some((k, v)) = a.next_entry::<String, StrictValue>()? {
|
||||
if out.insert(k.clone(), v.0).is_some() {
|
||||
return Err(serde::de::Error::custom(format!("Duplicate field '{k}'")));
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(out))
|
||||
}
|
||||
}
|
||||
d.deserialize_any(Strict)
|
||||
}
|
||||
struct StrictValue(Value);
|
||||
impl<'de> Deserialize<'de> for StrictValue {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||
strict_value(d).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
enum ItemKind3 {
|
||||
Information,
|
||||
Material,
|
||||
Energy,
|
||||
Event,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
enum Definition3 {
|
||||
Boolean,
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration { literals: Vec<String> },
|
||||
Record,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Item3 {
|
||||
kind: ItemKind3,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Type3 {
|
||||
definition: Definition3,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Field3 {
|
||||
type_ref: TypeRef,
|
||||
minimum: u32,
|
||||
maximum: Option<u32>,
|
||||
unit: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Interface3 {
|
||||
items: Vec<Id>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct MessageValue3 {
|
||||
field: Id,
|
||||
expression: String,
|
||||
}
|
||||
|
||||
pub(crate) fn relation_allowed(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Allocation"
|
||||
| "PortAllocation"
|
||||
| "Interaction"
|
||||
| "CapabilityInvolvement"
|
||||
| "ChainInvolvement"
|
||||
| "RoleRealization"
|
||||
| "RoleAssignment"
|
||||
| "FunctionalExchange"
|
||||
| "BoundaryExchange"
|
||||
| "MissionCapability"
|
||||
| "CapabilityInclude"
|
||||
| "CapabilityExtend"
|
||||
| "CapabilityGeneralization"
|
||||
| "MissionInvolvement"
|
||||
| "SequenceLink"
|
||||
| "StateTransition"
|
||||
| "Availability"
|
||||
| "ExchangeAllocation"
|
||||
| "InterfaceProvision"
|
||||
| "InterfaceRequirement"
|
||||
| "StateAction"
|
||||
)
|
||||
}
|
||||
pub(crate) fn element_allowed(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"Entity"
|
||||
| "Actor"
|
||||
| "Role"
|
||||
| "Activity"
|
||||
| "Capability"
|
||||
| "OperationalMission"
|
||||
| "Process"
|
||||
| "System"
|
||||
| "SystemActor"
|
||||
| "Function"
|
||||
| "SystemCapability"
|
||||
| "Mission"
|
||||
| "FunctionalChain"
|
||||
| "ExchangeItem"
|
||||
| "DataType"
|
||||
| "DataField"
|
||||
| "Interface"
|
||||
| "Port"
|
||||
| "Scenario"
|
||||
| "Lifeline"
|
||||
| "Occurrence"
|
||||
| "StateMachine"
|
||||
| "Region"
|
||||
| "State"
|
||||
| "Mode"
|
||||
| "Initial"
|
||||
| "Final"
|
||||
| "Choice"
|
||||
| "Junction"
|
||||
| "Fork"
|
||||
| "Join"
|
||||
| "ShallowHistory"
|
||||
| "DeepHistory"
|
||||
| "EntryPoint"
|
||||
| "ExitPoint"
|
||||
| "Terminate"
|
||||
| "Event"
|
||||
| "Constraint"
|
||||
)
|
||||
}
|
||||
pub(crate) fn legacy_defaults(kind: Kind) -> Option<Value> {
|
||||
Some(match kind {
|
||||
Kind::ExchangeItem => json!({"kind":"Information"}),
|
||||
Kind::DataType => json!({"definition":"String"}),
|
||||
Kind::DataField => json!({"type_ref":"String","minimum":1,"maximum":1,"unit":""}),
|
||||
Kind::Interface => json!({"items":[]}),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
fn at(id: Id, field: &str, code: &str, message: impl Into<String>) -> FileError {
|
||||
let message = message.into();
|
||||
FileError::Model {
|
||||
message: format!("V3 {code} at {id}.{field}: {message}"),
|
||||
issues: vec![validation::Issue {
|
||||
severity: validation::Severity::Error,
|
||||
element: Some(id),
|
||||
code: code.into(),
|
||||
field: Some(field.into()),
|
||||
message,
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn singleton(value: &Value) -> Option<(&str, &Value)> {
|
||||
let object = value.as_object()?;
|
||||
(object.len() == 1)
|
||||
.then(|| object.iter().next().unwrap())
|
||||
.map(|(k, v)| (k.as_str(), v))
|
||||
}
|
||||
fn old_payload<T: serde::de::DeserializeOwned>(
|
||||
id: Id,
|
||||
field: &str,
|
||||
value: Value,
|
||||
) -> Result<T, FileError> {
|
||||
serde_json::from_value(value).map_err(|error| at(id, field, "legacy_shape", error.to_string()))
|
||||
}
|
||||
|
||||
pub(crate) fn migrate(old: Project3, limits: ParseLimits) -> Result<DecodeResult, FileError> {
|
||||
if old.schema_version != 3 {
|
||||
return Err(FileError::Invalid("Expected schema_version 3.".into()));
|
||||
}
|
||||
let graph = old.elements.len().saturating_add(old.relations.len());
|
||||
let records = graph
|
||||
.saturating_add(old.diagrams.len())
|
||||
.saturating_add(old.diagrams.values().map(|d| d.nodes.len()).sum::<usize>())
|
||||
.saturating_add(old.traces.len())
|
||||
.saturating_add(old.artifact_links.len())
|
||||
.saturating_add(1);
|
||||
limits
|
||||
.check_counts(graph, records, 0)
|
||||
.map_err(FileError::Limit)?;
|
||||
let mut report = MigrationReport::data_upgrade(3);
|
||||
let mut value = serde_json::to_value(&old)?;
|
||||
let mut allocations = Vec::new();
|
||||
for (key, e) in &old.elements {
|
||||
if *key != e.id {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"id",
|
||||
"identity",
|
||||
"Element map key differs from its identity.",
|
||||
));
|
||||
}
|
||||
let mut body = e.body.clone();
|
||||
let (layer, inner) = singleton(&body)
|
||||
.filter(|(name, _)| matches!(*name, "Oa" | "Sa"))
|
||||
.ok_or_else(|| {
|
||||
at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Expected one OA or SA constructor.",
|
||||
)
|
||||
})?;
|
||||
let layer = layer.to_owned();
|
||||
let outer = inner
|
||||
.as_str()
|
||||
.or_else(|| singleton(inner).map(|(name, _)| name));
|
||||
if !outer.is_some_and(|name| {
|
||||
if layer == "Oa" {
|
||||
matches!(
|
||||
name,
|
||||
"Entity"
|
||||
| "Actor"
|
||||
| "Role"
|
||||
| "Activity"
|
||||
| "Capability"
|
||||
| "Mission"
|
||||
| "Process"
|
||||
| "Shared"
|
||||
)
|
||||
} else {
|
||||
matches!(
|
||||
name,
|
||||
"System"
|
||||
| "Actor"
|
||||
| "Function"
|
||||
| "Capability"
|
||||
| "Mission"
|
||||
| "FunctionalChain"
|
||||
| "Shared"
|
||||
)
|
||||
}
|
||||
}) {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Unknown schema3 layer constructor.",
|
||||
));
|
||||
}
|
||||
if let Some(("Shared", shared)) = singleton(inner) {
|
||||
let name = shared
|
||||
.as_str()
|
||||
.or_else(|| singleton(shared).map(|(name, _)| name))
|
||||
.ok_or_else(|| {
|
||||
at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Expected one schema3 shared constructor.",
|
||||
)
|
||||
})?
|
||||
.to_owned();
|
||||
if !matches!(
|
||||
name.as_str(),
|
||||
"ExchangeItem"
|
||||
| "DataType"
|
||||
| "DataField"
|
||||
| "Interface"
|
||||
| "Port"
|
||||
| "Scenario"
|
||||
| "Lifeline"
|
||||
| "Occurrence"
|
||||
| "StateMachine"
|
||||
| "Event"
|
||||
| "Region"
|
||||
| "State"
|
||||
| "Constraint"
|
||||
) {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
format!("Unknown schema3 shared constructor '{name}'."),
|
||||
));
|
||||
}
|
||||
let payload = shared.get(&name).cloned().unwrap_or(Value::Null);
|
||||
let changed = match name.as_str() {
|
||||
"ExchangeItem" => {
|
||||
let old: Item3 = old_payload(e.id, "body.kind", payload)?;
|
||||
let (kind, mechanism) = match old.kind {
|
||||
ItemKind3::Information => ("Information", "Unspecified"),
|
||||
ItemKind3::Material => ("Material", "Unspecified"),
|
||||
ItemKind3::Energy => ("Energy", "Unspecified"),
|
||||
ItemKind3::Event => ("Information", "Event"),
|
||||
};
|
||||
Some(json!({"kind":kind,"mechanism":mechanism,"supertype":null}))
|
||||
}
|
||||
"DataType" => {
|
||||
let old: Type3 = old_payload(e.id, "body.definition", payload)?;
|
||||
let definition = match old.definition {
|
||||
Definition3::Boolean => json!("Boolean"),
|
||||
Definition3::Integer => json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
Definition3::Real => json!({"Real":{"minimum":null,"maximum":null}}),
|
||||
Definition3::String => {
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null}})
|
||||
}
|
||||
Definition3::Enumeration { literals } => {
|
||||
json!({"Enumeration":{"literals":literals}})
|
||||
}
|
||||
Definition3::Record => json!({"Record":{"supertype":null}}),
|
||||
};
|
||||
Some(json!({"definition":definition,"default":null,"unit":""}))
|
||||
}
|
||||
"DataField" => {
|
||||
let old: Field3 = old_payload(e.id, "body", payload)?;
|
||||
Some(
|
||||
json!({"type_ref":old.type_ref,"minimum":old.minimum,"maximum":old.maximum,"unit":old.unit,"direction":"Unspecified","storage":"Value","default":null}),
|
||||
)
|
||||
}
|
||||
"Interface" => {
|
||||
let old: Interface3 = old_payload(e.id, "items", payload)?;
|
||||
let mut seen = BTreeSet::new();
|
||||
for (order, item) in old.items.into_iter().enumerate() {
|
||||
if !seen.insert(item) {
|
||||
return Err(at(
|
||||
e.id,
|
||||
"items",
|
||||
"duplicate_reference",
|
||||
"Interface items must be unique.",
|
||||
));
|
||||
}
|
||||
allocations.push((e.id, item, order));
|
||||
}
|
||||
Some(json!({"supertype":null}))
|
||||
}
|
||||
"Occurrence" => {
|
||||
let mut payload = payload;
|
||||
if let Some(values) = payload.pointer_mut("/body/Exchange/values") {
|
||||
let old: Vec<MessageValue3> =
|
||||
old_payload(e.id, "body.values", values.clone())?;
|
||||
*values=Value::Array(old.into_iter().map(|v|json!({"field":v.field,"expression":v.expression,"path":[]})).collect());
|
||||
}
|
||||
Some(payload)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(changed) = changed {
|
||||
let description = match name.as_str() {
|
||||
"ExchangeItem" if changed["mechanism"] == "Event" => {
|
||||
"Legacy Event nature becomes Information with Event mechanism; supertype is absent."
|
||||
}
|
||||
"ExchangeItem" => {
|
||||
"Nature is retained; mechanism is Unspecified and supertype is absent."
|
||||
}
|
||||
"DataType" => {
|
||||
"Existing type is retained with unbounded numeric/string limits, no scalar default or unit, and no Record supertype."
|
||||
}
|
||||
"DataField" => {
|
||||
"Type, ownership, multiplicity and unit are retained; direction is Unspecified, storage is Value and default is absent."
|
||||
}
|
||||
"Interface" => {
|
||||
"Items become identified ordered allocations with Unspecified protocols; supertype is absent."
|
||||
}
|
||||
_ => {
|
||||
"Value expressions are retained; unique enclosing field paths are resolved and ambiguous legacy paths remain unresolved."
|
||||
}
|
||||
};
|
||||
body[&layer]["Shared"][&name] = changed;
|
||||
report
|
||||
.semantic_defaults
|
||||
.push(crate::persistence::SemanticDefault {
|
||||
object: e.id,
|
||||
field: "body".into(),
|
||||
description: description.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Current deserialization checks the reused, unchanged outer/typed parts.
|
||||
let _: ElementBody = serde_json::from_value(body.clone())
|
||||
.map_err(|error| at(e.id, "body", "legacy_shape", error.to_string()))?;
|
||||
value["elements"][key.to_string()]["body"] = body;
|
||||
}
|
||||
for (key, r) in &old.relations {
|
||||
if *key != r.id {
|
||||
return Err(at(
|
||||
r.id,
|
||||
"id",
|
||||
"identity",
|
||||
"Relation map key differs from its identity.",
|
||||
));
|
||||
}
|
||||
if !singleton(&r.body).is_some_and(|(name, _)| relation_allowed(name)) {
|
||||
return Err(at(
|
||||
r.id,
|
||||
"body",
|
||||
"legacy_shape",
|
||||
"Relation constructor was not part of schema3.",
|
||||
));
|
||||
}
|
||||
}
|
||||
value["schema_version"] = SCHEMA_VERSION.into();
|
||||
let mut project: Project = serde_json::from_value(value)?;
|
||||
let mut references = project
|
||||
.elements
|
||||
.values()
|
||||
.map(|e| e.steps().map_or(0, Vec::len))
|
||||
.sum::<usize>()
|
||||
.saturating_add(allocations.len().saturating_mul(2));
|
||||
limits
|
||||
.check_counts(
|
||||
project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len())
|
||||
.saturating_add(allocations.len()),
|
||||
project.identities().len().saturating_add(allocations.len()),
|
||||
references,
|
||||
)
|
||||
.map_err(FileError::Limit)?;
|
||||
let mut ids: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
for (source, target, order) in allocations {
|
||||
let id = Id::new_v5(
|
||||
&project.id,
|
||||
format!("v2-interface-item:{source}:{target}").as_bytes(),
|
||||
);
|
||||
if !ids.insert(id) {
|
||||
return Err(at(
|
||||
source,
|
||||
"items",
|
||||
"identity_collision",
|
||||
format!(
|
||||
"Deterministic interface allocation {id} already belongs to another object."
|
||||
),
|
||||
));
|
||||
}
|
||||
let name = format!("{} · {}", project.named(source), project.named(target));
|
||||
let alias = project.fresh_alias(
|
||||
&format!(
|
||||
"{}_{}_allocation",
|
||||
project
|
||||
.elements
|
||||
.get(&source)
|
||||
.map_or("interface", |e| e.alias.as_str()),
|
||||
project
|
||||
.elements
|
||||
.get(&target)
|
||||
.map_or("item", |e| e.alias.as_str())
|
||||
),
|
||||
"interface_item_allocation",
|
||||
id,
|
||||
);
|
||||
project.relations.insert(
|
||||
id,
|
||||
Relation {
|
||||
id,
|
||||
alias: alias.clone(),
|
||||
name,
|
||||
description: String::new(),
|
||||
body: RelationBody::InterfaceItemAllocation {
|
||||
source,
|
||||
target,
|
||||
order: order.try_into().map_err(|_| {
|
||||
at(source, "items", "model_limit", "Too many interface items.")
|
||||
})?,
|
||||
transmission: TransmissionProtocol::Unspecified,
|
||||
acquisition: AcquisitionProtocol::Unspecified,
|
||||
},
|
||||
},
|
||||
);
|
||||
report.generated_interface_allocations.push(
|
||||
crate::persistence::GeneratedInterfaceAllocation {
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
alias,
|
||||
order: order as u32,
|
||||
},
|
||||
);
|
||||
}
|
||||
let annotations: Vec<_> = project
|
||||
.elements
|
||||
.values()
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
values,
|
||||
..
|
||||
},
|
||||
..
|
||||
}) => Some((e.id, *exchange, values.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (owner, exchange, values) in annotations {
|
||||
let Some(relation) = project.relations.get(&exchange) else {
|
||||
continue;
|
||||
};
|
||||
for (index, value) in values.into_iter().enumerate() {
|
||||
let resolution =
|
||||
crate::data::resolve_legacy_field_path(&project, relation, value.field)
|
||||
.map_err(|message| at(owner, "body.values", "model_limit", message))?;
|
||||
if let crate::data::LegacyFieldPathResolution::Unique(path) = resolution {
|
||||
if !path.is_empty() {
|
||||
references = references.saturating_add(path.len());
|
||||
limits
|
||||
.check_counts(
|
||||
project
|
||||
.elements
|
||||
.len()
|
||||
.saturating_add(project.relations.len()),
|
||||
project.identities().len(),
|
||||
references,
|
||||
)
|
||||
.map_err(FileError::Limit)?;
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project
|
||||
.elements
|
||||
.get_mut(&owner)
|
||||
.and_then(Element::shared_mut)
|
||||
{
|
||||
values[index].path = path;
|
||||
}
|
||||
}
|
||||
} else if matches!(
|
||||
resolution,
|
||||
crate::data::LegacyFieldPathResolution::Ambiguous
|
||||
) {
|
||||
report.unresolved_message_values.push(crate::persistence::UnresolvedMessageValue { occurrence:owner,field:value.field,reason:"Several carried paths reach this legacy field; choose an explicit argument path.".into() });
|
||||
}
|
||||
}
|
||||
}
|
||||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||||
super::validate_project(&project)?;
|
||||
Ok(DecodeResult {
|
||||
project,
|
||||
migration: Some(report),
|
||||
})
|
||||
}
|
||||
+13
-179
@@ -6,7 +6,7 @@ use std::{
|
||||
use uuid::Uuid;
|
||||
|
||||
pub type Id = Uuid;
|
||||
pub const SCHEMA_VERSION: u32 = 4;
|
||||
pub const SCHEMA_VERSION: u32 = 3;
|
||||
pub const MAX_COORDINATE: f32 = 1_000_000.;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
@@ -265,79 +265,17 @@ pub enum ExchangeItemKind {
|
||||
Information,
|
||||
Material,
|
||||
Energy,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum ExchangeMechanism {
|
||||
#[default]
|
||||
Unspecified,
|
||||
Flow,
|
||||
Event,
|
||||
Operation,
|
||||
SharedData,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum ParameterDirection {
|
||||
#[default]
|
||||
Unspecified,
|
||||
In,
|
||||
Out,
|
||||
InOut,
|
||||
Return,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum FieldStorage {
|
||||
#[default]
|
||||
Value,
|
||||
Reference,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DataValue {
|
||||
Boolean(bool),
|
||||
Integer(i64),
|
||||
Real(f64),
|
||||
String(String),
|
||||
Enumeration(String),
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum TransmissionProtocol {
|
||||
#[default]
|
||||
Unspecified,
|
||||
Synchronous,
|
||||
Asynchronous,
|
||||
Unicast,
|
||||
Multicast,
|
||||
Broadcast,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum AcquisitionProtocol {
|
||||
#[default]
|
||||
Unspecified,
|
||||
Read,
|
||||
Accept,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum DataTypeDefinition {
|
||||
Boolean,
|
||||
Integer {
|
||||
minimum: Option<i64>,
|
||||
maximum: Option<i64>,
|
||||
},
|
||||
Real {
|
||||
minimum: Option<f64>,
|
||||
maximum: Option<f64>,
|
||||
},
|
||||
String {
|
||||
minimum_length: u32,
|
||||
maximum_length: Option<u32>,
|
||||
},
|
||||
Enumeration {
|
||||
literals: Vec<String>,
|
||||
},
|
||||
Record {
|
||||
supertype: Option<Id>,
|
||||
},
|
||||
Integer,
|
||||
Real,
|
||||
String,
|
||||
Enumeration { literals: Vec<String> },
|
||||
Record,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -515,8 +453,6 @@ pub struct LifelineBinding {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MessageValue {
|
||||
#[serde(default)]
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
pub expression: String,
|
||||
}
|
||||
@@ -566,25 +502,18 @@ pub enum OccurrenceBody {
|
||||
pub enum SharedBody {
|
||||
ExchangeItem {
|
||||
kind: ExchangeItemKind,
|
||||
mechanism: ExchangeMechanism,
|
||||
supertype: Option<Id>,
|
||||
},
|
||||
DataType {
|
||||
definition: DataTypeDefinition,
|
||||
default: Option<DataValue>,
|
||||
unit: String,
|
||||
},
|
||||
DataField {
|
||||
type_ref: TypeRef,
|
||||
minimum: u32,
|
||||
maximum: Option<u32>,
|
||||
unit: String,
|
||||
direction: ParameterDirection,
|
||||
storage: FieldStorage,
|
||||
default: Option<Vec<DataValue>>,
|
||||
},
|
||||
Interface {
|
||||
supertype: Option<Id>,
|
||||
items: Vec<Id>,
|
||||
},
|
||||
Port {
|
||||
contract: PortContract,
|
||||
@@ -743,27 +672,17 @@ impl ElementBody {
|
||||
match k {
|
||||
Kind::ExchangeItem => SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Unspecified,
|
||||
supertype: None,
|
||||
},
|
||||
Kind::DataType => SharedBody::DataType {
|
||||
definition: DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
definition: DataTypeDefinition::String,
|
||||
},
|
||||
Kind::DataField => SharedBody::DataField {
|
||||
type_ref: TypeRef::String,
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
},
|
||||
Kind::Interface => SharedBody::Interface { supertype: None },
|
||||
Kind::Interface => SharedBody::Interface { items: vec![] },
|
||||
// Port creation requires an explicit contract and owner.
|
||||
Kind::Port => return None,
|
||||
Kind::Scenario => SharedBody::Scenario {
|
||||
@@ -833,8 +752,6 @@ impl SharedBody {
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum RelationKind {
|
||||
InterfaceItemAllocation,
|
||||
DataAssociation,
|
||||
Allocation,
|
||||
PortAllocation,
|
||||
Interaction,
|
||||
@@ -858,9 +775,7 @@ pub enum RelationKind {
|
||||
StateAction,
|
||||
}
|
||||
impl RelationKind {
|
||||
pub const ALL: [Self; 23] = [
|
||||
Self::InterfaceItemAllocation,
|
||||
Self::DataAssociation,
|
||||
pub const ALL: [Self; 21] = [
|
||||
Self::Allocation,
|
||||
Self::PortAllocation,
|
||||
Self::Interaction,
|
||||
@@ -885,8 +800,6 @@ impl RelationKind {
|
||||
];
|
||||
pub fn permits(self, from: Kind, to: Kind) -> bool {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation => from == Kind::Interface && to == Kind::ExchangeItem,
|
||||
Self::DataAssociation => from == Kind::DataType && to == Kind::DataType,
|
||||
Self::Allocation => from.is_performer() && to.is_work(),
|
||||
Self::PortAllocation => from == Kind::Port && to == Kind::Port,
|
||||
Self::Interaction => from == Kind::Activity && to == Kind::Activity,
|
||||
@@ -942,8 +855,6 @@ impl RelationKind {
|
||||
}
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation => "Interface → allocated exchange item",
|
||||
Self::DataAssociation => "Record type → record type",
|
||||
Self::Allocation => "Performer → leaf activity/function",
|
||||
Self::PortAllocation => "Component port → allocated function port",
|
||||
Self::Interaction => "OA activity → OA activity",
|
||||
@@ -975,8 +886,6 @@ impl RelationKind {
|
||||
impl fmt::Display for RelationKind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::InterfaceItemAllocation => "Interface item allocation",
|
||||
Self::DataAssociation => "Data association",
|
||||
Self::Allocation => "Allocation",
|
||||
Self::PortAllocation => "Port allocation",
|
||||
Self::Interaction => "Interaction",
|
||||
@@ -1013,25 +922,6 @@ pub struct Relation {
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub enum RelationBody {
|
||||
InterfaceItemAllocation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
order: u32,
|
||||
transmission: TransmissionProtocol,
|
||||
acquisition: AcquisitionProtocol,
|
||||
},
|
||||
DataAssociation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
source_role: String,
|
||||
target_role: String,
|
||||
source_minimum: u32,
|
||||
source_maximum: Option<u32>,
|
||||
target_minimum: u32,
|
||||
target_maximum: Option<u32>,
|
||||
source_navigable: bool,
|
||||
target_navigable: bool,
|
||||
},
|
||||
Allocation {
|
||||
source: Id,
|
||||
target: Id,
|
||||
@@ -1149,8 +1039,6 @@ impl Relation {
|
||||
impl RelationBody {
|
||||
pub fn kind(&self) -> RelationKind {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation { .. } => RelationKind::InterfaceItemAllocation,
|
||||
Self::DataAssociation { .. } => RelationKind::DataAssociation,
|
||||
Self::Allocation { .. } => RelationKind::Allocation,
|
||||
Self::PortAllocation { .. } => RelationKind::PortAllocation,
|
||||
Self::Interaction { .. } => RelationKind::Interaction,
|
||||
@@ -1176,9 +1064,7 @@ impl RelationBody {
|
||||
}
|
||||
pub fn endpoints(&self) -> (Id, Id) {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
Self::Allocation { source, target }
|
||||
| Self::PortAllocation { source, target }
|
||||
| Self::Interaction { source, target, .. }
|
||||
| Self::CapabilityInvolvement { source, target }
|
||||
@@ -1203,9 +1089,7 @@ impl RelationBody {
|
||||
}
|
||||
pub fn set_endpoints(&mut self, a: Id, b: Id) {
|
||||
match self {
|
||||
Self::InterfaceItemAllocation { source, target, .. }
|
||||
| Self::DataAssociation { source, target, .. }
|
||||
| Self::Allocation { source, target }
|
||||
Self::Allocation { source, target }
|
||||
| Self::PortAllocation { source, target }
|
||||
| Self::Interaction { source, target, .. }
|
||||
| Self::CapabilityInvolvement { source, target }
|
||||
@@ -1241,25 +1125,6 @@ impl RelationBody {
|
||||
}
|
||||
pub fn new(kind: RelationKind, source: Id, target: Id) -> Self {
|
||||
match kind {
|
||||
RelationKind::InterfaceItemAllocation => Self::InterfaceItemAllocation {
|
||||
source,
|
||||
target,
|
||||
order: 0,
|
||||
transmission: TransmissionProtocol::Unspecified,
|
||||
acquisition: AcquisitionProtocol::Unspecified,
|
||||
},
|
||||
RelationKind::DataAssociation => Self::DataAssociation {
|
||||
source,
|
||||
target,
|
||||
source_role: String::new(),
|
||||
target_role: String::new(),
|
||||
source_minimum: 0,
|
||||
source_maximum: None,
|
||||
target_minimum: 0,
|
||||
target_maximum: None,
|
||||
source_navigable: false,
|
||||
target_navigable: false,
|
||||
},
|
||||
RelationKind::Allocation => Self::Allocation { source, target },
|
||||
RelationKind::PortAllocation => Self::PortAllocation { source, target },
|
||||
RelationKind::Interaction => Self::Interaction {
|
||||
@@ -1412,34 +1277,6 @@ pub struct EdgeLabel {
|
||||
pub offset: Position,
|
||||
}
|
||||
pub const MAX_ROUTE_POINTS: usize = 128;
|
||||
/// Presentation side; independent of the semantic port direction.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum PortAnchorSide {
|
||||
Left,
|
||||
Right,
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
impl PortAnchorSide {
|
||||
pub fn normal(self) -> Position {
|
||||
match self {
|
||||
Self::Left => Position { x: -1., y: 0. },
|
||||
Self::Right => Position { x: 1., y: 0. },
|
||||
Self::Top => Position { x: 0., y: -1. },
|
||||
Self::Bottom => Position { x: 0., y: 1. },
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Manual position of a semantic child port on one owner placement.
|
||||
/// Absence from the placement's list selects automatic positioning.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PortAnchor {
|
||||
pub port: Id,
|
||||
pub side: PortAnchorSide,
|
||||
/// Fraction of the complete edge, from top (vertical) or left (horizontal).
|
||||
pub offset: f32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NodePlacement {
|
||||
@@ -1449,8 +1286,6 @@ pub struct NodePlacement {
|
||||
pub position: Position,
|
||||
pub size: Size,
|
||||
pub collapsed: bool,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub port_anchors: Vec<PortAnchor>,
|
||||
}
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum ArtifactRelation {
|
||||
@@ -1838,7 +1673,6 @@ impl Project {
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: Vec::new(),
|
||||
};
|
||||
if let Some(d) = self.diagrams.get_mut(&diagram) {
|
||||
d.nodes.insert(id, node);
|
||||
|
||||
@@ -5,8 +5,6 @@ use thiserror::Error;
|
||||
#[path = "migration_v2/mod.rs"]
|
||||
pub(crate) mod v2;
|
||||
pub(crate) use v2::adapter::migrate as migrate_v2_limited;
|
||||
#[path = "migration_v3/mod.rs"]
|
||||
pub(crate) mod v3;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -17,50 +15,6 @@ pub struct MigrationReport {
|
||||
pub generated_endpoints: Vec<GeneratedEndpoint>,
|
||||
pub orientation_defaults: Vec<OrientationDefault>,
|
||||
pub required_resolutions: Vec<Id>,
|
||||
#[serde(default)]
|
||||
pub semantic_defaults: Vec<SemanticDefault>,
|
||||
#[serde(default)]
|
||||
pub generated_interface_allocations: Vec<GeneratedInterfaceAllocation>,
|
||||
#[serde(default)]
|
||||
pub unresolved_message_values: Vec<UnresolvedMessageValue>,
|
||||
}
|
||||
impl MigrationReport {
|
||||
pub(crate) fn data_upgrade(from_schema: u32) -> Self {
|
||||
Self {
|
||||
from_schema,
|
||||
to_schema: SCHEMA_VERSION,
|
||||
preserved_ports: vec![],
|
||||
generated_endpoints: vec![],
|
||||
orientation_defaults: vec![],
|
||||
required_resolutions: vec![],
|
||||
semantic_defaults: vec![],
|
||||
generated_interface_allocations: vec![],
|
||||
unresolved_message_values: vec![],
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct SemanticDefault {
|
||||
pub object: Id,
|
||||
pub field: String,
|
||||
pub description: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct GeneratedInterfaceAllocation {
|
||||
pub id: Id,
|
||||
pub source: Id,
|
||||
pub target: Id,
|
||||
pub alias: String,
|
||||
pub order: u32,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct UnresolvedMessageValue {
|
||||
pub occurrence: Id,
|
||||
pub field: Id,
|
||||
pub reason: String,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -147,14 +101,13 @@ pub fn decode_with_report_limited(
|
||||
migrate_v1_with_report_limited(old, limits)?
|
||||
}
|
||||
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
|
||||
Some(4) => DecodeResult {
|
||||
Some(3) => DecodeResult {
|
||||
project: serde_json::from_slice(bytes)?,
|
||||
migration: None,
|
||||
},
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1, 2, 3 or 4.".into(),
|
||||
"Unsupported or missing schema_version; expected 1, 2 or 3.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+45
-55
@@ -64,9 +64,9 @@ pub struct LegacyWorkPortSplitRequest {
|
||||
pub outgoing_items: Vec<Id>,
|
||||
}
|
||||
|
||||
/// A specialized carried item satisfies a declared base item.
|
||||
pub fn item_satisfies(project: &Project, carried: Id, declared: Id) -> bool {
|
||||
crate::data::item_satisfies(project, carried, declared)
|
||||
/// R6 specialization has one explicit seam; current models support identity only.
|
||||
pub fn item_satisfies(_project: &Project, carried: Id, declared: Id) -> bool {
|
||||
carried == declared
|
||||
}
|
||||
pub fn items_satisfy(project: &Project, carried: &[Id], declared: &[Id]) -> bool {
|
||||
carried.iter().all(|item| {
|
||||
@@ -130,7 +130,13 @@ fn declared_interface_items(p: &Project, port: Id) -> Option<Vec<Id>> {
|
||||
Some(
|
||||
interfaces
|
||||
.into_iter()
|
||||
.flat_map(|id| crate::data::interface_items(p, id))
|
||||
.filter_map(|id| p.elements.get(&id))
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Interface { items }) => Some(items),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
@@ -841,11 +847,6 @@ fn legacy_allows(e: &Element, outgoing: bool) -> bool {
|
||||
matches!(e.shared(), Some(SharedBody::Port { contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction, .. }) }) if if outgoing { *direction != PortDirection::In } else { *direction != PortDirection::Out })
|
||||
}
|
||||
fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mut Vec<Issue>) {
|
||||
// Every carried-item constraint is a subset check. An empty exchange cannot
|
||||
// violate one, so endpoint feasibility need not scan all interface roles.
|
||||
if r.body.items().is_empty() {
|
||||
return;
|
||||
}
|
||||
match port.shared() {
|
||||
Some(SharedBody::Port {
|
||||
contract: PortContract::FunctionInput { incoming_items },
|
||||
@@ -897,10 +898,14 @@ fn validate_carried_contract(p: &Project, r: &Relation, port: &Element, out: &mu
|
||||
..
|
||||
}) => {
|
||||
let mut allowed = declared_interface_items(p, port.id);
|
||||
if let Some(interface) = legacy.as_ref().and_then(|l| l.interface) {
|
||||
let items = crate::data::interface_items(p, interface);
|
||||
if let Some(SharedBody::Interface { items }) = legacy
|
||||
.as_ref()
|
||||
.and_then(|l| l.interface)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(Element::shared)
|
||||
{
|
||||
if r.kind() == RelationKind::BoundaryExchange
|
||||
&& !items_satisfy(p, r.body.items(), &items)
|
||||
&& !items_satisfy(p, r.body.items(), items)
|
||||
{
|
||||
error(
|
||||
out,
|
||||
@@ -1055,6 +1060,34 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec<Is
|
||||
"This boundary exchange retains unresolved generic port semantics.",
|
||||
);
|
||||
}
|
||||
for (port, peer) in [(a, b), (b, a)] {
|
||||
if !matches!(
|
||||
port_contract(port),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for required in p.relations.values().filter(|role| {
|
||||
role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id
|
||||
}) {
|
||||
if !p.relations.values().any(|role| {
|
||||
role.kind() == RelationKind::InterfaceProvision
|
||||
&& role.source() == peer.id
|
||||
&& role.target() == required.target()
|
||||
}) {
|
||||
warning(
|
||||
out,
|
||||
required.id,
|
||||
"unsatisfied_interface_requirement",
|
||||
"target",
|
||||
"The connected partner has not declared this required interface as provided.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RelationBody::PortAllocation { source, target } => {
|
||||
let (Some(component), Some(function)) =
|
||||
@@ -1134,49 +1167,6 @@ pub(crate) fn validate_port_relation(p: &Project, r: &Relation, out: &mut Vec<Is
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
/// Whole-model advisory checks. Missing peer declarations are warnings, so they
|
||||
/// must not make endpoint feasibility scan the relation graph per candidate.
|
||||
pub(crate) fn validate_boundary_interface_requirements(
|
||||
p: &Project,
|
||||
r: &Relation,
|
||||
out: &mut Vec<Issue>,
|
||||
) {
|
||||
let RelationBody::BoundaryExchange { source, target, .. } = &r.body else {
|
||||
return;
|
||||
};
|
||||
let (Some(a), Some(b)) = (p.elements.get(source), p.elements.get(target)) else {
|
||||
return;
|
||||
};
|
||||
for (port, peer) in [(a, b), (b, a)] {
|
||||
if !matches!(
|
||||
port_contract(port),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
for required in p.relations.values().filter(|role| {
|
||||
role.kind() == RelationKind::InterfaceRequirement && role.source() == port.id
|
||||
}) {
|
||||
if !p.relations.values().any(|role| {
|
||||
role.kind() == RelationKind::InterfaceProvision
|
||||
&& role.source() == peer.id
|
||||
&& crate::data::interface_satisfies(p, role.target(), required.target())
|
||||
}) {
|
||||
warning(
|
||||
out,
|
||||
required.id,
|
||||
"unsatisfied_interface_requirement",
|
||||
"target",
|
||||
"The connected partner has not declared this required interface as provided.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
let (Some(boundary), Some(work)) = (p.relations.get(&r.source()), p.relations.get(&r.target()))
|
||||
else {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
pub use crate::model::PortAnchorSide;
|
||||
use crate::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod port_layout;
|
||||
pub use port_layout::{port_anchor_offset, port_anchor_position};
|
||||
#[path = "projection/state.rs"]
|
||||
mod state;
|
||||
pub(crate) use state::node_size as state_node_size;
|
||||
@@ -39,31 +36,34 @@ pub struct ProjectedPort {
|
||||
pub position: Position,
|
||||
pub side: PortAnchorSide,
|
||||
}
|
||||
/// Semantic fallback for an unconnected port. Neutral ports have no preferred
|
||||
/// side; the projected view resolves their placement using peers and capacity.
|
||||
/// A preferred side never constrains an authored anchor.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum PortAnchorSide {
|
||||
Left,
|
||||
Right,
|
||||
Bottom,
|
||||
}
|
||||
pub fn port_anchor_side(e: &Element) -> Option<PortAnchorSide> {
|
||||
let Some(SharedBody::Port { contract, legacy }) = e.shared() else {
|
||||
return None;
|
||||
};
|
||||
match contract {
|
||||
PortContract::FunctionInput { .. } => Some(PortAnchorSide::Left),
|
||||
PortContract::FunctionOutput { .. } => Some(PortAnchorSide::Right),
|
||||
Some(match contract {
|
||||
PortContract::FunctionInput { .. } => PortAnchorSide::Left,
|
||||
PortContract::FunctionOutput { .. } => PortAnchorSide::Right,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::In,
|
||||
} => Some(PortAnchorSide::Left),
|
||||
} => PortAnchorSide::Left,
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
} => Some(PortAnchorSide::Right),
|
||||
} => PortAnchorSide::Right,
|
||||
PortContract::Legacy => match legacy.as_ref().map(|l| l.direction) {
|
||||
Some(PortDirection::In) => Some(PortAnchorSide::Left),
|
||||
Some(PortDirection::Out) => Some(PortAnchorSide::Right),
|
||||
_ => None,
|
||||
Some(PortDirection::In) => PortAnchorSide::Left,
|
||||
Some(PortDirection::Out) => PortAnchorSide::Right,
|
||||
_ => PortAnchorSide::Bottom,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
_ => PortAnchorSide::Bottom,
|
||||
})
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
|
||||
pub struct ProjectedView {
|
||||
@@ -71,11 +71,38 @@ pub struct ProjectedView {
|
||||
pub edges: Vec<ProjectedEdge>,
|
||||
pub ports: Vec<ProjectedPort>,
|
||||
}
|
||||
pub(crate) fn minimum_node_size(p: &Project, element: Id, size: Size) -> Size {
|
||||
port_layout::minimum_size(p, element, size)
|
||||
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
|
||||
let count = [PortAnchorSide::Left, PortAnchorSide::Right]
|
||||
.into_iter()
|
||||
.map(|side| {
|
||||
p.children(element)
|
||||
.filter(|port| port_anchor_side(port) == Some(side))
|
||||
.count()
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
if count > 0 {
|
||||
size.height = size.height.max(76. + 28. * (count + 1) as f32);
|
||||
}
|
||||
let bottom = p
|
||||
.children(element)
|
||||
.filter(|port| port_anchor_side(port) == Some(PortAnchorSide::Bottom))
|
||||
.count();
|
||||
if bottom > 0 {
|
||||
size.width = size.width.max(28. * (bottom + 1) as f32);
|
||||
}
|
||||
size
|
||||
}
|
||||
pub(crate) fn port_footer(_p: &Project, _element: Id) -> f32 {
|
||||
24.
|
||||
pub(crate) fn port_footer(p: &Project, element: Id) -> f32 {
|
||||
let count = p
|
||||
.children(element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.count();
|
||||
if count == 0 {
|
||||
24.
|
||||
} else {
|
||||
28. * count as f32 + 24.
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
|
||||
@@ -296,7 +323,39 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
}
|
||||
nodes.sort_by_key(|n| (n.depth, n.id));
|
||||
enclose_with_headers(p, &mut nodes, d.projection.state_machine().is_some());
|
||||
for index in (0..nodes.len()).rev() {
|
||||
let child = nodes[index].clone();
|
||||
// Boundary vertices are pinned to their containing State/Machine after
|
||||
// containment has been measured. They must not expand that boundary.
|
||||
if d.projection.state_machine().is_some()
|
||||
&& p.elements
|
||||
.get(&child.element)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = child
|
||||
.visual_parent
|
||||
.and_then(|id| nodes.iter().position(|n| n.id == id))
|
||||
{
|
||||
let footer = port_footer(p, nodes[parent].element);
|
||||
let header = if d.projection.state_machine().is_some() {
|
||||
state_header_height(p, nodes[parent].element)
|
||||
} else {
|
||||
64.
|
||||
};
|
||||
let p = &mut nodes[parent];
|
||||
let right =
|
||||
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
|
||||
let bottom =
|
||||
(p.position.y + p.size.height).max(child.position.y + child.size.height + footer);
|
||||
p.position.x = p.position.x.min(child.position.x - 24.);
|
||||
// Reserve two readable header lines above the first nested card.
|
||||
p.position.y = p.position.y.min(child.position.y - header);
|
||||
p.size.width = right - p.position.x;
|
||||
p.size.height = bottom - p.position.y;
|
||||
}
|
||||
}
|
||||
if d.projection.state_machine().is_some() {
|
||||
state::snap_boundaries(p, &mut nodes);
|
||||
}
|
||||
@@ -322,24 +381,58 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
None
|
||||
};
|
||||
let visible_ports: BTreeSet<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.kind() == Kind::Port
|
||||
&& e.parent
|
||||
.and_then(|id| placed.get(&id))
|
||||
.is_some_and(|id| visible.contains(id))
|
||||
})
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
let mut ports = Vec::new();
|
||||
for node in &nodes {
|
||||
let mut children: Vec<_> = p
|
||||
.children(node.element)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.collect();
|
||||
children.sort_by_key(|e| (&e.alias, e.id));
|
||||
for anchor_side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let side: Vec<_> = children
|
||||
.iter()
|
||||
.filter(|e| port_anchor_side(e) == Some(anchor_side))
|
||||
.collect();
|
||||
for (index, port) in side.iter().enumerate() {
|
||||
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
|
||||
let y = if p.elements[&node.element].kind().is_performer()
|
||||
|| nodes
|
||||
.iter()
|
||||
.any(|child| child.visual_parent == Some(node.id))
|
||||
{
|
||||
node.position.y + node.size.height - 24. - index as f32 * 28.
|
||||
} else {
|
||||
node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction
|
||||
};
|
||||
let position = match anchor_side {
|
||||
PortAnchorSide::Left => Position {
|
||||
x: node.position.x,
|
||||
y,
|
||||
},
|
||||
PortAnchorSide::Right => Position {
|
||||
x: node.position.x + node.size.width,
|
||||
y,
|
||||
},
|
||||
PortAnchorSide::Bottom => Position {
|
||||
x: node.position.x + node.size.width * fraction,
|
||||
y: node.position.y + node.size.height,
|
||||
},
|
||||
};
|
||||
ports.push(ProjectedPort {
|
||||
element: port.id,
|
||||
owner: node.id,
|
||||
position,
|
||||
side: anchor_side,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut edges = Vec::new();
|
||||
for r in p.relations.values() {
|
||||
// Interface allocations are selectable nested dictionary rows. Their
|
||||
// independently identified contracts do not add noisy diagram arrows.
|
||||
if r.kind() == RelationKind::InterfaceItemAllocation {
|
||||
continue;
|
||||
}
|
||||
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
|
||||
continue;
|
||||
}
|
||||
@@ -375,8 +468,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
|
||||
&& (source != target
|
||||
|| ((d.projection.state_machine().is_some()
|
||||
|| r.kind() == RelationKind::DataAssociation)
|
||||
|| (d.projection.state_machine().is_some()
|
||||
&& r.source() == r.target()
|
||||
&& nodes
|
||||
.iter()
|
||||
@@ -402,10 +494,10 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
RelationBody::BoundaryExchange {
|
||||
oriented: false,
|
||||
..
|
||||
} | RelationBody::DataAssociation { .. }
|
||||
}
|
||||
),
|
||||
source_port: source_port.filter(|id| visible_ports.contains(id)),
|
||||
target_port: target_port.filter(|id| visible_ports.contains(id)),
|
||||
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -426,53 +518,9 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dedicated state views contain state symbols, not semantic component ports.
|
||||
// Port layout would re-enclose already snapped boundary vertices.
|
||||
let ports = if d.projection.state_machine().is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
port_layout::layout(p, d, &mut nodes, &edges)
|
||||
};
|
||||
ProjectedView {
|
||||
nodes,
|
||||
edges,
|
||||
ports,
|
||||
}
|
||||
}
|
||||
|
||||
fn enclose(p: &Project, nodes: &mut [ProjectedNode]) {
|
||||
enclose_with_headers(p, nodes, false);
|
||||
}
|
||||
fn enclose_with_headers(p: &Project, nodes: &mut [ProjectedNode], state_view: bool) {
|
||||
for index in (0..nodes.len()).rev() {
|
||||
let child = nodes[index].clone();
|
||||
if state_view
|
||||
&& p.elements
|
||||
.get(&child.element)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::EntryPoint | Kind::ExitPoint))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if let Some(parent) = child
|
||||
.visual_parent
|
||||
.and_then(|id| nodes.iter().position(|n| n.id == id))
|
||||
{
|
||||
let footer = port_footer(p, nodes[parent].element);
|
||||
let header = if state_view {
|
||||
state_header_height(p, nodes[parent].element)
|
||||
} else {
|
||||
64.
|
||||
};
|
||||
let p = &mut nodes[parent];
|
||||
let right =
|
||||
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
|
||||
let bottom =
|
||||
(p.position.y + p.size.height).max(child.position.y + child.size.height + footer);
|
||||
p.position.x = p.position.x.min(child.position.x - 24.);
|
||||
// Reserve two readable header lines above the first nested card.
|
||||
p.position.y = p.position.y.min(child.position.y - header);
|
||||
p.size.width = right - p.position.x;
|
||||
p.size.height = bottom - p.position.y;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,383 +0,0 @@
|
||||
//! Presentation-only port anchors. Automatic choices depend on owner geometry,
|
||||
//! never on routed elbows, so routing cannot feed back into layout.
|
||||
use super::*;
|
||||
|
||||
const GAP: f32 = 28.;
|
||||
const CORNER: f32 = 18.;
|
||||
const SIDES: [PortAnchorSide; 4] = [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
];
|
||||
|
||||
pub fn port_anchor_position(node: &ProjectedNode, side: PortAnchorSide, offset: f32) -> Position {
|
||||
let (x, y) = match side {
|
||||
PortAnchorSide::Left => (0., node.size.height * offset),
|
||||
PortAnchorSide::Right => (node.size.width, node.size.height * offset),
|
||||
PortAnchorSide::Top => (node.size.width * offset, 0.),
|
||||
PortAnchorSide::Bottom => (node.size.width * offset, node.size.height),
|
||||
};
|
||||
Position {
|
||||
x: node.position.x + x,
|
||||
y: node.position.y + y,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn port_anchor_offset(node: &ProjectedNode, side: PortAnchorSide, position: Position) -> f32 {
|
||||
let value = match side {
|
||||
PortAnchorSide::Left | PortAnchorSide::Right => {
|
||||
(position.y - node.position.y) / node.size.height
|
||||
}
|
||||
PortAnchorSide::Top | PortAnchorSide::Bottom => {
|
||||
(position.x - node.position.x) / node.size.width
|
||||
}
|
||||
};
|
||||
value.clamp(0., 1.)
|
||||
}
|
||||
|
||||
fn center(node: &ProjectedNode) -> Position {
|
||||
Position {
|
||||
x: node.position.x + node.size.width / 2.,
|
||||
y: node.position.y + node.size.height / 2.,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Choice {
|
||||
port: Id,
|
||||
side: PortAnchorSide,
|
||||
automatic_side: PortAnchorSide,
|
||||
manual: Option<f32>,
|
||||
peer: Option<Position>,
|
||||
}
|
||||
|
||||
fn facing(node: &ProjectedNode, peer: Position) -> Option<PortAnchorSide> {
|
||||
let c = center(node);
|
||||
let dx = (peer.x - c.x) / node.size.width;
|
||||
let dy = (peer.y - c.y) / node.size.height;
|
||||
if dx.abs().max(dy.abs()) < 0.001 {
|
||||
return None;
|
||||
}
|
||||
if dx.abs() >= dy.abs() {
|
||||
Some(if dx < 0. {
|
||||
PortAnchorSide::Left
|
||||
} else {
|
||||
PortAnchorSide::Right
|
||||
})
|
||||
} else {
|
||||
Some(if dy < 0. {
|
||||
PortAnchorSide::Top
|
||||
} else {
|
||||
PortAnchorSide::Bottom
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn clear_exit(
|
||||
node: &ProjectedNode,
|
||||
side: PortAnchorSide,
|
||||
peers: &BTreeSet<Id>,
|
||||
by_id: &BTreeMap<Id, &ProjectedNode>,
|
||||
) -> bool {
|
||||
let a = port_anchor_position(node, side, 0.5);
|
||||
let normal = side.normal();
|
||||
let b = Position {
|
||||
x: a.x + normal.x * crate::routing::PORT_STUB,
|
||||
y: a.y + normal.y * crate::routing::PORT_STUB,
|
||||
};
|
||||
peers.iter().all(|peer| {
|
||||
let other = by_id[peer];
|
||||
// A containing endpoint's body is traversable, just as in routing.
|
||||
let mut ancestor = node.visual_parent;
|
||||
for _ in 0..by_id.len() {
|
||||
let Some(id) = ancestor else {
|
||||
break;
|
||||
};
|
||||
if id == *peer {
|
||||
return true;
|
||||
}
|
||||
ancestor = by_id.get(&id).and_then(|n| n.visual_parent);
|
||||
}
|
||||
let padding = crate::routing::CLEARANCE;
|
||||
let left = other.position.x - padding;
|
||||
let right = other.position.x + other.size.width + padding;
|
||||
let top = other.position.y - padding;
|
||||
let bottom = other.position.y + other.size.height + padding;
|
||||
if a.x == b.x {
|
||||
!(a.x > left && a.x < right && a.y.min(b.y) < bottom && a.y.max(b.y) > top)
|
||||
} else {
|
||||
!(a.y > top && a.y < bottom && a.x.min(b.x) < right && a.x.max(b.x) > left)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Peers {
|
||||
x: f64,
|
||||
y: f64,
|
||||
count: usize,
|
||||
owners: BTreeSet<Id>,
|
||||
}
|
||||
impl Peers {
|
||||
fn center(&self) -> Position {
|
||||
Position {
|
||||
x: (self.x / self.count as f64) as f32,
|
||||
y: (self.y / self.count as f64) as f32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn minimum_size(p: &Project, element: Id, mut size: Size) -> Size {
|
||||
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
|
||||
let mut neutral = 0;
|
||||
for port in p.children(element).filter(|p| p.kind() == Kind::Port) {
|
||||
if let Some(side) = port_anchor_side(port) {
|
||||
*counts.get_mut(&side).unwrap() += 1;
|
||||
} else {
|
||||
neutral += 1;
|
||||
}
|
||||
}
|
||||
for _ in 0..neutral {
|
||||
let side = *SIDES.iter().min_by_key(|s| counts[s]).unwrap();
|
||||
*counts.get_mut(&side).unwrap() += 1;
|
||||
}
|
||||
let vertical = counts[&PortAnchorSide::Left].max(counts[&PortAnchorSide::Right]);
|
||||
let horizontal = counts[&PortAnchorSide::Top].max(counts[&PortAnchorSide::Bottom]);
|
||||
if vertical > 0 {
|
||||
size.height = size.height.max(76. + GAP * (vertical + 1) as f32);
|
||||
}
|
||||
if horizontal > 0 {
|
||||
size.width = size.width.max(GAP * (horizontal + 1) as f32);
|
||||
}
|
||||
size
|
||||
}
|
||||
|
||||
pub(super) fn layout(
|
||||
p: &Project,
|
||||
d: &Diagram,
|
||||
nodes: &mut [ProjectedNode],
|
||||
edges: &[ProjectedEdge],
|
||||
) -> Vec<ProjectedPort> {
|
||||
let by_id: BTreeMap<_, _> = nodes.iter().map(|n| (n.id, n)).collect();
|
||||
let mut peers: BTreeMap<(Id, Id), Peers> = BTreeMap::new();
|
||||
for edge in edges {
|
||||
// Port allocation is implementation traceability, not a communication
|
||||
// peer. It must not turn an external boundary port toward its own work.
|
||||
if !p.relations.get(&edge.id).is_some_and(|r| {
|
||||
matches!(
|
||||
r.kind(),
|
||||
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
|
||||
)
|
||||
}) {
|
||||
continue;
|
||||
}
|
||||
for (owner, port, other) in [
|
||||
(edge.source, edge.source_port, edge.target),
|
||||
(edge.target, edge.target_port, edge.source),
|
||||
] {
|
||||
if let (Some(port), Some(other)) = (port, by_id.get(&other)) {
|
||||
let c = center(other);
|
||||
let entry = peers.entry((owner, port)).or_default();
|
||||
entry.x += f64::from(c.x);
|
||||
entry.y += f64::from(c.y);
|
||||
entry.count += 1;
|
||||
entry.owners.insert(other.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut children: BTreeMap<Id, Vec<&Element>> = BTreeMap::new();
|
||||
for port in p.elements.values().filter(|e| e.kind() == Kind::Port) {
|
||||
if let Some(parent) = port.parent {
|
||||
children.entry(parent).or_default().push(port);
|
||||
}
|
||||
}
|
||||
let mut choices = BTreeMap::<Id, Vec<Choice>>::new();
|
||||
for node in nodes.iter() {
|
||||
let anchors: BTreeMap<_, _> = d.nodes[&node.id]
|
||||
.port_anchors
|
||||
.iter()
|
||||
.map(|a| (a.port, a))
|
||||
.collect();
|
||||
let mut counts: BTreeMap<_, usize> = SIDES.into_iter().map(|s| (s, 0)).collect();
|
||||
// Compute the automatic capacity independently of manual overrides.
|
||||
// Moving a port must not resize its owner or flip unrelated ports.
|
||||
for port in children.get(&node.element).into_iter().flatten() {
|
||||
let connected = peers.get(&(node.id, port.id));
|
||||
let peer = connected.map(Peers::center);
|
||||
let clear =
|
||||
|side| connected.is_none_or(|peers| clear_exit(node, side, &peers.owners, &by_id));
|
||||
let anchor = anchors.get(&port.id);
|
||||
let side = {
|
||||
peer.and_then(|v| facing(node, v))
|
||||
.filter(|side| clear(*side))
|
||||
}
|
||||
.or_else(|| port_anchor_side(port).filter(|side| clear(*side)))
|
||||
.or_else(|| {
|
||||
SIDES
|
||||
.iter()
|
||||
.filter(|side| clear(**side))
|
||||
.min_by_key(|s| counts[s])
|
||||
.copied()
|
||||
})
|
||||
.unwrap_or_else(|| *SIDES.iter().min_by_key(|s| counts[s]).unwrap());
|
||||
*counts.get_mut(&side).unwrap() += 1;
|
||||
choices.entry(node.id).or_default().push(Choice {
|
||||
port: port.id,
|
||||
side: anchor.map_or(side, |a| a.side),
|
||||
automatic_side: side,
|
||||
manual: anchor.map(|a| a.offset),
|
||||
peer,
|
||||
});
|
||||
}
|
||||
}
|
||||
// One capacity pass followed by semantic enclosure. Side choices stay frozen
|
||||
// throughout this pass; expanded parents cannot cause layout oscillation.
|
||||
for node in nodes.iter_mut() {
|
||||
for side in SIDES {
|
||||
let count = choices
|
||||
.get(&node.id)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|c| c.automatic_side == side)
|
||||
.count();
|
||||
if count == 0 {
|
||||
continue;
|
||||
}
|
||||
let minimum = 2. * CORNER + GAP * count.saturating_sub(1) as f32;
|
||||
match side {
|
||||
PortAnchorSide::Left | PortAnchorSide::Right => {
|
||||
node.size.height = node.size.height.max(minimum)
|
||||
}
|
||||
PortAnchorSide::Top | PortAnchorSide::Bottom => {
|
||||
node.size.width = node.size.width.max(minimum)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
super::enclose(p, nodes);
|
||||
let mut result = Vec::new();
|
||||
for node in nodes.iter() {
|
||||
let Some(choices) = choices.get(&node.id) else {
|
||||
continue;
|
||||
};
|
||||
for side in SIDES {
|
||||
let mut automatic: Vec<_> = choices
|
||||
.iter()
|
||||
.filter(|c| c.side == side && c.manual.is_none())
|
||||
.collect();
|
||||
let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
|
||||
automatic.sort_by(|a, b| {
|
||||
let axis = |c: &Choice| c.peer.map(|p| if horizontal { p.x } else { p.y });
|
||||
match (axis(a), axis(b)) {
|
||||
(Some(a), Some(b)) => a.total_cmp(&b),
|
||||
(Some(_), None) => std::cmp::Ordering::Less,
|
||||
(None, Some(_)) => std::cmp::Ordering::Greater,
|
||||
_ => std::cmp::Ordering::Equal,
|
||||
}
|
||||
.then_with(|| a.port.cmp(&b.port))
|
||||
});
|
||||
let length = if horizontal {
|
||||
node.size.width
|
||||
} else {
|
||||
node.size.height
|
||||
};
|
||||
let mut fixed = Vec::new();
|
||||
for choice in choices.iter().filter(|c| c.side == side) {
|
||||
if let Some(offset) = choice.manual {
|
||||
fixed.push(offset * length);
|
||||
result.push(ProjectedPort {
|
||||
element: choice.port,
|
||||
owner: node.id,
|
||||
side,
|
||||
position: port_anchor_position(node, side, offset),
|
||||
});
|
||||
}
|
||||
}
|
||||
let positions = automatic_positions(length, &fixed, automatic.len());
|
||||
for (choice, distance) in automatic.into_iter().zip(positions) {
|
||||
result.push(ProjectedPort {
|
||||
element: choice.port,
|
||||
owner: node.id,
|
||||
side,
|
||||
position: port_anchor_position(node, side, distance / length),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Allocate free intervals around authored anchors. If manual anchors are too
|
||||
/// crowded to satisfy the desired gap, retain them and degrade spacing evenly.
|
||||
fn automatic_positions(length: f32, fixed: &[f32], count: usize) -> Vec<f32> {
|
||||
if count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut fixed = fixed.to_vec();
|
||||
fixed.sort_by(f32::total_cmp);
|
||||
let mut gap = GAP;
|
||||
let intervals = loop {
|
||||
let mut start = CORNER.min(length / 2.);
|
||||
let end = (length - CORNER).max(start);
|
||||
let mut free = Vec::new();
|
||||
for &anchor in &fixed {
|
||||
if anchor - gap >= start {
|
||||
free.push((start, (anchor - gap).min(end)));
|
||||
}
|
||||
start = start.max(anchor + gap);
|
||||
if start > end {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if start <= end {
|
||||
free.push((start, end));
|
||||
}
|
||||
free.retain(|(a, b)| b >= a);
|
||||
let capacity: usize = free
|
||||
.iter()
|
||||
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
|
||||
.sum();
|
||||
if capacity >= count {
|
||||
break free;
|
||||
}
|
||||
gap /= 2.;
|
||||
if gap < 0.001 {
|
||||
return (0..count)
|
||||
.map(|i| length * (i + 1) as f32 / (count + 1) as f32)
|
||||
.collect();
|
||||
}
|
||||
};
|
||||
let capacities: Vec<_> = intervals
|
||||
.iter()
|
||||
.map(|(a, b)| (((b - a) / gap).floor() as usize).saturating_add(1))
|
||||
.collect();
|
||||
let total: usize = capacities.iter().sum();
|
||||
let mut allocated: Vec<_> = capacities
|
||||
.iter()
|
||||
.map(|capacity| (count as u128 * *capacity as u128 / total as u128) as usize)
|
||||
.collect();
|
||||
let mut remainder: Vec<_> = capacities
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, capacity)| (count as u128 * *capacity as u128 % total as u128, i))
|
||||
.collect();
|
||||
remainder.sort_by(|(a, i), (b, j)| b.cmp(a).then_with(|| i.cmp(j)));
|
||||
let unassigned = count - allocated.iter().sum::<usize>();
|
||||
for (_, index) in remainder.into_iter().take(unassigned) {
|
||||
allocated[index] += 1;
|
||||
}
|
||||
intervals
|
||||
.into_iter()
|
||||
.zip(allocated)
|
||||
.flat_map(|((a, b), count)| {
|
||||
(0..count).map(move |i| {
|
||||
if count == 1 {
|
||||
(a + b) / 2.
|
||||
} else {
|
||||
a + (b - a) * i as f32 / (count - 1) as f32
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -33,13 +33,8 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
type_ref: TypeRef::Named(id),
|
||||
..
|
||||
} => visit(id),
|
||||
SharedBody::Interface { supertype }
|
||||
| SharedBody::ExchangeItem { supertype, .. }
|
||||
| SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
..
|
||||
} => {
|
||||
if let Some(id) = supertype {
|
||||
SharedBody::Interface { items } => {
|
||||
for id in items {
|
||||
visit(id);
|
||||
}
|
||||
}
|
||||
@@ -88,9 +83,6 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
|
||||
visit(id);
|
||||
}
|
||||
for value in values {
|
||||
for id in &mut value.path {
|
||||
visit(id);
|
||||
}
|
||||
visit(&mut value.field);
|
||||
}
|
||||
}
|
||||
@@ -200,11 +192,6 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
@@ -369,10 +356,6 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
for d in p.diagrams.values_mut() {
|
||||
d.nodes.retain(|id, _| !removed.contains(id));
|
||||
d.routes.retain(|route| !removed.contains(&route.edge));
|
||||
for node in d.nodes.values_mut() {
|
||||
node.port_anchors
|
||||
.retain(|anchor| !removed.contains(&anchor.port));
|
||||
}
|
||||
}
|
||||
let clear = |v: &mut Option<Id>| {
|
||||
if v.is_some_and(|id| removed.contains(&id)) {
|
||||
@@ -385,12 +368,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
}
|
||||
if let Some(shared) = e.shared_mut() {
|
||||
match shared {
|
||||
SharedBody::Interface { supertype }
|
||||
| SharedBody::ExchangeItem { supertype, .. }
|
||||
| SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
..
|
||||
} => clear(supertype),
|
||||
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
|
||||
SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
} => items.retain(|id| !removed.contains(id)),
|
||||
@@ -426,10 +404,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
clear(sender);
|
||||
clear(receiver);
|
||||
clear(reply_to);
|
||||
values.retain(|value| {
|
||||
!removed.contains(&value.field)
|
||||
&& value.path.iter().all(|id| !removed.contains(id))
|
||||
});
|
||||
values.retain(|value| !removed.contains(&value.field));
|
||||
}
|
||||
OccurrenceBody::Action { action, lifeline } => {
|
||||
clear(action);
|
||||
@@ -501,37 +476,20 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
|
||||
exchange: Some(id), ..
|
||||
},
|
||||
..
|
||||
}) => p.relations.get(id).map(|r| {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = e.shared()
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let retained: Vec<_> = values
|
||||
.iter()
|
||||
.filter(|value| {
|
||||
crate::data::carried_value_path(p, r, &value.path, value.field).is_some()
|
||||
|| (value.path.is_empty()
|
||||
&& crate::data::carried_field_paths(p, r)
|
||||
.iter()
|
||||
.any(|f| f.field == value.field))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
(e.id, retained)
|
||||
}),
|
||||
}) => p
|
||||
.relations
|
||||
.get(id)
|
||||
.map(|r| (e.id, crate::validation::carried_fields(p, r))),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
for (id, retained) in allowed {
|
||||
for (id, fields) in allowed {
|
||||
if let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
|
||||
{
|
||||
*values = retained;
|
||||
values.retain(|value| fields.contains(&value.field));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+69
-260
@@ -1,7 +1,7 @@
|
||||
//! Deterministic, bounded diagram geometry. Only authored pins belong in the model;
|
||||
//! callers share this derived geometry for drawing, picking, fitting and export.
|
||||
use crate::projection::{self, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, Relation, RelationBody, RelationKind, Size};
|
||||
use crate::projection::{self, PortAnchorSide, ProjectedEdge, ProjectedNode, ProjectedView};
|
||||
use crate::{Diagram, Id, Position, Project, RelationKind, Size};
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
|
||||
use std::fmt;
|
||||
@@ -55,122 +55,6 @@ pub struct EdgeLabelGeometry {
|
||||
pub bounds: RoutingBounds,
|
||||
}
|
||||
|
||||
/// Derived labels and navigation markers for the two named association ends.
|
||||
/// Canvas and SVG consume these same coordinates; no extra diagram state exists.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AssociationEndGeometry {
|
||||
pub source_end: bool,
|
||||
pub label: EdgeLabelGeometry,
|
||||
pub tip: Position,
|
||||
pub inward: Position,
|
||||
pub navigable: bool,
|
||||
}
|
||||
|
||||
pub fn association_end_geometry(
|
||||
relation: &Relation,
|
||||
routed: &RoutedEdge,
|
||||
) -> Vec<AssociationEndGeometry> {
|
||||
let RelationBody::DataAssociation {
|
||||
source_navigable,
|
||||
target_navigable,
|
||||
..
|
||||
} = &relation.body
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut ends: Vec<AssociationEndGeometry> = Vec::new();
|
||||
for (source_end, navigable) in [(true, *source_navigable), (false, *target_navigable)] {
|
||||
let Some(tip) = (if source_end {
|
||||
routed.points.first()
|
||||
} else {
|
||||
routed.points.last()
|
||||
})
|
||||
.copied() else {
|
||||
continue;
|
||||
};
|
||||
let inward = if source_end {
|
||||
routed.points.iter().find(|p| **p != tip)
|
||||
} else {
|
||||
routed.points.iter().rev().find(|p| **p != tip)
|
||||
};
|
||||
let Some(inward) = inward.copied() else {
|
||||
continue;
|
||||
};
|
||||
let full = crate::data::association_end_label(relation, source_end).unwrap_or_default();
|
||||
let text = if let Some((role, multiplicity)) = full.rsplit_once(" [") {
|
||||
let role = if role.chars().count() > 32 {
|
||||
role.chars()
|
||||
.take(31)
|
||||
.chain(std::iter::once('…'))
|
||||
.collect::<String>()
|
||||
} else {
|
||||
role.to_owned()
|
||||
};
|
||||
format!("{role} [{multiplicity}")
|
||||
} else {
|
||||
full
|
||||
};
|
||||
let size = Size {
|
||||
width: text.chars().count() as f32 * 7.0 + 12.0,
|
||||
height: 22.0,
|
||||
};
|
||||
let dx = inward.x - tip.x;
|
||||
let dy = inward.y - tip.y;
|
||||
let length = dx.hypot(dy);
|
||||
let direction = pos(dx / length, dy / length);
|
||||
// One normal sign puts opposite endpoints of a straight edge on
|
||||
// opposite sides because their inward tangent directions are reversed.
|
||||
let along =
|
||||
direction.x.abs() * size.width / 2.0 + direction.y.abs() * size.height / 2.0 + 12.0;
|
||||
let normal =
|
||||
direction.y.abs() * size.width / 2.0 + direction.x.abs() * size.height / 2.0 + 8.0;
|
||||
let mut center = pos(
|
||||
tip.x + direction.x * along - direction.y * normal,
|
||||
tip.y + direction.y * along + direction.x * normal,
|
||||
);
|
||||
if let Some(other) = ends.first()
|
||||
&& (center.x - other.label.center.x).abs()
|
||||
< (size.width + other.label.bounds.size.width) / 2.0 + 6.0
|
||||
&& (center.y - other.label.center.y).abs()
|
||||
< (size.height + other.label.bounds.size.height) / 2.0 + 6.0
|
||||
{
|
||||
// Same-side anchors on a self-loop can still be close. Separate
|
||||
// their complete bounds along this end's normal in one step.
|
||||
let perpendicular = pos(-direction.y, direction.x);
|
||||
let distance = (center.x - other.label.center.x) * perpendicular.x
|
||||
+ (center.y - other.label.center.y) * perpendicular.y;
|
||||
let clearance = (perpendicular.x.abs() * (size.width + other.label.bounds.size.width)
|
||||
+ perpendicular.y.abs() * (size.height + other.label.bounds.size.height))
|
||||
/ 2.0
|
||||
+ 8.0;
|
||||
let shift = if distance < 0.0 {
|
||||
-clearance - distance
|
||||
} else {
|
||||
clearance - distance
|
||||
};
|
||||
center.x += perpendicular.x * shift;
|
||||
center.y += perpendicular.y * shift;
|
||||
}
|
||||
ends.push(AssociationEndGeometry {
|
||||
source_end,
|
||||
label: EdgeLabelGeometry {
|
||||
text,
|
||||
fraction: if source_end { 0.0 } else { 1.0 },
|
||||
anchor: tip,
|
||||
center,
|
||||
bounds: RoutingBounds {
|
||||
position: pos(center.x - size.width / 2.0, center.y - size.height / 2.0),
|
||||
size,
|
||||
},
|
||||
},
|
||||
tip,
|
||||
inward,
|
||||
navigable,
|
||||
});
|
||||
}
|
||||
ends
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RouteWarning {
|
||||
PinInsideObstacle { pin: usize, obstacle: Id },
|
||||
@@ -380,7 +264,11 @@ fn endpoints(node: &ProjectedNode, port: Option<Id>, view: &ProjectedView) -> Ve
|
||||
.iter()
|
||||
.find(|p| p.element == id && p.owner == node.id)
|
||||
}) {
|
||||
let normal = port.side.normal();
|
||||
let normal = match port.side {
|
||||
PortAnchorSide::Left => pos(-1.0, 0.0),
|
||||
PortAnchorSide::Right => pos(1.0, 0.0),
|
||||
PortAnchorSide::Bottom => pos(0.0, 1.0),
|
||||
};
|
||||
return vec![Endpoint {
|
||||
point: port.position,
|
||||
stub: pos(
|
||||
@@ -431,10 +319,9 @@ pub fn route_diagram(project: &Project, diagram: &Diagram, view: &ProjectedView)
|
||||
}
|
||||
|
||||
/// Transient route/label editing with unchanged projected nodes and ports.
|
||||
/// Retains other edges from a settled baseline and scores the selected path
|
||||
/// against preceding stable IDs. Labels avoid all currently displayed paths.
|
||||
/// Recompute the complete view after commit: later paths and automatic labels
|
||||
/// may settle again. Also recompute whenever node/port geometry changes.
|
||||
/// Retains other edges from a settled baseline and scores the selected edge
|
||||
/// against preceding stable IDs, exactly as a complete solve does. Recompute
|
||||
/// the complete view after commit, or whenever node/port geometry changes.
|
||||
pub fn route_edge_preview(
|
||||
project: &Project,
|
||||
diagram: &Diagram,
|
||||
@@ -471,6 +358,7 @@ fn route_diagram_impl(
|
||||
ordered.sort_by_key(|e| e.id);
|
||||
let mut edges = BTreeMap::new();
|
||||
let mut previous = Vec::new();
|
||||
let mut previous_labels = Vec::new();
|
||||
let mut budget = VIEW_SEARCH_BUDGET;
|
||||
let mut bounds = BoundsBuilder::default();
|
||||
for node in &view.nodes {
|
||||
@@ -485,7 +373,7 @@ fn route_diagram_impl(
|
||||
},
|
||||
);
|
||||
}
|
||||
for edge in &ordered {
|
||||
for edge in ordered {
|
||||
if let Some(cached) = preview.and_then(|(baseline, changed)| {
|
||||
(edge.id != changed)
|
||||
.then(|| baseline.edges.get(&edge.id))
|
||||
@@ -494,6 +382,10 @@ fn route_diagram_impl(
|
||||
for &point in &cached.points {
|
||||
bounds.point(point);
|
||||
}
|
||||
if !cached.label.text.is_empty() {
|
||||
bounds.rect(cached.label.bounds.position, cached.label.bounds.size);
|
||||
previous_labels.push(cached.label.bounds);
|
||||
}
|
||||
record_segments(&mut previous, &cached.points);
|
||||
edges.insert(edge.id, cached.clone());
|
||||
continue;
|
||||
@@ -508,8 +400,19 @@ fn route_diagram_impl(
|
||||
let custom_suspended = promoted;
|
||||
let route = if promoted { None } else { stored };
|
||||
let pins = route.map_or_else(Vec::new, |r| r.waypoints.clone());
|
||||
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
|
||||
|
||||
let mut traversable = BTreeSet::new();
|
||||
ancestors(edge.source, &nodes, &mut traversable);
|
||||
ancestors(edge.target, &nodes, &mut traversable);
|
||||
let obstacles: Vec<_> = rectangles
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut r = *r;
|
||||
if traversable.contains(&r.id) {
|
||||
r.bottom = r.bottom.min(r.top + headers[&r.id]);
|
||||
}
|
||||
r.inflate(CLEARANCE)
|
||||
})
|
||||
.collect();
|
||||
let cached = preview.and_then(|(baseline, changed)| {
|
||||
(edge.id == changed)
|
||||
.then(|| baseline.edges.get(&edge.id))
|
||||
@@ -520,11 +423,24 @@ fn route_diagram_impl(
|
||||
if cached.pins == pins && cached.custom_suspended == custom_suspended {
|
||||
// Label-only editing cannot change the connection beneath the
|
||||
// pointer, including a settled fallback with routing warnings.
|
||||
for &point in &cached.points {
|
||||
let mut updated = cached.clone();
|
||||
updated.label = make_label(
|
||||
project,
|
||||
edge.id,
|
||||
&updated.points,
|
||||
route.and_then(|r| r.label.as_ref()),
|
||||
&obstacles,
|
||||
&previous_labels,
|
||||
);
|
||||
for &point in &updated.points {
|
||||
bounds.point(point);
|
||||
}
|
||||
record_segments(&mut previous, &cached.points);
|
||||
edges.insert(edge.id, cached.clone());
|
||||
if !updated.label.text.is_empty() {
|
||||
bounds.rect(updated.label.bounds.position, updated.label.bounds.size);
|
||||
previous_labels.push(updated.label.bounds);
|
||||
}
|
||||
record_segments(&mut previous, &updated.points);
|
||||
edges.insert(edge.id, updated);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -559,16 +475,28 @@ fn route_diagram_impl(
|
||||
.into_iter()
|
||||
.map(|v| if total > 0.0 { v / total } else { 0.0 })
|
||||
.collect();
|
||||
let label = make_label(
|
||||
project,
|
||||
edge.id,
|
||||
&points,
|
||||
route.and_then(|r| r.label.as_ref()),
|
||||
&obstacles,
|
||||
&previous_labels,
|
||||
);
|
||||
for &point in &points {
|
||||
bounds.point(point);
|
||||
}
|
||||
if !label.text.is_empty() {
|
||||
bounds.rect(label.bounds.position, label.bounds.size);
|
||||
previous_labels.push(label.bounds);
|
||||
}
|
||||
record_segments(&mut previous, &points);
|
||||
edges.insert(
|
||||
edge.id,
|
||||
RoutedEdge {
|
||||
id: edge.id,
|
||||
points,
|
||||
label: empty_label(),
|
||||
label,
|
||||
pins,
|
||||
pin_fractions,
|
||||
warnings,
|
||||
@@ -577,76 +505,12 @@ fn route_diagram_impl(
|
||||
},
|
||||
);
|
||||
}
|
||||
// Settle all paths before placing text: a later connection can cross an
|
||||
// earlier label even when the labels themselves do not overlap. This pass
|
||||
// never changes paths or authored metadata. Previews retain other cached
|
||||
// labels and avoid the paths actually shown during the gesture.
|
||||
let mut previous_labels = Vec::new();
|
||||
for routed in edges.values() {
|
||||
if let Some(relation) = project.relations.get(&routed.id) {
|
||||
for end in association_end_geometry(relation, routed) {
|
||||
bounds.rect(end.label.bounds.position, end.label.bounds.size);
|
||||
previous_labels.push(end.label.bounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
for edge in ordered {
|
||||
let Some(routed) = edges.get_mut(&edge.id) else {
|
||||
continue;
|
||||
};
|
||||
if preview.is_none_or(|(baseline, changed)| {
|
||||
edge.id == changed || !baseline.edges.contains_key(&edge.id)
|
||||
}) {
|
||||
let obstacles = edge_obstacles(edge, &nodes, &rectangles, &headers);
|
||||
let authored_label = (!routed.custom_suspended)
|
||||
.then(|| {
|
||||
authored
|
||||
.get(&edge.id)
|
||||
.and_then(|route| route.label.as_ref())
|
||||
})
|
||||
.flatten();
|
||||
routed.label = make_label(
|
||||
project,
|
||||
edge.id,
|
||||
&routed.points,
|
||||
authored_label,
|
||||
&obstacles,
|
||||
&previous_labels,
|
||||
&previous,
|
||||
);
|
||||
}
|
||||
if !routed.label.text.is_empty() {
|
||||
bounds.rect(routed.label.bounds.position, routed.label.bounds.size);
|
||||
previous_labels.push(routed.label.bounds);
|
||||
}
|
||||
}
|
||||
RoutedView {
|
||||
edges,
|
||||
bounds: bounds.finish(),
|
||||
}
|
||||
}
|
||||
|
||||
fn edge_obstacles(
|
||||
edge: &ProjectedEdge,
|
||||
nodes: &BTreeMap<Id, &ProjectedNode>,
|
||||
rectangles: &[Rect],
|
||||
headers: &BTreeMap<Id, f32>,
|
||||
) -> Vec<Rect> {
|
||||
let mut traversable = BTreeSet::new();
|
||||
ancestors(edge.source, nodes, &mut traversable);
|
||||
ancestors(edge.target, nodes, &mut traversable);
|
||||
rectangles
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let mut r = *r;
|
||||
if traversable.contains(&r.id) {
|
||||
r.bottom = r.bottom.min(r.top + headers[&r.id]);
|
||||
}
|
||||
r.inflate(CLEARANCE)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Segment {
|
||||
a: Position,
|
||||
@@ -703,7 +567,7 @@ fn route_edge(
|
||||
let mut candidates = Vec::new();
|
||||
for a in endpoints(source, edge.source_port, view) {
|
||||
for b in endpoints(target, edge.target_port, view) {
|
||||
// A real self-relation must leave and return at distinct anchors.
|
||||
// A real self-transition must leave and return at distinct anchors.
|
||||
// Reusing one anchor makes the cheapest path a degenerate stub.
|
||||
if edge.source == edge.target
|
||||
&& edge.source_port.is_none()
|
||||
@@ -1231,8 +1095,7 @@ fn label_text(project: &Project, edge: Id) -> String {
|
||||
let text = match relation.kind() {
|
||||
RelationKind::Interaction
|
||||
| RelationKind::FunctionalExchange
|
||||
| RelationKind::BoundaryExchange
|
||||
| RelationKind::DataAssociation => relation.name.as_str(),
|
||||
| RelationKind::BoundaryExchange => relation.name.as_str(),
|
||||
RelationKind::CapabilityInclude => "«include»",
|
||||
RelationKind::CapabilityExtend => "«extend»",
|
||||
RelationKind::CapabilityGeneralization => "specializes",
|
||||
@@ -1246,22 +1109,6 @@ fn label_text(project: &Project, edge: Id) -> String {
|
||||
text.to_owned()
|
||||
}
|
||||
}
|
||||
fn empty_label() -> EdgeLabelGeometry {
|
||||
EdgeLabelGeometry {
|
||||
text: String::new(),
|
||||
fraction: 0.5,
|
||||
anchor: pos(0.0, 0.0),
|
||||
center: pos(0.0, 0.0),
|
||||
bounds: RoutingBounds {
|
||||
position: pos(0.0, 0.0),
|
||||
size: Size {
|
||||
width: 0.0,
|
||||
height: 0.0,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn make_label(
|
||||
project: &Project,
|
||||
edge: Id,
|
||||
@@ -1269,7 +1116,6 @@ fn make_label(
|
||||
authored: Option<&crate::EdgeLabel>,
|
||||
obstacles: &[Rect],
|
||||
previous_labels: &[RoutingBounds],
|
||||
strokes: &[Segment],
|
||||
) -> EdgeLabelGeometry {
|
||||
let text = label_text(project, edge);
|
||||
let size = Size {
|
||||
@@ -1294,8 +1140,8 @@ fn make_label(
|
||||
)
|
||||
};
|
||||
let mut fraction = authored.map_or(0.5, |l| l.fraction);
|
||||
// Keep authored placement exact. Automatic labels avoid all visible
|
||||
// strokes, with earlier stable-ID labels taking precedence.
|
||||
// Keep authored placement exact. Automatic labels share the stable-ID
|
||||
// prefix used for route scoring, so previews and settled solves agree.
|
||||
if authored.is_none() && !text.is_empty() {
|
||||
let total = total_length(points);
|
||||
let mut traversed = 0.0;
|
||||
@@ -1339,7 +1185,6 @@ fn make_label(
|
||||
candidate,
|
||||
obstacles,
|
||||
previous_labels,
|
||||
strokes,
|
||||
best.as_ref().map(|(score, _, _)| *score),
|
||||
&mut comparisons,
|
||||
) else {
|
||||
@@ -1348,7 +1193,7 @@ fn make_label(
|
||||
if best.as_ref().is_none_or(|(old, _, _)| score < *old) {
|
||||
best = Some((score, candidate_fraction, candidate_offset));
|
||||
}
|
||||
if score == (0, 0, 0) {
|
||||
if score == (0, 0) {
|
||||
break 'candidate;
|
||||
}
|
||||
}
|
||||
@@ -1372,10 +1217,9 @@ fn label_collisions(
|
||||
bounds: RoutingBounds,
|
||||
obstacles: &[Rect],
|
||||
labels: &[RoutingBounds],
|
||||
strokes: &[Segment],
|
||||
best: Option<(usize, usize, usize)>,
|
||||
best: Option<(usize, usize)>,
|
||||
comparisons: &mut usize,
|
||||
) -> Option<(usize, usize, usize)> {
|
||||
) -> Option<(usize, usize)> {
|
||||
let mut nodes = 0;
|
||||
for obstacle in obstacles {
|
||||
if *comparisons == 0 {
|
||||
@@ -1386,7 +1230,7 @@ fn label_collisions(
|
||||
nodes += 1;
|
||||
}
|
||||
if best.is_some_and(|score| nodes > score.0) {
|
||||
return Some((nodes, usize::MAX, usize::MAX));
|
||||
return Some((nodes, usize::MAX));
|
||||
}
|
||||
}
|
||||
// Once a candidate is worse than the current best, additional collisions
|
||||
@@ -1408,35 +1252,11 @@ fn label_collisions(
|
||||
{
|
||||
collisions += 1;
|
||||
if collisions >= label_limit {
|
||||
return Some((nodes, collisions, usize::MAX));
|
||||
}
|
||||
}
|
||||
}
|
||||
let text = Rect {
|
||||
id: Id::nil(),
|
||||
left: bounds.position.x,
|
||||
top: bounds.position.y,
|
||||
right: bounds.position.x + bounds.size.width,
|
||||
bottom: bounds.position.y + bounds.size.height,
|
||||
}
|
||||
.inflate(3.0);
|
||||
let stroke_limit = best
|
||||
.filter(|score| (nodes, collisions) == (score.0, score.1))
|
||||
.map_or(usize::MAX, |score| score.2.saturating_add(1));
|
||||
let mut crossings = 0;
|
||||
for stroke in strokes {
|
||||
if *comparisons == 0 {
|
||||
return None;
|
||||
}
|
||||
*comparisons -= 1;
|
||||
if text.crosses(stroke.a, stroke.b) {
|
||||
crossings += 1;
|
||||
if crossings >= stroke_limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((nodes, collisions, crossings))
|
||||
Some((nodes, collisions))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -1458,22 +1278,11 @@ impl BoundsBuilder {
|
||||
}
|
||||
fn finish(self) -> RoutingBounds {
|
||||
let (left, top, right, bottom) = self.extents.unwrap_or((0.0, 0.0, 0.0, 0.0));
|
||||
// Subtraction and subsequent addition can round in opposite directions
|
||||
// when an authored label extends far left/up. Bounds must still enclose
|
||||
// every original point, including the far right and bottom edges.
|
||||
let span = |start: f32, end: f32| {
|
||||
let extent = end - start;
|
||||
if start + extent < end {
|
||||
extent.next_up()
|
||||
} else {
|
||||
extent
|
||||
}
|
||||
};
|
||||
RoutingBounds {
|
||||
position: pos(left, top),
|
||||
size: Size {
|
||||
width: span(left, right),
|
||||
height: span(top, bottom),
|
||||
width: right - left,
|
||||
height: bottom - top,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,16 +44,6 @@ pub enum Command {
|
||||
diagram: Id,
|
||||
edge: Id,
|
||||
},
|
||||
SetPortAnchor {
|
||||
diagram: Id,
|
||||
node: Id,
|
||||
anchor: PortAnchor,
|
||||
},
|
||||
RemovePortAnchor {
|
||||
diagram: Id,
|
||||
node: Id,
|
||||
port: Id,
|
||||
},
|
||||
RelocateNode {
|
||||
node: Id,
|
||||
diagram: Id,
|
||||
@@ -114,7 +104,6 @@ pub struct PreparedCommand {
|
||||
struct IdentityProvenance {
|
||||
original: BTreeSet<Id>,
|
||||
current_to_original: BTreeMap<Id, Id>,
|
||||
explicit_replacement: bool,
|
||||
}
|
||||
|
||||
impl IdentityProvenance {
|
||||
@@ -124,12 +113,10 @@ impl IdentityProvenance {
|
||||
Self {
|
||||
original,
|
||||
current_to_original,
|
||||
explicit_replacement: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn replace(&mut self, project: &Project) {
|
||||
self.explicit_replacement = true;
|
||||
let present: BTreeSet<_> = project.identities().into_iter().map(|(id, _)| id).collect();
|
||||
self.current_to_original = self
|
||||
.original
|
||||
@@ -183,13 +170,6 @@ pub fn prepare_command(
|
||||
)?;
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
provenance.retain_present(&candidate);
|
||||
if !provenance.explicit_replacement {
|
||||
crate::data::prune_removed_inheritance(
|
||||
project,
|
||||
&mut candidate,
|
||||
&provenance.current_to_original,
|
||||
);
|
||||
}
|
||||
validate_candidate(&candidate)?;
|
||||
Ok(PreparedCommand {
|
||||
project: candidate,
|
||||
@@ -297,13 +277,6 @@ pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project,
|
||||
)?;
|
||||
}
|
||||
crate::references::finish_deletions(&mut candidate, &pending_deletions);
|
||||
if !provenance.explicit_replacement {
|
||||
crate::data::prune_removed_inheritance(
|
||||
project,
|
||||
&mut candidate,
|
||||
&provenance.current_to_original,
|
||||
);
|
||||
}
|
||||
validate_candidate(&candidate)?;
|
||||
Ok(candidate)
|
||||
}
|
||||
@@ -706,45 +679,6 @@ fn apply_command(
|
||||
.ok_or_else(missing)? = value.clone();
|
||||
Some(*owner)
|
||||
}
|
||||
Command::SetPortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
anchor,
|
||||
} => {
|
||||
let n = p
|
||||
.diagrams
|
||||
.get_mut(diagram)
|
||||
.and_then(|d| d.nodes.get_mut(node))
|
||||
.ok_or_else(missing)?;
|
||||
if let Some(existing) = n.port_anchors.iter_mut().find(|a| a.port == anchor.port) {
|
||||
*existing = anchor.clone();
|
||||
} else {
|
||||
n.port_anchors.push(anchor.clone());
|
||||
}
|
||||
Some(anchor.port)
|
||||
}
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port,
|
||||
} => {
|
||||
let n = p
|
||||
.diagrams
|
||||
.get_mut(diagram)
|
||||
.and_then(|d| d.nodes.get_mut(node))
|
||||
.ok_or_else(missing)?;
|
||||
if !p
|
||||
.elements
|
||||
.get(port)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
|
||||
{
|
||||
return Err(ModelError(
|
||||
"Port positioning requires a direct child port of the owner placement.".into(),
|
||||
));
|
||||
}
|
||||
n.port_anchors.retain(|anchor| anchor.port != *port);
|
||||
Some(*port)
|
||||
}
|
||||
Command::SetEdgeRoute { diagram, route } => {
|
||||
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
|
||||
if let Some(existing) = d.routes.iter_mut().find(|r| r.edge == route.edge) {
|
||||
|
||||
@@ -999,7 +999,7 @@ mod transaction_tests {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(directory.path().join("a.arcadia"), "oa_activity a \"A\" {}").unwrap();
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
//! Pure, bounded source projects. All includes resolve against supplied UTF-8
|
||||
//! Pure, bounded source3 projects. All includes resolve against supplied UTF-8
|
||||
//! documents; this module never reads a filesystem, URL, or process directory.
|
||||
use crate::{Id, Kind, Project, language, persistence::DecodeResult, validation};
|
||||
use language::{Diagnostic, ParseLimits, Span};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
#[path = "source_project/source_migration.rs"]
|
||||
mod source_migration;
|
||||
#[path = "source_project/source_structure.rs"]
|
||||
mod source_structure;
|
||||
|
||||
@@ -280,24 +278,21 @@ pub fn lint_bundle(bundle: &SourceBundle, limits: ProjectLimits) -> Vec<SourceDi
|
||||
}
|
||||
}
|
||||
|
||||
/// Compile reachable source3/4 files, explicitly upgrade data contracts, and stabilize UUIDs.
|
||||
/// Compile all reachable files as one source3 model and stabilize omitted UUIDs.
|
||||
/// Missing/unreachable files and every admission or engineering error leave the
|
||||
/// input untouched. V1/V2 single-file migration remains in `language::parse`.
|
||||
pub fn compile_bundle(
|
||||
bundle: &SourceBundle,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
let (mut compiled, insertions) = compile_once(bundle, limits)?;
|
||||
if !insertions.is_empty() {
|
||||
let mut stable = bundle.clone();
|
||||
apply_edits(&mut stable, insertions)?;
|
||||
// Re-index actual returned bytes before performing the schema upgrade.
|
||||
compiled = compile_once(&stable, limits)?.0;
|
||||
let (compiled, insertions) = compile_once(bundle, limits)?;
|
||||
if insertions.is_empty() {
|
||||
return Ok(compiled);
|
||||
}
|
||||
if compiled.decoded.migration.is_some() {
|
||||
return source_migration::upgrade(&compiled, limits);
|
||||
}
|
||||
Ok(compiled)
|
||||
let mut stable = bundle.clone();
|
||||
apply_edits(&mut stable, insertions)?;
|
||||
// Re-index the actual returned bytes. Never expose offsets into pre-ID text.
|
||||
compile_once(&stable, limits).map(|(compiled, _)| compiled)
|
||||
}
|
||||
|
||||
fn compile_once(
|
||||
@@ -573,7 +568,7 @@ fn compile_once(
|
||||
span,
|
||||
}),
|
||||
};
|
||||
let decoded =
|
||||
let project =
|
||||
language::resolve_declarations(header.project, &declarations, &aliases, limits.model)
|
||||
.map_err(|(index, error)| {
|
||||
let path = declaration_paths.get(index).unwrap_or(&bundle.entry);
|
||||
@@ -583,7 +578,7 @@ fn compile_once(
|
||||
related: Vec::new(),
|
||||
}]
|
||||
})?;
|
||||
let diagnostics = model_diagnostics(bundle, &index, &decoded.project);
|
||||
let diagnostics = model_diagnostics(bundle, &index, &project);
|
||||
let errors: Vec<_> = diagnostics
|
||||
.iter()
|
||||
.filter(|issue| issue.diagnostic.severity == validation::Severity::Error)
|
||||
@@ -595,7 +590,10 @@ fn compile_once(
|
||||
Ok((
|
||||
CompiledSourceProject {
|
||||
bundle: bundle.clone(),
|
||||
decoded,
|
||||
decoded: DecodeResult {
|
||||
project,
|
||||
migration: None,
|
||||
},
|
||||
index,
|
||||
diagnostics,
|
||||
},
|
||||
@@ -720,7 +718,7 @@ fn apply_edits(bundle: &mut SourceBundle, edits: Edits) -> Result<(), Vec<Source
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Canonical, readable source4 export. Like `language::format`, the caller is
|
||||
/// Canonical, readable source3 export. Like `language::format`, the caller is
|
||||
/// responsible for supplying a valid model; compilation checks the result.
|
||||
/// Existing source layout is preserved by `update_bundle`, not this formatter.
|
||||
pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
@@ -736,7 +734,7 @@ pub fn format_bundle(project: &Project) -> SourceBundle {
|
||||
.push_str(&unindent(&declaration));
|
||||
}
|
||||
let mut entry = format!(
|
||||
"arcadia 4;\n{}{{\n description {};\n",
|
||||
"arcadia 3;\n{}{{\n description {};\n",
|
||||
project_header(project),
|
||||
quote(&project.description)
|
||||
);
|
||||
@@ -925,7 +923,6 @@ pub fn update_bundle_with_identity_map(
|
||||
after: new_text,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
};
|
||||
let local = edits.entry(path.clone()).or_default();
|
||||
update.header(&actual.header, &previous.header, &next.header, local);
|
||||
@@ -954,7 +951,6 @@ pub fn update_bundle_with_identity_map(
|
||||
after: &after,
|
||||
old_aliases: &old_aliases,
|
||||
new_aliases: &new_aliases,
|
||||
legacy_value_paths: false,
|
||||
};
|
||||
let mut local = Vec::new();
|
||||
update.header(
|
||||
@@ -1123,9 +1119,6 @@ struct SyntaxUpdate<'a> {
|
||||
after: &'a str,
|
||||
old_aliases: &'a BTreeMap<String, String>,
|
||||
new_aliases: &'a BTreeMap<String, String>,
|
||||
// Source3 migration only adds path qualification; the existing field identity
|
||||
// must keep its authored argument trivia during that one-time transformation.
|
||||
legacy_value_paths: bool,
|
||||
}
|
||||
impl SyntaxUpdate<'_> {
|
||||
fn header(
|
||||
@@ -1345,11 +1338,11 @@ impl SyntaxUpdate<'_> {
|
||||
let previous = before.unwrap_or(&[]);
|
||||
let old_keys: Option<Vec<_>> = previous
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.before, self.old_aliases, self.legacy_value_paths))
|
||||
.map(|item| item_key(item, self.before, self.old_aliases))
|
||||
.collect();
|
||||
let new_keys: Option<Vec<_>> = after
|
||||
.iter()
|
||||
.map(|item| item_key(item, self.after, self.new_aliases, self.legacy_value_paths))
|
||||
.map(|item| item_key(item, self.after, self.new_aliases))
|
||||
.collect();
|
||||
let keyed = old_keys.zip(new_keys).filter(|(old, new)| {
|
||||
old.iter().collect::<BTreeSet<_>>().len() == old.len()
|
||||
@@ -1456,7 +1449,6 @@ fn item_key(
|
||||
value: &language::EditValue,
|
||||
source: &str,
|
||||
aliases: &BTreeMap<String, String>,
|
||||
legacy_value_paths: bool,
|
||||
) -> Option<String> {
|
||||
use language::EditKind;
|
||||
match &value.kind {
|
||||
@@ -1465,41 +1457,15 @@ fn item_key(
|
||||
"literal:{}",
|
||||
&source[value.span.start..value.span.end]
|
||||
)),
|
||||
EditKind::Record(fields) => ["field", "referenced", "edge", "port"]
|
||||
.into_iter()
|
||||
.find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| {
|
||||
if key == "field" && !legacy_value_paths {
|
||||
let path = fields
|
||||
.get("path")
|
||||
.and_then(|field| {
|
||||
if let EditKind::List(path) = &field.value.kind {
|
||||
path.iter()
|
||||
.map(|hop| {
|
||||
if let EditKind::Reference(alias) = &hop.kind {
|
||||
aliases.get(alias).cloned()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Option<Vec<_>>>()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
format!("field:{}:{id}", path.join("/"))
|
||||
} else {
|
||||
format!("{key}:{id}")
|
||||
}
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
EditKind::Record(fields) => ["field", "referenced", "edge"].into_iter().find_map(|key| {
|
||||
fields.get(key).and_then(|field| {
|
||||
if let EditKind::Reference(alias) = &field.value.kind {
|
||||
aliases.get(alias).map(|id| format!("{key}:{id}"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}),
|
||||
EditKind::List(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
//! Source3 data upgrade through token edits; authored modules, nesting and trivia survive.
|
||||
use super::*;
|
||||
|
||||
pub(super) fn upgrade(
|
||||
current: &CompiledSourceProject,
|
||||
limits: ProjectLimits,
|
||||
) -> Result<CompiledSourceProject, Vec<SourceDiagnostic>> {
|
||||
let report = current
|
||||
.decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.expect("an explicit source migration report");
|
||||
let project = ¤t.decoded.project;
|
||||
let canonical: BTreeMap<_, _> = language::format_declarations(project).into_iter().collect();
|
||||
let changed: BTreeSet<_> = report
|
||||
.semantic_defaults
|
||||
.iter()
|
||||
.map(|default| default.object)
|
||||
.collect();
|
||||
let aliases: BTreeMap<_, _> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, alias)| (alias.to_owned(), id.to_string()))
|
||||
.collect();
|
||||
let mut edits: Edits = BTreeMap::new();
|
||||
for (path, source) in ¤t.bundle.files {
|
||||
let declarations =
|
||||
language::edit_declarations(source, path == ¤t.bundle.entry, limits.model)
|
||||
.map_err(|diagnostic| {
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: *diagnostic,
|
||||
related: vec![],
|
||||
}]
|
||||
})?;
|
||||
for (id, actual) in declarations {
|
||||
if !changed.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let Some(after) = canonical.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
let mut next = language::edit_declaration(after).map_err(|diagnostic| {
|
||||
vec![SourceDiagnostic {
|
||||
path: path.clone(),
|
||||
diagnostic: *diagnostic,
|
||||
related: vec![],
|
||||
}]
|
||||
})?;
|
||||
if actual.lexical_owner.is_some()
|
||||
&& let language::EditKind::Record(fields) = &actual.fields.kind
|
||||
&& !fields.contains_key("parent")
|
||||
&& let language::EditKind::Record(fields) = &mut next.fields.kind
|
||||
{
|
||||
fields.remove("parent");
|
||||
}
|
||||
let update = SyntaxUpdate {
|
||||
source,
|
||||
before: source,
|
||||
after,
|
||||
old_aliases: &aliases,
|
||||
new_aliases: &aliases,
|
||||
legacy_value_paths: true,
|
||||
};
|
||||
update.value(
|
||||
&actual.fields,
|
||||
Some(&actual.fields),
|
||||
&next.fields,
|
||||
edits.entry(path.clone()).or_default(),
|
||||
);
|
||||
}
|
||||
}
|
||||
let source = ¤t.bundle.files[¤t.bundle.entry];
|
||||
let version = significant(source)
|
||||
.get(1)
|
||||
.map(|token| token.span)
|
||||
.expect("a parsed version token");
|
||||
edits
|
||||
.entry(current.bundle.entry.clone())
|
||||
.or_default()
|
||||
.push((version, "4".into()));
|
||||
// New allocation relations are emitted into the allocating interface's
|
||||
// existing module, outside its lexical element body. Existing source never moves.
|
||||
let mut added: BTreeMap<String, String> = BTreeMap::new();
|
||||
for allocation in &report.generated_interface_allocations {
|
||||
let path = current
|
||||
.index
|
||||
.declarations
|
||||
.get(&allocation.source)
|
||||
.map(|location| location.path.clone())
|
||||
.unwrap_or_else(|| current.bundle.entry.clone());
|
||||
let ending = newline(¤t.bundle.files[&path]);
|
||||
let text = added.entry(path).or_default();
|
||||
text.push_str(ending);
|
||||
text.push_str(&canonical[&allocation.id].replace('\n', ending));
|
||||
}
|
||||
for (path, text) in added {
|
||||
let offset = if path == current.bundle.entry {
|
||||
current.index.project_body.span.end
|
||||
} else {
|
||||
current.bundle.files[&path].len()
|
||||
};
|
||||
edits.entry(path).or_default().push((
|
||||
Span {
|
||||
start: offset,
|
||||
end: offset,
|
||||
},
|
||||
text,
|
||||
));
|
||||
}
|
||||
let mut bundle = current.bundle.clone();
|
||||
apply_edits(&mut bundle, edits)?;
|
||||
let (mut result, insertions) = compile_once(&bundle, limits)?;
|
||||
if !insertions.is_empty() || result.decoded.project != *project {
|
||||
return Err(vec![problem(
|
||||
&bundle,
|
||||
&bundle.entry,
|
||||
Span::default(),
|
||||
"migration_source_mismatch",
|
||||
"Targeted source3 upgrade did not reproduce the migrated project.",
|
||||
)]);
|
||||
}
|
||||
result.decoded.migration = Some(report.clone());
|
||||
Ok(result)
|
||||
}
|
||||
@@ -251,8 +251,6 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
|
||||
}
|
||||
(
|
||||
RelationBody::Allocation { .. }
|
||||
| RelationBody::InterfaceItemAllocation { .. }
|
||||
| RelationBody::DataAssociation { .. }
|
||||
| RelationBody::ExchangeAllocation { .. }
|
||||
| RelationBody::CapabilityInvolvement { .. }
|
||||
| RelationBody::CapabilityInclude { .. }
|
||||
@@ -423,24 +421,6 @@ pub fn plan_transition(
|
||||
}
|
||||
if let Some(e) = project.elements.get(&id) {
|
||||
selected.extend(crate::references::element_references(e));
|
||||
// Allocations are owned by interfaces. Independent data
|
||||
// associations travel only when both endpoint types are
|
||||
// already required; they do not expand the dictionary.
|
||||
if matches!(e.kind(), Kind::Interface | Kind::DataType) {
|
||||
let contracts: Vec<_> = project
|
||||
.relations
|
||||
.values()
|
||||
.filter_map(|r| {
|
||||
((r.kind() == RelationKind::InterfaceItemAllocation
|
||||
&& r.source() == id)
|
||||
|| (r.kind() == RelationKind::DataAssociation
|
||||
&& selected.contains(&r.source())
|
||||
&& selected.contains(&r.target())))
|
||||
.then_some(r.id)
|
||||
})
|
||||
.collect();
|
||||
selected.extend(contracts);
|
||||
}
|
||||
// Contextual links are owned by the process/chain shared by
|
||||
// their endpoint involvements, independently of list order.
|
||||
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
|
||||
@@ -1001,7 +981,6 @@ pub fn plan_transition(
|
||||
height: 94.,
|
||||
},
|
||||
collapsed: false,
|
||||
port_anchors: Vec::new(),
|
||||
};
|
||||
working
|
||||
.diagrams
|
||||
|
||||
@@ -388,10 +388,68 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
if let Some(shared) = e.shared() {
|
||||
match shared {
|
||||
SharedBody::DataType { .. }
|
||||
| SharedBody::DataField { .. }
|
||||
| SharedBody::Interface { .. }
|
||||
| SharedBody::ExchangeItem { .. } => crate::data::validate_element(p, e, &mut out),
|
||||
SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Enumeration { literals },
|
||||
} => {
|
||||
let mut seen = BTreeSet::new();
|
||||
for literal in literals {
|
||||
text(&mut out, e.id, "literals", literal);
|
||||
if !seen.insert(literal) {
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"duplicate_literal",
|
||||
"literals",
|
||||
"Enumeration literals must be unique.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
SharedBody::DataField {
|
||||
type_ref,
|
||||
minimum,
|
||||
maximum,
|
||||
unit,
|
||||
} => {
|
||||
if maximum.is_some_and(|max| max < *minimum) {
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"invalid_multiplicity",
|
||||
"maximum",
|
||||
"Maximum multiplicity must be at least minimum, or unbounded.",
|
||||
);
|
||||
}
|
||||
text(&mut out, e.id, "unit", unit);
|
||||
if let TypeRef::Named(id) = type_ref {
|
||||
expect_element(p, &mut out, e.id, *id, "type_ref", |k| k == Kind::DataType);
|
||||
}
|
||||
if let Some(parent) = e.parent.and_then(|id| p.elements.get(&id))
|
||||
&& parent.kind() == Kind::DataType
|
||||
&& !matches!(
|
||||
parent.shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record
|
||||
})
|
||||
)
|
||||
{
|
||||
error(
|
||||
&mut out,
|
||||
e.id,
|
||||
"field_owner",
|
||||
"parent",
|
||||
"Fields under a data type require a Record type.",
|
||||
);
|
||||
}
|
||||
}
|
||||
SharedBody::Interface { items } => {
|
||||
check_ids(&mut out, e.id, "items", items);
|
||||
for id in items {
|
||||
expect_element(p, &mut out, e.id, *id, "items", |k| {
|
||||
k == Kind::ExchangeItem
|
||||
});
|
||||
}
|
||||
}
|
||||
SharedBody::Port { .. } => crate::ports::validate_port(p, e, &mut out),
|
||||
SharedBody::Scenario {
|
||||
viewpoint,
|
||||
@@ -585,11 +643,8 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
text(&mut out, r.id, "name", &r.name);
|
||||
text(&mut out, r.id, "description", &r.description);
|
||||
validate_relation(p, r, &mut out);
|
||||
crate::data::validate_relation(p, r, &mut out);
|
||||
crate::ports::validate_boundary_interface_requirements(p, r, &mut out);
|
||||
validate_relation_context(p, r, &mut out);
|
||||
}
|
||||
crate::data::validate_record_cycles(p, &mut out);
|
||||
for (key, d) in &p.diagrams {
|
||||
if *key != d.id {
|
||||
error(
|
||||
@@ -705,40 +760,6 @@ pub fn validate(p: &Project) -> Vec<Issue> {
|
||||
}
|
||||
let mut elements = BTreeSet::new();
|
||||
for (key, n) in &d.nodes {
|
||||
let mut ports = BTreeSet::new();
|
||||
for anchor in &n.port_anchors {
|
||||
if !ports.insert(anchor.port) {
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"duplicate_port_anchor",
|
||||
"port_anchors",
|
||||
"Each port has at most one manual anchor per owner placement.",
|
||||
);
|
||||
}
|
||||
if !p
|
||||
.elements
|
||||
.get(&anchor.port)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent == Some(n.element))
|
||||
{
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"port_anchor_owner",
|
||||
"port_anchors",
|
||||
"A manual anchor must reference a direct child port of its owner placement; repair anchors in the same transaction when reparenting.",
|
||||
);
|
||||
}
|
||||
if !anchor.offset.is_finite() || !(0. ..=1.).contains(&anchor.offset) {
|
||||
error(
|
||||
&mut out,
|
||||
n.id,
|
||||
"invalid_port_offset",
|
||||
"port_anchors",
|
||||
"Port offset must be finite and between zero and one.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if *key != n.id {
|
||||
error(
|
||||
&mut out,
|
||||
@@ -1380,10 +1401,23 @@ fn enclosing_loops(p: &Project, occurrence: Id) -> BTreeSet<Id> {
|
||||
}
|
||||
/// Fields carried by an exchange, including fields of nested named records.
|
||||
pub fn carried_fields(p: &Project, relation: &Relation) -> BTreeSet<Id> {
|
||||
crate::data::carried_field_paths(p, relation)
|
||||
.into_iter()
|
||||
.map(|f| f.field)
|
||||
.collect()
|
||||
let mut pending = relation.body.items().to_vec();
|
||||
let mut seen = BTreeSet::new();
|
||||
let mut fields = BTreeSet::new();
|
||||
while let Some(owner) = pending.pop() {
|
||||
if !seen.insert(owner) {
|
||||
continue;
|
||||
}
|
||||
for field in p.children(owner) {
|
||||
if let Some(SharedBody::DataField { type_ref, .. }) = field.shared() {
|
||||
fields.insert(field.id);
|
||||
if let TypeRef::Named(id) = type_ref {
|
||||
pending.push(*id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fields
|
||||
}
|
||||
fn occurrence_uses_lifeline(body: &OccurrenceBody, line: Id) -> bool {
|
||||
match body {
|
||||
@@ -1614,125 +1648,36 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
|
||||
}
|
||||
}
|
||||
}
|
||||
let relation = exchange.and_then(|id| p.relations.get(&id));
|
||||
if message_kind == &MessageKind::Call
|
||||
&& relation.is_some_and(|r| {
|
||||
!r.body
|
||||
.items()
|
||||
.iter()
|
||||
.any(|id| crate::data::item_mechanism(p, *id) == Some(ExchangeMechanism::Operation))
|
||||
})
|
||||
{
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"call_without_operation",
|
||||
"exchange",
|
||||
"This generic call carries no Operation item; define an operation contract when appropriate.",
|
||||
);
|
||||
}
|
||||
let allowed = exchange
|
||||
.and_then(|id| p.relations.get(&id))
|
||||
.map(|r| carried_fields(p, r));
|
||||
let mut fields = BTreeSet::new();
|
||||
for value in values {
|
||||
expect_element(p, out, e.id, value.field, "values.field", |kind| {
|
||||
kind == Kind::DataField
|
||||
});
|
||||
for hop in &value.path {
|
||||
expect_element(p, out, e.id, *hop, "values.path", |kind| {
|
||||
kind == Kind::DataField
|
||||
});
|
||||
}
|
||||
text(out, e.id, "values.expression", &value.expression);
|
||||
if !fields.insert((&value.path, value.field)) {
|
||||
if !fields.insert(value.field) {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"duplicate_message_value",
|
||||
"values",
|
||||
"Each qualified carried field has at most one contextual value annotation.",
|
||||
"Each carried field has at most one contextual value annotation in a message.",
|
||||
);
|
||||
}
|
||||
if value.path.len() > crate::data::MAX_VALUE_PATH_DEPTH {
|
||||
if allowed
|
||||
.as_ref()
|
||||
.is_some_and(|allowed| !allowed.contains(&value.field))
|
||||
{
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_path_limit",
|
||||
"values.path",
|
||||
"The value path exceeds the bounded authoring depth.",
|
||||
"message_value_field",
|
||||
"values.field",
|
||||
"Value annotations must reference fields carried by this message's exchange, including nested record fields.",
|
||||
);
|
||||
}
|
||||
if let Some(r) = relation {
|
||||
if let Some(path) = crate::data::carried_value_path(p, r, &value.path, value.field) {
|
||||
if crate::data::item_mechanism(p, path.item) == Some(ExchangeMechanism::Operation) {
|
||||
let permitted = match message_kind {
|
||||
MessageKind::Call => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Reply => matches!(
|
||||
path.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::Out
|
||||
| ParameterDirection::InOut
|
||||
| ParameterDirection::Return
|
||||
),
|
||||
_ => true,
|
||||
};
|
||||
if !permitted {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_parameter_phase",
|
||||
"values.path",
|
||||
"Call values address In/InOut parameters; replies address Out/InOut/Return parameters.",
|
||||
);
|
||||
}
|
||||
if path.direction == ParameterDirection::Unspecified {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
"message_parameter_direction",
|
||||
"values.path",
|
||||
"The containing operation parameter has no direction yet.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let legacy = value
|
||||
.path
|
||||
.is_empty()
|
||||
.then(|| crate::data::resolve_legacy_field_path(p, r, value.field).ok())
|
||||
.flatten();
|
||||
if matches!(
|
||||
legacy,
|
||||
Some(
|
||||
crate::data::LegacyFieldPathResolution::Unique(_)
|
||||
| crate::data::LegacyFieldPathResolution::Ambiguous
|
||||
)
|
||||
) {
|
||||
warning(
|
||||
out,
|
||||
e.id,
|
||||
if legacy == Some(crate::data::LegacyFieldPathResolution::Ambiguous) {
|
||||
"message_value_ambiguous_path"
|
||||
} else {
|
||||
"message_value_missing_path"
|
||||
},
|
||||
"values.path",
|
||||
"This legacy nested field annotation needs an explicit containing parameter path; its expression is preserved.",
|
||||
);
|
||||
} else {
|
||||
error(
|
||||
out,
|
||||
e.id,
|
||||
"message_value_field",
|
||||
"values.path",
|
||||
"The qualified field must be carried by the exchange, following Value record fields only.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if value.expression.trim().is_empty() {
|
||||
warning(
|
||||
out,
|
||||
@@ -2105,10 +2050,7 @@ fn relation_path_exists(p: &Project, kind: RelationKind, from: Id, to: Id) -> bo
|
||||
fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
let (source, target) = r.body.endpoints();
|
||||
for id in crate::references::relation_references(r) {
|
||||
// Most relation references name elements. Avoid constructing all model
|
||||
// identities per endpoint while retaining the general lookup semantics
|
||||
// for other record kinds and malformed map keys.
|
||||
if !p.elements.get(&id).is_some_and(|element| element.id == id) && !p.contains(id) {
|
||||
if !p.contains(id) {
|
||||
error(
|
||||
out,
|
||||
r.id,
|
||||
@@ -2150,11 +2092,7 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
|
||||
};
|
||||
if a.layer() != b.layer()
|
||||
|| !r.kind().permits(a.kind(), b.kind())
|
||||
|| (a.id == b.id
|
||||
&& !matches!(
|
||||
r.kind(),
|
||||
RelationKind::StateTransition | RelationKind::DataAssociation
|
||||
))
|
||||
|| (a.id == b.id && r.kind() != RelationKind::StateTransition)
|
||||
{
|
||||
error(
|
||||
out,
|
||||
@@ -2405,10 +2343,8 @@ pub fn can_parent(p: &Project, child_kind: Kind, layer: Layer, parent: Option<Id
|
||||
owner.layer() == layer
|
||||
&& parent_permits(owner.kind(), child_kind)
|
||||
&& match owner.shared() {
|
||||
Some(SharedBody::DataType { definition, .. })
|
||||
if child_kind == Kind::DataField =>
|
||||
{
|
||||
matches!(definition, DataTypeDefinition::Record { .. })
|
||||
Some(SharedBody::DataType { definition }) if child_kind == Kind::DataField => {
|
||||
*definition == DataTypeDefinition::Record
|
||||
}
|
||||
Some(SharedBody::Occurrence { body, .. }) if child_kind == Kind::Occurrence => {
|
||||
matches!(
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
// Frozen authored schema3 compatibility input. Do not rewrite as current syntax.
|
||||
arcadia 3;
|
||||
project legacy_data "Legacy data contract" id "00000000-0000-0000-0000-000000000001" {
|
||||
sa_function produce "Produce" id "00000000-0000-0000-0000-000000000002" {}
|
||||
sa_function consume "Consume" id "00000000-0000-0000-0000-000000000003" {}
|
||||
sa_exchange_item packet "Position event" id "00000000-0000-0000-0000-000000000004" {
|
||||
kind Event;
|
||||
// Reused record parameters retain their own identities.
|
||||
sa_data_field origin "Origin" id "00000000-0000-0000-0000-000000000007" { type_ref { Named @coordinate; }; }
|
||||
sa_data_field destination "Destination" id "00000000-0000-0000-0000-000000000008" { type_ref { Named @coordinate; }; }
|
||||
}
|
||||
sa_data_type coordinate "Coordinate" id "00000000-0000-0000-0000-000000000005" {
|
||||
definition Record;
|
||||
sa_data_field latitude "Latitude" id "00000000-0000-0000-0000-000000000006" { type_ref Real; unit "deg"; }
|
||||
}
|
||||
sa_interface sender_contract "Sender contract" id "00000000-0000-0000-0000-000000000009" { items [@packet]; }
|
||||
sa_interface receiver_contract "Receiver contract" id "00000000-0000-0000-0000-00000000000a" { items [@packet]; }
|
||||
sa_port output "Output" id "00000000-0000-0000-0000-00000000000b" {
|
||||
parent @produce; contract { FunctionOutput { outgoing_items [@packet]; }; }; legacy none;
|
||||
}
|
||||
sa_port input "Input" id "00000000-0000-0000-0000-00000000000c" {
|
||||
parent @consume; contract { FunctionInput { incoming_items [@packet]; }; }; legacy none;
|
||||
}
|
||||
functional_exchange position_exchange "Position exchange" id "00000000-0000-0000-0000-00000000000d" {
|
||||
source @produce; target @consume; source_port @output; target_port @input; items [@packet];
|
||||
}
|
||||
sa_scenario scenario "Position scenario" id "00000000-0000-0000-0000-00000000000e" {
|
||||
viewpoint Functional; steps [@message];
|
||||
sa_lifeline sender "Sender" id "00000000-0000-0000-0000-00000000000f" { represented @produce; }
|
||||
sa_lifeline receiver "Receiver" id "00000000-0000-0000-0000-000000000010" { represented @consume; }
|
||||
sa_occurrence message "Position" id "00000000-0000-0000-0000-000000000011" {
|
||||
body { Exchange {
|
||||
exchange @position_exchange; sender @sender; receiver @receiver;
|
||||
values [{ field @latitude; expression "navigation.latitude"; }];
|
||||
}; };
|
||||
}
|
||||
}
|
||||
}
|
||||
-312
@@ -1,312 +0,0 @@
|
||||
{
|
||||
"schema_version": 3,
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"alias": "legacy_data",
|
||||
"name": "Legacy data contract",
|
||||
"description": "",
|
||||
"elements": {
|
||||
"00000000-0000-0000-0000-000000000002": {
|
||||
"id": "00000000-0000-0000-0000-000000000002",
|
||||
"alias": "produce",
|
||||
"name": "Produce",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000003": {
|
||||
"id": "00000000-0000-0000-0000-000000000003",
|
||||
"alias": "consume",
|
||||
"name": "Consume",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": "Function"
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000004": {
|
||||
"id": "00000000-0000-0000-0000-000000000004",
|
||||
"alias": "packet",
|
||||
"name": "Position event",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"ExchangeItem": {
|
||||
"kind": "Event"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000005": {
|
||||
"id": "00000000-0000-0000-0000-000000000005",
|
||||
"alias": "coordinate",
|
||||
"name": "Coordinate",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataType": {
|
||||
"definition": "Record"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000006": {
|
||||
"id": "00000000-0000-0000-0000-000000000006",
|
||||
"alias": "latitude",
|
||||
"name": "Latitude",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000005",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataField": {
|
||||
"type_ref": "Real",
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"unit": "deg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000007": {
|
||||
"id": "00000000-0000-0000-0000-000000000007",
|
||||
"alias": "origin",
|
||||
"name": "Origin",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000004",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataField": {
|
||||
"type_ref": {
|
||||
"Named": "00000000-0000-0000-0000-000000000005"
|
||||
},
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"unit": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000008": {
|
||||
"id": "00000000-0000-0000-0000-000000000008",
|
||||
"alias": "destination",
|
||||
"name": "Destination",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000004",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"DataField": {
|
||||
"type_ref": {
|
||||
"Named": "00000000-0000-0000-0000-000000000005"
|
||||
},
|
||||
"minimum": 1,
|
||||
"maximum": 1,
|
||||
"unit": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000009": {
|
||||
"id": "00000000-0000-0000-0000-000000000009",
|
||||
"alias": "sender_contract",
|
||||
"name": "Sender contract",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Interface": {
|
||||
"items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000a": {
|
||||
"id": "00000000-0000-0000-0000-00000000000a",
|
||||
"alias": "receiver_contract",
|
||||
"name": "Receiver contract",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Interface": {
|
||||
"items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000b": {
|
||||
"id": "00000000-0000-0000-0000-00000000000b",
|
||||
"alias": "output",
|
||||
"name": "Output",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000002",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionOutput": {
|
||||
"outgoing_items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000c": {
|
||||
"id": "00000000-0000-0000-0000-00000000000c",
|
||||
"alias": "input",
|
||||
"name": "Input",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-000000000003",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Port": {
|
||||
"contract": {
|
||||
"FunctionInput": {
|
||||
"incoming_items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
},
|
||||
"legacy": null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000e": {
|
||||
"id": "00000000-0000-0000-0000-00000000000e",
|
||||
"alias": "scenario",
|
||||
"name": "Position scenario",
|
||||
"description": "",
|
||||
"parent": null,
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Scenario": {
|
||||
"viewpoint": "Functional",
|
||||
"context": null,
|
||||
"precondition": "",
|
||||
"postcondition": "",
|
||||
"steps": [
|
||||
"00000000-0000-0000-0000-000000000011"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-00000000000f": {
|
||||
"id": "00000000-0000-0000-0000-00000000000f",
|
||||
"alias": "sender",
|
||||
"name": "Sender",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-00000000000e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Lifeline": {
|
||||
"represented": "00000000-0000-0000-0000-000000000002"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000010": {
|
||||
"id": "00000000-0000-0000-0000-000000000010",
|
||||
"alias": "receiver",
|
||||
"name": "Receiver",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-00000000000e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Lifeline": {
|
||||
"represented": "00000000-0000-0000-0000-000000000003"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"00000000-0000-0000-0000-000000000011": {
|
||||
"id": "00000000-0000-0000-0000-000000000011",
|
||||
"alias": "message",
|
||||
"name": "Position",
|
||||
"description": "",
|
||||
"parent": "00000000-0000-0000-0000-00000000000e",
|
||||
"body": {
|
||||
"Sa": {
|
||||
"Shared": {
|
||||
"Occurrence": {
|
||||
"body": {
|
||||
"Exchange": {
|
||||
"exchange": "00000000-0000-0000-0000-00000000000d",
|
||||
"sender": "00000000-0000-0000-0000-00000000000f",
|
||||
"receiver": "00000000-0000-0000-0000-000000000010",
|
||||
"message_kind": "Asynchronous",
|
||||
"reply_to": null,
|
||||
"values": [
|
||||
{
|
||||
"field": "00000000-0000-0000-0000-000000000006",
|
||||
"expression": "navigation.latitude"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"precondition": "",
|
||||
"postcondition": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"relations": {
|
||||
"00000000-0000-0000-0000-00000000000d": {
|
||||
"id": "00000000-0000-0000-0000-00000000000d",
|
||||
"alias": "position_exchange",
|
||||
"name": "Position exchange",
|
||||
"description": "",
|
||||
"body": {
|
||||
"FunctionalExchange": {
|
||||
"source": "00000000-0000-0000-0000-000000000002",
|
||||
"target": "00000000-0000-0000-0000-000000000003",
|
||||
"source_port": "00000000-0000-0000-0000-00000000000b",
|
||||
"target_port": "00000000-0000-0000-0000-00000000000c",
|
||||
"items": [
|
||||
"00000000-0000-0000-0000-000000000004"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"diagrams": {},
|
||||
"artifact_links": {},
|
||||
"traces": {}
|
||||
}
|
||||
@@ -168,20 +168,6 @@ project mission "Harbor safety — 港 🌊" {
|
||||
.find(|e| e.alias == "event_record")
|
||||
.unwrap()
|
||||
.id;
|
||||
let associated = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Associated record", None)
|
||||
.unwrap();
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
p.elements.get_mut(&associated).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Record { supertype: None };
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::DataAssociation,
|
||||
"Related record",
|
||||
record,
|
||||
associated,
|
||||
);
|
||||
for (index, type_ref) in [
|
||||
TypeRef::Boolean,
|
||||
TypeRef::Integer,
|
||||
@@ -204,30 +190,18 @@ project mission "Harbor safety — 港 🌊" {
|
||||
minimum: 1,
|
||||
maximum: Some(5),
|
||||
unit: "unit".into(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
for definition in [
|
||||
DataTypeDefinition::Boolean,
|
||||
DataTypeDefinition::Integer {
|
||||
minimum: None,
|
||||
maximum: None,
|
||||
},
|
||||
DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
DataTypeDefinition::Integer,
|
||||
DataTypeDefinition::String,
|
||||
] {
|
||||
let id = p
|
||||
.add_element(Kind::DataType, Layer::Sa, "Primitive type", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition,
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
|
||||
SharedBody::DataType { definition };
|
||||
}
|
||||
for kind in [
|
||||
ExchangeItemKind::Material,
|
||||
@@ -242,11 +216,7 @@ project mission "Harbor safety — 港 🌊" {
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind,
|
||||
mechanism: ExchangeMechanism::Unspecified,
|
||||
supertype: None,
|
||||
};
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind };
|
||||
}
|
||||
let scenario = p
|
||||
.elements
|
||||
@@ -491,7 +461,7 @@ fn authoring_defaults_and_forward_references_remain_strict() {
|
||||
#[test]
|
||||
fn typed_unknown_fields_enums_duplicates_and_reference_spelling_are_rejected() {
|
||||
for (source, expected) in [
|
||||
("arcadia 5; project mission \"x\" {}", "Unsupported schema"),
|
||||
("arcadia 4; project mission \"x\" {}", "Unsupported schema"),
|
||||
(
|
||||
"arcadia 2; project mission \"x\" { oa_activity a \"A\" { nonsense 1; } }",
|
||||
"Unknown field",
|
||||
@@ -795,27 +765,9 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(from_source, from_json);
|
||||
let original: serde_json::Value = serde_json::from_slice(old_json).unwrap();
|
||||
let mut retained = serde_json::to_value(&from_json).unwrap();
|
||||
assert_eq!(retained["schema_version"], 4);
|
||||
assert_eq!(retained["schema_version"], 3);
|
||||
retained["schema_version"] = 2.into();
|
||||
let allocations: Vec<_> = from_json
|
||||
.relations
|
||||
.values()
|
||||
.filter_map(|relation| {
|
||||
if let RelationBody::InterfaceItemAllocation {
|
||||
source,
|
||||
target,
|
||||
order,
|
||||
..
|
||||
} = relation.body
|
||||
{
|
||||
Some((source, target, order))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
for element in retained["elements"].as_object_mut().unwrap().values_mut() {
|
||||
let element_id: Id = element["id"].as_str().unwrap().parse().unwrap();
|
||||
for layer in ["Oa", "Sa"] {
|
||||
let Some(shared) = element["body"]
|
||||
.get_mut(layer)
|
||||
@@ -827,46 +779,6 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
assert_eq!(port["contract"], "Legacy");
|
||||
*port = port["legacy"].clone();
|
||||
}
|
||||
if let Some(item) = shared
|
||||
.get_mut("ExchangeItem")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
if item["mechanism"] == "Event" {
|
||||
item.insert("kind".into(), "Event".into());
|
||||
}
|
||||
item.remove("mechanism");
|
||||
item.remove("supertype");
|
||||
}
|
||||
if let Some(kind) = shared
|
||||
.get_mut("DataType")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
kind.remove("default");
|
||||
kind.remove("unit");
|
||||
let definition = &kind["definition"];
|
||||
if let Some(name) = ["Integer", "Real", "String", "Record"]
|
||||
.into_iter()
|
||||
.find(|name| definition.get(name).is_some())
|
||||
{
|
||||
kind.insert("definition".into(), name.into());
|
||||
}
|
||||
}
|
||||
if let Some(field) = shared
|
||||
.get_mut("DataField")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
{
|
||||
for key in ["direction", "storage", "default"] {
|
||||
field.remove(key);
|
||||
}
|
||||
}
|
||||
if let Some(interface) = shared.get_mut("Interface") {
|
||||
let mut entries: Vec<_> = allocations
|
||||
.iter()
|
||||
.filter(|(source, _, _)| *source == element_id)
|
||||
.collect();
|
||||
entries.sort_by_key(|(_, _, order)| *order);
|
||||
*interface = serde_json::json!({"items":entries.into_iter().map(|(_,target,_)|target).collect::<Vec<_>>()});
|
||||
}
|
||||
if let Some(scenario) = shared
|
||||
.get_mut("Scenario")
|
||||
.and_then(serde_json::Value::as_object_mut)
|
||||
@@ -885,10 +797,6 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
}
|
||||
}
|
||||
}
|
||||
retained["relations"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.retain(|_, relation| relation["body"].get("InterfaceItemAllocation").is_none());
|
||||
for relation in retained["relations"].as_object_mut().unwrap().values_mut() {
|
||||
if let Some(boundary) = relation["body"].get_mut("BoundaryExchange") {
|
||||
assert_eq!(boundary["oriented"], true);
|
||||
@@ -918,7 +826,7 @@ fn original_v2_text_and_json_preserve_identity_and_mixed_scenarios_after_extensi
|
||||
);
|
||||
assert_eq!(
|
||||
from_json.relations.len(),
|
||||
original["relations"].as_object().unwrap().len() + allocations.len()
|
||||
original["relations"].as_object().unwrap().len()
|
||||
);
|
||||
assert_eq!(
|
||||
from_json.diagrams.len(),
|
||||
@@ -991,7 +899,7 @@ fn v1_text_and_json_migrate_deterministically_without_losing_legacy_semantics()
|
||||
assert_eq!(from_text, from_json);
|
||||
assert_eq!(read(text), from_text);
|
||||
assert_eq!(persistence::decode(json).unwrap(), from_json);
|
||||
assert_eq!(from_text.schema_version, 4);
|
||||
assert_eq!(from_text.schema_version, 3);
|
||||
assert!(!from_text.elements.values().any(|e| e.layer() == Layer::Sa));
|
||||
let raw: serde_json::Value = serde_json::from_slice(json).unwrap();
|
||||
for (id, record) in raw["elements"].as_object().unwrap() {
|
||||
@@ -1192,7 +1100,6 @@ fn complete_normative_examples_are_accepted_by_the_real_parser() {
|
||||
}
|
||||
if code.trim_start().starts_with("arcadia 2;")
|
||||
|| code.trim_start().starts_with("arcadia 3;")
|
||||
|| code.trim_start().starts_with("arcadia 4;")
|
||||
{
|
||||
read(&code);
|
||||
examples += 1;
|
||||
|
||||
@@ -1,816 +0,0 @@
|
||||
use oa_core::{language, persistence, projection::*, routing::*, source_project::*, *};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn pos(x: f32, y: f32) -> Position {
|
||||
Position { x, y }
|
||||
}
|
||||
fn fixture() -> (Project, Id, Id, Id, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Send", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Receive", None)
|
||||
.unwrap();
|
||||
let edge = p.relate(RelationKind::FunctionalExchange, "Information", a, b);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = p.relations[&edge].body
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let na = p.place(d, a, pos(100., 100.));
|
||||
let nb = p.place(d, b, pos(900., 100.));
|
||||
(p, d, na, nb, source_port, target_port, edge)
|
||||
}
|
||||
fn manual(p: &mut Project, d: Id, n: Id, port: Id, side: PortAnchorSide, offset: f32) {
|
||||
let prepared = prepare_command(
|
||||
p,
|
||||
&Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor { port, side, offset },
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(prepared.selected, Some(port));
|
||||
*p = prepared.project;
|
||||
}
|
||||
fn port(view: &ProjectedView, id: Id) -> &ProjectedPort {
|
||||
view.ports.iter().find(|p| p.element == id).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_side_uses_exact_final_border_fraction_and_outward_routing_normal() {
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let (mut p, d, na, _, a, _, edge) = fixture();
|
||||
let before = project_diagram(&p, &p.diagrams[&d]);
|
||||
manual(&mut p, d, na, a, side, 0.3);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(
|
||||
view.nodes, before.nodes,
|
||||
"A port drag must not resize or move its owner"
|
||||
);
|
||||
let node = view.nodes.iter().find(|n| n.id == na).unwrap();
|
||||
let anchor = port(&view, a);
|
||||
assert_eq!(anchor.side, side);
|
||||
assert_eq!(anchor.position, port_anchor_position(node, side, 0.3));
|
||||
assert!((port_anchor_offset(node, side, anchor.position) - 0.3).abs() < 0.00001);
|
||||
let route = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
let points = &route.edges[&edge].points;
|
||||
assert_eq!(points[0], anchor.position);
|
||||
let normal = side.normal();
|
||||
let delta = pos(points[1].x - points[0].x, points[1].y - points[0].y);
|
||||
assert!(
|
||||
delta.x * normal.x + delta.y * normal.y >= PORT_STUB - 0.001,
|
||||
"{side:?}: {points:?}"
|
||||
);
|
||||
assert!(
|
||||
points
|
||||
.windows(2)
|
||||
.all(|w| w[0].x == w[1].x || w[0].y == w[1].y)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_connected_ports_face_peers_and_unconnected_ports_use_four_sides() {
|
||||
for (peer, expected) in [
|
||||
(pos(-700., 100.), PortAnchorSide::Left),
|
||||
(pos(900., 100.), PortAnchorSide::Right),
|
||||
(pos(100., -700.), PortAnchorSide::Top),
|
||||
(pos(100., 900.), PortAnchorSide::Bottom),
|
||||
] {
|
||||
let mut p = Project::empty();
|
||||
let a = p
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let b = p
|
||||
.add_element(Kind::SystemActor, Layer::Sa, "Partner", None)
|
||||
.unwrap();
|
||||
let mut ports = Vec::new();
|
||||
for owner in [a, b] {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Boundary",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
ports.push(id);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::BoundaryExchange,
|
||||
"Connection",
|
||||
ports[0],
|
||||
ports[1],
|
||||
);
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
p.place(d, a, pos(100., 100.));
|
||||
p.place(d, b, peer);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(port(&view, ports[0]).side, expected);
|
||||
let before = view.ports.clone();
|
||||
for element in p.elements.values_mut() {
|
||||
element.alias = format!("renamed_{}", element.id.simple());
|
||||
}
|
||||
assert_eq!(project_diagram(&p, &p.diagrams[&d]).ports, before);
|
||||
p.relations.clear();
|
||||
for i in 0..3 {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
a,
|
||||
&format!("Spare {i}"),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let sides: std::collections::BTreeSet<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|port| p.elements[&port.element].parent == Some(a))
|
||||
.map(|port| port.side)
|
||||
.collect();
|
||||
assert_eq!(sides.len(), 4);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_upsert_reset_and_undo_preserve_unrelated_node_fields_and_list_order() {
|
||||
let (mut p, d, n, _, a, _, _) = fixture();
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let extra = Id::new_v4();
|
||||
let spare = make_port(
|
||||
&p,
|
||||
extra,
|
||||
owner,
|
||||
"Spare",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(extra, spare);
|
||||
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.2);
|
||||
manual(&mut p, d, n, extra, PortAnchorSide::Bottom, 0.7);
|
||||
let before = p.clone();
|
||||
let mut session = Session::new(p);
|
||||
session
|
||||
.execute(Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor {
|
||||
port: a,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.45,
|
||||
},
|
||||
})
|
||||
.unwrap();
|
||||
let changed = session.project().clone();
|
||||
let anchors = &changed.diagrams[&d].nodes[&n].port_anchors;
|
||||
assert_eq!(
|
||||
anchors.iter().map(|a| a.port).collect::<Vec<_>>(),
|
||||
[a, extra]
|
||||
);
|
||||
assert_eq!(anchors[1], before.diagrams[&d].nodes[&n].port_anchors[1]);
|
||||
assert_eq!(
|
||||
changed.diagrams[&d].nodes[&n].position,
|
||||
before.diagrams[&d].nodes[&n].position
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &before);
|
||||
session.redo();
|
||||
assert_eq!(session.project(), &changed);
|
||||
session
|
||||
.execute(Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: a,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
session.project().diagrams[&d].nodes[&n].port_anchors.len(),
|
||||
1
|
||||
);
|
||||
let revision = session.revision();
|
||||
session
|
||||
.execute(Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: a,
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(session.revision(), revision);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_anchors_and_payloads_fail_atomically_including_duplicate_owner_placements() {
|
||||
let (p, d, n, _, port, other_port, _) = fixture();
|
||||
for (port, offset) in [
|
||||
(port, f32::NAN),
|
||||
(port, f32::INFINITY),
|
||||
(port, -0.1),
|
||||
(port, 1.1),
|
||||
(other_port, 0.5),
|
||||
(Id::new_v4(), 0.5),
|
||||
(p.diagrams[&d].nodes[&n].element, 0.5),
|
||||
] {
|
||||
let mut session = Session::new(p.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::SetPortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
anchor: PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Left,
|
||||
offset
|
||||
}
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), 0);
|
||||
}
|
||||
let anchor = json!({"port": port, "side": "Top", "offset": 0.5});
|
||||
for bad in [
|
||||
json!({"port":port,"side":"Top"}),
|
||||
json!({"port":port,"side":"Diagonal","offset":0.5}),
|
||||
json!({"port":port,"side":"Top","offset":0.5,"owner":n}),
|
||||
] {
|
||||
assert!(serde_json::from_value::<PortAnchor>(bad).is_err());
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<Command>(
|
||||
json!({"SetPortAnchor":{"diagram":d,"node":n,"anchor":anchor,"unknown":true}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut duplicate = p.clone();
|
||||
let value = PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Left,
|
||||
offset: 0.5,
|
||||
};
|
||||
duplicate
|
||||
.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&n)
|
||||
.unwrap()
|
||||
.port_anchors = vec![value.clone(), value];
|
||||
assert!(prepare_command(&p, &Command::Replace(duplicate)).is_err());
|
||||
let mut duplicate = p.diagrams[&d].nodes[&n].clone();
|
||||
duplicate.id = Id::new_v4();
|
||||
duplicate.alias = "duplicate_view".into();
|
||||
assert!(
|
||||
prepare_command(
|
||||
&p,
|
||||
&Command::CreateNode {
|
||||
diagram: d,
|
||||
node: duplicate
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reidentification_deletion_reuse_and_owner_repair_preserve_correct_identity() {
|
||||
let (mut p, d, n, target, _, _, _) = fixture();
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let id = Id::new_v4();
|
||||
let value = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
"Spare output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, value.clone());
|
||||
manual(&mut p, d, n, id, PortAnchorSide::Top, 0.25);
|
||||
let new_id = Id::new_v4();
|
||||
let renamed = prepare_command(&p, &Command::Reidentify(id, new_id))
|
||||
.unwrap()
|
||||
.project;
|
||||
assert_eq!(renamed.diagrams[&d].nodes[&n].port_anchors[0].port, new_id);
|
||||
let reused = prepare_command(
|
||||
&p,
|
||||
&Command::Batch(vec![Command::Delete(id), Command::CreateElement(value)]),
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(reused.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
let parent = Some(p.diagrams[&d].nodes[&target].element);
|
||||
assert!(
|
||||
prepare_command(
|
||||
&p,
|
||||
&Command::Reparent {
|
||||
element: id,
|
||||
parent
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut edited = p.elements[&id].clone();
|
||||
edited.parent = parent;
|
||||
assert!(prepare_command(&p, &Command::EditElement(id, edited.clone())).is_err());
|
||||
let repaired = prepare_command(
|
||||
&p,
|
||||
&Command::Batch(vec![
|
||||
Command::RemovePortAnchor {
|
||||
diagram: d,
|
||||
node: n,
|
||||
port: id,
|
||||
},
|
||||
Command::EditElement(id, edited),
|
||||
]),
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(repaired.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
assert_eq!(repaired.elements[&id].parent, parent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_views_resize_layout_and_hidden_owners_keep_manual_metadata() {
|
||||
let (mut p, d, n, _, a, _, _) = fixture();
|
||||
manual(&mut p, d, n, a, PortAnchorSide::Top, 0.35);
|
||||
let mut second = p.diagrams[&d].clone();
|
||||
second.id = Id::new_v4();
|
||||
second.alias = "review_view".into();
|
||||
second.nodes.clear();
|
||||
let other_d = second.id;
|
||||
p.diagrams.insert(second.id, second);
|
||||
let owner = p.diagrams[&d].nodes[&n].element;
|
||||
let other_n = p.place(other_d, owner, pos(50., 50.));
|
||||
manual(&mut p, other_d, other_n, a, PortAnchorSide::Bottom, 0.8);
|
||||
let original = p.diagrams[&d].nodes[&n].port_anchors.clone();
|
||||
let mut edited = p.diagrams[&d].nodes[&n].clone();
|
||||
edited.size = Size {
|
||||
width: 600.,
|
||||
height: 450.,
|
||||
};
|
||||
edited.position = pos(200., 250.);
|
||||
p = prepare_command(
|
||||
&p,
|
||||
&Command::EditNode {
|
||||
diagram: d,
|
||||
original: n,
|
||||
node: edited,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(port(&view, a).position, pos(410., 250.));
|
||||
p = prepare_command(&p, &Command::Layout(d)).unwrap().project;
|
||||
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
|
||||
assert_eq!(
|
||||
p.diagrams[&other_d].nodes[&other_n].port_anchors[0].side,
|
||||
PortAnchorSide::Bottom
|
||||
);
|
||||
let mut diagram = p.diagrams[&d].clone();
|
||||
diagram.projection = ProjectionKind::Capabilities;
|
||||
p = prepare_command(&p, &Command::EditDiagram(d, diagram))
|
||||
.unwrap()
|
||||
.project;
|
||||
assert!(project_diagram(&p, &p.diagrams[&d]).ports.is_empty());
|
||||
assert_eq!(p.diagrams[&d].nodes[&n].port_anchors, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_owner_bounds_group_movement_and_authored_routes_share_the_same_geometry() {
|
||||
let (mut p, d, na, nb, a, b, edge) = fixture();
|
||||
let parent = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Group", None)
|
||||
.unwrap();
|
||||
for node in [na, nb] {
|
||||
let element = p.diagrams[&d].nodes[&node].element;
|
||||
p.elements.get_mut(&element).unwrap().parent = Some(parent);
|
||||
}
|
||||
let group = p.place(d, parent, pos(50., 0.));
|
||||
let obstacle = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Sibling obstacle", Some(parent))
|
||||
.unwrap();
|
||||
let obstacle_node = p.place(d, obstacle, pos(440., 160.));
|
||||
let group_port = Id::new_v4();
|
||||
let element = make_port(
|
||||
&p,
|
||||
group_port,
|
||||
parent,
|
||||
"Group input",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(group_port, element);
|
||||
manual(&mut p, d, group, group_port, PortAnchorSide::Top, 0.25);
|
||||
manual(&mut p, d, na, a, PortAnchorSide::Bottom, 0.5);
|
||||
manual(&mut p, d, nb, b, PortAnchorSide::Bottom, 0.5);
|
||||
p = prepare_command(
|
||||
&p,
|
||||
&Command::SetEdgeRoute {
|
||||
diagram: d,
|
||||
route: EdgeRoute {
|
||||
edge,
|
||||
waypoints: vec![pos(500., 420.)],
|
||||
label: None,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.project;
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let parent_body = view.nodes.iter().find(|n| n.id == group).unwrap();
|
||||
assert!(parent_body.size.width > p.diagrams[&d].nodes[&group].size.width);
|
||||
assert_eq!(
|
||||
port(&view, group_port).position,
|
||||
port_anchor_position(parent_body, PortAnchorSide::Top, 0.25)
|
||||
);
|
||||
let routes = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
assert!(
|
||||
routes.edges[&edge].warnings.is_empty(),
|
||||
"{:?}",
|
||||
routes.edges[&edge]
|
||||
);
|
||||
let obstacle = view.nodes.iter().find(|n| n.id == obstacle_node).unwrap();
|
||||
for pair in routes.edges[&edge].points.windows(2) {
|
||||
let [a, b] = [pair[0], pair[1]];
|
||||
let crosses = if a.x == b.x {
|
||||
a.x > obstacle.position.x
|
||||
&& a.x < obstacle.position.x + obstacle.size.width
|
||||
&& a.y.min(b.y) < obstacle.position.y + obstacle.size.height
|
||||
&& a.y.max(b.y) > obstacle.position.y
|
||||
} else {
|
||||
a.y > obstacle.position.y
|
||||
&& a.y < obstacle.position.y + obstacle.size.height
|
||||
&& a.x.min(b.x) < obstacle.position.x + obstacle.size.width
|
||||
&& a.x.max(b.x) > obstacle.position.x
|
||||
};
|
||||
assert!(!crosses);
|
||||
}
|
||||
let mut session = Session::new(p.clone());
|
||||
session
|
||||
.execute(Command::MoveNode {
|
||||
diagram: d,
|
||||
node: group,
|
||||
position: pos(190., 80.),
|
||||
with_descendants: true,
|
||||
})
|
||||
.unwrap();
|
||||
let moved = session.project();
|
||||
let after = project_diagram(moved, &moved.diagrams[&d]);
|
||||
for before in &view.ports {
|
||||
assert_eq!(
|
||||
port(&after, before.element).position,
|
||||
pos(before.position.x + 140., before.position.y + 80.)
|
||||
);
|
||||
}
|
||||
for (id, node) in &p.diagrams[&d].nodes {
|
||||
assert_eq!(moved.diagrams[&d].nodes[id].port_anchors, node.port_anchors);
|
||||
}
|
||||
assert_eq!(moved.diagrams[&d].routes[0].waypoints, [pos(640., 500.)]);
|
||||
assert!(
|
||||
route_diagram(moved, &moved.diagrams[&d], &after).edges[&edge]
|
||||
.warnings
|
||||
.is_empty()
|
||||
);
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_peer_gap_uses_clear_side_instead_of_a_blocked_facing_stub() {
|
||||
let (mut p, d, a, b, output, input, edge) = fixture();
|
||||
p.diagrams
|
||||
.get_mut(&d)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&b)
|
||||
.unwrap()
|
||||
.position = pos(100., 260.);
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
let source = view.nodes.iter().find(|n| n.id == a).unwrap();
|
||||
let target = view.nodes.iter().find(|n| n.id == b).unwrap();
|
||||
assert!(target.position.y - source.position.y - source.size.height < PORT_STUB + CLEARANCE);
|
||||
assert_eq!(port(&view, output).side, PortAnchorSide::Right);
|
||||
assert_eq!(port(&view, input).side, PortAnchorSide::Left);
|
||||
let routed = route_diagram(&p, &p.diagrams[&d], &view);
|
||||
assert!(
|
||||
routed.edges[&edge].warnings.is_empty(),
|
||||
"{:?}",
|
||||
routed.edges[&edge]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_neutral_ports_balance_capacity_without_a_giant_bottom_footer() {
|
||||
let mut p = Project::empty();
|
||||
let owner = p
|
||||
.add_element(Kind::System, Layer::Sa, "Many interfaces", None)
|
||||
.unwrap();
|
||||
for i in 0..100 {
|
||||
let id = Id::from_u128(i + 1);
|
||||
let port = make_port(
|
||||
&p,
|
||||
id,
|
||||
owner,
|
||||
&format!("Interface {i}"),
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(id, port);
|
||||
}
|
||||
let d = p.ensure_diagram(Layer::Sa);
|
||||
let node = p.place(d, owner, pos(0., 0.));
|
||||
let view = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(view.ports.len(), 100);
|
||||
assert!(view.nodes[0].size.width < 1000. && view.nodes[0].size.height < 1000.);
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let mut offsets: Vec<_> = view
|
||||
.ports
|
||||
.iter()
|
||||
.filter(|p| p.side == side)
|
||||
.map(|p| port_anchor_offset(&view.nodes[0], side, p.position))
|
||||
.collect();
|
||||
assert_eq!(offsets.len(), 25);
|
||||
offsets.sort_by(f32::total_cmp);
|
||||
let length = if matches!(side, PortAnchorSide::Left | PortAnchorSide::Right) {
|
||||
view.nodes[0].size.height
|
||||
} else {
|
||||
view.nodes[0].size.width
|
||||
};
|
||||
assert!(offsets.windows(2).all(|w| (w[1] - w[0]) * length >= 27.99));
|
||||
}
|
||||
let id = view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.side == PortAnchorSide::Left)
|
||||
.unwrap()
|
||||
.element;
|
||||
manual(&mut p, d, node, id, PortAnchorSide::Top, 0.5);
|
||||
let after = project_diagram(&p, &p.diagrams[&d]);
|
||||
assert_eq!(after.nodes, view.nodes);
|
||||
let placed = port(&after, id);
|
||||
for other in after.ports.iter().filter(|p| p.element != id) {
|
||||
assert_eq!(other.side, port(&view, other.element).side);
|
||||
let distance = (other.position.x - placed.position.x).abs()
|
||||
+ (other.position.y - placed.position.y).abs();
|
||||
assert!(
|
||||
distance >= 14.,
|
||||
"An automatic port overlaps the authored anchor"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const SOURCE: &str = r#"arcadia 3;
|
||||
project ports "Ports" {
|
||||
sa_function owner "Owner" {}
|
||||
sa_port first "First" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
|
||||
sa_port second "Second" { parent @owner; contract { FunctionOutput { outgoing_items []; }; }; }
|
||||
diagram view "View" { layer Sa; }
|
||||
node owner_view { diagram @view; element @owner; port_anchors [
|
||||
// first rationale
|
||||
{ port @first; side Top; offset 0.25; },
|
||||
// second rationale
|
||||
{ port @second; side Bottom; offset 0.75; }
|
||||
]; }
|
||||
}
|
||||
"#;
|
||||
fn find(p: &Project, alias: &str) -> Id {
|
||||
p.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_json_roundtrip_defaults_and_admission_include_anchor_records() {
|
||||
let p = language::parse(SOURCE).unwrap();
|
||||
let formatted = language::format(&p);
|
||||
assert!(formatted.contains("port @first;") && formatted.contains("side Top;"));
|
||||
assert_eq!(language::parse(&formatted).unwrap(), p);
|
||||
assert_eq!(
|
||||
persistence::decode(&serde_json::to_vec(&p).unwrap()).unwrap(),
|
||||
p
|
||||
);
|
||||
assert_eq!(
|
||||
compile_bundle(&format_bundle(&p), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
p
|
||||
);
|
||||
// project + function + two ports + diagram + node + two anchors = 8.
|
||||
let limits = language::ParseLimits {
|
||||
max_records: 7,
|
||||
..language::ParseLimits::UNLIMITED
|
||||
};
|
||||
assert!(limits.check_model(&p).is_err());
|
||||
assert!(persistence::decode_limited(&serde_json::to_vec(&p).unwrap(), limits).is_err());
|
||||
assert!(language::parse_limited(SOURCE, limits).is_err());
|
||||
assert!(
|
||||
compile_bundle(
|
||||
&format_bundle(&p),
|
||||
ProjectLimits {
|
||||
model: limits,
|
||||
..ProjectLimits::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
let mut value = serde_json::to_value(&p).unwrap();
|
||||
let d = find(&p, "view");
|
||||
let n = find(&p, "owner_view");
|
||||
value["diagrams"][d.to_string()]["nodes"][n.to_string()]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("port_anchors");
|
||||
let read = persistence::decode(&serde_json::to_vec(&value).unwrap()).unwrap();
|
||||
assert!(read.diagrams[&d].nodes[&n].port_anchors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_updater_keeps_comment_attachment_through_identity_swap_and_anchor_reordering() {
|
||||
let current = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.replace('\n', "\r\n"))]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = ¤t.decoded.project;
|
||||
let d = find(p, "view");
|
||||
let n = find(p, "owner_view");
|
||||
let first = find(p, "first");
|
||||
let second = find(p, "second");
|
||||
let temp = Id::new_v4();
|
||||
let mut commands = vec![
|
||||
Command::Reidentify(first, temp),
|
||||
Command::Reidentify(second, first),
|
||||
Command::Reidentify(temp, second),
|
||||
Command::SetAlias(second, "revised_first".into()),
|
||||
];
|
||||
let candidate = prepare_command(p, &Command::Batch(commands.clone()))
|
||||
.unwrap()
|
||||
.project;
|
||||
let mut node = candidate.diagrams[&d].nodes[&n].clone();
|
||||
node.port_anchors.reverse();
|
||||
node.port_anchors[1].offset = 0.5;
|
||||
commands.push(Command::EditNode {
|
||||
diagram: d,
|
||||
original: n,
|
||||
node,
|
||||
});
|
||||
let prepared = prepare_command(p, &Command::Batch(commands)).unwrap();
|
||||
let result = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = &result.bundle.files["project.arcadia"];
|
||||
assert!(
|
||||
text.contains("// first rationale\r\n { port @revised_first; side Top; offset 0.5; }"),
|
||||
"{text}"
|
||||
);
|
||||
assert!(
|
||||
text.contains("// second rationale\r\n { port @second; side Bottom; offset 0.75; }"),
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(result.decoded.project, prepared.project);
|
||||
assert_eq!(
|
||||
update_bundle(&result, &prepared.project, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle,
|
||||
result.bundle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resetting_last_source_anchors_preserves_comments_and_can_add_a_new_manual_record() {
|
||||
let current = compile_bundle(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), SOURCE.into())]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let p = ¤t.decoded.project;
|
||||
let diagram = find(p, "view");
|
||||
let node = find(p, "owner_view");
|
||||
let first = find(p, "first");
|
||||
let second = find(p, "second");
|
||||
let prepared = prepare_command(
|
||||
p,
|
||||
&Command::Batch(vec![
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port: first,
|
||||
},
|
||||
Command::RemovePortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
port: second,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
let cleared = update_bundle_with_identity_map(
|
||||
¤t,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cleared.decoded.project, prepared.project);
|
||||
let text = &cleared.bundle.files["project.arcadia"];
|
||||
assert!(text.contains("// first rationale") && text.contains("// second rationale"));
|
||||
assert!(!text.contains("port_anchors"));
|
||||
let prepared = prepare_command(
|
||||
&cleared.decoded.project,
|
||||
&Command::SetPortAnchor {
|
||||
diagram,
|
||||
node,
|
||||
anchor: PortAnchor {
|
||||
port: second,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.5,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let added = update_bundle_with_identity_map(
|
||||
&cleared,
|
||||
&prepared.project,
|
||||
&prepared.identity_map,
|
||||
ProjectLimits::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(added.decoded.project, prepared.project);
|
||||
assert_eq!(
|
||||
added.bundle.files["project.arcadia"]
|
||||
.matches("// second rationale")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_source_anchor_records_have_located_errors() {
|
||||
for bad in [
|
||||
"{ port @first; side Top; }",
|
||||
"{ port @first; side Top; offset -0.1; }",
|
||||
"{ port @first; side Corner; offset 0.5; }",
|
||||
"{ port @owner; side Top; offset 0.5; }",
|
||||
"{ port @first; side Top; offset 0.5; unexpected true; }",
|
||||
"{ port @first; side Top; offset 0.1; }, { port @first; side Right; offset 0.2; }",
|
||||
] {
|
||||
let start = SOURCE.find("port_anchors [").unwrap() + "port_anchors [".len();
|
||||
let end = SOURCE[start..].find("]; }").unwrap() + start;
|
||||
let source = format!("{}\n{}\n{}", &SOURCE[..start], bad, &SOURCE[end..]);
|
||||
let errors = language::parse(&source).expect_err(bad);
|
||||
assert!(errors.iter().any(|e| e.line >= 7), "{bad}: {errors:?}");
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
assert_eq!(decoded.project, from_source.project);
|
||||
assert_eq!(decoded.migration, from_source.migration);
|
||||
let report = decoded.migration.unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (2, 4));
|
||||
assert_eq!((report.from_schema, report.to_schema), (2, 3));
|
||||
assert_eq!(report.preserved_ports.len(), 4);
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.orientation_defaults.len(), 1);
|
||||
@@ -56,7 +56,6 @@ fn frozen_original_v2_reader_preserves_every_original_record() {
|
||||
+ old["traces"].as_object().unwrap().len()
|
||||
+ old["artifact_links"].as_object().unwrap().len()
|
||||
+ 1
|
||||
+ report.generated_interface_allocations.len()
|
||||
);
|
||||
let mut current = serde_json::to_value(&decoded.project).unwrap();
|
||||
for diagram in current["diagrams"].as_object_mut().unwrap().values_mut() {
|
||||
@@ -144,7 +143,7 @@ fn missing_v2_endpoints_are_deterministic_reported_and_independently_typed() {
|
||||
}
|
||||
}
|
||||
let canonical = language::format(&first.project);
|
||||
assert!(canonical.starts_with("arcadia 4;"));
|
||||
assert!(canonical.starts_with("arcadia 3;"));
|
||||
let reopened = persistence::decode_with_report(canonical.as_bytes()).unwrap();
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(reopened.project, first.project);
|
||||
@@ -224,13 +223,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
|
||||
_ => 0,
|
||||
})
|
||||
})
|
||||
.sum::<usize>()
|
||||
+ decode(&old)
|
||||
.migration
|
||||
.unwrap()
|
||||
.generated_interface_allocations
|
||||
.len()
|
||||
* 2;
|
||||
.sum::<usize>();
|
||||
for limits in [
|
||||
ParseLimits {
|
||||
max_graph_objects: graph + 1,
|
||||
@@ -251,7 +244,7 @@ fn migration_admission_counts_generated_objects_records_and_references() {
|
||||
));
|
||||
}
|
||||
let accepted = ParseLimits {
|
||||
max_graph_objects: migrated.elements.len() + migrated.relations.len(),
|
||||
max_graph_objects: graph + 2,
|
||||
max_records: records,
|
||||
max_step_references: reference_count,
|
||||
..ParseLimits::UNLIMITED
|
||||
@@ -274,7 +267,7 @@ fn old_contract_is_validated_before_new_shapes_and_v1_flows_through_v2() {
|
||||
assert!(error.contains("V2") && error.contains(&fe), "{error}");
|
||||
let v1 = include_bytes!("../../../examples/legacy-v1-coastal-rescue.oa.json");
|
||||
let result = persistence::decode_with_report(v1).unwrap();
|
||||
assert_eq!(result.project.schema_version, 4);
|
||||
assert_eq!(result.project.schema_version, 3);
|
||||
assert_eq!(result.migration.unwrap().from_schema, 1);
|
||||
assert_eq!(persistence::decode(v1).unwrap(), result.project);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use oa_core::{language, persistence, *};
|
||||
|
||||
const SOURCE: &str = r#"arcadia 4;
|
||||
const SOURCE: &str = r#"arcadia 3;
|
||||
project typed_ports "Typed ports" {
|
||||
sa_system producer "Producer" {}
|
||||
sa_actor consumer "Consumer" {}
|
||||
@@ -9,8 +9,7 @@ project typed_ports "Typed ports" {
|
||||
allocation sends "Produces" { source @producer; target @send; }
|
||||
allocation receives "Consumes" { source @consumer; target @receive; }
|
||||
sa_exchange_item data "Data" {}
|
||||
sa_interface service "Service" {}
|
||||
interface_item_allocation service_data "Service data" { source @service; target @data; }
|
||||
sa_interface service "Service" { items [@data]; }
|
||||
sa_port output "Output" { parent @send; contract { FunctionOutput { outgoing_items [@data]; }; }; }
|
||||
sa_port input "Input" { parent @receive; contract { FunctionInput { incoming_items [@data]; }; }; }
|
||||
sa_port provider "Provider port" { parent @producer; contract { Component { kind Standard; orientation Unset; }; }; }
|
||||
@@ -27,7 +26,7 @@ project typed_ports "Typed ports" {
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
fn source3_covers_typed_contracts_roles_allocations_and_nested_legacy_references() {
|
||||
let decoded = language::parse_with_report(SOURCE).unwrap();
|
||||
assert!(decoded.migration.is_none());
|
||||
let canonical = language::format(&decoded.project);
|
||||
@@ -70,7 +69,7 @@ fn source4_covers_typed_contracts_roles_allocations_and_nested_legacy_references
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
|
||||
fn source3_requires_explicit_port_contract_and_fe_endpoints_without_generation() {
|
||||
for (remove, expected) in [
|
||||
("source_port @output; ", "source_port"),
|
||||
("target_port @input; ", "target_port"),
|
||||
@@ -91,7 +90,7 @@ fn source4_requires_explicit_port_contract_and_fe_endpoints_without_generation()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source4_rejects_mixed_component_kinds_and_nested_reference_typos() {
|
||||
fn source3_rejects_mixed_component_kinds_and_nested_reference_typos() {
|
||||
let mixed = SOURCE.replace(
|
||||
"parent @consumer; contract { Component { kind Standard; orientation Unset;",
|
||||
"parent @consumer; contract { Component { kind Flow; orientation Unset;",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,228 +0,0 @@
|
||||
use oa_core::{language, persistence, *};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const SOURCE3: &str = include_str!("fixtures/v3-data-contract.arcadia");
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(n)
|
||||
}
|
||||
|
||||
// Frozen JSON uses the same authored schema3 contract as the source fixture;
|
||||
// this helper never serializes current defaults under version3.
|
||||
fn original3() -> Value {
|
||||
serde_json::from_str(include_str!("fixtures/v3-data-contract.json")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn genuine_v3_preserves_typed_ports_and_allocates_deterministic_relationships() {
|
||||
let old = original3();
|
||||
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
let source = language::parse_with_report(SOURCE3).unwrap();
|
||||
assert_eq!(decoded.project, source.project);
|
||||
assert_eq!(decoded.migration, source.migration);
|
||||
let project = &decoded.project;
|
||||
let report = decoded.migration.as_ref().unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 4));
|
||||
assert_eq!(project.identities().len(), 19);
|
||||
assert!(report.preserved_ports.is_empty());
|
||||
assert!(report.generated_endpoints.is_empty());
|
||||
assert_eq!(report.generated_interface_allocations.len(), 2);
|
||||
for allocation in &report.generated_interface_allocations {
|
||||
assert_eq!(
|
||||
allocation.id,
|
||||
Id::new_v5(
|
||||
&id(1),
|
||||
format!(
|
||||
"v2-interface-item:{}:{}",
|
||||
allocation.source, allocation.target
|
||||
)
|
||||
.as_bytes()
|
||||
)
|
||||
);
|
||||
assert_eq!(allocation.target, id(4));
|
||||
assert_eq!(allocation.order, 0);
|
||||
assert!(matches!(
|
||||
project.relations[&allocation.id].body,
|
||||
RelationBody::InterfaceItemAllocation {
|
||||
transmission: TransmissionProtocol::Unspecified,
|
||||
acquisition: AcquisitionProtocol::Unspecified,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
let now = serde_json::to_value(project).unwrap();
|
||||
for port in [id(11), id(12)] {
|
||||
assert_eq!(
|
||||
now["elements"][port.to_string()],
|
||||
old["elements"][port.to_string()]
|
||||
);
|
||||
}
|
||||
assert!(matches!(
|
||||
project.elements[&id(4)].shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Event,
|
||||
supertype: None
|
||||
})
|
||||
));
|
||||
assert_eq!(report.unresolved_message_values.len(), 1);
|
||||
assert!(
|
||||
validation::validate(project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let canonical = language::format(project);
|
||||
assert!(canonical.starts_with("arcadia 4;"));
|
||||
let reopened = language::parse_with_report(&canonical).unwrap();
|
||||
assert_eq!(&reopened.project, project);
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(canonical, language::format(&reopened.project));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_changed_payloads_reject_schema4_fields_and_new_constructor_names() {
|
||||
let mut old = original3();
|
||||
old["elements"][id(4).to_string()]["body"]["Sa"]["Shared"]["ExchangeItem"]["mechanism"] =
|
||||
json!("Flow");
|
||||
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
let mut old = original3();
|
||||
old["elements"][id(5).to_string()]["body"]["Sa"]["Shared"]["DataType"]["definition"] =
|
||||
json!({"Record":{"supertype":null}});
|
||||
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
let mut old = original3();
|
||||
old["elements"][id(17).to_string()]["body"]["Sa"]["Shared"]["Occurrence"]["body"]["Exchange"]
|
||||
["values"][0]["path"] = json!([]);
|
||||
assert!(persistence::decode(&serde_json::to_vec(&old).unwrap()).is_err());
|
||||
let source = SOURCE3.replace("kind Event;", "kind Information; mechanism Flow;");
|
||||
assert!(language::parse(&source).is_err());
|
||||
let source = SOURCE3.replace(
|
||||
"functional_exchange position_exchange",
|
||||
"interface_item_allocation position_exchange",
|
||||
);
|
||||
assert!(language::parse(&source).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_allocation_id_collision_is_located_without_overwriting_any_object() {
|
||||
let mut old = original3();
|
||||
let collision = Id::new_v5(
|
||||
&id(1),
|
||||
format!("v2-interface-item:{}:{}", id(9), id(4)).as_bytes(),
|
||||
);
|
||||
old["artifact_links"][collision.to_string()] = json!({"id":collision,"alias":"retained_artifact","name":"Retain","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
|
||||
let error = persistence::decode(&serde_json::to_vec(&old).unwrap()).unwrap_err();
|
||||
match error {
|
||||
persistence::FileError::Model { issues, .. } => {
|
||||
assert_eq!(issues[0].code, "identity_collision");
|
||||
assert_eq!(issues[0].element, Some(id(9)));
|
||||
assert_eq!(issues[0].field.as_deref(), Some("items"));
|
||||
}
|
||||
other => panic!("unexpected error {other}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unique_legacy_nested_value_is_qualified_and_physical_nature_is_not_guessed() {
|
||||
let source = SOURCE3.replace("kind Event;", "kind Material;").replace(
|
||||
"type_ref { Named @coordinate; }; }\n }",
|
||||
"type_ref String; }\n }",
|
||||
);
|
||||
let decoded = language::parse_with_report(&source).unwrap();
|
||||
assert!(matches!(
|
||||
decoded.project.elements[&id(4)].shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material,
|
||||
mechanism: ExchangeMechanism::Unspecified,
|
||||
..
|
||||
})
|
||||
));
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = decoded.project.elements[&id(17)].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(values[0].path, vec![id(7)]);
|
||||
assert_eq!(values[0].expression, "navigation.latitude");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v1_and_frozen_v2_inputs_reach_four_through_the_shared_data_upgrade() {
|
||||
let v2 =
|
||||
persistence::decode_with_report(include_bytes!("fixtures/v2-coastal-rescue.json")).unwrap();
|
||||
let text =
|
||||
language::parse_with_report(include_str!("fixtures/v2-coastal-rescue.arcadia")).unwrap();
|
||||
assert_eq!(v2.project, text.project);
|
||||
assert_eq!(v2.migration, text.migration);
|
||||
assert_eq!(v2.migration.unwrap().to_schema, 4);
|
||||
let v1 = language::parse_with_report(
|
||||
"arcadia 1; project \"Old\" id \"00000000-0000-0000-0000-000000000001\" {}",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(v1.project.schema_version, 4);
|
||||
assert_eq!(v1.migration.unwrap().from_schema, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_alias_collisions_are_stable_and_duplicate_legacy_payload_fields_reject() {
|
||||
let mut old = original3();
|
||||
let first = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
let alias = first.migration.unwrap().generated_interface_allocations[0]
|
||||
.alias
|
||||
.clone();
|
||||
old["artifact_links"][id(100).to_string()] = json!({"id":id(100),"alias":alias,"name":"Existing alias","description":"","owner":id(1),"relation":"References","uri":"docs/contract.md","revision":null,"fragment":null});
|
||||
let bytes = serde_json::to_vec(&old).unwrap();
|
||||
let migrated = persistence::decode_with_report(&bytes).unwrap();
|
||||
let generated = &migrated
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.generated_interface_allocations[0];
|
||||
assert_ne!(generated.alias, alias);
|
||||
assert!(generated.alias.starts_with(&alias));
|
||||
assert_eq!(
|
||||
migrated.project,
|
||||
persistence::decode_with_report(&bytes).unwrap().project
|
||||
);
|
||||
let duplicate = include_str!("fixtures/v3-data-contract.json").replace(
|
||||
"\"kind\": \"Event\"",
|
||||
"\"kind\": \"Event\", \"kind\": \"Material\"",
|
||||
);
|
||||
assert!(
|
||||
persistence::decode(duplicate.as_bytes())
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Duplicate field 'kind'")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recursive_legacy_record_paths_remain_ambiguous_instead_of_becoming_one_argument() {
|
||||
let mut old = original3();
|
||||
old["elements"][id(8).to_string()]["body"]["Sa"]["Shared"]["DataField"]["type_ref"] =
|
||||
json!("String");
|
||||
old["elements"][id(100).to_string()] = json!({"id":id(100),"alias":"next_coordinate","name":"Next coordinate","description":"","parent":id(5),"body":{"Sa":{"Shared":{"DataField":{"type_ref":{"Named":id(5)},"minimum":0,"maximum":1,"unit":""}}}}});
|
||||
let decoded = persistence::decode_with_report(&serde_json::to_vec(&old).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
decoded
|
||||
.migration
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.unresolved_message_values
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = decoded.project.elements[&id(17)].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert!(values[0].path.is_empty());
|
||||
assert!(
|
||||
validation::validate(&decoded.project)
|
||||
.iter()
|
||||
.any(|issue| issue.code == "message_value_ambiguous_path")
|
||||
);
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
use oa_core::{language, source_project::*, *};
|
||||
use std::collections::BTreeMap;
|
||||
fn id(n: u128) -> Id {
|
||||
Id::from_u128(n)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_v3_bundle_upgrade_preserves_includes_nested_ownership_comments_and_identity() {
|
||||
let original = include_str!("fixtures/v3-data-contract.arcadia");
|
||||
let header = original.find(" sa_function").unwrap();
|
||||
let close = original.rfind('}').unwrap();
|
||||
let entry = format!(
|
||||
"{} // Contracts stay in their original module.\n include \"contracts.arcadia\";\n}}\n",
|
||||
&original[..header]
|
||||
);
|
||||
let fragment = original[header..close].replace(
|
||||
"items [@packet];",
|
||||
"items [@ // interface contract decision\npacket];",
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), entry),
|
||||
("contracts.arcadia".into(), fragment),
|
||||
]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.bundle.files.len(), 2);
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("arcadia 4;"));
|
||||
assert!(compiled.bundle.files["project.arcadia"].contains("include \"contracts.arcadia\";"));
|
||||
for text in [
|
||||
"// Contracts stay in their original module.",
|
||||
"// Reused record parameters retain their own identities.",
|
||||
"// interface contract decision",
|
||||
] {
|
||||
assert!(
|
||||
compiled
|
||||
.bundle
|
||||
.files
|
||||
.values()
|
||||
.any(|source| source.contains(text)),
|
||||
"Lost {text}"
|
||||
);
|
||||
}
|
||||
assert_eq!(compiled.index.lexical_owners[&id(6)], id(5));
|
||||
assert_eq!(compiled.index.lexical_owners[&id(7)], id(4));
|
||||
assert_eq!(compiled.index.lexical_owners[&id(8)], id(4));
|
||||
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
|
||||
let report = compiled.decoded.migration.as_ref().unwrap();
|
||||
assert_eq!(report.from_schema, 3);
|
||||
for allocation in &report.generated_interface_allocations {
|
||||
assert_eq!(
|
||||
compiled.index.declarations[&allocation.id].path,
|
||||
"contracts.arcadia"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
compiled
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.diagnostic.code == "message_value_ambiguous_path")
|
||||
);
|
||||
let second = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(second.bundle, compiled.bundle);
|
||||
assert_eq!(second.decoded.project, compiled.decoded.project);
|
||||
assert!(second.decoded.migration.is_none());
|
||||
let mut renamed = compiled.decoded.project.clone();
|
||||
renamed.elements.get_mut(&id(6)).unwrap().name = "Latitude degrees".into();
|
||||
let updated = update_bundle(&compiled, &renamed, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(updated.index.lexical_owners, compiled.index.lexical_owners);
|
||||
assert!(updated.bundle.files["contracts.arcadia"].contains("Latitude degrees"));
|
||||
assert!(
|
||||
bundle.files["project.arcadia"].contains("arcadia 3;"),
|
||||
"Migration does not mutate the caller's draft"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema4_typed_values_supertype_paths_and_protocols_roundtrip() {
|
||||
let source = r#"arcadia 4; project data_contract "Data contract" {
|
||||
sa_data_type bounded "Bounded" { definition { Integer { minimum -9223372036854775808; maximum 9223372036854775807; }; }; default { Integer -9223372036854775808; }; unit "m"; }
|
||||
sa_data_type label_type "Label" { definition { String { minimum_length 1; maximum_length 4; }; }; default { String "航海"; }; }
|
||||
sa_data_type base "Base" { definition { Record { supertype none; }; }; }
|
||||
sa_data_type derived "Derived" { definition { Record { supertype @base; }; }; }
|
||||
sa_exchange_item operation "Operation" { mechanism Operation;
|
||||
sa_data_field value "Value" { type_ref { Named @bounded; }; direction In; storage Value; default [{ Integer 42; }]; }
|
||||
}
|
||||
sa_interface contract "Contract" { supertype none; }
|
||||
interface_item_allocation member "Operation member" { source @contract; target @operation; transmission Synchronous; acquisition Accept; order 3; }
|
||||
data_association association "Related records" { source @base; target @derived; source_role "parent"; target_role "children"; source_minimum 0; source_maximum 1; target_minimum 0; target_maximum none; source_navigable true; target_navigable true; }
|
||||
}"#;
|
||||
let parsed = language::parse(source).unwrap();
|
||||
let formatted = language::format(&parsed);
|
||||
assert!(formatted.contains("supertype @base"));
|
||||
assert!(formatted.contains("transmission Synchronous"));
|
||||
assert_eq!(language::parse(&formatted).unwrap(), parsed);
|
||||
let compiled = compile_bundle(&format_bundle(&parsed), ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, parsed);
|
||||
assert!(compiled.decoded.migration.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omitted_ids_are_stabilized_before_allocation_identity_generation() {
|
||||
let bundle=SourceBundle{entry:"project.arcadia".into(),files:BTreeMap::from([("project.arcadia".into(),"arcadia 3; project old_model \"Old\" { sa_exchange_item event_item \"Event\" { kind Event; } sa_interface contract \"Contract\" { items [@event_item]; } }".into())])};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let report = compiled.decoded.migration.as_ref().unwrap();
|
||||
let allocation = &report.generated_interface_allocations[0];
|
||||
assert_eq!(
|
||||
allocation.id,
|
||||
Id::new_v5(
|
||||
&compiled.decoded.project.id,
|
||||
format!(
|
||||
"v2-interface-item:{}:{}",
|
||||
allocation.source, allocation.target
|
||||
)
|
||||
.as_bytes()
|
||||
)
|
||||
);
|
||||
let reopened = compile_bundle(&compiled.bundle, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(reopened.decoded.project, compiled.decoded.project);
|
||||
assert_eq!(reopened.bundle, compiled.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inferring_a_legacy_path_keeps_rationale_inside_its_argument() {
|
||||
let source = include_str!("fixtures/v3-data-contract.arcadia")
|
||||
.replace(
|
||||
"type_ref { Named @coordinate; }; }\n }",
|
||||
"type_ref String; }\n }",
|
||||
)
|
||||
.replace(
|
||||
"field @latitude; expression",
|
||||
"field @latitude; // original argument rationale\nexpression",
|
||||
);
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([("project.arcadia".into(), source)]),
|
||||
};
|
||||
let compiled = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let source = &compiled.bundle.files["project.arcadia"];
|
||||
let argument = &source[source.find("values [{").unwrap()..];
|
||||
let argument = &argument[..argument.find('}').unwrap()];
|
||||
assert!(
|
||||
argument.contains("// original argument rationale"),
|
||||
"{source}"
|
||||
);
|
||||
assert!(argument.contains("path [@origin]"), "{source}");
|
||||
assert_eq!(compiled.index.lexical_owners[&id(17)], id(14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qualified_value_reordering_and_path_reidentification_keep_each_arguments_comments() {
|
||||
let mut project = language::parse(include_str!("fixtures/v3-data-contract.arcadia")).unwrap();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*values = vec![
|
||||
MessageValue {
|
||||
path: vec![id(7)],
|
||||
field: id(6),
|
||||
expression: "origin value".into(),
|
||||
},
|
||||
MessageValue {
|
||||
path: vec![id(8)],
|
||||
field: id(6),
|
||||
expression: "destination value".into(),
|
||||
},
|
||||
];
|
||||
let mut bundle = format_bundle(&project);
|
||||
for source in bundle.files.values_mut() {
|
||||
*source = source
|
||||
.replace(
|
||||
"expression \"origin value\";",
|
||||
"expression \"origin value\"; // origin argument rationale",
|
||||
)
|
||||
.replace(
|
||||
"expression \"destination value\";",
|
||||
"expression \"destination value\"; // destination argument rationale",
|
||||
);
|
||||
}
|
||||
let current = compile_bundle(&bundle, ProjectLimits::default()).unwrap();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = project.elements.get_mut(&id(17)).unwrap().shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
values.reverse();
|
||||
let renamed = Id::from_u128(100);
|
||||
let mut identities: BTreeMap<_, _> = project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.map(|(id, _)| (id, id))
|
||||
.collect();
|
||||
identities.insert(id(7), renamed);
|
||||
reidentify(&mut project, id(7), renamed).unwrap();
|
||||
project.elements.get_mut(&renamed).unwrap().alias = "revised_origin".into();
|
||||
let result =
|
||||
update_bundle_with_identity_map(¤t, &project, &identities, ProjectLimits::default())
|
||||
.unwrap();
|
||||
let source = &result.bundle.files[&result.index.declarations[&id(17)].path];
|
||||
let destination = source.find("// destination argument rationale").unwrap();
|
||||
let origin = source.find("// origin argument rationale").unwrap();
|
||||
assert!(
|
||||
destination < origin,
|
||||
"Distinct uses of the same terminal field must reorder as complete argument annotations: {source}"
|
||||
);
|
||||
assert!(source[origin..].contains("path [@revised_origin]"));
|
||||
assert_eq!(result.decoded.project, project);
|
||||
}
|
||||
@@ -447,12 +447,11 @@ fn standard_interface_roles_do_not_follow_message_direction_and_kind_mixing_is_r
|
||||
p = execute_batch(&p, &plan_component_exchange(&p, &req).unwrap()).unwrap();
|
||||
let (source, target) = p.relations[&boundary].body.endpoints();
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Service");
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
let requirement = p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Caller requires service",
|
||||
@@ -500,12 +499,11 @@ fn legacy_split_preserves_original_identity_prose_interface_artifact_constraint_
|
||||
let (mut p, a, b, item, forward) = fixture();
|
||||
let (old, _) = endpoints(&p, forward);
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original interface");
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
p.elements.get_mut(&old).unwrap().description = "Original bidirectional rationale".into();
|
||||
*p.elements.get_mut(&old).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
@@ -661,12 +659,11 @@ fn directed_legacy_resolution_retains_unclassified_interface_and_role_identity()
|
||||
let (mut p, _, _, item, fe) = fixture();
|
||||
let output = endpoints(&p, fe).0;
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Original service");
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
*p.elements.get_mut(&output).unwrap().shared_mut().unwrap() = SharedBody::Port {
|
||||
contract: PortContract::Legacy,
|
||||
legacy: Some(LegacyPortContract {
|
||||
@@ -814,19 +811,16 @@ fn projection_anchors_are_contract_aware_and_unoriented_connections_keep_their_i
|
||||
let edge = view.edges.iter().find(|e| e.id == boundary).unwrap();
|
||||
assert!(!edge.oriented);
|
||||
assert_eq!(edge.source_port, Some(p.relations[&boundary].source()));
|
||||
for (element, side) in [
|
||||
(p.relations[&boundary].source(), PortAnchorSide::Right),
|
||||
(p.relations[&boundary].target(), PortAnchorSide::Left),
|
||||
] {
|
||||
assert_eq!(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|port| port.element == element)
|
||||
.unwrap()
|
||||
.side,
|
||||
side
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
view.ports
|
||||
.iter()
|
||||
.filter(|port| [
|
||||
p.relations[&boundary].source(),
|
||||
p.relations[&boundary].target()
|
||||
]
|
||||
.contains(&port.element))
|
||||
.all(|port| port.side == PortAnchorSide::Bottom)
|
||||
);
|
||||
let (output, input) = endpoints(&p, fe);
|
||||
assert_eq!(
|
||||
view.ports
|
||||
|
||||
@@ -94,9 +94,7 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
.add_element(Kind::DataType, layer, "Request record", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
definition: DataTypeDefinition::Record,
|
||||
};
|
||||
let field = p
|
||||
.add_element(Kind::DataField, layer, "Requested level", Some(record))
|
||||
@@ -162,7 +160,6 @@ fn fixture(layer: Layer) -> Fixture {
|
||||
}) = p.elements.get_mut(&call).unwrap().shared_mut()
|
||||
{
|
||||
values.push(MessageValue {
|
||||
path: vec![payload],
|
||||
field,
|
||||
expression: "requested_level".into(),
|
||||
});
|
||||
|
||||
@@ -357,12 +357,9 @@ fn workshop_automatic_labels_are_readable_without_mutating_authored_routes() {
|
||||
size
|
||||
}
|
||||
),
|
||||
"{} overlaps {}: label {:?}, node {:?}, path {:?}",
|
||||
"{} overlaps {}",
|
||||
label.text,
|
||||
p.named(node.element),
|
||||
label.bounds,
|
||||
node,
|
||||
routed.edges[&edge.id].points
|
||||
p.named(node.element)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
use oa_core::{projection::project_diagram, routing::*, *};
|
||||
|
||||
fn crosses(bounds: RoutingBounds, a: Position, b: Position) -> bool {
|
||||
let left = bounds.position.x;
|
||||
let top = bounds.position.y;
|
||||
let right = left + bounds.size.width;
|
||||
let bottom = top + bounds.size.height;
|
||||
if a.x == b.x {
|
||||
a.x > left && a.x < right && a.y.min(b.y) < bottom && a.y.max(b.y) > top
|
||||
} else {
|
||||
a.y > top && a.y < bottom && a.x.min(b.x) < right && a.x.max(b.x) > left
|
||||
}
|
||||
}
|
||||
|
||||
fn fixture(functional_first: bool) -> (Project, Id, Id, Id) {
|
||||
let mut project = Project::demo();
|
||||
let diagram = project
|
||||
.diagrams
|
||||
.values()
|
||||
.find(|d| d.layer == Layer::Sa && d.projection == ProjectionKind::Allocation)
|
||||
.unwrap()
|
||||
.id;
|
||||
// Freeze the observed default arrangement independently of random demo
|
||||
// identities. The two parallel border connections are only 20 units apart.
|
||||
for node in project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.values_mut()
|
||||
{
|
||||
let (x, y, width, height) = match project.elements[&node.element].alias.as_str() {
|
||||
"vessel_communication_service" => (60., 60., 272., 220.),
|
||||
"rescue_coordination_system" => (572., 60., 272., 220.),
|
||||
"transmit_distress_report" => (84., 124., 224., 132.),
|
||||
"classify_incident_and_alert_operator" => (596., 124., 224., 132.),
|
||||
alias => panic!("Unexpected demo placement {alias}"),
|
||||
};
|
||||
node.position = Position { x, y };
|
||||
node.size = Size { width, height };
|
||||
}
|
||||
let old_fe = project
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.alias == "validated_distress_report")
|
||||
.unwrap()
|
||||
.id;
|
||||
let old_boundary = project
|
||||
.relations
|
||||
.values()
|
||||
.find(|r| r.alias == "distress_reporting_connection")
|
||||
.unwrap()
|
||||
.id;
|
||||
let fe = Id::from_u128(if functional_first { 1 } else { 2 });
|
||||
let boundary = Id::from_u128(if functional_first { 2 } else { 1 });
|
||||
let project = execute_batch(
|
||||
&project,
|
||||
&[
|
||||
Command::Reidentify(old_fe, fe),
|
||||
Command::Reidentify(old_boundary, boundary),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
(project, diagram, fe, boundary)
|
||||
}
|
||||
|
||||
fn stroke_collisions(project: &Project, routed: &RoutedView) -> Vec<String> {
|
||||
let mut collisions = Vec::new();
|
||||
for edge in routed.edges.values().filter(|e| !e.label.text.is_empty()) {
|
||||
for other in routed.edges.values().filter(|e| e.id != edge.id) {
|
||||
for segment in other.points.windows(2) {
|
||||
if crosses(edge.label.bounds, segment[0], segment[1]) {
|
||||
collisions.push(format!(
|
||||
"{} ({}) label {:?} crosses {} ({}) segment {:?}",
|
||||
edge.label.text,
|
||||
edge.id,
|
||||
edge.label.bounds,
|
||||
project.named(other.id),
|
||||
other.id,
|
||||
segment
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
collisions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn demo_architecture_labels_do_not_cover_earlier_or_later_connection_strokes() {
|
||||
for functional_first in [true, false] {
|
||||
let (project, diagram, _, _) = fixture(functional_first);
|
||||
let before = project.clone();
|
||||
let d = &project.diagrams[&diagram];
|
||||
let view = project_diagram(&project, d);
|
||||
let routed = route_diagram(&project, d, &view);
|
||||
let collisions = stroke_collisions(&project, &routed);
|
||||
assert!(collisions.is_empty(), "{collisions:#?}");
|
||||
assert_eq!(project, before);
|
||||
assert_eq!(routed, route_diagram(&project, d, &view));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_overlap_is_exact_and_reset_only_repositions_automatic_text() {
|
||||
let (mut project, diagram, fe, _) = fixture(true);
|
||||
let view = project_diagram(&project, &project.diagrams[&diagram]);
|
||||
let automatic = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
let authored = EdgeLabel {
|
||||
fraction: 0.5,
|
||||
offset: Position { x: 0., y: -18. },
|
||||
};
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.routes
|
||||
.push(EdgeRoute {
|
||||
edge: fe,
|
||||
waypoints: vec![],
|
||||
label: Some(authored.clone()),
|
||||
});
|
||||
let authored_snapshot = project.clone();
|
||||
let explicit = route_diagram(&project, &project.diagrams[&diagram], &view);
|
||||
let label = &explicit.edges[&fe].label;
|
||||
assert_eq!(label.fraction, authored.fraction);
|
||||
assert_eq!(
|
||||
label.center,
|
||||
Position {
|
||||
x: label.anchor.x + authored.offset.x,
|
||||
y: label.anchor.y + authored.offset.y
|
||||
}
|
||||
);
|
||||
assert!(
|
||||
!stroke_collisions(&project, &explicit).is_empty(),
|
||||
"The authored label deliberately retains the native overlap"
|
||||
);
|
||||
assert_eq!(project, authored_snapshot);
|
||||
for (&id, edge) in &automatic.edges {
|
||||
assert_eq!(edge.points, explicit.edges[&id].points);
|
||||
assert_eq!(edge.pins, explicit.edges[&id].pins);
|
||||
assert_eq!(edge.warnings, explicit.edges[&id].warnings);
|
||||
}
|
||||
project.diagrams.get_mut(&diagram).unwrap().routes[0].label = None;
|
||||
let reset = route_edge_preview(&project, &project.diagrams[&diagram], &view, &explicit, fe);
|
||||
assert_eq!(reset.edges[&fe], automatic.edges[&fe]);
|
||||
for (&id, edge) in &explicit.edges {
|
||||
if id != fe {
|
||||
assert_eq!(reset.edges[&id], *edge);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ fn setup() -> tempfile::TempDir {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; }\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir(directory.path().join("parts")).unwrap();
|
||||
@@ -59,7 +59,7 @@ fn loading_keeps_original_no_uuid_bytes_and_saving_stabilizes_exactly_once() {
|
||||
fn invalid_and_missing_includes_keep_editable_text_and_located_diagnostics() {
|
||||
let directory = setup();
|
||||
let entry = directory.path().join("project.arcadia");
|
||||
fs::write(&entry, "arcadia 4; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(&entry, "arcadia 3; project p \"P\" {\n include \"missing.arcadia\";\n include \"parts/a.arcadia\";\n}\n").unwrap();
|
||||
fs::write(
|
||||
directory.path().join("parts/a.arcadia"),
|
||||
"oa_activity a \"A\" { invalid",
|
||||
@@ -102,7 +102,7 @@ fn discovery_never_refreshes_drafts_and_preserves_detached_buffers() {
|
||||
let overlays = BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 4; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
"arcadia 3; project p \"P\" { include \"new.arcadia\"; }".into(),
|
||||
),
|
||||
(
|
||||
"parts/a.arcadia".into(),
|
||||
@@ -176,7 +176,7 @@ fn new_destinations_require_explicit_missing_expectations_and_reject_collisions(
|
||||
files: BTreeMap::from([
|
||||
(
|
||||
"project.arcadia".into(),
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }".into(),
|
||||
),
|
||||
("child.arcadia".into(), "oa_activity a \"A\" {}".into()),
|
||||
]),
|
||||
@@ -256,7 +256,7 @@ fn physical_hard_link_aliases_are_rejected() {
|
||||
.unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 4; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"parts/a.arcadia\"; include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
@@ -286,7 +286,7 @@ fn symlinks_to_outside_or_inside_project_are_rejected() {
|
||||
std::os::unix::fs::symlink(&destination, &link).unwrap();
|
||||
fs::write(
|
||||
directory.path().join("project.arcadia"),
|
||||
"arcadia 4; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"alias.arcadia\"; }",
|
||||
)
|
||||
.unwrap();
|
||||
let loaded = load_project(
|
||||
|
||||
@@ -6,10 +6,10 @@ fn source() -> CompiledSourceProject {
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 4; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("project.arcadia".into(), "arcadia 3; project p \"Identity review\" { include \"team/first.arcadia\"; include \"team/second.arcadia\"; include \"contracts.arcadia\"; }".into()),
|
||||
("team/first.arcadia".into(), "// Rationale for first\nsa_exchange_item first \"First\" { description \"First rationale\"; }\n".into()),
|
||||
("team/second.arcadia".into(), "// Rationale for second\nsa_exchange_item second \"Second\" {}\n".into()),
|
||||
("contracts.arcadia".into(), "sa_event contract \"Contract\" { body { Signal { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }; }; }\n".into()),
|
||||
("contracts.arcadia".into(), "sa_interface contract \"Contract\" { items [\n @first // first item rationale\n ,\n @second // second item rationale\n ]; }\n".into()),
|
||||
]),
|
||||
},
|
||||
ProjectLimits::default(),
|
||||
@@ -91,9 +91,8 @@ fn identity_swap_and_reorder_keep_both_declarations_and_item_comments_with_origi
|
||||
reidentify(&mut candidate, second, first).unwrap();
|
||||
reidentify(&mut candidate, temporary, second).unwrap();
|
||||
let contract = find(&candidate, "contract");
|
||||
let Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) = candidate.elements.get_mut(&contract).unwrap().shared_mut()
|
||||
let Some(SharedBody::Interface { items }) =
|
||||
candidate.elements.get_mut(&contract).unwrap().shared_mut()
|
||||
else {
|
||||
panic!("interface")
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ fn compile(a: &str, b: &str) -> CompiledSourceProject {
|
||||
compile_bundle(&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "arcadia 4; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("project.arcadia".into(), "arcadia 3; project p \"Nested writeback\" { include \"a.arcadia\"; include \"b.arcadia\"; }\n".into()),
|
||||
("a.arcadia".into(), a.into()), ("b.arcadia".into(), b.into()),
|
||||
]),
|
||||
}, ProjectLimits::default()).unwrap()
|
||||
|
||||
@@ -30,7 +30,7 @@ fn id(project: &Project, alias: &str) -> Id {
|
||||
#[test]
|
||||
fn route_edits_keep_diagram_file_ownership_and_comments_with_edge_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -95,7 +95,7 @@ diagram review "Review" {
|
||||
#[test]
|
||||
fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
let mut input = bundle(
|
||||
"arcadia 4; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
"arcadia 3; project p \"Routing\" { include \"views.arcadia\"; include \"work.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"work.arcadia",
|
||||
@@ -127,7 +127,7 @@ fn invalid_cross_file_route_references_and_geometry_locate_the_authored_view() {
|
||||
|
||||
fn typed() -> SourceBundle {
|
||||
bundle(
|
||||
"arcadia 4; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
"arcadia 3; project test_model \"航海\" {\n include \"exchanges.arcadia\";\n include \"work/send.arcadia\";\n include \"work/receive.arcadia\";\n include \"types.arcadia\";\n}",
|
||||
&[
|
||||
(
|
||||
"exchanges.arcadia",
|
||||
@@ -194,7 +194,7 @@ fn cross_file_typed_ports_global_forward_references_and_stable_ids() {
|
||||
#[test]
|
||||
fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"sa_function first \"First\" {}\nsa_function second \"Second\" {}\n",
|
||||
@@ -228,7 +228,7 @@ fn advisory_lint_locations_refer_to_raw_drafts_before_identity_stabilization() {
|
||||
#[test]
|
||||
fn source_errors_keep_exact_fragment_unicode_positions() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"// Пример\nsa_function child \"航海\" { parent @missing; }",
|
||||
@@ -274,7 +274,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
),
|
||||
] {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[("a.arcadia", left), ("b.arcadia", right)],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -285,7 +285,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
assert_eq!(error.related[0].path, "a.arcadia");
|
||||
}
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"child.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"child.arcadia\"; }",
|
||||
&[("child.arcadia", "oa_activity p \"Collision\" {}")],
|
||||
);
|
||||
let error = compile_bundle(&input, ProjectLimits::default())
|
||||
@@ -297,7 +297,7 @@ fn duplicate_alias_and_identity_report_both_files_including_project_identity() {
|
||||
#[test]
|
||||
fn includes_are_once_only_and_cycles_missing_and_unreachable_files_are_located() {
|
||||
let mut input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"sub/a.arcadia\"; include \"sub/b.arcadia\"; }",
|
||||
&[
|
||||
("sub/a.arcadia", "include \"../shared.arcadia\";"),
|
||||
("sub/b.arcadia", "include \"../shared.arcadia\";"),
|
||||
@@ -356,7 +356,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
);
|
||||
assert!(resolve_include("../bad.arcadia", "a.arcadia").is_err());
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" {}",
|
||||
"arcadia 3; project p \"P\" {}",
|
||||
&[("A.arcadia", ""), ("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -370,7 +370,7 @@ fn portable_paths_reject_escapes_devices_urls_and_case_collisions() {
|
||||
#[test]
|
||||
fn project_admission_is_aggregate_and_precedes_global_resolution() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
("a.arcadia", "oa_activity one \"One\" { parent @missing; }"),
|
||||
("b.arcadia", "oa_activity two \"Two\" {}"),
|
||||
@@ -591,7 +591,7 @@ fn failed_update_preserves_candidate_and_rejects_stale_index() {
|
||||
|
||||
#[test]
|
||||
fn single_string_and_legacy_apis_remain_explicit_and_filesystem_free() {
|
||||
let source = "arcadia 4; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
let source = "arcadia 3; project p \"P\" { include \"missing.arcadia\"; }";
|
||||
assert!(
|
||||
language::parse(source).unwrap_err()[0]
|
||||
.message
|
||||
@@ -681,7 +681,7 @@ fn json_bundle_rejects_duplicate_file_keys_and_unknown_fields() {
|
||||
#[test]
|
||||
fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_indexed() {
|
||||
let input = bundle(
|
||||
"arcadia 4;project p\"P\"{include \"child.arcadia\";}",
|
||||
"arcadia 3;project p\"P\"{include \"child.arcadia\";}",
|
||||
&[(
|
||||
"child.arcadia",
|
||||
"oa_activity a\"A\"{}diagram d\"D\"{}node n{diagram @d;element @a;}",
|
||||
@@ -703,7 +703,7 @@ fn generated_id_insertion_handles_adjacent_header_tokens_and_remains_exactly_ind
|
||||
#[test]
|
||||
fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_aggregate() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"a.arcadia\"; }",
|
||||
&[("a.arcadia", "")],
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -720,7 +720,7 @@ fn duplicate_include_edges_count_toward_admission_and_ordered_references_are_agg
|
||||
"project_limit"
|
||||
);
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"a.arcadia\"; include \"b.arcadia\"; }",
|
||||
&[
|
||||
(
|
||||
"a.arcadia",
|
||||
@@ -763,7 +763,7 @@ fn include_inspection_reuses_strict_entry_fragment_syntax_and_real_spans() {
|
||||
&source[span.start..span.end],
|
||||
"include \"../shared.arcadia\";"
|
||||
);
|
||||
let entry = "arcadia 4; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
let entry = "arcadia 3; project p \"P\" { include \"sub/work.arcadia\"; }";
|
||||
assert_eq!(
|
||||
inspect_includes("project.arcadia", entry, true, ProjectLimits::default()).unwrap()[0]
|
||||
.target,
|
||||
@@ -829,7 +829,7 @@ fn comments(source: &str) -> Vec<String> {
|
||||
|
||||
#[test]
|
||||
fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_spelling() {
|
||||
let entry = "arcadia 4;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let entry = "arcadia 3;\r\nproject p // project title\r\n \"Original project\" {\r\n description // project reason\r\n \"Before\";\r\n include \"behavior.arcadia\";\r\n}\r\n";
|
||||
let source = "oa_state_machine machine \"Machine\" {}\r\noa_region region_one \"Region\" { parent @machine; }\r\noa_state first \"First\" { parent @region_one; }\r\noa_state second \"Second\" { parent @region_one; }\r\nstate_transition route // route title\r\n \"Original transition\" {\r\n\tguard // feasibility\r\n\t \"old guard\"; // retain explanation\r\n\tdescription \"Keep\\u0020spelling\";\r\n\tsource @ // endpoint proof\r\n first; target @second;\r\n}\r\n";
|
||||
let current = compile(&bundle(entry, &[("behavior.arcadia", source)]));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -864,7 +864,7 @@ fn scalar_header_guard_and_project_edits_preserve_exact_crlf_trivia_and_escape_s
|
||||
#[test]
|
||||
fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after_at() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity root_work // root rationale\n \"Root\\u0020name\" { description \"root_work is prose\"; }\noa_activity child \"Child\" { parent @ // parent proof\n root_work; }\n",
|
||||
@@ -887,7 +887,7 @@ fn alias_updates_touch_only_header_and_reference_tokens_even_with_comments_after
|
||||
#[test]
|
||||
fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"work.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"work.arcadia\"; }",
|
||||
&[(
|
||||
"work.arcadia",
|
||||
"oa_activity work \"Work\" {\r\n\t// owner rationale\r\n\tparent none ; // preserve spacing\r\n}\r\n",
|
||||
@@ -913,7 +913,7 @@ fn omitted_defaults_stay_omitted_and_new_fields_do_not_reformat_surviving_fields
|
||||
#[test]
|
||||
fn kind_change_removes_field_syntax_but_keeps_its_comments_and_other_fields() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"item.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"item.arcadia\"; }",
|
||||
&[(
|
||||
"item.arcadia",
|
||||
"oa_exchange_item item \"Item\" {\n description \"Keep\\u0020this\" ; // description note\n kind // classification rationale\n Information ;\n parent none;\n}\n",
|
||||
@@ -953,7 +953,7 @@ fn significant_words(source: &str) -> Vec<String> {
|
||||
#[test]
|
||||
fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comments() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"event.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"event.arcadia\"; }",
|
||||
&[(
|
||||
"event.arcadia",
|
||||
"oa_event timer \"Timer\" { body { Time // time rationale\n { expression // expression rationale\n \"before\" ; kind After; // clock rationale\n }; }; }\n",
|
||||
@@ -986,7 +986,7 @@ fn constructor_tag_changes_retain_shared_nested_fields_and_removed_field_comment
|
||||
#[test]
|
||||
fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"ports.arcadia\"; }",
|
||||
&[(
|
||||
"ports.arcadia",
|
||||
"sa_function work \"Work\" {}\nsa_port input \"Input\" { parent @work; contract Legacy; // contract rationale\n legacy { direction In; // direction rationale\n interface none; // interface rationale\n }; }\n",
|
||||
@@ -1017,10 +1017,10 @@ fn scalar_record_constructor_changes_keep_comments_inside_removed_values() {
|
||||
#[test]
|
||||
fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_identity() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }; }; }\n",
|
||||
"sa_exchange_item a \"A\" {} sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // before A\n @ // A reference rationale\n a // after A\n ,\n // before B\n @b // after B\n ]; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
@@ -1029,9 +1029,8 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
|
||||
let b = id(&project, "b");
|
||||
let service = id(&project, "service");
|
||||
project.elements.get_mut(&a).unwrap().alias = "renamed_a".into();
|
||||
if let Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) = project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
if let Some(SharedBody::Interface { items }) =
|
||||
project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
{
|
||||
*items = vec![b, a];
|
||||
} else {
|
||||
@@ -1052,10 +1051,10 @@ fn reference_list_reordering_carries_item_comments_and_alias_spelling_with_ident
|
||||
#[test]
|
||||
fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_event service \"Service\" { body { Signal { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }; }; }\n",
|
||||
"sa_exchange_item a \"A\" { // declaration explanation\n } sa_exchange_item b \"B\" {}\nsa_interface service \"Service\" { items [\n // explanation for removed A\n @a,\n // explanation for retained B\n @b ]; }\n",
|
||||
)],
|
||||
);
|
||||
let current = compile(&input);
|
||||
@@ -1064,9 +1063,8 @@ fn removed_list_items_and_declarations_keep_comments_as_enclosing_annotations()
|
||||
let b = id(&project, "b");
|
||||
let service = id(&project, "service");
|
||||
project.elements.remove(&a);
|
||||
if let Some(SharedBody::Event {
|
||||
body: EventBody::Signal { items },
|
||||
}) = project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
if let Some(SharedBody::Interface { items }) =
|
||||
project.elements.get_mut(&service).unwrap().shared_mut()
|
||||
{
|
||||
*items = vec![b];
|
||||
} else {
|
||||
@@ -1104,7 +1102,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
}; }; }
|
||||
"#;
|
||||
let current = compile(&bundle(
|
||||
"arcadia 4; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"conversation.arcadia\"; }",
|
||||
&[("conversation.arcadia", source)],
|
||||
));
|
||||
let mut project = current.decoded.project.clone();
|
||||
@@ -1153,7 +1151,7 @@ oa_occurrence message "Message" { parent @conversation; body { Exchange {
|
||||
#[test]
|
||||
fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotations() {
|
||||
let input = bundle(
|
||||
"arcadia 4; project p \"P\" { include \"types.arcadia\"; }",
|
||||
"arcadia 3; project p \"P\" { include \"types.arcadia\"; }",
|
||||
&[(
|
||||
"types.arcadia",
|
||||
"oa_data_type values \"Values\" { definition { Enumeration { literals [ // enumeration rationale\n ]; }; }; }\n",
|
||||
@@ -1162,7 +1160,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
|
||||
let empty = compile(&input);
|
||||
let mut project = empty.decoded.project.clone();
|
||||
let values = id(&project, "values");
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
if let Some(SharedBody::DataType { definition }) =
|
||||
project.elements.get_mut(&values).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Enumeration {
|
||||
@@ -1187,7 +1185,7 @@ fn added_list_items_empty_lists_and_literal_changes_preserve_existing_annotation
|
||||
.replace("\"Beta\"", "\"Beta\" // beta explanation\n"),
|
||||
);
|
||||
let annotated = compile(&annotated);
|
||||
if let Some(SharedBody::DataType { definition, .. }) =
|
||||
if let Some(SharedBody::DataType { definition }) =
|
||||
project.elements.get_mut(&values).unwrap().shared_mut()
|
||||
{
|
||||
*definition = DataTypeDefinition::Enumeration {
|
||||
|
||||
@@ -328,12 +328,11 @@ fn items_ports_interfaces_and_exchange_allocation_are_typed() {
|
||||
let f2 = add(&mut p, Kind::Function, Layer::Sa, "Receive", None);
|
||||
let item = add(&mut p, Kind::ExchangeItem, Layer::Sa, "Message", None);
|
||||
let interface = add(&mut p, Kind::Interface, Layer::Sa, "Link interface", None);
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Interface payload",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
*p.elements
|
||||
.get_mut(&interface)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Interface { items: vec![item] };
|
||||
let port = |p: &mut Project, name: &str, parent, direction| {
|
||||
let id = Id::new_v4();
|
||||
let contract = if p.elements[&parent].kind() == Kind::Function {
|
||||
@@ -741,9 +740,6 @@ fn reidentifying_data_types_and_function_ports_updates_every_typed_reference() {
|
||||
minimum: 0,
|
||||
maximum: None,
|
||||
unit: "items".into(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
let function = add(&mut p, Kind::Function, Layer::Sa, "Function", None);
|
||||
let other = add(&mut p, Kind::Function, Layer::Sa, "Other", None);
|
||||
@@ -808,40 +804,8 @@ fn every_demo_identity_can_be_remapped_and_deleted_without_dangling_references()
|
||||
"Reidentification must preserve all values and order"
|
||||
);
|
||||
if id != p.id {
|
||||
let deleted = if p
|
||||
.relations
|
||||
.get(&id)
|
||||
.is_some_and(|r| r.kind() == RelationKind::InterfaceItemAllocation)
|
||||
{
|
||||
// An explicitly empty interface cannot silently accept the
|
||||
// still-carried payload. Removing its definition needs a
|
||||
// reviewed edit of that dependent contract in the same batch.
|
||||
let mut session = Session::new(p.clone());
|
||||
assert!(
|
||||
session
|
||||
.execute(Command::Delete(id))
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("port_interface_items")
|
||||
);
|
||||
assert_eq!(session.project(), &p);
|
||||
assert_eq!(session.revision(), 0);
|
||||
let item = p.relations[&id].target();
|
||||
let mut commands = vec![Command::Delete(id)];
|
||||
commands.extend(
|
||||
p.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::BoundaryExchange
|
||||
&& r.body.items().contains(&item)
|
||||
})
|
||||
.map(|r| Command::Delete(r.id)),
|
||||
);
|
||||
execute_batch(&p, &commands).unwrap()
|
||||
} else {
|
||||
execute_batch(&p, &[Command::Delete(id)])
|
||||
.unwrap_or_else(|err| panic!("Delete {id}: {err}"))
|
||||
};
|
||||
let deleted = execute_batch(&p, &[Command::Delete(id)])
|
||||
.unwrap_or_else(|err| panic!("Delete {id}: {err}"));
|
||||
accepted(&deleted);
|
||||
}
|
||||
}
|
||||
@@ -926,15 +890,13 @@ fn demo_has_both_layers_complete_contracts_and_no_checks() {
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Material,
|
||||
..
|
||||
kind: ExchangeItemKind::Material
|
||||
})
|
||||
)));
|
||||
assert!(p.elements.values().any(|e| matches!(
|
||||
e.shared(),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy,
|
||||
..
|
||||
kind: ExchangeItemKind::Energy
|
||||
})
|
||||
)));
|
||||
}
|
||||
|
||||
+26
-1056
File diff suppressed because it is too large
Load Diff
@@ -1,395 +0,0 @@
|
||||
use super::*;
|
||||
use crate::behavior::{self, states};
|
||||
use oa_core::source_project::ProjectLimits;
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
machine: Id,
|
||||
source: Id,
|
||||
target: Id,
|
||||
work: Id,
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let owner = project
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let machine = project
|
||||
.add_element(
|
||||
Kind::StateMachine,
|
||||
Layer::Sa,
|
||||
"Operating modes",
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Sa, "Operation", Some(machine))
|
||||
.unwrap();
|
||||
let source = project
|
||||
.add_element(Kind::State, Layer::Sa, "Ready", Some(region))
|
||||
.unwrap();
|
||||
let target = project
|
||||
.add_element(Kind::State, Layer::Sa, "Waiting", Some(region))
|
||||
.unwrap();
|
||||
let work = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Monitor connection", None)
|
||||
.unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(source));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
Fixture {
|
||||
app,
|
||||
machine,
|
||||
source,
|
||||
target,
|
||||
work,
|
||||
}
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
// Do not poll returned I/O tasks: these tests exercise the App transaction
|
||||
// boundary without displaying a file dialog or writing to the filesystem.
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn state_event(app: &mut App, event: states::Event) {
|
||||
send(app, Message::Behavior(behavior::Event::States(event)));
|
||||
}
|
||||
|
||||
fn choice(app: &App, id: Id) -> Choice {
|
||||
Choice {
|
||||
id,
|
||||
label: app.project().named(id).into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn start_transition(fixture: &mut Fixture, complete: bool) {
|
||||
let Fixture {
|
||||
app,
|
||||
machine,
|
||||
source,
|
||||
target,
|
||||
work,
|
||||
} = fixture;
|
||||
state_event(
|
||||
app,
|
||||
states::Event::Start(*machine, states::Tool::Transition, *source),
|
||||
);
|
||||
for event in [
|
||||
states::Event::Name("Wait for recovery".into()),
|
||||
states::Event::Trigger(choice(app, *work)),
|
||||
states::Event::Effect(choice(app, *work)),
|
||||
states::Event::TriggerNotes("A monitor result".into()),
|
||||
states::Event::Guard("connection unavailable".into()),
|
||||
states::Event::EffectNotes("Record the timeout".into()),
|
||||
states::Event::Timed(true),
|
||||
states::Event::TimeKind("At".into()),
|
||||
states::Event::Expression("12:30 UTC".into()),
|
||||
] {
|
||||
state_event(app, event);
|
||||
}
|
||||
if complete {
|
||||
let target = choice(app, *target);
|
||||
state_event(app, states::Event::Target(target));
|
||||
}
|
||||
assert_eq!(app.behavior.pending_owner(), Some(*machine));
|
||||
}
|
||||
|
||||
fn assert_typed_transition(app: &App, source: Id, target: Id, work: Id) {
|
||||
assert_eq!(app.project().relations.len(), 1);
|
||||
let relation = app.project().relations.values().next().unwrap();
|
||||
assert_eq!(relation.name, "Wait for recovery");
|
||||
let RelationBody::StateTransition {
|
||||
source: actual_source,
|
||||
target: actual_target,
|
||||
triggers,
|
||||
effects,
|
||||
trigger,
|
||||
guard,
|
||||
effect,
|
||||
..
|
||||
} = &relation.body
|
||||
else {
|
||||
panic!("Expected the prepared state transition");
|
||||
};
|
||||
assert_eq!((*actual_source, *actual_target), (source, target));
|
||||
assert_eq!(effects, &[work]);
|
||||
assert_eq!(trigger, "A monitor result");
|
||||
assert_eq!(guard, "connection unavailable");
|
||||
assert_eq!(effect, "Record the timeout");
|
||||
assert_eq!(triggers.len(), 2);
|
||||
assert_eq!(triggers[0], work);
|
||||
assert!(matches!(
|
||||
app.project().elements[&triggers[1]].shared(),
|
||||
Some(SharedBody::Event {
|
||||
body: EventBody::Time { kind: TimeEventKind::At, expression }
|
||||
}) if expression == "12:30 UTC"
|
||||
));
|
||||
assert_eq!(
|
||||
app.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Event)
|
||||
.count(),
|
||||
1,
|
||||
"Retry must not leave a time event from the rejected transaction"
|
||||
);
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn source_writeback_failure_keeps_typed_behavior_form_for_repair_and_one_undo() {
|
||||
let mut fixture = fixture();
|
||||
let bundle = fixture.app.document.bundle().clone();
|
||||
let mut limits = ProjectLimits::default();
|
||||
limits.model.max_source_bytes = bundle.files.values().map(String::len).sum::<usize>() + 2000;
|
||||
fixture.app.document = Document::from_bundle(bundle.clone(), limits).unwrap();
|
||||
fixture.app.sync();
|
||||
let before = fixture.app.document.snapshot();
|
||||
start_transition(&mut fixture, true);
|
||||
let app = &mut fixture.app;
|
||||
state_event(app, states::Event::Name("Transition".repeat(1024)));
|
||||
let prepared = app
|
||||
.behavior
|
||||
.prepare_pending(app.project())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(execute_batch(app.project(), &[prepared]).is_ok());
|
||||
|
||||
state_event(app, states::Event::Submit);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.source_workspace.bundle(), bundle);
|
||||
assert_eq!(app.document.revision(), 0);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(app.behavior.has_pending());
|
||||
assert!(app.dirty());
|
||||
assert!(!app.source_dirty());
|
||||
|
||||
state_event(app, states::Event::Name("Wait for recovery".into()));
|
||||
state_event(app, states::Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_typed_transition(app, fixture.source, fixture.target, fixture.work);
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert_eq!(app.source_workspace.bundle(), *app.document.bundle());
|
||||
let generated_files: Vec<_> = app
|
||||
.document
|
||||
.bundle()
|
||||
.files
|
||||
.iter()
|
||||
.filter(|(path, _)| !bundle.files.contains_key(*path))
|
||||
.map(|(path, text)| (path.clone(), text.clone()))
|
||||
.collect();
|
||||
assert!(
|
||||
!generated_files.is_empty(),
|
||||
"The time event creates a source file"
|
||||
);
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
bundle
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_workspace.reachable,
|
||||
bundle.files.keys().cloned().collect::<BTreeSet<_>>()
|
||||
);
|
||||
// Undo restores the accepted bundle exactly. Workspace retains clean buffers
|
||||
// from removed includes so the user may inspect or explicitly close them.
|
||||
for (path, text) in generated_files {
|
||||
assert!(!app.source_workspace.reachable.contains(&path));
|
||||
let detached = &app.source_workspace.files[&path];
|
||||
assert_eq!(detached.text, text);
|
||||
assert!(!detached.dirty());
|
||||
}
|
||||
assert!(!app.source_workspace.detached_dirty());
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_behavior_form_is_dirty_close_save_retains_it_and_cancel_allows_switching() {
|
||||
let mut fixture = fixture();
|
||||
let before = fixture.app.document.snapshot();
|
||||
start_transition(&mut fixture, false);
|
||||
let app = &mut fixture.app;
|
||||
assert!(!app.document.dirty());
|
||||
assert!(app.dirty());
|
||||
assert!(app.title().contains(" *"));
|
||||
let window = window::Id::unique();
|
||||
send(app, Message::Request(Pending::Close(window)));
|
||||
assert!(matches!(app.pending, Some(Pending::Close(id)) if id == window));
|
||||
send(app, Message::SaveAndContinue);
|
||||
assert!(app.behavior.has_pending());
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.pending.is_none());
|
||||
assert!(!app.continue_after_save);
|
||||
assert!(!app.busy);
|
||||
assert!(app.file_prompt.is_none());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
|
||||
state_event(app, states::Event::Cancel);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert!(!app.dirty());
|
||||
send(app, Message::Page(Page::Text));
|
||||
assert_eq!(app.page, Page::Text);
|
||||
send(app, Message::Select(fixture.target));
|
||||
assert_eq!(app.selected, fixture.target);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_prepares_behavior_before_opening_destination_and_cancel_keeps_one_undo() {
|
||||
let mut fixture = fixture();
|
||||
let before = fixture.app.document.snapshot();
|
||||
start_transition(&mut fixture, true);
|
||||
let app = &mut fixture.app;
|
||||
send(app, Message::Save(true));
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_typed_transition(app, fixture.source, fixture.target, fixture.work);
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert!(
|
||||
app.document.dirty(),
|
||||
"Choosing a destination is not a successful disk save"
|
||||
);
|
||||
assert!(app.file_prompt.is_some() || app.busy);
|
||||
if app.file_prompt.is_some() {
|
||||
send(app, Message::CancelFile);
|
||||
} else {
|
||||
send(app, Message::SavePath(None));
|
||||
}
|
||||
assert!(!app.busy);
|
||||
assert!(app.document.dirty());
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_behavior_blocks_navigation_property_edits_and_model_history() {
|
||||
let mut fixture = fixture();
|
||||
let before = fixture.app.document.snapshot();
|
||||
start_transition(&mut fixture, true);
|
||||
let app = &mut fixture.app;
|
||||
let properties = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
for message in [
|
||||
Message::Page(Page::Diagram),
|
||||
Message::Page(Page::Catalog),
|
||||
Message::Page(Page::Text),
|
||||
Message::Page(Page::Guide),
|
||||
Message::Layer(Layer::Oa),
|
||||
Message::Select(fixture.target),
|
||||
Message::Explore(Some(fixture.target)),
|
||||
Message::BrowseGroup(crate::explorer::Group::Work),
|
||||
Message::Field("/name".into(), "Overwrite properties".into()),
|
||||
Message::Command(Command::Rename(fixture.source, "Overwrite model".into())),
|
||||
Message::ConfirmDelete,
|
||||
Message::Undo,
|
||||
Message::Redo,
|
||||
Message::ShortcutUndo(false),
|
||||
Message::SourceForSelection,
|
||||
Message::SourceNavigation(crate::source_navigation::Event::Open(false)),
|
||||
] {
|
||||
send(app, message);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.selected, fixture.source);
|
||||
assert_eq!(app.layer, Layer::Sa);
|
||||
assert_eq!(app.behavior.pending_owner(), Some(fixture.machine));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().materialize().unwrap(),
|
||||
properties
|
||||
);
|
||||
assert!(!app.source_dirty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_behavior_canvas_events_do_not_apply_property_drafts() {
|
||||
let mut fixture = fixture();
|
||||
let app = &mut fixture.app;
|
||||
let before = app.document.snapshot();
|
||||
send(
|
||||
app,
|
||||
Message::Field("/name".into(), "Unapplied state name".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
for event in [
|
||||
behavior::Event::Topology(behavior::topology::Event::Viewport(iced::Size::new(
|
||||
900., 600.,
|
||||
))),
|
||||
behavior::Event::Topology(behavior::topology::Event::Zoom(0.7)),
|
||||
behavior::Event::Topology(behavior::topology::Event::Fit),
|
||||
] {
|
||||
send(app, Message::Behavior(event));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert!(app.draft_dirty());
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_eq!(app.source_workspace.bundle(), before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_behavior_blocks_source_creation_and_source_history_replay() {
|
||||
for redo in [false, true] {
|
||||
let mut fixture = fixture();
|
||||
let app = &mut fixture.app;
|
||||
let baseline = app.source_workspace.bundle();
|
||||
let edited = format!(
|
||||
"{}\n// Source draft that must stay in history.\n",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
send(
|
||||
app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new(edited),
|
||||
))),
|
||||
);
|
||||
send(
|
||||
app,
|
||||
if redo {
|
||||
Message::SourceUndo
|
||||
} else {
|
||||
Message::RevertSource
|
||||
},
|
||||
);
|
||||
assert!(!app.source_dirty());
|
||||
start_transition(&mut fixture, true);
|
||||
let app = &mut fixture.app;
|
||||
send(
|
||||
app,
|
||||
if redo {
|
||||
Message::SourceRedo
|
||||
} else {
|
||||
Message::SourceUndo
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.source_workspace.bundle(),
|
||||
baseline,
|
||||
"Source history must not create a competing draft"
|
||||
);
|
||||
send(app, Message::SourceNewPath("queued.arcadia".into()));
|
||||
send(app, Message::SourceCreateFile);
|
||||
assert_eq!(
|
||||
app.source_workspace.bundle(),
|
||||
baseline,
|
||||
"A queued create-file action must not create a competing draft"
|
||||
);
|
||||
assert!(!app.source_dirty());
|
||||
assert_eq!(app.behavior.pending_owner(), Some(fixture.machine));
|
||||
state_event(app, states::Event::Submit);
|
||||
assert!(!app.behavior.has_pending());
|
||||
assert_typed_transition(app, fixture.source, fixture.target, fixture.work);
|
||||
}
|
||||
}
|
||||
@@ -1,200 +0,0 @@
|
||||
//! Adapter from captured state-canvas events to the shared document transaction.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
state_edit::{self, Context, Event, Tool},
|
||||
};
|
||||
|
||||
impl App {
|
||||
/// The behavior view resolved independently of the current page. Capture
|
||||
/// this before selection/layer changes, while it still identifies the view
|
||||
/// whose camera and tool are retained during navigation.
|
||||
pub(super) fn state_view_identity(&self) -> (Option<Id>, Option<Id>) {
|
||||
let owner = behavior::selected_owner(self);
|
||||
let diagram = owner.and_then(|owner| behavior::state_view::diagram(self, owner));
|
||||
(owner, diagram)
|
||||
}
|
||||
|
||||
pub(super) fn reset_state_view_after_navigation(
|
||||
&mut self,
|
||||
previous: (Option<Id>, Option<Id>),
|
||||
) -> bool {
|
||||
let current = self.state_view_identity();
|
||||
if current == previous {
|
||||
return false;
|
||||
}
|
||||
self.behavior.diagram = behavior::state_view::State {
|
||||
diagram: current.1,
|
||||
..Default::default()
|
||||
};
|
||||
true
|
||||
}
|
||||
|
||||
pub fn state_context(&self) -> Option<Context> {
|
||||
if self.page != Page::Behavior {
|
||||
return None;
|
||||
}
|
||||
let diagram = self.state_view_identity().1?;
|
||||
Some(Context {
|
||||
document: self.canvas_document,
|
||||
diagram,
|
||||
revision: self.document.revision(),
|
||||
epoch: self.canvas_epoch,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn fit_state_diagram(&mut self) {
|
||||
let Some(context) = self.state_context() else {
|
||||
return;
|
||||
};
|
||||
let diagram = &self.project().diagrams[&context.diagram];
|
||||
let view = projection::project_diagram(self.project(), diagram);
|
||||
let bounds = routing::route_diagram(self.project(), diagram, &view).bounds;
|
||||
let viewport = self.behavior.diagram.viewport.unwrap_or(iced::Size::new(
|
||||
(self.window_size.width - 610.).max(280.),
|
||||
(self.window_size.height - 320.).max(240.),
|
||||
));
|
||||
let width = (viewport.width - 48.).max(1.);
|
||||
let height = (viewport.height - 80.).max(1.);
|
||||
let zoom = (width / bounds.size.width.max(1.))
|
||||
.min(height / bounds.size.height.max(1.))
|
||||
.clamp(0.15, 1.25);
|
||||
self.behavior.diagram.zoom = zoom;
|
||||
self.behavior.diagram.pan = iced::Vector::new(
|
||||
24. + (width - bounds.size.width * zoom) / 2. - bounds.position.x * zoom,
|
||||
56. + (height - bounds.size.height * zoom) / 2. - bounds.position.y * zoom,
|
||||
);
|
||||
}
|
||||
|
||||
pub(super) fn edit_state_diagram(&mut self, event: Event) -> Task<Message> {
|
||||
if self.page != Page::Behavior {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(captured) = event.context() {
|
||||
let Some(current) = self.state_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
if state_edit::validate_context(self.project(), captured, current).is_err() {
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
match event {
|
||||
Event::Viewport(_, size) => {
|
||||
if size.width.is_finite()
|
||||
&& size.height.is_finite()
|
||||
&& size.width > 0.
|
||||
&& size.height > 0.
|
||||
{
|
||||
let resized = self.behavior.diagram.viewport != Some(size);
|
||||
self.behavior.diagram.viewport = Some(size);
|
||||
if resized && self.behavior.diagram.fit {
|
||||
self.fit_state_diagram();
|
||||
}
|
||||
}
|
||||
}
|
||||
Event::Fit(_) => {
|
||||
self.behavior.diagram.fit = true;
|
||||
self.fit_state_diagram();
|
||||
}
|
||||
Event::Zoom(_, zoom) if zoom.is_finite() => {
|
||||
let state = &mut self.behavior.diagram;
|
||||
let new = zoom.clamp(0.15, 3.);
|
||||
let center = state.viewport.map_or(iced::Vector::new(300., 220.), |s| {
|
||||
iced::Vector::new(s.width / 2., s.height / 2.)
|
||||
});
|
||||
state.pan = center - (center - state.pan) * (new / state.zoom);
|
||||
state.zoom = new;
|
||||
state.fit = false;
|
||||
}
|
||||
Event::Pan(_, delta) if delta.x.is_finite() && delta.y.is_finite() => {
|
||||
self.behavior.diagram.pan += delta;
|
||||
self.behavior.diagram.fit = false;
|
||||
}
|
||||
Event::Select(_, id) => {
|
||||
if !self.behavior.has_pending() {
|
||||
self.select(id);
|
||||
self.selected_node = None;
|
||||
}
|
||||
}
|
||||
Event::ContextMenu {
|
||||
context,
|
||||
object,
|
||||
placement,
|
||||
anchor,
|
||||
} => {
|
||||
let mut request = crate::model_menu::Request::object(object, anchor);
|
||||
request.canvas = Some(context);
|
||||
request.placement = placement;
|
||||
return self.handle(Message::Menu(crate::model_menu::Event::Open(request)));
|
||||
}
|
||||
Event::EditTransition(_, id) => {
|
||||
if self.behavior.has_pending() || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
return self.handle(Message::Behavior(behavior::Event::States(
|
||||
behavior::states::Event::Edit(id),
|
||||
)));
|
||||
}
|
||||
Event::Choose(tool) => {
|
||||
if !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
self.behavior.diagram.tool = tool;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
Event::CreateView(machine) => {
|
||||
if behavior::selected_owner(self) != Some(machine) || !self.apply() {
|
||||
return Task::none();
|
||||
}
|
||||
match state_edit::create_view(self.project(), machine) {
|
||||
Ok(plan) => {
|
||||
if self.execute(plan.command) {
|
||||
self.behavior.active_owner = Some(machine);
|
||||
self.behavior.diagram.diagram = Some(plan.diagram);
|
||||
self.behavior.diagram.placement = plan.placement;
|
||||
self.behavior.diagram.tool = Tool::Select;
|
||||
self.behavior.diagram.fit = true;
|
||||
self.selected = plan.selected;
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
self.fit_state_diagram();
|
||||
self.status = "State diagram created · existing model elements are placed in this view".into();
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
Event::Zoom(..) | Event::Pan(..) => {}
|
||||
mutation => {
|
||||
if self.behavior.has_pending() || self.draft_dirty() || self.source_dirty() {
|
||||
self.status =
|
||||
"Apply or cancel pending edits before changing the state diagram.".into();
|
||||
return Task::none();
|
||||
}
|
||||
let Some(context) = self.state_context() else {
|
||||
return Task::none();
|
||||
};
|
||||
match state_edit::plan(self.project(), context, &mutation) {
|
||||
Ok(plan) => {
|
||||
if self.execute(plan.command) {
|
||||
self.behavior.diagram.diagram = Some(plan.diagram);
|
||||
self.behavior.diagram.placement = plan.placement;
|
||||
self.selected = plan.selected;
|
||||
self.selected_node = None;
|
||||
self.refresh_draft();
|
||||
if !matches!(mutation, Event::Move { .. }) {
|
||||
self.behavior.diagram.tool = Tool::Select;
|
||||
self.canvas_epoch = self.canvas_epoch.wrapping_add(1);
|
||||
}
|
||||
self.status =
|
||||
"State diagram updated · Undo restores the complete operation"
|
||||
.into();
|
||||
}
|
||||
}
|
||||
Err(error) => self.status = error.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
@@ -1,979 +0,0 @@
|
||||
//! App transaction and draft boundaries for captured state-canvas messages.
|
||||
use super::*;
|
||||
use crate::behavior::{
|
||||
self,
|
||||
state_edit::{self, Context, Endpoint, Event, Tool},
|
||||
states,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
app: App,
|
||||
machine: Id,
|
||||
region: Id,
|
||||
a: Id,
|
||||
b: Id,
|
||||
leaf: Id,
|
||||
members: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
fn send(app: &mut App, message: Message) {
|
||||
// Exercise the real App boundary without polling asynchronous I/O tasks.
|
||||
let _ = app.update(message);
|
||||
}
|
||||
|
||||
fn event(app: &mut App, event: Event) {
|
||||
send(app, Message::StateDiagram(event));
|
||||
}
|
||||
|
||||
fn form(app: &mut App, event: states::Event) {
|
||||
send(app, Message::Behavior(behavior::Event::States(event)));
|
||||
}
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
let mut project = Project::empty();
|
||||
let mut add = |kind, name, parent| project.add_element(kind, Layer::Sa, name, parent).unwrap();
|
||||
let owner = add(Kind::System, "System", None);
|
||||
let machine = add(Kind::StateMachine, "Operating modes", Some(owner));
|
||||
let region = add(Kind::Region, "Main", Some(machine));
|
||||
let initial = add(Kind::Initial, "Start", Some(region));
|
||||
let a = add(Kind::State, "Operating", Some(region));
|
||||
let b = add(Kind::State, "Standby", Some(region));
|
||||
let nested = add(Kind::Region, "Work", Some(a));
|
||||
let leaf = add(Kind::State, "Working", Some(nested));
|
||||
let other_machine = add(Kind::StateMachine, "Other machine", None);
|
||||
let other_region = add(Kind::Region, "Other region", Some(other_machine));
|
||||
add(Kind::State, "Foreign state", Some(other_region));
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(a));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
assert_eq!(behavior::selected_owner(&app), Some(machine));
|
||||
Fixture {
|
||||
app,
|
||||
machine,
|
||||
region,
|
||||
a,
|
||||
b,
|
||||
leaf,
|
||||
members: BTreeSet::from([machine, region, initial, a, b, nested, leaf]),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_view(f: &mut Fixture) -> Context {
|
||||
event(&mut f.app, Event::CreateView(f.machine));
|
||||
f.app
|
||||
.state_context()
|
||||
.expect("CreateView must activate the new persisted view")
|
||||
}
|
||||
|
||||
fn node(app: &App, diagram: Id, element: Id) -> Id {
|
||||
app.project().diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == element)
|
||||
.unwrap()
|
||||
.id
|
||||
}
|
||||
|
||||
fn assert_source_model_agree(app: &App) {
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(app.document.bundle(), LIMITS)
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
assert_eq!(
|
||||
source_workspace::reachable(&app.source_workspace.bundle()).unwrap(),
|
||||
*app.document.bundle()
|
||||
);
|
||||
assert!(!app.source_dirty());
|
||||
}
|
||||
|
||||
struct NavigationFixture {
|
||||
app: App,
|
||||
machine_a: Id,
|
||||
diagram_a: Id,
|
||||
state_a: Id,
|
||||
transition_a: Id,
|
||||
root_event: Id,
|
||||
machine_b: Id,
|
||||
diagram_b: Id,
|
||||
state_b: Id,
|
||||
}
|
||||
|
||||
fn navigation_fixture(layer_b: Layer) -> NavigationFixture {
|
||||
let mut project = Project::empty();
|
||||
let mut machine = |layer, name| {
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, layer, name, None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, layer, "Main", Some(machine))
|
||||
.unwrap();
|
||||
let state = project
|
||||
.add_element(Kind::State, layer, "Ready", Some(region))
|
||||
.unwrap();
|
||||
(machine, state)
|
||||
};
|
||||
let (machine_a, state_a) = machine(Layer::Sa, "Machine A");
|
||||
let (machine_b, state_b) = machine(layer_b, "Machine B");
|
||||
let root_event = project
|
||||
.add_element(Kind::Event, Layer::Sa, "External event", None)
|
||||
.unwrap();
|
||||
let transition_a = project.relate(RelationKind::StateTransition, "Repeat", state_a, state_a);
|
||||
let plan_a = state_edit::create_view(&project, machine_a).unwrap();
|
||||
project = prepare_command(&project, &plan_a.command).unwrap().project;
|
||||
let plan_b = state_edit::create_view(&project, machine_b).unwrap();
|
||||
project = prepare_command(&project, &plan_b.command).unwrap().project;
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(state_a));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
send(
|
||||
&mut app,
|
||||
Message::BehaviorDiagram(Choice {
|
||||
id: plan_a.diagram,
|
||||
label: "A view".into(),
|
||||
}),
|
||||
);
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
&mut app,
|
||||
Event::Viewport(context, iced::Size::new(820., 540.)),
|
||||
);
|
||||
event(&mut app, Event::Zoom(context, 0.65));
|
||||
event(
|
||||
&mut app,
|
||||
Event::Pan(context, iced::Vector::new(-1_400., -800.)),
|
||||
);
|
||||
event(
|
||||
&mut app,
|
||||
Event::Choose(Tool::Reconnect {
|
||||
relation: transition_a,
|
||||
endpoint: Endpoint::Target,
|
||||
}),
|
||||
);
|
||||
assert_eq!(app.state_context().unwrap().diagram, plan_a.diagram);
|
||||
assert!(!app.behavior.diagram.fit);
|
||||
NavigationFixture {
|
||||
app,
|
||||
machine_a,
|
||||
diagram_a: plan_a.diagram,
|
||||
state_a,
|
||||
transition_a,
|
||||
root_event,
|
||||
machine_b,
|
||||
diagram_b: plan_b.diagram,
|
||||
state_b,
|
||||
}
|
||||
}
|
||||
|
||||
fn assert_switched_state_view(f: &mut NavigationFixture) {
|
||||
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_b));
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_b);
|
||||
let defaults = behavior::state_view::State::default();
|
||||
let state = &f.app.behavior.diagram;
|
||||
assert_eq!(state.diagram, Some(f.diagram_b));
|
||||
assert_eq!(
|
||||
state.tool,
|
||||
Tool::Select,
|
||||
"Reconnect from A must not be armed in B"
|
||||
);
|
||||
assert_eq!(state.pan, defaults.pan);
|
||||
assert_eq!(state.zoom, defaults.zoom);
|
||||
assert!(state.fit);
|
||||
assert!(
|
||||
state.viewport.is_none(),
|
||||
"B needs its own first viewport measurement"
|
||||
);
|
||||
assert!(state.placement.is_none());
|
||||
let context = f.app.state_context().unwrap();
|
||||
event(
|
||||
&mut f.app,
|
||||
Event::Viewport(context, iced::Size::new(820., 540.)),
|
||||
);
|
||||
assert!(
|
||||
f.app.behavior.diagram.pan != defaults.pan || f.app.behavior.diagram.zoom != defaults.zoom,
|
||||
"The unchanged widget size still fits a newly selected view"
|
||||
);
|
||||
}
|
||||
|
||||
fn catalog_selects_another_machine(layer_b: Layer) {
|
||||
let mut f = navigation_fixture(layer_b);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Page(Page::Catalog));
|
||||
send(&mut f.app, Message::Select(f.state_b));
|
||||
assert_eq!(f.app.layer, layer_b);
|
||||
send(&mut f.app, Message::Page(Page::Behavior));
|
||||
assert_switched_state_view(&mut f);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_source_model_agree(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_catalog_selection_of_another_machine_resets_camera_and_tool() {
|
||||
catalog_selects_another_machine(Layer::Sa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_catalog_selection_across_layers_resets_camera_and_tool() {
|
||||
catalog_selects_another_machine(Layer::Oa);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_layer_change_resets_the_new_machine_at_the_same_viewport_size() {
|
||||
let mut f = navigation_fixture(Layer::Oa);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Layer(Layer::Oa));
|
||||
assert_switched_state_view(&mut f);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
assert_source_model_agree(&f.app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_return_to_the_same_view_preserves_camera_tool_and_fit_mode() {
|
||||
for fit in [false, true] {
|
||||
let mut f = navigation_fixture(Layer::Sa);
|
||||
if fit {
|
||||
let context = f.app.state_context().unwrap();
|
||||
event(&mut f.app, Event::Fit(context));
|
||||
}
|
||||
let state = &f.app.behavior.diagram;
|
||||
let retained = (
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport,
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Page(Page::Catalog));
|
||||
send(&mut f.app, Message::Select(f.state_a));
|
||||
send(&mut f.app, Message::Page(Page::Behavior));
|
||||
let context = f.app.state_context().unwrap();
|
||||
event(
|
||||
&mut f.app,
|
||||
Event::Viewport(context, iced::Size::new(820., 540.)),
|
||||
);
|
||||
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
|
||||
assert_eq!(context.diagram, f.diagram_a);
|
||||
let state = &f.app.behavior.diagram;
|
||||
assert_eq!(
|
||||
(
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport
|
||||
),
|
||||
retained
|
||||
);
|
||||
assert_eq!(
|
||||
state.tool,
|
||||
Tool::Reconnect {
|
||||
relation: f.transition_a,
|
||||
endpoint: Endpoint::Target
|
||||
}
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_navigation_root_event_and_same_layer_keep_the_current_camera_and_tool() {
|
||||
let mut f = navigation_fixture(Layer::Sa);
|
||||
let state = &f.app.behavior.diagram;
|
||||
let retained = (
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport,
|
||||
);
|
||||
let before = f.app.document.snapshot();
|
||||
send(&mut f.app, Message::Select(f.root_event));
|
||||
assert_eq!(f.app.selected, f.root_event);
|
||||
send(&mut f.app, Message::Page(Page::Catalog));
|
||||
send(&mut f.app, Message::Page(Page::Behavior));
|
||||
send(&mut f.app, Message::Layer(Layer::Sa));
|
||||
assert_eq!(behavior::selected_owner(&f.app), Some(f.machine_a));
|
||||
assert_eq!(f.app.state_context().unwrap().diagram, f.diagram_a);
|
||||
let state = &f.app.behavior.diagram;
|
||||
assert_eq!(
|
||||
(
|
||||
state.diagram,
|
||||
state.pan,
|
||||
state.zoom,
|
||||
state.tool,
|
||||
state.fit,
|
||||
state.viewport
|
||||
),
|
||||
retained
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &f.app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_view_places_only_its_machine_and_restores_exact_source_in_one_undo() {
|
||||
let mut f = fixture();
|
||||
let before = f.app.document.snapshot();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let diagram = &app.project().diagrams[&context.diagram];
|
||||
assert_eq!(
|
||||
diagram.projection,
|
||||
ProjectionKind::StateMachine { machine: f.machine }
|
||||
);
|
||||
assert_eq!(
|
||||
diagram
|
||||
.nodes
|
||||
.values()
|
||||
.map(|n| n.element)
|
||||
.collect::<BTreeSet<_>>(),
|
||||
f.members
|
||||
);
|
||||
assert_eq!(app.project().elements, before.decoded.project.elements);
|
||||
assert_eq!(app.project().relations, before.decoded.project.relations);
|
||||
assert_eq!(app.selected, f.machine);
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(app.document.revision(), 1);
|
||||
assert_source_model_agree(app);
|
||||
let accepted = app.document.snapshot();
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
assert!(!app.dirty());
|
||||
assert_source_model_agree(app);
|
||||
send(app, Message::Redo);
|
||||
assert_eq!(app.project(), &accepted.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &accepted.bundle);
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_create_connect_select_and_move_keep_semantic_identity_and_atomic_history() {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let before_create = app.document.snapshot();
|
||||
event(
|
||||
app,
|
||||
Event::CreateAt {
|
||||
context,
|
||||
owner: f.region,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 900., y: 700. },
|
||||
},
|
||||
);
|
||||
let created = app.selected;
|
||||
assert_eq!(app.project().elements[&created].parent, Some(f.region));
|
||||
assert_eq!(app.project().elements[&created].kind(), Kind::State);
|
||||
assert_eq!(
|
||||
app.project().elements.len(),
|
||||
before_create.decoded.project.elements.len() + 1
|
||||
);
|
||||
let created_node = node(app, context.diagram, created);
|
||||
assert_ne!(created_node, created);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&context.diagram].nodes[&created_node].position,
|
||||
Position { x: 900., y: 700. }
|
||||
);
|
||||
assert_source_model_agree(app);
|
||||
let before_connect = app.document.snapshot();
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context,
|
||||
source: f.a,
|
||||
target: created,
|
||||
},
|
||||
);
|
||||
let relation = app.selected;
|
||||
assert!(matches!(app.project().relations[&relation].body,
|
||||
RelationBody::StateTransition { source, target, transition_kind: TransitionKind::External, .. }
|
||||
if source == f.a && target == created));
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(behavior::selected_owner(app), Some(f.machine));
|
||||
assert_source_model_agree(app);
|
||||
|
||||
let context = app.state_context().unwrap();
|
||||
let before_select = app.document.snapshot();
|
||||
event(app, Event::Select(context, f.a));
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_eq!(
|
||||
app.state_context(),
|
||||
Some(context),
|
||||
"The press selection must not invalidate its following drag release"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before_select, &app.document.snapshot()));
|
||||
let placement = node(app, context.diagram, f.a);
|
||||
let leaf = node(app, context.diagram, f.leaf);
|
||||
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
|
||||
let leaf_original = app.project().diagrams[&context.diagram].nodes[&leaf].position;
|
||||
event(
|
||||
app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: Position {
|
||||
x: original.x + 31.,
|
||||
y: original.y + 43.,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(
|
||||
app.project().diagrams[&context.diagram].nodes[&leaf].position,
|
||||
Position {
|
||||
x: leaf_original.x + 31.,
|
||||
y: leaf_original.y + 43.
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
app.document.revision(),
|
||||
4,
|
||||
"View, vertex, edge, and drag each commit once"
|
||||
);
|
||||
assert_source_model_agree(app);
|
||||
for expected in [before_select, before_connect, before_create] {
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.project(), &expected.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &expected.bundle);
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
assert!(app.project().diagrams.contains_key(&context.diagram));
|
||||
assert!(!app.project().contains(created));
|
||||
assert!(!app.project().contains(relation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_context_dimensions_reject_stale_selection_camera_and_model_messages() {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.selected;
|
||||
let pan = app.behavior.diagram.pan;
|
||||
let zoom = app.behavior.diagram.zoom;
|
||||
let viewport = app.behavior.diagram.viewport;
|
||||
let placement = node(app, context.diagram, f.a);
|
||||
for changed in 0..4 {
|
||||
let mut captured = context;
|
||||
match changed {
|
||||
0 => captured.document = Id::new_v4(),
|
||||
1 => captured.diagram = Id::new_v4(),
|
||||
2 => captured.revision += 1,
|
||||
_ => captured.epoch += 1,
|
||||
}
|
||||
for stale in [
|
||||
Event::Select(captured, f.b),
|
||||
Event::Viewport(captured, iced::Size::new(999., 777.)),
|
||||
Event::Pan(captured, iced::Vector::new(55., 66.)),
|
||||
Event::Zoom(captured, 2.7),
|
||||
Event::Move {
|
||||
context: captured,
|
||||
node: placement,
|
||||
position: Position { x: 88., y: 99. },
|
||||
},
|
||||
Event::Connect {
|
||||
context: captured,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
Event::CreateAt {
|
||||
context: captured,
|
||||
owner: f.region,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 800., y: 800. },
|
||||
},
|
||||
] {
|
||||
event(app, stale);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.behavior.diagram.pan, pan);
|
||||
assert_eq!(app.behavior.diagram.zoom, zoom);
|
||||
assert_eq!(app.behavior.diagram.viewport, viewport);
|
||||
assert_eq!(app.state_context(), Some(context));
|
||||
assert!(!app.behavior.has_pending());
|
||||
}
|
||||
}
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_view_tool_and_history_changes_invalidate_old_release_events() {
|
||||
let mut f = fixture();
|
||||
let first = create_view(&mut f);
|
||||
let second = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
assert_ne!(first.diagram, second.diagram);
|
||||
let first_choice = Choice {
|
||||
id: first.diagram,
|
||||
label: app.project().named(first.diagram).into(),
|
||||
};
|
||||
send(app, Message::BehaviorDiagram(first_choice));
|
||||
let restored_view = app.state_context().unwrap();
|
||||
assert_eq!(restored_view.diagram, first.diagram);
|
||||
let before = app.document.snapshot();
|
||||
for captured in [first, second] {
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: captured,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
}
|
||||
event(app, Event::Choose(Tool::Transition));
|
||||
let armed = app.state_context().unwrap();
|
||||
assert_ne!(restored_view.epoch, armed.epoch);
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: restored_view,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: armed,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().relations.len(),
|
||||
before.decoded.project.relations.len() + 1
|
||||
);
|
||||
let committed = app.state_context().unwrap();
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
let undone = app.document.snapshot();
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: armed,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
event(
|
||||
app,
|
||||
Event::Connect {
|
||||
context: committed,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
|
||||
send(app, Message::Page(Page::Diagram));
|
||||
assert!(app.state_context().is_none());
|
||||
let diagram_count = app.project().diagrams.len();
|
||||
event(app, Event::CreateView(f.machine));
|
||||
assert_eq!(app.project().diagrams.len(), diagram_count);
|
||||
assert!(Arc::ptr_eq(&undone, &app.document.snapshot()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejected_creation_keeps_source_and_selection_then_valid_owner_can_retry() {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let before = app.document.snapshot();
|
||||
let selected = app.selected;
|
||||
event(
|
||||
app,
|
||||
Event::CreateAt {
|
||||
context,
|
||||
owner: f.a,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 600., y: 700. },
|
||||
},
|
||||
);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.state_context(), Some(context));
|
||||
event(
|
||||
app,
|
||||
Event::CreateAt {
|
||||
context,
|
||||
owner: f.region,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 600., y: 700. },
|
||||
},
|
||||
);
|
||||
assert_eq!(app.project().elements[&app.selected].parent, Some(f.region));
|
||||
assert_eq!(app.document.revision(), context.revision + 1);
|
||||
assert_source_model_agree(app);
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_drafts_block_every_direct_mutation_and_passive_camera_preserves_inputs() {
|
||||
for pending in ["properties", "source", "behavior"] {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
event(
|
||||
&mut f.app,
|
||||
Event::Connect {
|
||||
context,
|
||||
source: f.a,
|
||||
target: f.b,
|
||||
},
|
||||
);
|
||||
let relation = f.app.selected;
|
||||
// A valid Place request must have a genuinely absent placement.
|
||||
let missing = f
|
||||
.app
|
||||
.project()
|
||||
.make_element(Kind::State, Layer::Sa, "Not yet displayed", Some(f.region))
|
||||
.unwrap();
|
||||
let missing_id = missing.id;
|
||||
send(
|
||||
&mut f.app,
|
||||
Message::Command(Command::CreateElement(missing)),
|
||||
);
|
||||
send(&mut f.app, Message::Select(f.a));
|
||||
let app = &mut f.app;
|
||||
let context = app.state_context().unwrap();
|
||||
let placement = node(app, context.diagram, f.a);
|
||||
let at = app.project().diagrams[&context.diagram].nodes[&placement].position;
|
||||
let mutations = [
|
||||
Event::CreateAt {
|
||||
context,
|
||||
owner: f.region,
|
||||
kind: Kind::State,
|
||||
position: Position { x: 700., y: 800. },
|
||||
},
|
||||
Event::Place {
|
||||
context,
|
||||
element: missing_id,
|
||||
position: Position { x: 800., y: 900. },
|
||||
},
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: Position {
|
||||
x: at.x + 50.,
|
||||
y: at.y + 70.,
|
||||
},
|
||||
},
|
||||
Event::Connect {
|
||||
context,
|
||||
source: f.b,
|
||||
target: f.a,
|
||||
},
|
||||
Event::Reconnect {
|
||||
context,
|
||||
relation,
|
||||
endpoint: Endpoint::Target,
|
||||
vertex: f.leaf,
|
||||
},
|
||||
];
|
||||
for mutation in &mutations {
|
||||
let plan = state_edit::plan(app.project(), context, mutation).unwrap();
|
||||
assert_ne!(
|
||||
&prepare_command(app.project(), &plan.command)
|
||||
.unwrap()
|
||||
.project,
|
||||
app.project(),
|
||||
"Each blocked probe must otherwise be a valid, substantive model change"
|
||||
);
|
||||
}
|
||||
match pending {
|
||||
"properties" => send(app, Message::Field("/name".into(), "Unapplied name".into())),
|
||||
"source" => {
|
||||
let draft = format!(
|
||||
"{}\n// Unapplied source note.\n",
|
||||
app.source_workspace.text()
|
||||
);
|
||||
send(app, Message::SourceEdit(text_editor::Action::SelectAll));
|
||||
send(
|
||||
app,
|
||||
Message::SourceEdit(text_editor::Action::Edit(text_editor::Edit::Paste(
|
||||
Arc::new(draft),
|
||||
))),
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
form(app, states::Event::Edit(relation));
|
||||
form(app, states::Event::Name("Retained form name".into()));
|
||||
form(app, states::Event::Guard("temperature nominal".into()));
|
||||
form(app, states::Event::TriggerNotes("Retained trigger".into()));
|
||||
}
|
||||
}
|
||||
assert_eq!(app.draft_dirty(), pending == "properties");
|
||||
assert_eq!(app.source_dirty(), pending == "source");
|
||||
assert_eq!(app.behavior.has_pending(), pending == "behavior");
|
||||
let before = app.document.snapshot();
|
||||
let source = app.source_workspace.bundle();
|
||||
let properties = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
let pending_candidate = app
|
||||
.behavior
|
||||
.prepare_pending(app.project())
|
||||
.unwrap()
|
||||
.map(|command| prepare_command(app.project(), &command).unwrap().project);
|
||||
let selected = app.selected;
|
||||
for mutation in mutations {
|
||||
event(app, mutation);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().materialize().unwrap(),
|
||||
properties
|
||||
);
|
||||
}
|
||||
for passive in [
|
||||
Event::Viewport(context, iced::Size::new(960., 640.)),
|
||||
Event::Pan(context, iced::Vector::new(12., 18.)),
|
||||
Event::Zoom(context, 0.7),
|
||||
Event::Fit(context),
|
||||
] {
|
||||
event(app, passive);
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()), "{pending}");
|
||||
assert_eq!(app.source_workspace.bundle(), source);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().materialize().unwrap(),
|
||||
properties
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
app.behavior
|
||||
.prepare_pending(app.project())
|
||||
.unwrap()
|
||||
.map(|command| prepare_command(app.project(), &command).unwrap().project),
|
||||
pending_candidate
|
||||
);
|
||||
match pending {
|
||||
"properties" => send(app, Message::RevertProperties),
|
||||
"source" => send(app, Message::RevertSource),
|
||||
_ => form(app, states::Event::Cancel),
|
||||
}
|
||||
assert!(!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending());
|
||||
let context = app.state_context().unwrap();
|
||||
event(
|
||||
app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: Position {
|
||||
x: at.x + 50.,
|
||||
y: at.y + 70.,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_ne!(app.document.bundle(), &before.bundle);
|
||||
assert_source_model_agree(app);
|
||||
send(app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fallback_owner_first_selection_keeps_the_captured_context_for_its_first_move() {
|
||||
let f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
// One eligible machine makes the fallback deterministic. Start from a loaded
|
||||
// project with no selected behavior element and no explicitly chosen owner.
|
||||
project
|
||||
.elements
|
||||
.retain(|id, element| f.members.contains(id) || element.kind() == Kind::System);
|
||||
let plan = state_edit::create_view(&project, f.machine).unwrap();
|
||||
let project = prepare_command(&project, &plan.command).unwrap().project;
|
||||
let mut app = App::with_project(project);
|
||||
assert_eq!(app.selected, app.project().id);
|
||||
assert!(app.behavior.active_owner.is_none());
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
let context = app.state_context().unwrap();
|
||||
assert_eq!(context.diagram, plan.diagram);
|
||||
assert_eq!(behavior::selected_owner(&app), Some(f.machine));
|
||||
let before = app.document.snapshot();
|
||||
let placement = node(&app, context.diagram, f.a);
|
||||
let original = app.project().diagrams[&context.diagram].nodes[&placement].position;
|
||||
event(&mut app, Event::Select(context, f.a));
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_eq!(
|
||||
app.state_context(),
|
||||
Some(context),
|
||||
"Pinning the already-visible fallback owner must not advance the gesture epoch"
|
||||
);
|
||||
assert_eq!(app.behavior.diagram.placement, Some(placement));
|
||||
assert!(Arc::ptr_eq(&before, &app.document.snapshot()));
|
||||
let destination = Position {
|
||||
x: original.x + 37.,
|
||||
y: original.y + 29.,
|
||||
};
|
||||
event(
|
||||
&mut app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: destination,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&context.diagram].nodes[&placement].position,
|
||||
destination
|
||||
);
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_eq!(
|
||||
app.document.revision(),
|
||||
1,
|
||||
"The first release must commit without another press"
|
||||
);
|
||||
assert_source_model_agree(&app);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before.bundle);
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_size_viewport_after_revision_or_tool_epoch_does_not_refit_the_camera() {
|
||||
let mut f = fixture();
|
||||
let context = create_view(&mut f);
|
||||
let app = &mut f.app;
|
||||
let size = iced::Size::new(960., 640.);
|
||||
event(app, Event::Viewport(context, size));
|
||||
assert!(app.behavior.diagram.fit);
|
||||
let camera = (app.behavior.diagram.pan, app.behavior.diagram.zoom);
|
||||
let placement = node(app, context.diagram, f.a);
|
||||
// Move far enough to change projected bounds. Refitting now would visibly
|
||||
// jump the camera even though the canvas has not changed size.
|
||||
event(
|
||||
app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: placement,
|
||||
position: Position { x: 4000., y: 3000. },
|
||||
},
|
||||
);
|
||||
let changed = app.state_context().unwrap();
|
||||
assert_ne!(changed.revision, context.revision);
|
||||
assert_eq!(
|
||||
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
|
||||
camera
|
||||
);
|
||||
assert!(app.behavior.diagram.fit);
|
||||
let after_move = app.document.snapshot();
|
||||
event(app, Event::Viewport(changed, size));
|
||||
assert_eq!(
|
||||
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
|
||||
camera,
|
||||
"Context initialization is not a canvas resize"
|
||||
);
|
||||
event(app, Event::Choose(Tool::Transition));
|
||||
let changed_tool = app.state_context().unwrap();
|
||||
assert_ne!(changed_tool.epoch, changed.epoch);
|
||||
event(app, Event::Viewport(changed_tool, size));
|
||||
assert_eq!(
|
||||
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
|
||||
camera
|
||||
);
|
||||
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
|
||||
// A real resize still uses automatic fitting while fit mode is active.
|
||||
event(
|
||||
app,
|
||||
Event::Viewport(changed_tool, iced::Size::new(1200., 720.)),
|
||||
);
|
||||
assert_ne!(
|
||||
(app.behavior.diagram.pan, app.behavior.diagram.zoom),
|
||||
camera
|
||||
);
|
||||
assert!(Arc::ptr_eq(&after_move, &app.document.snapshot()));
|
||||
assert_source_model_agree(app);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_semantic_states_keep_architecture_and_behavior_placements_independent() {
|
||||
let f = fixture();
|
||||
let mut project = f.app.project().clone();
|
||||
let architecture = project.ensure_diagram(Layer::Sa);
|
||||
let arch_a = project.place(architecture, f.a, Position { x: 110., y: 90. });
|
||||
let arch_b = project.place(architecture, f.b, Position { x: 780., y: 240. });
|
||||
project.place(architecture, f.leaf, Position { x: 190., y: 310. });
|
||||
let state_view = state_edit::create_view(&project, f.machine).unwrap();
|
||||
let project = prepare_command(&project, &state_view.command)
|
||||
.unwrap()
|
||||
.project;
|
||||
let architecture_before = project.diagrams[&architecture].clone();
|
||||
let mut app = App::with_project(project);
|
||||
let architecture_choice = Choice {
|
||||
id: architecture,
|
||||
label: app.project().named(architecture).into(),
|
||||
};
|
||||
send(&mut app, Message::Diagram(architecture_choice));
|
||||
send(&mut app, Message::Select(f.b));
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(app.selected_node, Some(arch_b));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
let context = app.state_context().unwrap();
|
||||
assert_eq!(context.diagram, state_view.diagram);
|
||||
let state_a = node(&app, context.diagram, f.a);
|
||||
assert_ne!(state_a, arch_a);
|
||||
event(&mut app, Event::Select(context, f.a));
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert!(
|
||||
app.selected_node.is_none(),
|
||||
"Behavior must not borrow the architecture placement"
|
||||
);
|
||||
assert_eq!(app.behavior.diagram.placement, Some(state_a));
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
let before_move = app.document.snapshot();
|
||||
let original = app.project().diagrams[&context.diagram].nodes[&state_a].position;
|
||||
let destination = Position {
|
||||
x: original.x + 41.,
|
||||
y: original.y + 63.,
|
||||
};
|
||||
event(
|
||||
&mut app,
|
||||
Event::Move {
|
||||
context,
|
||||
node: state_a,
|
||||
position: destination,
|
||||
},
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&context.diagram].nodes[&state_a].position,
|
||||
destination
|
||||
);
|
||||
assert_eq!(app.project().diagrams[&architecture], architecture_before);
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(app.behavior.diagram.placement, Some(state_a));
|
||||
assert_source_model_agree(&app);
|
||||
let moved = app.document.snapshot();
|
||||
send(&mut app, Message::Page(Page::Diagram));
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(app.selected, f.a);
|
||||
assert_eq!(
|
||||
app.selected_node,
|
||||
Some(arch_a),
|
||||
"Returning to architecture resolves that view's placement of the same state"
|
||||
);
|
||||
assert!(Arc::ptr_eq(&moved, &app.document.snapshot()));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
assert!(app.selected_node.is_none());
|
||||
assert_eq!(app.behavior.diagram.placement, Some(state_a));
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &before_move.decoded.project);
|
||||
assert_eq!(app.document.bundle(), &before_move.bundle);
|
||||
assert_eq!(app.project().diagrams[&architecture], architecture_before);
|
||||
assert_eq!(app.behavior.diagram.placement, Some(state_a));
|
||||
assert!(!app.document.can_undo());
|
||||
}
|
||||
+107
-1734
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,195 +0,0 @@
|
||||
//! State actions capture their own diagram; architecture selection stays separate.
|
||||
use super::{
|
||||
state_edit::{self, Endpoint, Event, Tool},
|
||||
states,
|
||||
};
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
context_menu::{Entry, Menu},
|
||||
model_menu::{self, Opened},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
pub fn popup(app: &App, opened: &Opened, clean: bool) -> Option<Menu<Message>> {
|
||||
let project = app.project();
|
||||
let machine = super::selected_owner(app)
|
||||
.filter(|id| project.elements[id].kind() == Kind::StateMachine)?;
|
||||
let context = app.state_context();
|
||||
let request = &opened.request;
|
||||
let id = request.object;
|
||||
let item = |caption: &str, message: Message, enabled: bool| {
|
||||
if clean && enabled {
|
||||
Entry::action(
|
||||
caption,
|
||||
Message::Menu(model_menu::Event::Invoke(opened.token, Box::new(message))),
|
||||
)
|
||||
} else {
|
||||
Entry::disabled(caption)
|
||||
}
|
||||
};
|
||||
let diagram_event = Message::StateDiagram;
|
||||
let form = |tool| {
|
||||
Message::Behavior(super::Event::States(states::Event::Start(
|
||||
machine, tool, id,
|
||||
)))
|
||||
};
|
||||
let mut entries = vec![Entry::title(crate::views::short(project.named(id), 38))];
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or cancel pending edits first"));
|
||||
}
|
||||
|
||||
if let Some(context) = context.filter(|context| context.diagram == id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Fit diagram", diagram_event(Event::Fit(context)), true),
|
||||
item(
|
||||
"Create another state view",
|
||||
diagram_event(Event::CreateView(machine)),
|
||||
true,
|
||||
),
|
||||
Entry::separator(),
|
||||
item(
|
||||
"Create region…",
|
||||
diagram_event(Event::Choose(Tool::Region)),
|
||||
true,
|
||||
),
|
||||
item(
|
||||
"New transition with details…",
|
||||
form(states::Tool::Transition),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
} else {
|
||||
entries.extend([
|
||||
item("Properties", Message::EditProperties(id), true),
|
||||
item("Open source", Message::SourceForSelection, true),
|
||||
item(
|
||||
"Connections & references",
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
||||
true,
|
||||
),
|
||||
]);
|
||||
if let Some(element) = project.elements.get(&id)
|
||||
&& projection::state_machine_contains(project, machine, id)
|
||||
{
|
||||
entries.push(Entry::separator());
|
||||
let creates = if element.kind() == Kind::Region {
|
||||
Some((
|
||||
"Create state…",
|
||||
Kind::State,
|
||||
states::Tool::Vertex,
|
||||
Tool::Vertex(Kind::State),
|
||||
))
|
||||
} else if element.kind() == Kind::StateMachine || element.kind().is_stable_state() {
|
||||
Some((
|
||||
"Create region…",
|
||||
Kind::Region,
|
||||
states::Tool::Region,
|
||||
Tool::Region,
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some((caption, kind, typed, tool)) = creates {
|
||||
entries.push(if let Some(context) = context {
|
||||
item(
|
||||
caption,
|
||||
diagram_event(Event::Choose(tool)),
|
||||
state_edit::eligible_owner(project, context.diagram, id, kind),
|
||||
)
|
||||
} else {
|
||||
item(caption, form(typed), true)
|
||||
});
|
||||
}
|
||||
entries.push(item(
|
||||
"New transition with details…",
|
||||
form(states::Tool::Transition),
|
||||
true,
|
||||
));
|
||||
if let Some(context) = context
|
||||
&& let Some(node) = request
|
||||
.placement
|
||||
.and_then(|placement| project.diagrams[&context.diagram].nodes.get(&placement))
|
||||
.filter(|node| node.element == id)
|
||||
{
|
||||
entries.push(Entry::separator());
|
||||
entries.push(item(
|
||||
"Placement properties",
|
||||
Message::EditProperties(node.id),
|
||||
true,
|
||||
));
|
||||
if element.kind() == Kind::StateMachine
|
||||
|| element.kind() == Kind::Region
|
||||
|| element.kind().is_stable_state()
|
||||
{
|
||||
let mut changed = node.clone();
|
||||
changed.collapsed = !changed.collapsed;
|
||||
entries.push(item(
|
||||
if node.collapsed {
|
||||
"Unfold contents"
|
||||
} else {
|
||||
"Fold contents"
|
||||
},
|
||||
Message::Command(Command::EditNode {
|
||||
diagram: context.diagram,
|
||||
original: node.id,
|
||||
node: changed,
|
||||
}),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Hide from this diagram",
|
||||
Message::Command(Command::Delete(node.id)),
|
||||
true,
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(relation) = project.relations.get(&id)
|
||||
&& relation.kind() == RelationKind::StateTransition
|
||||
&& projection::state_machine_contains(project, machine, relation.source())
|
||||
&& projection::state_machine_contains(project, machine, relation.target())
|
||||
{
|
||||
entries.push(Entry::separator());
|
||||
let edit = context.map_or_else(
|
||||
|| Message::Behavior(super::Event::States(states::Event::Edit(id))),
|
||||
|context| diagram_event(Event::EditTransition(context, id)),
|
||||
);
|
||||
entries.push(item("Edit transition", edit, true));
|
||||
if let Some(context) = context {
|
||||
let visible = projection::project_view(project, context.diagram)
|
||||
.edges
|
||||
.iter()
|
||||
.any(|edge| edge.id == id);
|
||||
for (caption, endpoint) in [
|
||||
("Reconnect source", Endpoint::Source),
|
||||
("Reconnect target", Endpoint::Target),
|
||||
] {
|
||||
entries.push(item(
|
||||
caption,
|
||||
diagram_event(Event::Choose(Tool::Reconnect {
|
||||
relation: id,
|
||||
endpoint,
|
||||
})),
|
||||
visible,
|
||||
));
|
||||
}
|
||||
if !visible {
|
||||
entries.push(Entry::title(
|
||||
"Use transition details for hidden or internal endpoints",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if id != project.id {
|
||||
entries.push(Entry::separator());
|
||||
entries.push(item("Delete from model…", Message::Delete, true));
|
||||
}
|
||||
}
|
||||
Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(model_menu::Event::Close),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
//! State geometry follows semantic containment; presentation never reparents records.
|
||||
use super::*;
|
||||
|
||||
fn internal_transitions(p: &Project, id: Id) -> impl Iterator<Item = &Relation> {
|
||||
p.relations.values().filter(move |r| {
|
||||
matches!(&r.body, RelationBody::StateTransition {
|
||||
source, transition_kind: TransitionKind::Internal, ..
|
||||
} if *source == id)
|
||||
})
|
||||
}
|
||||
|
||||
fn content_width(p: &Project, owner: Id) -> f32 {
|
||||
p.children(owner)
|
||||
.map(|e| match e.kind() {
|
||||
Kind::Region => content_width(p, e.id) + 120.,
|
||||
k if k.is_stable_state() => content_width(p, e.id) + 32.,
|
||||
_ => 300.,
|
||||
})
|
||||
.fold(300., f32::max)
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub(super) fn states(p: &Project, owner: Id, selected: Id) -> Self {
|
||||
let mut scene = Self {
|
||||
scale: 1.,
|
||||
boxes: vec![],
|
||||
lines: vec![],
|
||||
size: Size::new(400., 180.),
|
||||
selected,
|
||||
};
|
||||
if !p.children(owner).any(|e| e.kind() == Kind::Region) {
|
||||
return scene;
|
||||
}
|
||||
let width = content_width(p, owner) + 40.;
|
||||
scene.boxes.push(BoxItem {
|
||||
id: owner,
|
||||
rect: Rectangle::new(Point::new(24., 20.), Size::new(width, 100.)),
|
||||
title: p.named(owner).into(),
|
||||
detail: String::new(),
|
||||
marker: Some(Kind::StateMachine),
|
||||
});
|
||||
let mut y = 64.;
|
||||
for region in p.children(owner).filter(|e| e.kind() == Kind::Region) {
|
||||
y = scene.state_region(p, region.id, 44., y, width - 40.) + 14.;
|
||||
}
|
||||
scene.boxes[0].rect.height = y - 20.;
|
||||
|
||||
// Entry/exit symbols are region-owned but attach to the containing boundary.
|
||||
let rectangles: BTreeMap<_, _> = scene.boxes.iter().map(|b| (b.id, b.rect)).collect();
|
||||
for b in &mut scene.boxes {
|
||||
if !matches!(b.marker, Some(Kind::EntryPoint | Kind::ExitPoint)) {
|
||||
continue;
|
||||
}
|
||||
let boundary = p
|
||||
.elements
|
||||
.get(&b.id)
|
||||
.and_then(|e| e.parent)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(|e| e.parent);
|
||||
if let Some(rect) = boundary.and_then(|id| rectangles.get(&id)) {
|
||||
// The symbol is centered 16 px into its 32 px hit target.
|
||||
b.rect.x = if b.marker == Some(Kind::EntryPoint) {
|
||||
rect.x - 16.
|
||||
} else {
|
||||
rect.x + rect.width - 16.
|
||||
};
|
||||
}
|
||||
}
|
||||
let vertices: BTreeMap<_, _> = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.filter(|b| b.marker.is_some_and(|k| k.is_state()))
|
||||
.map(|b| (b.id, (b.rect, b.marker.unwrap())))
|
||||
.collect();
|
||||
for (index, r) in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| {
|
||||
r.kind() == RelationKind::StateTransition
|
||||
&& vertices.contains_key(&r.source())
|
||||
&& vertices.contains_key(&r.target())
|
||||
})
|
||||
.enumerate()
|
||||
{
|
||||
if matches!(
|
||||
&r.body,
|
||||
RelationBody::StateTransition {
|
||||
transition_kind: TransitionKind::Internal,
|
||||
..
|
||||
}
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let (a, ak) = vertices[&r.source()];
|
||||
let (b, bk) = vertices[&r.target()];
|
||||
let ay = a.y + if ak.is_stable_state() { 25. } else { 28. };
|
||||
let by = if r.source() == r.target() {
|
||||
ay + 28.
|
||||
} else {
|
||||
b.y + if bk.is_stable_state() { 25. } else { 28. }
|
||||
};
|
||||
let ax = if ak.is_stable_state() {
|
||||
a.x + a.width
|
||||
} else {
|
||||
a.x + 32.
|
||||
};
|
||||
let bx = if bk.is_stable_state() {
|
||||
b.x + b.width
|
||||
} else {
|
||||
b.x + 32.
|
||||
};
|
||||
let lane = (ax.max(bx) + 28. + (index % 4) as f32 * 20.).max(360.);
|
||||
let activation = states::transition_summary(p, r);
|
||||
scene.lines.push(Line {
|
||||
id: r.id,
|
||||
points: vec![
|
||||
Point::new(ax, ay),
|
||||
Point::new(lane, ay),
|
||||
Point::new(lane, by),
|
||||
Point::new(bx, by),
|
||||
],
|
||||
label: format!("{}. {}", index + 1, short(&activation, 24)),
|
||||
label_at: Point::new(lane + 5., (ay + by) / 2.),
|
||||
dashed: matches!(
|
||||
&r.body,
|
||||
RelationBody::StateTransition {
|
||||
transition_kind: TransitionKind::Local,
|
||||
..
|
||||
}
|
||||
),
|
||||
});
|
||||
scene.size.width = scene.size.width.max(lane + 220.);
|
||||
}
|
||||
scene.size.width = scene.size.width.max(width + 180.);
|
||||
scene.size.height = (y + 24.).max(180.);
|
||||
scene
|
||||
}
|
||||
|
||||
fn state_region(&mut self, p: &Project, id: Id, x: f32, top: f32, width: f32) -> f32 {
|
||||
let index = self.boxes.len();
|
||||
self.boxes.push(BoxItem {
|
||||
id,
|
||||
rect: Rectangle::new(Point::new(x, top), Size::new(width, 90.)),
|
||||
title: format!("REGION · {}", p.named(id)),
|
||||
detail: String::new(),
|
||||
marker: Some(Kind::Region),
|
||||
});
|
||||
let mut y = top + 42.;
|
||||
for state in p.children(id).filter(|e| e.kind().is_state()) {
|
||||
let stable = state.kind().is_stable_state();
|
||||
let inner_width = if stable { width - 100. } else { 300. };
|
||||
let index = self.boxes.len();
|
||||
let detail = match state.shared() {
|
||||
Some(SharedBody::State {
|
||||
entry,
|
||||
exit,
|
||||
do_activity,
|
||||
..
|
||||
}) => [("entry", entry), ("do", do_activity), ("exit", exit)]
|
||||
.into_iter()
|
||||
.filter(|(_, value)| !value.is_empty())
|
||||
.map(|(label, value)| format!("{label} / {value}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" · "),
|
||||
_ => String::new(),
|
||||
};
|
||||
self.boxes.push(BoxItem {
|
||||
id: state.id,
|
||||
rect: Rectangle::new(
|
||||
Point::new(x + 18., y),
|
||||
Size::new(inner_width, if stable { 76. } else { 56. }),
|
||||
),
|
||||
title: state.name.clone(),
|
||||
detail,
|
||||
marker: Some(state.kind()),
|
||||
});
|
||||
let start = y;
|
||||
y += if stable { 76. } else { 56. };
|
||||
for r in internal_transitions(p, state.id) {
|
||||
self.boxes.push(BoxItem {
|
||||
id: r.id,
|
||||
rect: Rectangle::new(Point::new(x + 28., y), Size::new(inner_width - 20., 55.)),
|
||||
title: format!("INTERNAL · {}", r.name),
|
||||
detail: states::transition_summary(p, r),
|
||||
marker: None,
|
||||
});
|
||||
y += 61.;
|
||||
}
|
||||
for region in p.children(state.id).filter(|e| e.kind() == Kind::Region) {
|
||||
y = self.state_region(p, region.id, x + 34., y, inner_width - 32.) + 12.;
|
||||
}
|
||||
self.boxes[index].rect.height = y - start;
|
||||
y += 18.;
|
||||
}
|
||||
self.boxes[index].rect.height = (y - top).max(90.);
|
||||
top + self.boxes[index].rect.height
|
||||
}
|
||||
|
||||
pub(super) fn is_container(&self, item: &BoxItem) -> bool {
|
||||
matches!(
|
||||
item.marker,
|
||||
Some(Kind::Region | Kind::StateMachine | Kind::Occurrence)
|
||||
) || (item.marker.is_some_and(|kind| kind.is_stable_state())
|
||||
&& self.boxes.iter().any(|other| {
|
||||
other.id != item.id
|
||||
&& item.rect.contains(other.rect.position())
|
||||
&& item.rect.contains(Point::new(
|
||||
other.rect.x + other.rect.width,
|
||||
other.rect.y + other.rect.height,
|
||||
))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/// Pseudostates use actual vector shapes, with a full row as their hit target.
|
||||
pub(super) fn draw_symbol(f: &mut Frame, b: &BoxItem, color: Color) {
|
||||
let center = Point::new(b.rect.x + 16., b.rect.y + 28.);
|
||||
let stroke = Stroke::default().with_color(color).with_width(1.8);
|
||||
let circle = Path::circle(center, 11.);
|
||||
match b.marker.unwrap() {
|
||||
Kind::Initial | Kind::Junction => f.fill(
|
||||
&Path::circle(
|
||||
center,
|
||||
if b.marker == Some(Kind::Initial) {
|
||||
8.
|
||||
} else {
|
||||
5.
|
||||
},
|
||||
),
|
||||
color,
|
||||
),
|
||||
Kind::Final => {
|
||||
f.stroke(&circle, stroke);
|
||||
f.fill(&Path::circle(center, 7.), color);
|
||||
}
|
||||
Kind::Choice => f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(Point::new(center.x, center.y - 12.));
|
||||
p.line_to(Point::new(center.x + 12., center.y));
|
||||
p.line_to(Point::new(center.x, center.y + 12.));
|
||||
p.line_to(Point::new(center.x - 12., center.y));
|
||||
p.close();
|
||||
}),
|
||||
stroke,
|
||||
),
|
||||
Kind::Fork | Kind::Join => f.fill_rectangle(
|
||||
Point::new(center.x - 3., center.y - 18.),
|
||||
Size::new(6., 36.),
|
||||
color,
|
||||
),
|
||||
Kind::ShallowHistory | Kind::DeepHistory => {
|
||||
f.stroke(&circle, stroke);
|
||||
f.fill_text(Text {
|
||||
content: if b.marker == Some(Kind::DeepHistory) {
|
||||
"H*"
|
||||
} else {
|
||||
"H"
|
||||
}
|
||||
.into(),
|
||||
position: Point::new(center.x - 8., center.y - 9.),
|
||||
color,
|
||||
size: 13.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Kind::EntryPoint => {
|
||||
f.fill(&circle, Color::WHITE);
|
||||
f.stroke(&circle, stroke);
|
||||
}
|
||||
Kind::ExitPoint | Kind::Terminate => {
|
||||
if b.marker == Some(Kind::ExitPoint) {
|
||||
f.fill(&circle, Color::WHITE);
|
||||
f.stroke(&circle, stroke);
|
||||
}
|
||||
f.stroke(
|
||||
&Path::new(|p| {
|
||||
p.move_to(Point::new(center.x - 7., center.y - 7.));
|
||||
p.line_to(Point::new(center.x + 7., center.y + 7.));
|
||||
p.move_to(Point::new(center.x - 7., center.y + 7.));
|
||||
p.line_to(Point::new(center.x + 7., center.y - 7.));
|
||||
}),
|
||||
stroke,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
//! State workspace controls. Persistent semantics and layout live in the model.
|
||||
use super::state_edit::{Event, Tool};
|
||||
use super::*;
|
||||
use iced::widget::{Column, column, opaque, stack};
|
||||
|
||||
pub struct State {
|
||||
pub diagram: Option<Id>,
|
||||
pub placement: Option<Id>,
|
||||
pub tool: Tool,
|
||||
pub viewport: Option<Size>,
|
||||
pub zoom: f32,
|
||||
pub pan: iced::Vector,
|
||||
pub fit: bool,
|
||||
}
|
||||
impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
diagram: None,
|
||||
placement: None,
|
||||
tool: Tool::Select,
|
||||
viewport: None,
|
||||
zoom: 0.8,
|
||||
pan: iced::Vector::new(24., 48.),
|
||||
fit: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diagram(app: &App, machine: Id) -> Option<Id> {
|
||||
let valid = |id: &Id| {
|
||||
app.project()
|
||||
.diagrams
|
||||
.get(id)
|
||||
.is_some_and(|d| d.projection.state_machine() == Some(machine))
|
||||
};
|
||||
app.behavior
|
||||
.diagram
|
||||
.diagram
|
||||
.filter(valid)
|
||||
.or_else(|| app.project().diagrams.keys().find(|id| valid(id)).copied())
|
||||
}
|
||||
|
||||
fn event(e: Event) -> Message {
|
||||
Message::StateDiagram(e)
|
||||
}
|
||||
|
||||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let Some(id) = diagram(app, machine) else {
|
||||
let base = out.push(container(column![
|
||||
text("Create a state diagram").size(20),
|
||||
label("Place the machine’s existing regions and states in an editable view. Creating another view leaves the existing views unchanged."),
|
||||
button("Create diagram").padding(12).on_press(event(Event::CreateView(machine))),
|
||||
row![
|
||||
action("Add region…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Region, app.selected)))),
|
||||
action("Add state…", Message::Behavior(super::Event::States(states::Event::Start(machine, states::Tool::Vertex, app.selected)))),
|
||||
transition_details(machine, app.selected),
|
||||
].spacing(5),
|
||||
].spacing(14)).padding(24).style(style::card)).padding(18).into();
|
||||
return with_form(app, machine, base);
|
||||
};
|
||||
let context = app
|
||||
.state_context()
|
||||
.expect("a visible state diagram has a context");
|
||||
let p = app.project();
|
||||
let state = &app.behavior.diagram;
|
||||
let views = p
|
||||
.diagrams
|
||||
.values()
|
||||
.filter(|d| d.projection.state_machine() == Some(machine))
|
||||
.map(|d| Choice {
|
||||
id: d.id,
|
||||
label: d.name.clone(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let current = views.iter().find(|v| v.id == id).cloned();
|
||||
out = out.push(
|
||||
row![
|
||||
pick_list(views, current, Message::BehaviorDiagram)
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
action("+ View", event(Event::CreateView(machine))),
|
||||
action("Fit", event(Event::Fit(context))),
|
||||
action("−", event(Event::Zoom(context, state.zoom / 1.2))),
|
||||
label(format!("{:.0}%", state.zoom * 100.)),
|
||||
action("+", event(Event::Zoom(context, state.zoom * 1.2))),
|
||||
]
|
||||
.spacing(5)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
|
||||
let tool = |name: &'static str, value: Tool| {
|
||||
button(text(name).size(12))
|
||||
.padding([8, 10])
|
||||
.style(if state.tool == value {
|
||||
button::primary
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(event(Event::Choose(value)))
|
||||
};
|
||||
let kinds = [
|
||||
Kind::State,
|
||||
Kind::Mode,
|
||||
Kind::Initial,
|
||||
Kind::Final,
|
||||
Kind::Choice,
|
||||
Kind::Junction,
|
||||
Kind::Fork,
|
||||
Kind::Join,
|
||||
Kind::ShallowHistory,
|
||||
Kind::DeepHistory,
|
||||
Kind::EntryPoint,
|
||||
Kind::ExitPoint,
|
||||
Kind::Terminate,
|
||||
];
|
||||
let selected_kind = if let Tool::Vertex(kind) = state.tool {
|
||||
Some(kind)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
out = out.push(
|
||||
row![
|
||||
tool("Select / move", Tool::Select),
|
||||
pick_list(kinds.to_vec(), selected_kind, |k| event(Event::Choose(
|
||||
Tool::Vertex(k)
|
||||
)))
|
||||
.placeholder("+ State / symbol")
|
||||
.padding(8)
|
||||
.text_size(12),
|
||||
tool("+ Region", Tool::Region),
|
||||
tool("Transition", Tool::Transition),
|
||||
]
|
||||
.spacing(4),
|
||||
);
|
||||
|
||||
let missing = state_edit::unplaced(p, id)
|
||||
.into_iter()
|
||||
.map(|element| Choice {
|
||||
id: element,
|
||||
label: format!("{} · {}", p.named(element), p.elements[&element].kind()),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !missing.is_empty() {
|
||||
let position = Position {
|
||||
x: (48. - state.pan.x) / state.zoom,
|
||||
y: (90. - state.pan.y) / state.zoom,
|
||||
};
|
||||
out = out.push(
|
||||
pick_list(missing, None::<Choice>, move |c| {
|
||||
event(Event::Place {
|
||||
context,
|
||||
element: c.id,
|
||||
position,
|
||||
})
|
||||
})
|
||||
.placeholder("Show an element not yet in this view…")
|
||||
.padding(8)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
let canvas: Element<'_, Event> = canvas::Canvas::new(super::state_canvas::StateCanvas {
|
||||
project: p,
|
||||
diagram: id,
|
||||
context,
|
||||
selected: app.selected,
|
||||
tool: state.tool,
|
||||
zoom: state.zoom,
|
||||
pan: state.pan,
|
||||
editable: !app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into();
|
||||
out = out.push(canvas.map(event));
|
||||
let base: Element<'_, Message> = container(out.spacing(8))
|
||||
.padding(14)
|
||||
.width(Fill)
|
||||
.height(Fill)
|
||||
.into();
|
||||
with_form(app, machine, base)
|
||||
}
|
||||
|
||||
fn transition_details(machine: Id, selected: Id) -> Element<'static, Message> {
|
||||
action(
|
||||
"New transition with details…",
|
||||
Message::Behavior(super::Event::States(states::Event::Start(
|
||||
machine,
|
||||
states::Tool::Transition,
|
||||
selected,
|
||||
))),
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
fn with_form<'a>(app: &'a App, machine: Id, base: Element<'a, Message>) -> Element<'a, Message> {
|
||||
if let Some(form) = states::form(app, machine) {
|
||||
stack![
|
||||
base,
|
||||
opaque(
|
||||
container(
|
||||
container(
|
||||
scrollable(form).height((app.window_size.height - 250.).clamp(280., 610.))
|
||||
)
|
||||
.padding(18)
|
||||
.width(600)
|
||||
.style(style::card)
|
||||
)
|
||||
.center(Fill)
|
||||
.style(|_| container::Style {
|
||||
background: Some(Color::from_rgba(0.08, 0.12, 0.18, 0.38).into()),
|
||||
..Default::default()
|
||||
})
|
||||
)
|
||||
]
|
||||
.into()
|
||||
} else {
|
||||
base
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! State authoring through the same validated commands as source and REST.
|
||||
use super::*;
|
||||
use iced::widget::{checkbox, column, text_input};
|
||||
use iced::widget::{Column, checkbox, column, text_input};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
@@ -41,10 +41,15 @@ pub enum Event {
|
||||
Timed(bool),
|
||||
TimeKind(String),
|
||||
Expression(String),
|
||||
Viewport(Size),
|
||||
Zoom(f32),
|
||||
Fit,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
draft: Option<Draft>,
|
||||
viewport: Option<Size>,
|
||||
zoom: Option<f32>,
|
||||
}
|
||||
struct Draft {
|
||||
machine: Id,
|
||||
@@ -184,28 +189,7 @@ impl Draft {
|
||||
}
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_owner(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|d| d.machine)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a state modeling tool.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub(super) fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..) | Event::Edit(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Start(machine, tool, selected) => {
|
||||
let mut d = Draft::new(machine, tool);
|
||||
@@ -265,8 +249,20 @@ impl State {
|
||||
d.effect = effect.clone();
|
||||
self.draft = Some(d);
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
Event::Cancel => self.draft = None,
|
||||
Event::Submit => {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a state modeling tool.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
self.draft = None;
|
||||
return Ok(Some(command));
|
||||
}
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
Event::Fit => self.zoom = None,
|
||||
event => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match event {
|
||||
@@ -351,218 +347,359 @@ fn picker<'a>(
|
||||
.width(Fill)
|
||||
.into()
|
||||
}
|
||||
pub(super) fn form(app: &App, machine: Id) -> Option<Element<'_, Message>> {
|
||||
let p = app.project();
|
||||
let d = app
|
||||
.behavior
|
||||
.states
|
||||
.draft
|
||||
.as_ref()
|
||||
.filter(|d| d.machine == machine)?;
|
||||
let mut form = column![
|
||||
text(if d.original.is_some() {
|
||||
"Edit transition"
|
||||
} else {
|
||||
match d.tool {
|
||||
Tool::Vertex => "Add state or pseudostate",
|
||||
Tool::Region => "Add region",
|
||||
Tool::Transition => "Connect states",
|
||||
}
|
||||
})
|
||||
.size(17),
|
||||
text_input("Engineering name (optional)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Transition {
|
||||
if d.tool == Tool::Vertex {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.is_state())
|
||||
.collect::<Vec<_>>(),
|
||||
Some(d.vertex),
|
||||
|k| event(Event::Vertex(k)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
let parents = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
in_machine(p, e.id, machine)
|
||||
&& if d.tool == Tool::Vertex {
|
||||
e.kind() == Kind::Region
|
||||
} else {
|
||||
e.kind() == Kind::StateMachine || e.kind().is_stable_state()
|
||||
}
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent));
|
||||
form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." }));
|
||||
} else {
|
||||
let mut context = p.clone();
|
||||
if let Some(id) = d.original {
|
||||
context.relations.remove(&id);
|
||||
}
|
||||
let vertices: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind().is_state() && in_machine(p, e.id, machine))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
let targets = vertices
|
||||
pub(super) fn transition_summary(p: &Project, r: &Relation) -> String {
|
||||
let RelationBody::StateTransition {
|
||||
transition_kind,
|
||||
triggers,
|
||||
trigger,
|
||||
guard,
|
||||
effects,
|
||||
effect,
|
||||
..
|
||||
} = &r.body
|
||||
else {
|
||||
return r.name.clone();
|
||||
};
|
||||
let activation = if !triggers.is_empty() {
|
||||
triggers
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
d.source.is_some_and(|source| {
|
||||
if d.kind == TransitionKind::Internal {
|
||||
return c.id == source && p.elements[&source].kind().is_stable_state();
|
||||
}
|
||||
if d.kind == TransitionKind::Local
|
||||
&& p.elements[&source].kind().is_stable_state()
|
||||
{
|
||||
return c.id != source && in_machine(p, c.id, source);
|
||||
}
|
||||
validation::can_connect(&context, RelationKind::StateTransition, source, c.id)
|
||||
})
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" OR ")
|
||||
} else if !trigger.trim().is_empty() {
|
||||
format!("Unresolved trigger: {trigger}")
|
||||
} else if p
|
||||
.elements
|
||||
.get(&r.source())
|
||||
.is_some_and(|e| e.kind().is_stable_state())
|
||||
{
|
||||
"Completion".into()
|
||||
} else {
|
||||
"Transient routing".into()
|
||||
};
|
||||
let mut result = format!("{transition_kind:?} · {activation}");
|
||||
if !triggers.is_empty() && !trigger.is_empty() {
|
||||
result.push_str(&format!(" · {trigger}"));
|
||||
}
|
||||
if !guard.is_empty() {
|
||||
result.push_str(&format!(" [{guard}]"));
|
||||
}
|
||||
if !effects.is_empty() {
|
||||
result.push_str(&format!(
|
||||
" / {}",
|
||||
effects
|
||||
.iter()
|
||||
.map(|id| p.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
));
|
||||
}
|
||||
if !effect.is_empty() {
|
||||
result.push_str(&format!(" / {effect}"));
|
||||
}
|
||||
result
|
||||
}
|
||||
pub fn view<'a>(app: &'a App, machine: Id, mut out: Column<'a, Message>) -> Element<'a, Message> {
|
||||
let p = app.project();
|
||||
let state = &app.behavior.states;
|
||||
out = out.push(
|
||||
row![
|
||||
action(
|
||||
"+ Vertex",
|
||||
event(Event::Start(machine, Tool::Vertex, app.selected))
|
||||
),
|
||||
action(
|
||||
"+ Region",
|
||||
event(Event::Start(machine, Tool::Region, app.selected))
|
||||
),
|
||||
action(
|
||||
"+ Transition",
|
||||
event(Event::Start(machine, Tool::Transition, app.selected))
|
||||
),
|
||||
action("Properties", Message::EditProperties(machine)),
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
if let Some(d) = state.draft.as_ref().filter(|d| d.machine == machine) {
|
||||
let mut form = column![
|
||||
text(if d.original.is_some() {
|
||||
"Edit transition"
|
||||
} else {
|
||||
match d.tool {
|
||||
Tool::Vertex => "Add state or pseudostate",
|
||||
Tool::Region => "Add region",
|
||||
Tool::Transition => "Connect states",
|
||||
}
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
form = form
|
||||
.push(picker(vertices, d.source, "From state…", Event::Source))
|
||||
.push(picker(targets, d.target, "To state…", Event::Target))
|
||||
.push(
|
||||
pick_list(
|
||||
[
|
||||
TransitionChoice(TransitionKind::External),
|
||||
TransitionChoice(TransitionKind::Local),
|
||||
TransitionChoice(TransitionKind::Internal),
|
||||
],
|
||||
Some(TransitionChoice(d.kind)),
|
||||
|k| event(Event::Kind(k)),
|
||||
)
|
||||
.size(17),
|
||||
text_input("Engineering name (optional)", &d.name)
|
||||
.on_input(|s| event(Event::Name(s)))
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
let layer = p.elements[&machine].layer();
|
||||
let triggers = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == layer
|
||||
&& (e.kind() == Kind::Event || e.kind().is_work())
|
||||
&& !d.triggers.contains(&e.id)
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(label("Trigger events / work (alternatives)"));
|
||||
for id in &d.triggers {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveTrigger(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(
|
||||
triggers,
|
||||
None,
|
||||
"Add existing trigger…",
|
||||
Event::Trigger,
|
||||
))
|
||||
.push(
|
||||
checkbox(d.timed)
|
||||
.label("Add a time event with this transition")
|
||||
.on_toggle(|b| event(Event::Timed(b))),
|
||||
);
|
||||
if d.timed {
|
||||
form = form.push(
|
||||
row![
|
||||
.size(12),
|
||||
]
|
||||
.spacing(9);
|
||||
if d.tool != Tool::Transition {
|
||||
if d.tool == Tool::Vertex {
|
||||
form = form.push(
|
||||
pick_list(
|
||||
["After".to_string(), "At".to_string()],
|
||||
Some(format!("{:?}", d.time_kind)),
|
||||
|s| event(Event::TimeKind(s))
|
||||
Kind::ALL
|
||||
.into_iter()
|
||||
.filter(|k| k.is_state())
|
||||
.collect::<Vec<_>>(),
|
||||
Some(d.vertex),
|
||||
|k| event(Event::Vertex(k)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
text_input("Time expression, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
let parents = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
in_machine(p, e.id, machine)
|
||||
&& if d.tool == Tool::Vertex {
|
||||
e.kind() == Kind::Region
|
||||
} else {
|
||||
e.kind() == Kind::StateMachine || e.kind().is_stable_state()
|
||||
}
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(picker(parents, d.parent, "Choose owner…", Event::Parent));
|
||||
form = form.push(label(if d.tool == Tool::Region { "Sibling regions are concurrent. Select a State or Mode to create a nested region." } else { "Incomplete vertices remain editable; Model checks explain the connections still needed." }));
|
||||
} else {
|
||||
let mut context = p.clone();
|
||||
if let Some(id) = d.original {
|
||||
context.relations.remove(&id);
|
||||
}
|
||||
let vertices: Vec<_> = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind().is_state() && in_machine(p, e.id, machine))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
let targets = vertices
|
||||
.iter()
|
||||
.filter(|c| {
|
||||
d.source.is_some_and(|source| {
|
||||
if d.kind == TransitionKind::Internal {
|
||||
return c.id == source && p.elements[&source].kind().is_stable_state();
|
||||
}
|
||||
if d.kind == TransitionKind::Local
|
||||
&& p.elements[&source].kind().is_stable_state()
|
||||
{
|
||||
return c.id != source && in_machine(p, c.id, source);
|
||||
}
|
||||
validation::can_connect(
|
||||
&context,
|
||||
RelationKind::StateTransition,
|
||||
source,
|
||||
c.id,
|
||||
)
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
form = form
|
||||
.push(picker(vertices, d.source, "From state…", Event::Source))
|
||||
.push(picker(targets, d.target, "To state…", Event::Target))
|
||||
.push(
|
||||
pick_list(
|
||||
[
|
||||
TransitionChoice(TransitionKind::External),
|
||||
TransitionChoice(TransitionKind::Local),
|
||||
TransitionChoice(TransitionKind::Internal),
|
||||
],
|
||||
Some(TransitionChoice(d.kind)),
|
||||
|k| event(Event::Kind(k)),
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
let layer = p.elements[&machine].layer();
|
||||
let triggers = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| {
|
||||
e.layer() == layer
|
||||
&& (e.kind() == Kind::Event || e.kind().is_work())
|
||||
&& !d.triggers.contains(&e.id)
|
||||
})
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
form = form.push(label("Trigger events / work (alternatives)"));
|
||||
for id in &d.triggers {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveTrigger(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(
|
||||
triggers,
|
||||
None,
|
||||
"Add existing trigger…",
|
||||
Event::Trigger,
|
||||
))
|
||||
.push(
|
||||
checkbox(d.timed)
|
||||
.label("Add a time event with this transition")
|
||||
.on_toggle(|b| event(Event::Timed(b))),
|
||||
);
|
||||
if d.timed {
|
||||
form = form.push(
|
||||
row![
|
||||
pick_list(
|
||||
["After".to_string(), "At".to_string()],
|
||||
Some(format!("{:?}", d.time_kind)),
|
||||
|s| event(Event::TimeKind(s))
|
||||
)
|
||||
.padding(9)
|
||||
.text_size(12),
|
||||
text_input("Time expression, e.g. 30 s", &d.expression)
|
||||
.on_input(|s| event(Event::Expression(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
text_input("Trigger notes (optional)", &d.trigger)
|
||||
.on_input(|s| event(Event::TriggerNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
]
|
||||
.spacing(6),
|
||||
);
|
||||
)
|
||||
.push(
|
||||
text_input("Guard condition (optional)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
let effects = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
for id in &d.effects {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveEffect(*id)))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(effects, None, "Add effect work…", Event::Effect))
|
||||
.push(
|
||||
text_input("Effect notes (optional)", &d.effect)
|
||||
.on_input(|s| event(Event::EffectNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() {
|
||||
form=form.push(label("No event trigger: completion from a state, transient routing from a pseudostate."));
|
||||
}
|
||||
}
|
||||
form = form
|
||||
.push(
|
||||
text_input("Trigger notes (optional)", &d.trigger)
|
||||
.on_input(|s| event(Event::TriggerNotes(s)))
|
||||
let candidate = d.command(p).and_then(|command| {
|
||||
execute_batch(p, &[command])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
if let Err(error) = &candidate {
|
||||
form = form.push(text(error.clone()).size(12).color(style::MUTED));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Apply")
|
||||
.padding(9)
|
||||
.size(12),
|
||||
.on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
out = out.push(container(form).padding(14).style(style::card));
|
||||
}
|
||||
let mut scene = Scene::states(p, machine, app.selected);
|
||||
if scene.boxes.is_empty() {
|
||||
out=out.push(container(column![text("Start with a region").size(18),label("Add a region, then an Initial vertex and a State or Mode. Connect them to describe the default entry.")].spacing(12)).padding(24).style(style::card));
|
||||
} else {
|
||||
let width = state
|
||||
.viewport
|
||||
.map_or((app.window_size.width - 600.).max(200.), |s| s.width);
|
||||
let scale = state.zoom.unwrap_or_else(|| {
|
||||
((width - 24.).max(1.) / scene.size.width)
|
||||
.min(440. / scene.size.height)
|
||||
.clamp(0.05, 1.)
|
||||
});
|
||||
scene.scale = scale;
|
||||
let size = Size::new(scene.size.width * scale, scene.size.height * scale);
|
||||
out = out
|
||||
.push(
|
||||
row![
|
||||
action("Fit", event(Event::Fit)),
|
||||
action("−", event(Event::Zoom(scale / 1.25))),
|
||||
label(format!("{:.0}%", scale * 100.)),
|
||||
action("+", event(Event::Zoom(scale * 1.25))),
|
||||
action("100%", event(Event::Zoom(1.)))
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.push(
|
||||
text_input("Guard condition (optional)", &d.guard)
|
||||
.on_input(|s| event(Event::Guard(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
iced::widget::sensor(
|
||||
container(
|
||||
scrollable(
|
||||
canvas::Canvas::new(scene)
|
||||
.width(size.width)
|
||||
.height(size.height),
|
||||
)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.height(size.height.clamp(180., 460.)),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.style(style::card),
|
||||
)
|
||||
.on_show(|s| event(Event::Viewport(s)))
|
||||
.on_resize(|s| event(Event::Viewport(s))),
|
||||
);
|
||||
let effects = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == layer && e.kind().is_work() && !d.effects.contains(&e.id))
|
||||
.map(|e| choice(p, e.id))
|
||||
.collect();
|
||||
for id in &d.effects {
|
||||
form = form.push(
|
||||
row![
|
||||
text(p.named(*id)).size(12).width(Fill),
|
||||
action("Remove", event(Event::RemoveEffect(*id)))
|
||||
}
|
||||
for r in p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.kind() == RelationKind::StateTransition && in_machine(p, r.source(), machine))
|
||||
{
|
||||
out = out.push(
|
||||
container(
|
||||
column![
|
||||
action(
|
||||
&format!("{} → {}", p.named(r.source()), p.named(r.target())),
|
||||
event(Event::Edit(r.id))
|
||||
)
|
||||
.width(Fill),
|
||||
text(transition_summary(p, r)).size(12),
|
||||
action("Properties", Message::EditProperties(r.id))
|
||||
]
|
||||
.spacing(5),
|
||||
);
|
||||
}
|
||||
form = form
|
||||
.push(picker(effects, None, "Add effect work…", Event::Effect))
|
||||
.push(
|
||||
text_input("Effect notes (optional)", &d.effect)
|
||||
.on_input(|s| event(Event::EffectNotes(s)))
|
||||
.padding(9)
|
||||
.size(12),
|
||||
);
|
||||
if d.triggers.is_empty() && !d.timed && d.trigger.trim().is_empty() {
|
||||
form = form.push(label(
|
||||
"No event trigger: completion from a state, transient routing from a pseudostate.",
|
||||
));
|
||||
}
|
||||
)
|
||||
.padding(12)
|
||||
.style(style::card),
|
||||
);
|
||||
}
|
||||
let candidate = d.command(p).and_then(|command| {
|
||||
execute_batch(p, &[command])
|
||||
.map(|_| ())
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
if let Err(error) = &candidate {
|
||||
form = form.push(text(error.clone()).size(12).color(style::MUTED));
|
||||
}
|
||||
form = form.push(
|
||||
row![
|
||||
button("Apply")
|
||||
.padding(9)
|
||||
.on_press_maybe(candidate.is_ok().then_some(event(Event::Submit))),
|
||||
action("Cancel", event(Event::Cancel))
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
Some(container(form).padding(4).into())
|
||||
scrollable(out.padding(24)).into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -634,7 +771,7 @@ mod tests {
|
||||
assert_eq!(guard, "link unavailable");
|
||||
assert!(triggers.iter().any(|id| matches!(result.elements[id].shared(), Some(SharedBody::Event { body: EventBody::Time { kind:TimeEventKind::After, expression }}) if expression == "30 s")));
|
||||
assert_eq!(&language::parse(&language::format(result)).unwrap(), result);
|
||||
assert!(routing::state_transition_summary(result, r).contains(" OR "));
|
||||
assert!(transition_summary(result, r).contains(" OR "));
|
||||
session.undo();
|
||||
assert_eq!(session.project(), &p);
|
||||
}
|
||||
@@ -673,7 +810,6 @@ mod tests {
|
||||
}
|
||||
let mut result =
|
||||
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
||||
state.acknowledge_commit();
|
||||
let r = result.relations.values_mut().next().unwrap();
|
||||
r.description = "Retain configuration".into();
|
||||
let (id, alias) = (r.id, r.alias.clone());
|
||||
@@ -692,7 +828,14 @@ mod tests {
|
||||
result.relations[&id].source(),
|
||||
result.relations[&id].target()
|
||||
);
|
||||
assert!(!routing::state_transition_summary(&result, &result.relations[&id]).is_empty());
|
||||
let scene = Scene::states(&result, machine, id);
|
||||
assert!(
|
||||
scene
|
||||
.boxes
|
||||
.iter()
|
||||
.any(|b| b.id == id && b.title.starts_with("INTERNAL"))
|
||||
);
|
||||
assert!(!scene.lines.iter().any(|l| l.id == id));
|
||||
}
|
||||
#[test]
|
||||
fn selected_composite_owns_new_region_and_history_uses_selected_region() {
|
||||
@@ -703,7 +846,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let result =
|
||||
execute_batch(&p, &[state.update(&p, Event::Submit).unwrap().unwrap()]).unwrap();
|
||||
state.acknowledge_commit();
|
||||
let region = result
|
||||
.children(ready)
|
||||
.find(|e| e.kind() == Kind::Region)
|
||||
@@ -725,6 +867,29 @@ mod tests {
|
||||
.children(region)
|
||||
.any(|e| e.kind() == Kind::DeepHistory)
|
||||
);
|
||||
assert_eq!(language::parse(&language::format(&result)).unwrap(), result);
|
||||
let mut scene = Scene::states(&result, machine, ready);
|
||||
let container = scene.boxes.iter().find(|b| b.id == ready).unwrap().rect;
|
||||
let child = scene.boxes.iter().find(|b| b.id == region).unwrap().rect;
|
||||
assert!(container.contains(child.position()));
|
||||
assert!(container.contains(Point::new(child.x + child.width, child.y + child.height)));
|
||||
let history = scene
|
||||
.boxes
|
||||
.iter()
|
||||
.find(|b| b.marker == Some(Kind::DeepHistory))
|
||||
.unwrap();
|
||||
let id = history.id;
|
||||
let point = Point::new((history.rect.x + 16.) * 0.5, (history.rect.y + 28.) * 0.5);
|
||||
scene.scale = 0.5;
|
||||
let action = canvas::Program::update(
|
||||
&scene,
|
||||
&mut (),
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Rectangle::new(Point::ORIGIN, scene.size),
|
||||
mouse::Cursor::Available(point),
|
||||
)
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(action, Some(Message::Select(selected)) if selected == id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,28 +76,7 @@ struct Draft {
|
||||
control: ControlKind,
|
||||
}
|
||||
impl State {
|
||||
pub(super) fn pending_owner(&self) -> Option<Id> {
|
||||
self.draft.as_ref().map(|d| d.owner)
|
||||
}
|
||||
|
||||
pub(super) fn prepare_pending(&self, p: &Project) -> Result<Command, String> {
|
||||
let command = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a contribution tool first.")?
|
||||
.command(p)?;
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
pub(super) fn acknowledge_commit(&mut self) {
|
||||
self.draft = None;
|
||||
}
|
||||
|
||||
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Command>, String> {
|
||||
if self.draft.is_some() && matches!(&event, Event::Start(..)) {
|
||||
return Err(PENDING_DRAFT.into());
|
||||
}
|
||||
match event {
|
||||
Event::Viewport(size) => self.viewport = Some(size),
|
||||
Event::Zoom(zoom) => self.zoom = Some(zoom.clamp(0.1, 2.)),
|
||||
@@ -139,8 +118,19 @@ impl State {
|
||||
control: ControlKind::Choice,
|
||||
});
|
||||
}
|
||||
Event::Cancel => self.acknowledge_commit(),
|
||||
Event::Submit => return self.prepare_pending(p).map(Some),
|
||||
Event::Cancel => self.draft = None,
|
||||
Event::Submit => {
|
||||
let d = self
|
||||
.draft
|
||||
.as_ref()
|
||||
.ok_or("Choose a contribution tool first.")?;
|
||||
let command = d.command(p)?;
|
||||
// Validate before clearing the user's inputs. The ordinary command
|
||||
// handler remains responsible for the single undoable transaction.
|
||||
execute_batch(p, std::slice::from_ref(&command)).map_err(|e| e.to_string())?;
|
||||
self.draft = None;
|
||||
return Ok(Some(command));
|
||||
}
|
||||
other => {
|
||||
if let Some(d) = &mut self.draft {
|
||||
match other {
|
||||
@@ -1782,13 +1772,9 @@ mod tests {
|
||||
assert!(state.draft.is_some());
|
||||
state.update(&p, Event::Guard("ready".into())).unwrap();
|
||||
let command = state.update(&p, Event::Submit).unwrap().unwrap();
|
||||
assert!(state.draft.is_some());
|
||||
assert!(state.draft.is_none());
|
||||
assert!(matches!(command, Command::Batch(_)));
|
||||
assert_eq!(p.elements[&owner].steps().unwrap().len(), 2);
|
||||
let result = execute_batch(&p, &[command]).unwrap();
|
||||
state.acknowledge_commit();
|
||||
assert!(state.draft.is_none());
|
||||
assert_eq!(result.elements[&owner].steps().unwrap().len(), 6);
|
||||
}
|
||||
#[test]
|
||||
fn cycle_projection_terminates_and_retains_actual_links() {
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
//! A palette operation is transient; only a completed connection enters history.
|
||||
use crate::{connection_tool::Tool, route_edit::Context};
|
||||
use oa_core::{Id, Project, projection::ProjectedView};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Choose(Option<Tool>),
|
||||
Begin(Tool, Id),
|
||||
Endpoint(Context, Id),
|
||||
Restart(Context),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub context: Context,
|
||||
pub tool: Tool,
|
||||
pub source: Option<Id>,
|
||||
pub candidates: BTreeSet<Id>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(project: &Project, view: &ProjectedView, context: Context, tool: Tool) -> Self {
|
||||
let candidates = visible(view)
|
||||
.filter(|id| tool.can_start(project, *id))
|
||||
.collect();
|
||||
Self {
|
||||
context,
|
||||
tool,
|
||||
source: None,
|
||||
candidates,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start(&mut self, project: &Project, view: &ProjectedView, source: Id) {
|
||||
self.source = Some(source);
|
||||
let compatible = self.tool.targets(project, source);
|
||||
self.candidates = visible(view).filter(|id| compatible.contains(id)).collect();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn visible(view: &ProjectedView) -> impl Iterator<Item = Id> + '_ {
|
||||
view.nodes
|
||||
.iter()
|
||||
.map(|n| n.element)
|
||||
.chain(view.ports.iter().map(|p| p.element))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{App, Message, Page};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Send command", 0., 0.);
|
||||
let b = p.add(Kind::Function, "Receive command", 400., 0.);
|
||||
let hidden = p
|
||||
.add_element(Kind::Function, Layer::Sa, "Hidden work", None)
|
||||
.unwrap();
|
||||
(App::with_project(p), a, b, hidden)
|
||||
}
|
||||
fn send(app: &mut App, event: Event) {
|
||||
let _ = app.update(Message::Connection(event));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_endpoint_drawing_commits_ports_and_exchange_once_with_exact_history() {
|
||||
let (mut app, a, b, hidden) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
assert!(
|
||||
!app.connection
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.candidates
|
||||
.contains(&hidden)
|
||||
);
|
||||
let context = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(context, a));
|
||||
send(&mut app, Event::Endpoint(context, hidden));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(a));
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let relation = &app.project().relations[&app.selected];
|
||||
assert_eq!(relation.kind(), RelationKind::FunctionalExchange);
|
||||
assert_eq!((relation.source(), relation.target()), (a, b));
|
||||
assert_eq!(
|
||||
app.project()
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
let after = app.document.bundle().clone();
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(*app.document.bundle(), before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(*app.document.bundle(), after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancel_navigation_revision_and_dirty_drafts_cannot_commit_a_stale_connection() {
|
||||
let (mut app, a, b, _) = fixture();
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
let context = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(context, a));
|
||||
let _ = app.update(Message::Escape);
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert!(app.project().relations.is_empty());
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
let current = app.route_context().unwrap();
|
||||
send(&mut app, Event::Endpoint(current, a));
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert_eq!(
|
||||
app.connection.as_ref().unwrap().source,
|
||||
Some(a),
|
||||
"A stale click must leave the newer drawing session intact"
|
||||
);
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Route(crate::route_edit::Event::AddBend));
|
||||
assert_eq!(app.document.revision(), revision);
|
||||
assert!(app.connection.is_some());
|
||||
let _ = app.update(Message::Page(Page::Catalog));
|
||||
assert!(app.connection.is_none());
|
||||
let _ = app.update(Message::Page(Page::Diagram));
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
send(&mut app, Event::Endpoint(context, b));
|
||||
assert!(app.project().relations.is_empty());
|
||||
let _ = app.update(Message::Select(a));
|
||||
let _ = app.update(Message::Field("/name".into(), "Pending rename".into()));
|
||||
assert!(app.draft_dirty());
|
||||
send(&mut app, Event::Choose(Some(Tool::FunctionalExchange)));
|
||||
assert!(app.connection.is_none());
|
||||
assert_eq!(app.project().named(a), "Send command");
|
||||
}
|
||||
}
|
||||
@@ -1,610 +0,0 @@
|
||||
//! Semantic endpoints for direct canvas connections. Gesture state and visible
|
||||
//! hit targets belong to the canvas; ordinary core commands own model changes.
|
||||
use oa_core::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Tool {
|
||||
Interaction,
|
||||
FunctionalExchange,
|
||||
BoundaryExchange,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn for_layer(layer: Layer) -> &'static [Self] {
|
||||
match layer {
|
||||
Layer::Oa => &[Self::Interaction],
|
||||
Layer::Sa => &[Self::FunctionalExchange, Self::BoundaryExchange],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Interaction",
|
||||
Self::FunctionalExchange => "Functional exchange",
|
||||
Self::BoundaryExchange => "Boundary exchange",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn hint(self) -> &'static str {
|
||||
match self {
|
||||
Self::Interaction => "Click a source activity, then a target activity.",
|
||||
Self::FunctionalExchange => {
|
||||
"Click an output port or source function, then an input port or target function. Function cards use automatic ports."
|
||||
}
|
||||
Self::BoundaryExchange => {
|
||||
"Click a source component port, then a port on another participant. Flow goes from Out to In; Standard is unoriented."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_start(self, project: &Project, source: Id) -> bool {
|
||||
match self {
|
||||
Self::Interaction => project
|
||||
.elements
|
||||
.get(&source)
|
||||
.is_some_and(|e| e.layer() == Layer::Oa && e.kind() == Kind::Activity),
|
||||
Self::FunctionalExchange => function_endpoint(project, source, true).is_some(),
|
||||
Self::BoundaryExchange => component_endpoint(project, source)
|
||||
.is_some_and(|contract| component_allows(contract, true)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap feasibility for highlighting candidates. The caller intersects this
|
||||
/// with the actual projected nodes and ports; hidden objects are not hits.
|
||||
pub fn can_connect(self, project: &Project, source: Id, target: Id) -> bool {
|
||||
if !self.can_start(project, source) {
|
||||
return false;
|
||||
}
|
||||
match self {
|
||||
Self::Interaction => {
|
||||
validation::can_connect(project, RelationKind::Interaction, source, target)
|
||||
}
|
||||
Self::FunctionalExchange => {
|
||||
let (Some((source, _)), Some((target, _))) = (
|
||||
function_endpoint(project, source, true),
|
||||
function_endpoint(project, target, false),
|
||||
) else {
|
||||
return false;
|
||||
};
|
||||
validation::can_connect(project, RelationKind::FunctionalExchange, source, target)
|
||||
}
|
||||
Self::BoundaryExchange => {
|
||||
component_endpoint(project, target).is_some()
|
||||
&& validation::can_connect_boundary(
|
||||
project,
|
||||
source,
|
||||
target,
|
||||
boundary_oriented(project, source),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn targets(self, project: &Project, source: Id) -> BTreeSet<Id> {
|
||||
if !self.can_start(project, source) {
|
||||
return BTreeSet::new();
|
||||
}
|
||||
project
|
||||
.elements
|
||||
.keys()
|
||||
.copied()
|
||||
.filter(|target| self.can_connect(project, source, *target))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Return one validated history operation without modifying the input.
|
||||
/// FE endpoint generation uses the same atomic planner as the form workflow.
|
||||
/// No carried items are inferred from a port's allowed item declarations.
|
||||
pub fn command(self, project: &Project, source: Id, target: Id) -> Result<Command, ModelError> {
|
||||
if !self.can_connect(project, source, target) {
|
||||
return Err(ModelError(format!(
|
||||
"Choose compatible endpoints for {}. {}",
|
||||
self.label(),
|
||||
self.hint()
|
||||
)));
|
||||
}
|
||||
let id = Id::new_v4();
|
||||
if self == Self::FunctionalExchange {
|
||||
let (source, source_port) = function_endpoint(project, source, true)
|
||||
.expect("Functional source was checked above");
|
||||
let (target, target_port) = function_endpoint(project, target, false)
|
||||
.expect("Functional target was checked above");
|
||||
let name = engineering_name(project, self, source, target);
|
||||
return plan_functional_exchange(
|
||||
project,
|
||||
&FunctionalExchangeRequest {
|
||||
id,
|
||||
alias: project.fresh_alias(&name, "exchange", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
source,
|
||||
target,
|
||||
source_port,
|
||||
target_port,
|
||||
items: Vec::new(),
|
||||
},
|
||||
)
|
||||
.map(Command::Batch);
|
||||
}
|
||||
let name = engineering_name(project, self, source, target);
|
||||
let body = match self {
|
||||
Self::Interaction => RelationBody::new(RelationKind::Interaction, source, target),
|
||||
Self::BoundaryExchange => RelationBody::BoundaryExchange {
|
||||
source,
|
||||
target,
|
||||
oriented: boundary_oriented(project, source),
|
||||
items: Vec::new(),
|
||||
},
|
||||
Self::FunctionalExchange => unreachable!("Functional planner returned above"),
|
||||
};
|
||||
let command = Command::CreateRelation(Relation {
|
||||
id,
|
||||
alias: project.fresh_alias(&name, "relation", id),
|
||||
name,
|
||||
description: String::new(),
|
||||
body,
|
||||
});
|
||||
prepare_command(project, &command)?;
|
||||
Ok(command)
|
||||
}
|
||||
}
|
||||
|
||||
/// A clicked port determines its own function owner. It cannot be paired with
|
||||
/// a different remembered owner or silently substituted by automatic selection.
|
||||
fn function_endpoint(project: &Project, id: Id, outgoing: bool) -> Option<(Id, PortChoice)> {
|
||||
let element = project.elements.get(&id)?;
|
||||
if element.layer() != Layer::Sa {
|
||||
return None;
|
||||
}
|
||||
if element.kind() == Kind::Function {
|
||||
return Some((id, PortChoice::Auto));
|
||||
}
|
||||
match (port_contract(element)?, outgoing) {
|
||||
(PortContract::FunctionOutput { .. }, true)
|
||||
| (PortContract::FunctionInput { .. }, false) => {}
|
||||
_ => return None,
|
||||
}
|
||||
let owner = project.elements.get(&element.parent?)?;
|
||||
(owner.layer() == Layer::Sa && owner.kind() == Kind::Function)
|
||||
.then_some((owner.id, PortChoice::Reuse(id)))
|
||||
}
|
||||
|
||||
fn component_endpoint(project: &Project, id: Id) -> Option<&PortContract> {
|
||||
let element = project.elements.get(&id)?;
|
||||
let owner = project.elements.get(&element.parent?)?;
|
||||
if element.layer() != Layer::Sa || owner.layer() != Layer::Sa || !owner.kind().is_performer() {
|
||||
return None;
|
||||
}
|
||||
let contract = port_contract(element)?;
|
||||
match contract {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
..
|
||||
}
|
||||
| PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
} => Some(contract),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn boundary_oriented(project: &Project, source: Id) -> bool {
|
||||
matches!(
|
||||
component_endpoint(project, source),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
..
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
fn engineering_name(project: &Project, tool: Tool, source: Id, target: Id) -> String {
|
||||
let endpoint = |id| {
|
||||
if tool == Tool::BoundaryExchange
|
||||
&& let Some(owner) = project.elements.get(&id).and_then(|e| e.parent)
|
||||
{
|
||||
format!("{} / {}", project.named(owner), project.named(id))
|
||||
} else {
|
||||
project.named(id).to_owned()
|
||||
}
|
||||
};
|
||||
let arrow = if tool == Tool::BoundaryExchange && !boundary_oriented(project, source) {
|
||||
"—"
|
||||
} else {
|
||||
"→"
|
||||
};
|
||||
format!(
|
||||
"{}: {} {arrow} {}",
|
||||
tool.label(),
|
||||
endpoint(source),
|
||||
endpoint(target)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::document::Document;
|
||||
use oa_core::source_project::{ProjectLimits, compile_bundle, format_bundle};
|
||||
|
||||
fn element(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
|
||||
p.add_element(kind, layer, name, None).unwrap()
|
||||
}
|
||||
|
||||
fn port(p: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id {
|
||||
let id = Id::new_v4();
|
||||
let port = make_port(p, id, owner, name, contract).unwrap();
|
||||
p.elements.insert(id, port);
|
||||
id
|
||||
}
|
||||
|
||||
fn flow(orientation: ComponentPortOrientation) -> PortContract {
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
}
|
||||
}
|
||||
|
||||
fn input() -> PortContract {
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn output() -> PortContract {
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn created(project: &Project, command: &Command) -> (Project, Relation) {
|
||||
let prepared = prepare_command(project, command).unwrap();
|
||||
let relation = prepared.project.relations[&prepared.selected.unwrap()].clone();
|
||||
(prepared.project, relation)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operational_interaction_targets_only_distinct_oa_activities() {
|
||||
let mut p = Project::empty();
|
||||
let source = element(&mut p, Kind::Activity, Layer::Oa, "Send report");
|
||||
let target = element(&mut p, Kind::Activity, Layer::Oa, "Assess report");
|
||||
let actor = element(&mut p, Kind::Actor, Layer::Oa, "Dispatcher");
|
||||
let function = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let before = p.clone();
|
||||
assert_eq!(Tool::for_layer(Layer::Oa), &[Tool::Interaction]);
|
||||
assert_eq!(
|
||||
Tool::Interaction.targets(&p, source),
|
||||
BTreeSet::from([target])
|
||||
);
|
||||
for invalid in [source, actor, function, Id::new_v4()] {
|
||||
assert!(Tool::Interaction.command(&p, source, invalid).is_err());
|
||||
}
|
||||
let command = Tool::Interaction.command(&p, source, target).unwrap();
|
||||
assert!(matches!(command, Command::CreateRelation(_)));
|
||||
let (_, relation) = created(&p, &command);
|
||||
assert_eq!(relation.body.endpoints(), (source, target));
|
||||
assert_eq!(relation.name, "Interaction: Send report → Assess report");
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_function_ports_keep_owners_contracts_and_do_not_invent_carried_items() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
|
||||
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
|
||||
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
|
||||
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
|
||||
let source = port(
|
||||
&mut p,
|
||||
a,
|
||||
"Reports out",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![report],
|
||||
},
|
||||
);
|
||||
let target = port(
|
||||
&mut p,
|
||||
b,
|
||||
"Results in",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![result],
|
||||
},
|
||||
);
|
||||
let other = port(&mut p, a, "Other output", output());
|
||||
let before = p.clone();
|
||||
let command = Tool::FunctionalExchange
|
||||
.command(&p, source, target)
|
||||
.unwrap();
|
||||
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 1));
|
||||
let (accepted, relation) = created(&p, &command);
|
||||
assert!(matches!(relation.body, RelationBody::FunctionalExchange {
|
||||
source: s, target: t, source_port: sp, target_port: tp, ref items,
|
||||
} if (s,t,sp,tp) == (a,b,source,target) && items.is_empty()));
|
||||
assert_eq!(accepted.elements, p.elements);
|
||||
assert_eq!(relation.name, "Functional exchange: Detect → Assess");
|
||||
for (s, t) in [
|
||||
(target, source),
|
||||
(source, other),
|
||||
(source, a),
|
||||
(source, source),
|
||||
] {
|
||||
assert!(!Tool::FunctionalExchange.can_connect(&p, s, t));
|
||||
assert!(Tool::FunctionalExchange.command(&p, s, t).is_err());
|
||||
}
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_cards_use_unique_reuse_and_atomic_generation_when_ambiguous() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Function, Layer::Sa, "Detect");
|
||||
let b = element(&mut p, Kind::Function, Layer::Sa, "Assess");
|
||||
let out = port(&mut p, a, "Report out", output());
|
||||
let input = port(&mut p, b, "Report in", input());
|
||||
let (_, reused) = created(&p, &Tool::FunctionalExchange.command(&p, a, b).unwrap());
|
||||
assert!(
|
||||
matches!(reused.body, RelationBody::FunctionalExchange { source_port, target_port, .. }
|
||||
if (source_port,target_port) == (out,input))
|
||||
);
|
||||
let second = port(&mut p, a, "Other report out", output());
|
||||
let command = Tool::FunctionalExchange.command(&p, a, input).unwrap();
|
||||
assert!(matches!(&command, Command::Batch(commands) if commands.len() == 2));
|
||||
let (accepted, generated) = created(&p, &command);
|
||||
let RelationBody::FunctionalExchange {
|
||||
source_port,
|
||||
target_port,
|
||||
..
|
||||
} = generated.body
|
||||
else {
|
||||
panic!("Expected functional exchange")
|
||||
};
|
||||
assert_eq!(target_port, input);
|
||||
assert!(![out, second].contains(&source_port));
|
||||
assert_eq!(accepted.elements[&source_port].parent, Some(a));
|
||||
assert_eq!(accepted.elements.len(), p.elements.len() + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_candidates_match_core_orientation_and_reject_same_owner_or_mixed_kind() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "Response system");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Dispatcher");
|
||||
let mut endpoints = Vec::new();
|
||||
for owner in [a, b] {
|
||||
for orientation in [
|
||||
ComponentPortOrientation::Out,
|
||||
ComponentPortOrientation::In,
|
||||
ComponentPortOrientation::InOut,
|
||||
ComponentPortOrientation::Unset,
|
||||
] {
|
||||
endpoints.push(port(
|
||||
&mut p,
|
||||
owner,
|
||||
&format!("Flow {orientation:?}"),
|
||||
flow(orientation),
|
||||
));
|
||||
}
|
||||
endpoints.push(port(
|
||||
&mut p,
|
||||
owner,
|
||||
"Control",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
},
|
||||
));
|
||||
}
|
||||
for source in &endpoints {
|
||||
let oriented = boundary_oriented(&p, *source);
|
||||
let targets = Tool::BoundaryExchange.targets(&p, *source);
|
||||
for target in &endpoints {
|
||||
let expected = validation::can_connect_boundary(&p, *source, *target, oriented);
|
||||
assert_eq!(targets.contains(target), expected, "{source} → {target}");
|
||||
let command = Tool::BoundaryExchange.command(&p, *source, *target);
|
||||
assert_eq!(command.is_ok(), expected);
|
||||
if let Ok(command) = command {
|
||||
let (_, relation) = created(&p, &command);
|
||||
assert!(
|
||||
matches!(relation.body, RelationBody::BoundaryExchange { oriented: value, .. } if value == oriented)
|
||||
);
|
||||
assert!(relation.name.contains(" / "));
|
||||
assert_eq!(relation.name.contains('→'), oriented);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_feasibility_keeps_interface_warnings_and_final_carried_item_checks() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||
let standard = PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Unset,
|
||||
};
|
||||
let source = port(&mut p, a, "Service", standard.clone());
|
||||
let target = port(&mut p, b, "Required service", standard);
|
||||
let report = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Report");
|
||||
let result = element(&mut p, Kind::ExchangeItem, Layer::Sa, "Result");
|
||||
let provided = element(&mut p, Kind::Interface, Layer::Sa, "Reporting");
|
||||
let required = element(&mut p, Kind::Interface, Layer::Sa, "Results");
|
||||
for (interface, item) in [(provided, report), (required, result)] {
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
}
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides reporting",
|
||||
source,
|
||||
provided,
|
||||
);
|
||||
let requirement = p.relate(
|
||||
RelationKind::InterfaceRequirement,
|
||||
"Requires results",
|
||||
target,
|
||||
required,
|
||||
);
|
||||
validate_candidate(&p).unwrap();
|
||||
let before = p.clone();
|
||||
assert!(Tool::BoundaryExchange.targets(&p, source).contains(&target));
|
||||
assert!(validation::can_connect_boundary(&p, source, target, false));
|
||||
let (accepted, relation) = created(
|
||||
&p,
|
||||
&Tool::BoundaryExchange.command(&p, source, target).unwrap(),
|
||||
);
|
||||
let warnings: Vec<_> = validation::validate(&accepted)
|
||||
.into_iter()
|
||||
.filter(|issue| issue.code == "unsatisfied_interface_requirement")
|
||||
.collect();
|
||||
assert_eq!(warnings.len(), 1);
|
||||
assert_eq!(warnings[0].severity, validation::Severity::Warning);
|
||||
assert_eq!(warnings[0].element, Some(requirement));
|
||||
assert_eq!(warnings[0].field.as_deref(), Some("target"));
|
||||
|
||||
let mut carrying = relation.clone();
|
||||
let RelationBody::BoundaryExchange { items, .. } = &mut carrying.body else {
|
||||
unreachable!()
|
||||
};
|
||||
items.push(result);
|
||||
assert!(prepare_command(&p, &Command::CreateRelation(carrying.clone())).is_err());
|
||||
assert_eq!(p, before, "Rejected carried items never mutate the input");
|
||||
|
||||
p.relate(
|
||||
RelationKind::InterfaceProvision,
|
||||
"Provides results",
|
||||
source,
|
||||
required,
|
||||
);
|
||||
let prepared = prepare_command(&p, &Command::CreateRelation(carrying)).unwrap();
|
||||
assert!(
|
||||
validation::validate(&prepared.project)
|
||||
.iter()
|
||||
.all(|issue| issue.code != "unsatisfied_interface_requirement")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_port_owners_layers_and_contract_kinds_are_not_candidates() {
|
||||
let mut p = Project::empty();
|
||||
let function = element(&mut p, Kind::Function, Layer::Sa, "Function");
|
||||
let system = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let activity = element(&mut p, Kind::Activity, Layer::Oa, "Activity");
|
||||
let function_on_system = port(&mut p, system, "Wrong function owner", output());
|
||||
let component_on_function = port(
|
||||
&mut p,
|
||||
function,
|
||||
"Wrong component owner",
|
||||
flow(ComponentPortOrientation::Out),
|
||||
);
|
||||
let oa_function_port = port(&mut p, activity, "Wrong layer", output());
|
||||
let malformed_standard = port(
|
||||
&mut p,
|
||||
system,
|
||||
"Wrong Standard orientation",
|
||||
PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
orientation: ComponentPortOrientation::Out,
|
||||
},
|
||||
);
|
||||
for source in [
|
||||
function_on_system,
|
||||
component_on_function,
|
||||
oa_function_port,
|
||||
malformed_standard,
|
||||
] {
|
||||
for tool in Tool::for_layer(Layer::Sa) {
|
||||
assert!(!tool.can_start(&p, source));
|
||||
assert!(tool.targets(&p, source).is_empty());
|
||||
assert!(tool.command(&p, source, function).is_err());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn direct_connections_share_exact_source_history_and_model_parity() {
|
||||
let mut p = Project::empty();
|
||||
let oa_a = element(&mut p, Kind::Activity, Layer::Oa, "Detect incident");
|
||||
let oa_b = element(&mut p, Kind::Activity, Layer::Oa, "Assess incident");
|
||||
let sa_a = element(&mut p, Kind::Function, Layer::Sa, "Detect report");
|
||||
let sa_b = element(&mut p, Kind::Function, Layer::Sa, "Assess report");
|
||||
let system = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let actor = element(&mut p, Kind::SystemActor, Layer::Sa, "Actor");
|
||||
let boundary_a = port(&mut p, system, "Send", flow(ComponentPortOrientation::Out));
|
||||
let boundary_b = port(&mut p, actor, "Receive", flow(ComponentPortOrientation::In));
|
||||
let mut bundle = format_bundle(&p);
|
||||
bundle
|
||||
.files
|
||||
.get_mut(&bundle.entry.clone())
|
||||
.unwrap()
|
||||
.insert_str(0, "// Preserve engineering rationale exactly.\n");
|
||||
let mut document = Document::from_bundle(bundle, ProjectLimits::default()).unwrap();
|
||||
for (tool, source, target) in [
|
||||
(Tool::Interaction, oa_a, oa_b),
|
||||
(Tool::FunctionalExchange, sa_a, sa_b),
|
||||
(Tool::BoundaryExchange, boundary_a, boundary_b),
|
||||
] {
|
||||
let before = document.bundle().clone();
|
||||
let original = document.project().clone();
|
||||
let revision = document.revision();
|
||||
let command = tool.command(document.project(), source, target).unwrap();
|
||||
let expected = prepare_command(document.project(), &command).unwrap();
|
||||
let selected = document.execute(command).unwrap();
|
||||
assert_eq!(selected, expected.selected);
|
||||
assert_eq!(document.project(), &expected.project);
|
||||
assert_eq!(document.revision(), revision + 1);
|
||||
assert_eq!(
|
||||
compile_bundle(document.bundle(), ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected.project
|
||||
);
|
||||
let changed = document.bundle().clone();
|
||||
assert!(document.undo());
|
||||
assert_eq!(document.bundle(), &before);
|
||||
assert_eq!(document.project(), &original);
|
||||
assert!(document.redo());
|
||||
assert_eq!(document.bundle(), &changed);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boundary_feasibility_rejects_dangling_identity_in_a_malformed_element_map() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::System, Layer::Sa, "System");
|
||||
let b = element(&mut p, Kind::SystemActor, Layer::Sa, "Partner");
|
||||
let source = port(&mut p, a, "Send", flow(ComponentPortOrientation::Out));
|
||||
let target = port(&mut p, b, "Receive", flow(ComponentPortOrientation::In));
|
||||
assert!(Tool::BoundaryExchange.can_connect(&p, source, target));
|
||||
// An indexed key alone is not proof of a live model identity. Preserve
|
||||
// the reference check even when inspecting an inconsistent input model.
|
||||
p.elements.get_mut(&target).unwrap().id = Id::new_v4();
|
||||
let before = p.clone();
|
||||
assert!(!validation::can_connect_boundary(&p, source, target, true));
|
||||
assert!(!Tool::BoundaryExchange.targets(&p, source).contains(&target));
|
||||
assert!(Tool::BoundaryExchange.command(&p, source, target).is_err());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn final_command_validation_rejects_invalid_models_without_mutation() {
|
||||
let mut p = Project::empty();
|
||||
let a = element(&mut p, Kind::Activity, Layer::Oa, "Send");
|
||||
let b = element(&mut p, Kind::Activity, Layer::Oa, "Receive");
|
||||
p.elements.get_mut(&a).unwrap().alias = p.elements[&b].alias.clone();
|
||||
let before = p.clone();
|
||||
assert!(
|
||||
Tool::Interaction.can_connect(&p, a, b),
|
||||
"Candidate highlighting checks the proposed link; commit checks the whole model"
|
||||
);
|
||||
assert!(Tool::Interaction.command(&p, a, b).is_err());
|
||||
assert_eq!(p, before);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,559 +0,0 @@
|
||||
//! One address for a carried value, shared by scenario creation and property editing.
|
||||
use oa_core::*;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub struct FieldPathKey {
|
||||
pub path: Vec<Id>,
|
||||
pub field: Id,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct FieldPathChoice {
|
||||
pub key: FieldPathKey,
|
||||
pub label: String,
|
||||
pub picker_label: String,
|
||||
}
|
||||
impl std::fmt::Display for FieldPathChoice {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.picker_label)
|
||||
}
|
||||
}
|
||||
|
||||
fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &MessageKind) -> bool {
|
||||
data::item_mechanism(project, entry.item) != Some(ExchangeMechanism::Operation)
|
||||
|| match phase {
|
||||
MessageKind::Call => matches!(
|
||||
entry.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::In
|
||||
| ParameterDirection::InOut
|
||||
),
|
||||
MessageKind::Reply => matches!(
|
||||
entry.direction,
|
||||
ParameterDirection::Unspecified
|
||||
| ParameterDirection::Out
|
||||
| ParameterDirection::InOut
|
||||
| ParameterDirection::Return
|
||||
),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice {
|
||||
let qualified = entry
|
||||
.path
|
||||
.iter()
|
||||
.chain(std::iter::once(&entry.field))
|
||||
.map(|id| {
|
||||
project
|
||||
.elements
|
||||
.get(id)
|
||||
.map(|e| format!("{} (@{})", e.name, e.alias))
|
||||
.unwrap_or_else(|| id.to_string())
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let direction =
|
||||
if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) {
|
||||
match entry.direction {
|
||||
ParameterDirection::Unspecified => " · direction unspecified — review",
|
||||
ParameterDirection::In => " · In",
|
||||
ParameterDirection::Out => " · Out",
|
||||
ParameterDirection::InOut => " · InOut",
|
||||
ParameterDirection::Return => " · Return",
|
||||
}
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let terminal = qualified
|
||||
.last()
|
||||
.expect("A field path always has a terminal");
|
||||
let owner_path = qualified[..qualified.len() - 1].join(" → ");
|
||||
let picker_label = format!(
|
||||
"{terminal}{direction} — {}{}",
|
||||
project.named(entry.item),
|
||||
if owner_path.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(" / {owner_path}")
|
||||
},
|
||||
);
|
||||
FieldPathChoice {
|
||||
key: FieldPathKey {
|
||||
path: entry.path,
|
||||
field: entry.field,
|
||||
},
|
||||
label: format!(
|
||||
"{} / {}{direction}",
|
||||
project.named(entry.item),
|
||||
qualified.join(" → ")
|
||||
),
|
||||
picker_label,
|
||||
}
|
||||
}
|
||||
|
||||
// A bounded picker must expose its distinguishing field before a long path.
|
||||
// Keep the full address separately for the wrapping detail text.
|
||||
fn sorted_choices(mut choices: Vec<FieldPathChoice>) -> Vec<FieldPathChoice> {
|
||||
choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase());
|
||||
choices
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn choices(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
Ok(sorted_choices(
|
||||
data::try_carried_field_paths(project, exchange)?
|
||||
.into_iter()
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
/// Direct carried fields only: an unrelated deep branch must not prevent
|
||||
/// browsing a root field. Preserve the core resolver's first carried owner.
|
||||
pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec<FieldPathChoice> {
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
sorted_choices(
|
||||
exchange
|
||||
.body
|
||||
.items()
|
||||
.iter()
|
||||
.flat_map(|item| {
|
||||
data::effective_fields(project, *item)
|
||||
.into_iter()
|
||||
.filter_map(|field| {
|
||||
let SharedBody::DataField { direction, .. } = field.shared()? else {
|
||||
return None;
|
||||
};
|
||||
Some(data::CarriedFieldPath {
|
||||
item: *item,
|
||||
path: vec![],
|
||||
field: field.id,
|
||||
direction: *direction,
|
||||
})
|
||||
})
|
||||
})
|
||||
.filter(|entry| seen.insert(entry.field))
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Browse one record at a time, including explicitly chosen recursive hops.
|
||||
pub fn at_scope(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
scope: Option<&FieldPathKey>,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
match scope {
|
||||
Some(key) => children(project, exchange, phase, key),
|
||||
None => Ok(roots(project, exchange, phase)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve authored finite paths as well as the finite representative picker list.
|
||||
/// In particular, optional recursive Value records may be traversed repeatedly.
|
||||
pub fn resolve(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
key: &FieldPathKey,
|
||||
) -> Option<FieldPathChoice> {
|
||||
data::carried_value_path(project, exchange, &key.path, key.field)
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
.map(|entry| named_choice(project, entry))
|
||||
}
|
||||
|
||||
pub fn parent(key: &FieldPathKey) -> Option<FieldPathKey> {
|
||||
let (&field, path) = key.path.split_last()?;
|
||||
Some(FieldPathKey {
|
||||
path: path.to_vec(),
|
||||
field,
|
||||
})
|
||||
}
|
||||
|
||||
/// Expand exactly one selected record. This keeps recursive authoring explicit
|
||||
/// and finite, and never traverses Reference storage or manufactures value paths.
|
||||
pub fn children(
|
||||
project: &Project,
|
||||
exchange: &Relation,
|
||||
phase: &MessageKind,
|
||||
key: &FieldPathKey,
|
||||
) -> Result<Vec<FieldPathChoice>, String> {
|
||||
let Some(entry) = data::carried_value_path(project, exchange, &key.path, key.field)
|
||||
.filter(|entry| phase_allows(project, entry, phase))
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
let Some(SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(record),
|
||||
storage: FieldStorage::Value,
|
||||
..
|
||||
}) = project.elements.get(&key.field).and_then(Element::shared)
|
||||
else {
|
||||
return Ok(vec![]);
|
||||
};
|
||||
if !data::is_record(project, *record) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if key.path.len() >= data::MAX_VALUE_PATH_DEPTH {
|
||||
return Err(format!(
|
||||
"A value path can contain at most {} enclosing fields.",
|
||||
data::MAX_VALUE_PATH_DEPTH
|
||||
));
|
||||
}
|
||||
let mut path = key.path.clone();
|
||||
path.push(key.field);
|
||||
Ok(sorted_choices(
|
||||
data::effective_fields(project, *record)
|
||||
.into_iter()
|
||||
.map(|field| {
|
||||
named_choice(
|
||||
project,
|
||||
data::CarriedFieldPath {
|
||||
item: entry.item,
|
||||
path: path.clone(),
|
||||
field: field.id,
|
||||
direction: entry.direction,
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests {
|
||||
use super::*;
|
||||
|
||||
pub struct Payload {
|
||||
pub project: Project,
|
||||
pub exchange: Id,
|
||||
pub item: Id,
|
||||
pub leaf: Id,
|
||||
pub origin: Id,
|
||||
pub destination: Id,
|
||||
pub scenario: Id,
|
||||
}
|
||||
pub fn payload() -> Payload {
|
||||
let mut p = Project::empty();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Move", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Information,
|
||||
mechanism: ExchangeMechanism::Operation,
|
||||
supertype: None,
|
||||
};
|
||||
let record = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Coordinate", None)
|
||||
.unwrap();
|
||||
*p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: None },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
let leaf = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "latitude", Some(record))
|
||||
.unwrap();
|
||||
let origin = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "origin", Some(item))
|
||||
.unwrap();
|
||||
let destination = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "destination", Some(item))
|
||||
.unwrap();
|
||||
for (id, type_ref, direction) in [
|
||||
(leaf, TypeRef::Real, ParameterDirection::Unspecified),
|
||||
(origin, TypeRef::Named(record), ParameterDirection::In),
|
||||
(destination, TypeRef::Named(record), ParameterDirection::Out),
|
||||
] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref,
|
||||
direction,
|
||||
minimum: 1,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
}
|
||||
let source = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Request", None)
|
||||
.unwrap();
|
||||
let target = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Perform", None)
|
||||
.unwrap();
|
||||
let exchange = p.relate(RelationKind::Interaction, "Movement", source, target);
|
||||
p.relations.get_mut(&exchange).unwrap().body = RelationBody::Interaction {
|
||||
source,
|
||||
target,
|
||||
items: vec![item],
|
||||
};
|
||||
let scenario = p
|
||||
.add_element(Kind::Scenario, Layer::Oa, "Conversation", None)
|
||||
.unwrap();
|
||||
validate_candidate(&p).unwrap();
|
||||
Payload {
|
||||
project: p,
|
||||
exchange,
|
||||
item,
|
||||
leaf,
|
||||
origin,
|
||||
destination,
|
||||
scenario,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_choices_keep_reused_record_addresses_distinct() {
|
||||
let mut f = payload();
|
||||
assert_eq!(
|
||||
data::item_mechanism(&f.project, f.item),
|
||||
Some(ExchangeMechanism::Operation)
|
||||
);
|
||||
let call = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
call.iter()
|
||||
.any(|c| c.key.path == [f.origin] && c.key.field == f.leaf)
|
||||
);
|
||||
assert!(!call.iter().any(|c| c.key.path == [f.destination]));
|
||||
let reply = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Reply,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
reply
|
||||
.iter()
|
||||
.any(|c| c.key.path == [f.destination] && c.key.field == f.leaf)
|
||||
);
|
||||
assert!(!reply.iter().any(|c| c.key.path == [f.origin]));
|
||||
if let Some(SharedBody::DataField { direction, .. }) = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&f.destination)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
{
|
||||
*direction = ParameterDirection::InOut;
|
||||
}
|
||||
let both = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
let leaves = both
|
||||
.iter()
|
||||
.filter(|c| c.key.field == f.leaf)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(leaves.len(), 2);
|
||||
assert_ne!(leaves[0].key, leaves[1].key);
|
||||
assert_ne!(leaves[0].label, leaves[1].label);
|
||||
let values = leaves
|
||||
.into_iter()
|
||||
.map(|c| (c.key.clone(), "1".to_string()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
assert_eq!(values.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reference_parameter_stops_descent_and_unspecified_direction_remains_visible() {
|
||||
let mut f = payload();
|
||||
if let Some(SharedBody::DataField {
|
||||
storage, direction, ..
|
||||
}) = f.project.elements.get_mut(&f.origin).unwrap().shared_mut()
|
||||
{
|
||||
*storage = FieldStorage::Reference;
|
||||
*direction = ParameterDirection::Unspecified;
|
||||
}
|
||||
let call = choices(
|
||||
&f.project,
|
||||
&f.project.relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
)
|
||||
.unwrap();
|
||||
let direct = call.iter().find(|c| c.key.field == f.origin).unwrap();
|
||||
assert!(direct.key.path.is_empty());
|
||||
assert!(direct.label.contains("unspecified"));
|
||||
assert!(!call.iter().any(|c| c.key.path.contains(&f.origin)));
|
||||
}
|
||||
pub fn recursive_payload() -> (Payload, Id) {
|
||||
let mut f = payload();
|
||||
let record = f.project.elements[&f.leaf].parent.unwrap();
|
||||
let next = f
|
||||
.project
|
||||
.add_element(Kind::DataField, Layer::Oa, "next", Some(record))
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&next)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(record),
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
unit: String::new(),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
default: None,
|
||||
};
|
||||
validate_candidate(&f.project).unwrap();
|
||||
(f, next)
|
||||
}
|
||||
pub fn wide_payload() -> Payload {
|
||||
let mut f = payload();
|
||||
let record = f.project.elements[&f.leaf].parent.unwrap();
|
||||
for (owner, template, count) in [(f.item, f.origin, 95), (record, f.leaf, 127)] {
|
||||
for index in 0..count {
|
||||
let body = f.project.elements[&template].shared().unwrap().clone();
|
||||
let id = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::DataField,
|
||||
Layer::Oa,
|
||||
&format!("Field {template} {index}"),
|
||||
Some(owner),
|
||||
)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = body;
|
||||
}
|
||||
}
|
||||
f
|
||||
}
|
||||
#[test]
|
||||
fn roots_and_one_hop_children_do_not_expand_unrelated_payload_branches() {
|
||||
let mut f = wide_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let direct = roots(&f.project, exchange, &MessageKind::Call);
|
||||
assert_eq!(direct.len(), 96);
|
||||
assert!(direct.iter().all(|choice| choice.key.path.is_empty()));
|
||||
let representative = choices(&f.project, exchange, &MessageKind::Call).unwrap();
|
||||
assert_eq!(representative.len(), 96 * 129);
|
||||
for root in &direct {
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &root.key),
|
||||
Some(root.clone())
|
||||
);
|
||||
}
|
||||
let nested = children(&f.project, exchange, &MessageKind::Call, &direct[0].key).unwrap();
|
||||
assert_eq!(nested.len(), 128);
|
||||
for child in &nested {
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &child.key),
|
||||
Some(child.clone())
|
||||
);
|
||||
assert!(child.label.ends_with(" · In"));
|
||||
}
|
||||
// A temporarily over-limit model still exposes its unrelated root fields;
|
||||
// the editor must not expand the whole graph before showing a choice.
|
||||
for index in 0..32 {
|
||||
let body = f.project.elements[&f.origin].shared().unwrap().clone();
|
||||
let id = f
|
||||
.project
|
||||
.add_element(
|
||||
Kind::DataField,
|
||||
Layer::Oa,
|
||||
&format!("Extra {index}"),
|
||||
Some(f.item),
|
||||
)
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&id)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = body;
|
||||
}
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
assert!(choices(&f.project, exchange, &MessageKind::Call).is_err());
|
||||
assert_eq!(roots(&f.project, exchange, &MessageKind::Call).len(), 128);
|
||||
assert_eq!(
|
||||
children(&f.project, exchange, &MessageKind::Call, &direct[0].key)
|
||||
.unwrap()
|
||||
.len(),
|
||||
128
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() {
|
||||
let (f, next) = recursive_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let record = FieldPathKey {
|
||||
path: vec![f.origin],
|
||||
field: next,
|
||||
};
|
||||
let nested = children(&f.project, exchange, &MessageKind::Call, &record).unwrap();
|
||||
let leaf = nested.iter().find(|c| c.key.field == f.leaf).unwrap();
|
||||
assert_eq!(leaf.key.path, vec![f.origin, next]);
|
||||
assert!(
|
||||
!choices(&f.project, exchange, &MessageKind::Call)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|c| c.key == leaf.key)
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
|
||||
Some(leaf.clone())
|
||||
);
|
||||
assert_eq!(parent(&leaf.key), Some(record));
|
||||
let deepest = FieldPathKey {
|
||||
path: std::iter::once(f.origin)
|
||||
.chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1))
|
||||
.collect(),
|
||||
field: next,
|
||||
};
|
||||
assert!(resolve(&f.project, exchange, &MessageKind::Call, &deepest).is_some());
|
||||
assert!(
|
||||
children(&f.project, exchange, &MessageKind::Call, &deepest)
|
||||
.unwrap_err()
|
||||
.contains("128")
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() {
|
||||
let (f, next) = recursive_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let context = FieldPathKey {
|
||||
path: vec![f.origin, next, next],
|
||||
field: next,
|
||||
};
|
||||
let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap();
|
||||
let leaf = visible
|
||||
.iter()
|
||||
.find(|choice| choice.key.field == f.leaf)
|
||||
.unwrap();
|
||||
assert!(leaf.to_string().starts_with("latitude (@latitude)"));
|
||||
assert!(leaf.label.contains("origin (@origin) → next (@next)"));
|
||||
assert!(
|
||||
visible.windows(2).all(
|
||||
|pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase()
|
||||
)
|
||||
);
|
||||
assert_ne!(
|
||||
visible[0].to_string().chars().take(24).collect::<String>(),
|
||||
visible[1].to_string().chars().take(24).collect::<String>()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve(&f.project, exchange, &MessageKind::Call, &leaf.key),
|
||||
Some(leaf.clone())
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,283 +0,0 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
connection_edit,
|
||||
connection_tool::Tool,
|
||||
context_menu::Entry,
|
||||
diagram::{DiagramCanvas, State},
|
||||
model_menu, port_edit,
|
||||
};
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Vector, keyboard, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Transmit", 100., 200.);
|
||||
let b = p.add(Kind::Function, "Receive", 500., 200.);
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
p.elements.insert(
|
||||
output,
|
||||
make_port(
|
||||
&p,
|
||||
output,
|
||||
a,
|
||||
"Send",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
p.elements.insert(
|
||||
input,
|
||||
make_port(
|
||||
&p,
|
||||
input,
|
||||
b,
|
||||
"Receive",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let mut app = App::with_project(p);
|
||||
app.pan = Vector::new(20., 30.);
|
||||
(app, output, input)
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(260., 100.), Size::new(2200., 1600.))
|
||||
}
|
||||
fn canvas(app: &App) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: 1.,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: app.connection.as_ref(),
|
||||
}
|
||||
}
|
||||
fn screen(app: &App, id: Id) -> Point {
|
||||
let v = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let p = v.ports.iter().find(|p| p.element == id).unwrap().position;
|
||||
Point::new(bounds().x + app.pan.x + p.x, bounds().y + app.pan.y + p.y)
|
||||
}
|
||||
fn event(c: &DiagramCanvas<'_>, s: &mut State, e: mouse::Event, p: Point) -> Option<Message> {
|
||||
c.update(
|
||||
s,
|
||||
&canvas::Event::Mouse(e),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(p),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn choose(app: &App, caption: &str) -> Message {
|
||||
model_menu::popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|e| match e {
|
||||
Entry::Action { label, message, .. } if label == caption => message,
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("No action {caption}"))
|
||||
}
|
||||
fn send(app: &mut App, m: Message) {
|
||||
let _ = app.update(m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_and_arrow_panning_preserve_source_and_history() {
|
||||
for armed in [false, true] {
|
||||
let (mut app, a, _) = fixture();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))),
|
||||
);
|
||||
let context = app.route_context().unwrap();
|
||||
if armed {
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Endpoint(context, a)),
|
||||
);
|
||||
}
|
||||
let before = app.document.bundle().clone();
|
||||
let pan = app.pan;
|
||||
let mut state = State::default();
|
||||
let start = screen(&app, a);
|
||||
let end = start + Vector::new(140., 70.);
|
||||
let c = canvas(&app);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, end))
|
||||
.collect();
|
||||
for m in messages {
|
||||
send(&mut app, m);
|
||||
}
|
||||
assert_eq!(app.pan, pan + Vector::new(140., 70.));
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, armed.then_some(a));
|
||||
let e = canvas::Event::Keyboard(keyboard::Event::KeyPressed {
|
||||
key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
|
||||
modified_key: keyboard::Key::Named(keyboard::key::Named::ArrowRight),
|
||||
physical_key: keyboard::key::Physical::Code(keyboard::key::Code::ArrowRight),
|
||||
location: keyboard::Location::Standard,
|
||||
modifiers: keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
});
|
||||
let message = canvas(&app)
|
||||
.update(&mut state, &e, bounds(), mouse::Cursor::Available(end))
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0
|
||||
.unwrap();
|
||||
send(&mut app, message);
|
||||
assert_eq!(app.pan, pan + Vector::new(60., 70.));
|
||||
assert_eq!(app.connection.as_ref().unwrap().context, context);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_reset_and_draw_use_fresh_open_context() {
|
||||
let (mut app, a, b) = fixture();
|
||||
let c = app.route_context().unwrap();
|
||||
let v = projection::project_view(app.project(), c.diagram);
|
||||
let owner = v.ports.iter().find(|p| p.element == a).unwrap().owner;
|
||||
send(
|
||||
&mut app,
|
||||
Message::PortAnchor(port_edit::Event::Commit(
|
||||
c,
|
||||
owner,
|
||||
PortAnchor {
|
||||
port: a,
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.4,
|
||||
},
|
||||
)),
|
||||
);
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let mut request = model_menu::Request::object(a, screen(&app, a));
|
||||
request.canvas = app.route_context();
|
||||
let old = request.canvas.unwrap();
|
||||
send(&mut app, Message::Menu(model_menu::Event::Open(request)));
|
||||
assert_ne!(app.route_context().unwrap().epoch, old.epoch);
|
||||
let action = choose(&app, "Reset automatic position");
|
||||
send(&mut app, action);
|
||||
assert!(
|
||||
app.project().diagrams[&c.diagram].nodes[&owner]
|
||||
.port_anchors
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
let mut request = model_menu::Request::object(a, screen(&app, a));
|
||||
request.canvas = app.route_context();
|
||||
send(&mut app, Message::Menu(model_menu::Event::Open(request)));
|
||||
let caption = model_menu::popup(&app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
Entry::Action { label, .. } if label.starts_with("Draw functional exchange") => {
|
||||
Some(label.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
let action = choose(&app, &caption);
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.connection.as_ref().unwrap().source, Some(a));
|
||||
let context = app.route_context().unwrap();
|
||||
assert_eq!(app.connection.as_ref().unwrap().context, context);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Endpoint(context, b)),
|
||||
);
|
||||
assert_eq!(app.project().relations.len(), 1);
|
||||
let accepted = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
source_project::compile_bundle(&accepted, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_after_release_must_not_move_clicked_port() {
|
||||
let (mut app, a, _) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let start = screen(&app, a);
|
||||
let end = start + Vector::new(-120., -150.);
|
||||
let mut state = State::default();
|
||||
let c = canvas(&app);
|
||||
// Iced batches all window events until AboutToWait and passes the final
|
||||
// window cursor to every event, even when the final motion follows release.
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, end))
|
||||
.collect();
|
||||
for m in messages {
|
||||
send(&mut app, m);
|
||||
}
|
||||
assert!(
|
||||
app.document.bundle() == &before,
|
||||
"Moving the cursor after release must not author a port anchor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_after_source_click_release_must_not_create_connection() {
|
||||
let (mut app, a, b) = fixture();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))),
|
||||
);
|
||||
let start = screen(&app, a);
|
||||
let end = screen(&app, b);
|
||||
let mut state = State::default();
|
||||
let c = canvas(&app);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, end))
|
||||
.collect();
|
||||
for m in messages {
|
||||
send(&mut app, m);
|
||||
}
|
||||
assert!(
|
||||
app.project().relations.is_empty(),
|
||||
"Hover after releasing the source is not a connection commit"
|
||||
);
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
use crate::{
|
||||
app::{App, Message},
|
||||
connection_edit,
|
||||
connection_tool::Tool,
|
||||
diagram::{DiagramCanvas, State},
|
||||
model_menu, port_edit,
|
||||
};
|
||||
use iced::{
|
||||
Point, Rectangle, Size, Vector, mouse,
|
||||
widget::canvas::{self, Program},
|
||||
};
|
||||
use oa_core::*;
|
||||
|
||||
fn fixture() -> (App, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Transmit", 100., 200.);
|
||||
let b = p.add(Kind::Function, "Receive", 500., 200.);
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
p.elements.insert(
|
||||
output,
|
||||
make_port(
|
||||
&p,
|
||||
output,
|
||||
a,
|
||||
"Send",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
p.elements.insert(
|
||||
input,
|
||||
make_port(
|
||||
&p,
|
||||
input,
|
||||
b,
|
||||
"Receive",
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
(
|
||||
{
|
||||
let mut app = App::with_project(p);
|
||||
app.pan = Vector::new(20., 30.);
|
||||
app
|
||||
},
|
||||
output,
|
||||
input,
|
||||
)
|
||||
}
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(260., 100.), Size::new(2200., 1600.))
|
||||
}
|
||||
fn screen(p: Position, zoom: f32) -> Point {
|
||||
Point::new(bounds().x + p.x * zoom + 20., bounds().y + p.y * zoom + 30.)
|
||||
}
|
||||
fn canvas(app: &App, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: app.connection.as_ref(),
|
||||
}
|
||||
}
|
||||
fn event(
|
||||
c: &DiagramCanvas<'_>,
|
||||
state: &mut State,
|
||||
event: mouse::Event,
|
||||
final_cursor: Point,
|
||||
) -> Option<Message> {
|
||||
c.update(
|
||||
state,
|
||||
&canvas::Event::Mouse(event),
|
||||
bounds(),
|
||||
mouse::Cursor::Available(final_cursor),
|
||||
)
|
||||
.and_then(|a| a.into_inner().0)
|
||||
}
|
||||
fn batch(app: &App, state: &mut State, start: Point, end: Point, zoom: f32) -> Vec<Message> {
|
||||
let c = canvas(app, zoom);
|
||||
[
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, state, e, end))
|
||||
.collect()
|
||||
}
|
||||
fn assert_history(app: &mut App, before: &oa_core::source_project::SourceBundle, revision: u64) {
|
||||
assert_eq!(app.document.revision(), revision + 1);
|
||||
let after = app.document.bundle().clone();
|
||||
assert_eq!(
|
||||
oa_core::source_project::compile_bundle(&after, Default::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
*app.project()
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.document.bundle(), before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &after);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_fe_ports_commit_once_through_app_and_source_history() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))));
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let at = |id| {
|
||||
screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == id)
|
||||
.unwrap()
|
||||
.position,
|
||||
zoom,
|
||||
)
|
||||
};
|
||||
let messages = batch(&app, &mut State::default(), at(output), at(input), zoom);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(
|
||||
matches!(&messages[0],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==output)
|
||||
);
|
||||
assert!(
|
||||
matches!(&messages[1],Message::Connection(connection_edit::Event::Endpoint(_,id))if *id==input)
|
||||
);
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.project().relations.len(), 1);
|
||||
assert_eq!(app.project().elements.len(), 4);
|
||||
assert!(
|
||||
matches!(app.project().relations[&app.selected].body,RelationBody::FunctionalExchange{source_port,target_port,..}if source_port==output&&target_port==input)
|
||||
);
|
||||
assert!(app.connection.is_none());
|
||||
assert_history(&mut app, &before, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coalesced_port_drag_commits_once_through_app_and_source_history() {
|
||||
for zoom in [0.4, 1., 2.] {
|
||||
let (mut app, output, _) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let port = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
let dest = projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35);
|
||||
let messages = batch(
|
||||
&app,
|
||||
&mut State::default(),
|
||||
screen(port.position, zoom),
|
||||
screen(dest, zoom),
|
||||
zoom,
|
||||
);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert!(matches!(&messages[0],Message::Select(id)if *id==output));
|
||||
assert!(
|
||||
matches!(&messages[1],Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))if anchor.port==output&&anchor.side==PortAnchorSide::Top)
|
||||
);
|
||||
let node = owner.id;
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.project().elements.len(), 4);
|
||||
assert!(app.project().relations.is_empty());
|
||||
assert_eq!(
|
||||
app.project().diagrams[&app.active_diagram.unwrap()].nodes[&node].port_anchors[0].port,
|
||||
output
|
||||
);
|
||||
assert_history(&mut app, &before, revision);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_captured_motion_must_not_turn_following_click_into_old_port_drag() {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let a = screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == output)
|
||||
.unwrap()
|
||||
.position,
|
||||
1.,
|
||||
);
|
||||
let b = screen(
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == input)
|
||||
.unwrap()
|
||||
.position,
|
||||
1.,
|
||||
);
|
||||
let mut state = State::default();
|
||||
let _ = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved { position: a },
|
||||
a,
|
||||
);
|
||||
let right = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Right),
|
||||
a,
|
||||
)
|
||||
.unwrap();
|
||||
let _ = app.update(right);
|
||||
assert!(app.model_menu.is_some());
|
||||
// The overlay consumes the right release, movement to B, outside press and
|
||||
// its release. None of those events reach the underlying canvas.
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Close));
|
||||
let press = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
b,
|
||||
)
|
||||
.unwrap();
|
||||
let release = event(
|
||||
&canvas(&app, 1.),
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
b,
|
||||
);
|
||||
let picked = match &press {
|
||||
Message::Select(id) => Some(*id),
|
||||
_ => None,
|
||||
};
|
||||
let release_debug = format!("{release:?}");
|
||||
let _ = app.update(press);
|
||||
if let Some(message) = release {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(
|
||||
picked,
|
||||
Some(input),
|
||||
"Click at B picked another port; following release: {release_debug}"
|
||||
);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"A stationary click after closing a menu must not edit port geometry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drag_from_already_selected_stationary_port_keeps_source_between_batches() {
|
||||
let (mut app, output, _) = fixture();
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let port = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == port.owner).unwrap();
|
||||
let a = screen(port.position, 1.);
|
||||
let b = screen(
|
||||
projection::port_anchor_position(owner, PortAnchorSide::Top, 0.35),
|
||||
1.,
|
||||
);
|
||||
let mut state = State::default();
|
||||
let selected = batch(&app, &mut state, a, a, 1.);
|
||||
for message in selected {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(app.selected, output);
|
||||
// No new motion to A occurs: the previous selection ended there. Winit
|
||||
// batches press(A), motion(B), release(B) with cursor B on every event.
|
||||
let c = canvas(&app, 1.);
|
||||
let messages: Vec<_> = [
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: b },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
]
|
||||
.into_iter()
|
||||
.filter_map(|e| event(&c, &mut state, e, b))
|
||||
.collect();
|
||||
assert!(
|
||||
matches!(messages.first(),Some(Message::Select(id)) if *id==output),
|
||||
"Stationary source was lost between batches: {messages:?}"
|
||||
);
|
||||
assert!(
|
||||
matches!(messages.last(),Some(Message::PortAnchor(port_edit::Event::Commit(_,_,anchor))) if anchor.port==output)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modal_and_stale_epoch_block_queued_connection_and_port_commits() {
|
||||
for is_connection in [false, true] {
|
||||
for barrier in ["menu", "modal", "epoch"] {
|
||||
let (mut app, output, input) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
if is_connection {
|
||||
let _ = app.update(Message::Connection(connection_edit::Event::Choose(Some(
|
||||
Tool::FunctionalExchange,
|
||||
))));
|
||||
}
|
||||
let view = projection::project_view(app.project(), app.active_diagram.unwrap());
|
||||
let out = view.ports.iter().find(|p| p.element == output).unwrap();
|
||||
let target = if is_connection {
|
||||
view.ports
|
||||
.iter()
|
||||
.find(|p| p.element == input)
|
||||
.unwrap()
|
||||
.position
|
||||
} else {
|
||||
projection::port_anchor_position(
|
||||
view.nodes.iter().find(|n| n.id == out.owner).unwrap(),
|
||||
PortAnchorSide::Top,
|
||||
0.35,
|
||||
)
|
||||
};
|
||||
let messages = batch(
|
||||
&app,
|
||||
&mut State::default(),
|
||||
screen(out.position, 1.),
|
||||
screen(target, 1.),
|
||||
1.,
|
||||
);
|
||||
match barrier {
|
||||
"menu" => {
|
||||
let _ = app.update(model_menu::open(output, Point::ORIGIN));
|
||||
}
|
||||
"modal" => {
|
||||
let _ = app.update(Message::AddRelation);
|
||||
}
|
||||
_ => {
|
||||
let _ = app.update(Message::Escape);
|
||||
}
|
||||
}
|
||||
for message in messages {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&before,
|
||||
"{barrier}; connection={is_connection}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
use super::*;
|
||||
|
||||
fn canvas(project: &Project, diagram: Id, zoom: f32) -> DiagramCanvas<'_> {
|
||||
DiagramCanvas {
|
||||
project,
|
||||
diagram,
|
||||
selected: Id::nil(),
|
||||
zoom,
|
||||
pan: Vector::new(17., 23.),
|
||||
context: Context {
|
||||
document: project.id,
|
||||
diagram,
|
||||
revision: 0,
|
||||
epoch: 0,
|
||||
},
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn local(canvas: &DiagramCanvas<'_>, position: Position) -> Point {
|
||||
Point::new(
|
||||
canvas.pan.x + position.x * canvas.zoom,
|
||||
canvas.pan.y + position.y * canvas.zoom,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_port_center_wins_on_all_borders_and_zoom_levels() {
|
||||
let project = Project::empty();
|
||||
let diagram = Id::new_v4();
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let horizontal = matches!(side, PortAnchorSide::Top | PortAnchorSide::Bottom);
|
||||
let mut view = ProjectedView {
|
||||
ports: (0..2)
|
||||
.map(|i| projection::ProjectedPort {
|
||||
element: Id::from_u128(i + 10),
|
||||
owner: diagram,
|
||||
position: Position {
|
||||
x: 100. + if horizontal { i as f32 * 28. } else { 0. },
|
||||
y: 100. + if horizontal { 0. } else { i as f32 * 28. },
|
||||
},
|
||||
side,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
for zoom in [0.0001, 0.35, 1., 3.] {
|
||||
let canvas = canvas(&project, diagram, zoom);
|
||||
for _ in 0..2 {
|
||||
for port in &view.ports {
|
||||
assert_eq!(
|
||||
canvas.endpoint_at(&view, local(&canvas, port.position)),
|
||||
Some(port.element),
|
||||
"Exact port center must win at zoom {zoom} on {side:?}"
|
||||
);
|
||||
}
|
||||
view.ports.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coincident_ports_keep_selection_then_use_stable_identity() {
|
||||
let project = Project::empty();
|
||||
let diagram = Id::new_v4();
|
||||
let a = Id::from_u128(10);
|
||||
let b = Id::from_u128(20);
|
||||
let mut view = ProjectedView {
|
||||
ports: [b, a]
|
||||
.into_iter()
|
||||
.map(|element| projection::ProjectedPort {
|
||||
element,
|
||||
owner: diagram,
|
||||
position: Position { x: 100., y: 100. },
|
||||
side: PortAnchorSide::Right,
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let mut canvas = canvas(&project, diagram, 0.35);
|
||||
let at = local(&canvas, view.ports[0].position);
|
||||
for _ in 0..2 {
|
||||
canvas.selected = Id::nil();
|
||||
assert_eq!(canvas.endpoint_at(&view, at), Some(a));
|
||||
canvas.selected = b;
|
||||
assert_eq!(canvas.endpoint_at(&view, at), Some(b));
|
||||
view.ports.reverse();
|
||||
}
|
||||
assert!(canvas.hit_port(&view, Point::new(-100., -100.)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_owner_palette_selection_drag_and_context_menu_pick_the_same_port() {
|
||||
let mut project = Project::empty();
|
||||
let group = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Response", None)
|
||||
.unwrap();
|
||||
let owner = project
|
||||
.add_element(Kind::Function, Layer::Sa, "Send report", Some(group))
|
||||
.unwrap();
|
||||
let ports = [Id::from_u128(10), Id::from_u128(20)];
|
||||
for (i, id) in ports.into_iter().enumerate() {
|
||||
let port = make_port(
|
||||
&project,
|
||||
id,
|
||||
owner,
|
||||
&format!("Output {i}"),
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
project.elements.insert(id, port);
|
||||
}
|
||||
let diagram = project.ensure_diagram(Layer::Sa);
|
||||
project.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::FunctionalDataflow;
|
||||
project.place(diagram, group, Position { x: 60., y: 60. });
|
||||
let node = project.place(diagram, owner, Position { x: 100., y: 140. });
|
||||
project
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.port_anchors = ports
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(i, port)| PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Right,
|
||||
offset: 0.4 + i as f32 * 0.1,
|
||||
})
|
||||
.collect();
|
||||
validate_candidate(&project).unwrap();
|
||||
let canvas = canvas(&project, diagram, 0.35);
|
||||
let view = project_view(&project, diagram);
|
||||
let bounds = Rectangle::new(Point::new(200., 80.), Size::new(800., 600.));
|
||||
for port in &view.ports {
|
||||
let at = local(&canvas, port.position);
|
||||
assert_eq!(canvas.endpoint_at(&view, at), Some(port.element));
|
||||
let mut state = State::default();
|
||||
let cursor = mouse::Cursor::Available(Point::new(bounds.x + at.x, bounds.y + at.y));
|
||||
let press = canvas::Program::update(
|
||||
&canvas,
|
||||
&mut state,
|
||||
&canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
bounds,
|
||||
cursor,
|
||||
)
|
||||
.unwrap()
|
||||
.into_inner()
|
||||
.0;
|
||||
assert!(matches!(press, Some(Message::Select(id)) if id == port.element));
|
||||
assert_eq!(state.port_drag.as_ref().unwrap().anchor.port, port.element);
|
||||
let menu = canvas.context_request(&state, at, bounds);
|
||||
assert_eq!(menu.object, port.element);
|
||||
}
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
//! Passive renderer layer for port identity feedback. Iced batches every text
|
||||
//! primitive after meshes within one GPU layer, even across Canvas frames.
|
||||
//! A second stacked canvas places the complete tooltip above diagram text.
|
||||
use super::*;
|
||||
|
||||
pub struct PortLabels<'a>(pub DiagramCanvas<'a>);
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
view: RefCell<Option<(Context, ProjectedView)>>,
|
||||
context: Option<Context>,
|
||||
held_buttons: u8,
|
||||
}
|
||||
|
||||
impl canvas::Program<Message> for PortLabels<'_> {
|
||||
type State = State;
|
||||
|
||||
fn update(
|
||||
&self,
|
||||
state: &mut State,
|
||||
event: &canvas::Event,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Option<Action<Message>> {
|
||||
if state.context != Some(self.0.context) {
|
||||
state.context = Some(self.0.context);
|
||||
state.held_buttons = 0;
|
||||
}
|
||||
let button_mask = |button| match button {
|
||||
mouse::Button::Left => 1,
|
||||
mouse::Button::Middle => 2,
|
||||
_ => 0,
|
||||
};
|
||||
match event {
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(button)) if cursor.is_over(bounds) => {
|
||||
state.held_buttons |= button_mask(*button)
|
||||
}
|
||||
canvas::Event::Mouse(mouse::Event::ButtonReleased(button)) => {
|
||||
state.held_buttons &= !button_mask(*button)
|
||||
}
|
||||
canvas::Event::Window(iced::window::Event::Unfocused) => state.held_buttons = 0,
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed { key, .. })
|
||||
if *key == iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape) =>
|
||||
{
|
||||
state.held_buttons = 0
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Never capture input: every event still reaches the interactive canvas.
|
||||
matches!(event, canvas::Event::Mouse(_)).then(Action::request_redraw)
|
||||
}
|
||||
|
||||
fn draw(
|
||||
&self,
|
||||
state: &State,
|
||||
renderer: &Renderer,
|
||||
_: &Theme,
|
||||
bounds: Rectangle,
|
||||
cursor: mouse::Cursor,
|
||||
) -> Vec<canvas::Geometry> {
|
||||
if state.context == Some(self.0.context) && state.held_buttons != 0 {
|
||||
return vec![];
|
||||
}
|
||||
let canvas = &self.0;
|
||||
let mut cached = state.view.borrow_mut();
|
||||
if cached
|
||||
.as_ref()
|
||||
.is_none_or(|(context, _)| *context != canvas.context)
|
||||
{
|
||||
*cached = Some((canvas.context, project_view(canvas.project, canvas.diagram)));
|
||||
}
|
||||
let view = &cached.as_ref().unwrap().1;
|
||||
let Some(port) = cursor
|
||||
.position_in(bounds)
|
||||
.and_then(|p| canvas.hit_port(view, p))
|
||||
.or_else(|| view.ports.iter().find(|p| p.element == canvas.selected))
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
let center = point(port.position);
|
||||
let mut port_label = Frame::new(renderer, bounds.size());
|
||||
|
||||
let name = short(canvas.project.named(port.element), 40);
|
||||
let owner = canvas.project.elements[&port.element]
|
||||
.parent
|
||||
.map(|id| short(canvas.project.named(id), 40))
|
||||
.unwrap_or_default();
|
||||
let alias = &canvas.project.elements[&port.element].alias;
|
||||
let alias = if alias.chars().count() > 48 {
|
||||
format!(
|
||||
"@{}…{}",
|
||||
alias.chars().take(22).collect::<String>(),
|
||||
alias
|
||||
.chars()
|
||||
.skip(alias.chars().count() - 24)
|
||||
.collect::<String>()
|
||||
)
|
||||
} else {
|
||||
format!("@{alias}")
|
||||
};
|
||||
let width = ((name
|
||||
.chars()
|
||||
.count()
|
||||
.max(owner.chars().count())
|
||||
.max(alias.chars().count()) as f32
|
||||
* 7.)
|
||||
+ 16.)
|
||||
.min((bounds.width - 8.).max(0.));
|
||||
let height = 60.;
|
||||
let anchor = Point::new(
|
||||
center.x * canvas.zoom + canvas.pan.x + 12.,
|
||||
center.y * canvas.zoom + canvas.pan.y + 12.,
|
||||
);
|
||||
let origin = Point::new(
|
||||
anchor.x.clamp(4., (bounds.width - width - 4.).max(4.)),
|
||||
anchor.y.clamp(4., (bounds.height - height - 4.).max(4.)),
|
||||
);
|
||||
let background = Path::rounded_rectangle(origin, Size::new(width, height), 4.into());
|
||||
port_label.fill(&background, Color::WHITE);
|
||||
port_label.stroke(&background, Stroke::default().with_color(style::LINE));
|
||||
port_label.fill_text(Text {
|
||||
content: name,
|
||||
position: origin + Vector::new(8., 4.),
|
||||
color: style::TEAL,
|
||||
size: 12.into(),
|
||||
..Default::default()
|
||||
});
|
||||
port_label.fill_text(Text {
|
||||
content: owner,
|
||||
position: origin + Vector::new(8., 22.),
|
||||
color: style::MUTED,
|
||||
size: 11.into(),
|
||||
..Default::default()
|
||||
});
|
||||
port_label.fill_text(Text {
|
||||
content: alias,
|
||||
position: origin + Vector::new(8., 40.),
|
||||
color: style::MUTED,
|
||||
size: 11.into(),
|
||||
..Default::default()
|
||||
});
|
||||
vec![port_label.into_geometry()]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{app::App, model_menu};
|
||||
use canvas::Program;
|
||||
|
||||
fn fixture() -> (App, Id) {
|
||||
let mut project = Project::empty();
|
||||
let owner = project.add(Kind::Function, "Send report", 100., 150.);
|
||||
let port = Id::new_v4();
|
||||
project.elements.insert(
|
||||
port,
|
||||
make_port(
|
||||
&project,
|
||||
port,
|
||||
owner,
|
||||
"Report output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
(App::with_project(project), port)
|
||||
}
|
||||
|
||||
fn program(app: &App) -> PortLabels<'_> {
|
||||
PortLabels(DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: app.active_diagram.unwrap(),
|
||||
selected: app.selected,
|
||||
zoom: 1.,
|
||||
pan: Vector::ZERO,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: None,
|
||||
editable: true,
|
||||
connection: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn bounds() -> Rectangle {
|
||||
Rectangle::new(Point::new(120., 80.), Size::new(900., 600.))
|
||||
}
|
||||
|
||||
// Stack visits this canvas first. Every event must remain available to the
|
||||
// interactive child, and this layer must never levitate its cursor.
|
||||
fn passive(labels: &PortLabels<'_>, state: &mut State, event: canvas::Event, at: Point) {
|
||||
let cursor = mouse::Cursor::Available(at);
|
||||
if let Some(action) = labels.update(state, &event, bounds(), cursor) {
|
||||
let (message, _, status) = action.into_inner();
|
||||
assert!(message.is_none());
|
||||
assert_eq!(status, iced::event::Status::Ignored);
|
||||
}
|
||||
assert_eq!(
|
||||
labels.mouse_interaction(state, bounds(), cursor),
|
||||
mouse::Interaction::None
|
||||
);
|
||||
}
|
||||
|
||||
fn mouse(labels: &PortLabels<'_>, state: &mut State, event: mouse::Event) {
|
||||
passive(
|
||||
labels,
|
||||
state,
|
||||
canvas::Event::Mouse(event),
|
||||
bounds().center(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaving_and_reentering_keeps_suppression_until_each_held_button_releases() {
|
||||
let (app, _) = fixture();
|
||||
let labels = program(&app);
|
||||
let mut state = State::default();
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
);
|
||||
mouse(&labels, &mut state, mouse::Event::CursorLeft);
|
||||
mouse(&labels, &mut state, mouse::Event::CursorEntered);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::CursorMoved {
|
||||
position: bounds().center(),
|
||||
},
|
||||
);
|
||||
assert_ne!(
|
||||
state.held_buttons, 0,
|
||||
"Preview geometry remains active across reentry"
|
||||
);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Right),
|
||||
);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
);
|
||||
assert_ne!(state.held_buttons, 0, "Middle-button pan is still held");
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonReleased(mouse::Button::Middle),
|
||||
);
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_epoch_clears_suppression_when_the_overlay_consumes_release() {
|
||||
let (mut app, port) = fixture();
|
||||
let before = app.document.bundle().clone();
|
||||
let mut state = State::default();
|
||||
mouse(
|
||||
&program(&app),
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
);
|
||||
let old_context = state.context;
|
||||
let _ = app.update(model_menu::open(port, bounds().center()));
|
||||
assert!(app.model_menu.is_some());
|
||||
assert_ne!(old_context, app.route_context());
|
||||
// Left release and dismissal input are captured by the root popup.
|
||||
let _ = app.update(Message::Menu(model_menu::Event::Close));
|
||||
passive(
|
||||
&program(&app),
|
||||
&mut state,
|
||||
canvas::Event::Window(iced::window::Event::RedrawRequested(
|
||||
std::time::Instant::now(),
|
||||
)),
|
||||
bounds().center(),
|
||||
);
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
assert_eq!(state.context, app.route_context());
|
||||
assert_eq!(app.document.bundle(), &before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_presses_do_not_suppress_and_cancel_or_focus_loss_clear_held_buttons() {
|
||||
let (app, _) = fixture();
|
||||
let labels = program(&app);
|
||||
let mut state = State::default();
|
||||
passive(
|
||||
&labels,
|
||||
&mut state,
|
||||
canvas::Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)),
|
||||
Point::ORIGIN,
|
||||
);
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
for event in [
|
||||
canvas::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||
key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
modified_key: iced::keyboard::Key::Named(iced::keyboard::key::Named::Escape),
|
||||
physical_key: iced::keyboard::key::Physical::Code(
|
||||
iced::keyboard::key::Code::Escape,
|
||||
),
|
||||
location: iced::keyboard::Location::Standard,
|
||||
modifiers: iced::keyboard::Modifiers::empty(),
|
||||
text: None,
|
||||
repeat: false,
|
||||
}),
|
||||
canvas::Event::Window(iced::window::Event::Unfocused),
|
||||
] {
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
);
|
||||
mouse(
|
||||
&labels,
|
||||
&mut state,
|
||||
mouse::Event::ButtonPressed(mouse::Button::Middle),
|
||||
);
|
||||
passive(&labels, &mut state, event, bounds().center());
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passive_layer_leaves_port_drag_selection_and_commit_with_the_interactive_canvas() {
|
||||
let (app, port) = fixture();
|
||||
let labels = program(&app);
|
||||
let view = project_view(app.project(), labels.0.diagram);
|
||||
let projected = view.ports.iter().find(|p| p.element == port).unwrap();
|
||||
let owner = view.nodes.iter().find(|n| n.id == projected.owner).unwrap();
|
||||
let screen = |p: Position| point(p) + Vector::new(bounds().x, bounds().y);
|
||||
let start = screen(projected.position);
|
||||
let end = screen(projection::port_anchor_position(
|
||||
owner,
|
||||
PortAnchorSide::Top,
|
||||
0.3,
|
||||
));
|
||||
let mut state = State::default();
|
||||
let mut interactive = super::super::State::default();
|
||||
let mut messages = Vec::new();
|
||||
for event in [
|
||||
mouse::Event::CursorMoved { position: start },
|
||||
mouse::Event::ButtonPressed(mouse::Button::Left),
|
||||
mouse::Event::CursorMoved { position: end },
|
||||
mouse::Event::ButtonReleased(mouse::Button::Left),
|
||||
] {
|
||||
let event = canvas::Event::Mouse(event);
|
||||
passive(&labels, &mut state, event.clone(), end);
|
||||
if let Some(action) = labels.0.update(
|
||||
&mut interactive,
|
||||
&event,
|
||||
bounds(),
|
||||
mouse::Cursor::Available(end),
|
||||
) {
|
||||
let (message, _, _) = action.into_inner();
|
||||
messages.extend(message);
|
||||
}
|
||||
}
|
||||
assert!(matches!(messages.first(), Some(Message::Select(id)) if *id == port));
|
||||
assert!(
|
||||
matches!(messages.last(), Some(Message::PortAnchor(crate::port_edit::Event::Commit(_, _, anchor))) if anchor.port == port && anchor.side == PortAnchorSide::Top)
|
||||
);
|
||||
assert_eq!(messages.len(), 2);
|
||||
assert_eq!(state.held_buttons, 0);
|
||||
}
|
||||
}
|
||||
@@ -136,10 +136,7 @@ pub fn sidebar(app: &App) -> View<'_, Message> {
|
||||
.style(style::card),
|
||||
tooltip::Position::Right,
|
||||
));
|
||||
let id = entry.id;
|
||||
content = content.push(crate::context_menu::Area::new(outline, move |anchor| {
|
||||
crate::model_menu::open(id, anchor)
|
||||
}));
|
||||
content = content.push(outline);
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
@@ -205,30 +202,27 @@ fn item<'a>(app: &'a App, id: Id, context: bool) -> View<'a, Message> {
|
||||
info = info.push(label(path));
|
||||
}
|
||||
}
|
||||
crate::context_menu::Area::new(
|
||||
button(
|
||||
row![
|
||||
info.width(Fill),
|
||||
label(if count == 0 {
|
||||
"Details".into()
|
||||
} else {
|
||||
format!("{count} children")
|
||||
}),
|
||||
text("›").size(20)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(12)
|
||||
.on_press(Message::Explore(Some(id)))
|
||||
.style(if app.selected == id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
}),
|
||||
move |anchor| crate::model_menu::open(id, anchor),
|
||||
button(
|
||||
row![
|
||||
info.width(Fill),
|
||||
label(if count == 0 {
|
||||
"Details".into()
|
||||
} else {
|
||||
format!("{count} children")
|
||||
}),
|
||||
text("›").size(20)
|
||||
]
|
||||
.spacing(8)
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.width(Fill)
|
||||
.padding(12)
|
||||
.on_press(Message::Explore(Some(id)))
|
||||
.style(if app.selected == id {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
|
||||
+24
-359
@@ -14,7 +14,6 @@ use iced::{
|
||||
use oa_core::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
mod data_fields;
|
||||
mod port_fields;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -32,12 +31,6 @@ pub struct Input {
|
||||
pub raw: String,
|
||||
pub editor: text_editor::Content,
|
||||
}
|
||||
#[derive(Clone, Default)]
|
||||
pub struct ValueBrowser {
|
||||
pub scope: Option<crate::data_values::FieldPathKey>,
|
||||
pub page: usize,
|
||||
}
|
||||
|
||||
pub struct Draft {
|
||||
pub kind: RecordKind,
|
||||
pub original: Id,
|
||||
@@ -46,7 +39,6 @@ pub struct Draft {
|
||||
pub inputs: BTreeMap<String, Input>,
|
||||
pub exact_strings: bool,
|
||||
port_contract_drafts: BTreeMap<String, Value>,
|
||||
pub value_browsers: BTreeMap<String, ValueBrowser>,
|
||||
}
|
||||
|
||||
impl Draft {
|
||||
@@ -72,7 +64,6 @@ impl Draft {
|
||||
.values()
|
||||
.find_map(|d| d.nodes.get(&selected).map(|n| (d.id, n)))?;
|
||||
let mut value = json!(node);
|
||||
value["port_anchors"] = json!(node.port_anchors);
|
||||
value["diagram"] = json!(diagram);
|
||||
(RecordKind::Node(diagram), value)
|
||||
};
|
||||
@@ -84,14 +75,11 @@ impl Draft {
|
||||
inputs: BTreeMap::new(),
|
||||
exact_strings: false,
|
||||
port_contract_drafts: BTreeMap::new(),
|
||||
value_browsers: BTreeMap::new(),
|
||||
};
|
||||
draft.rebuild();
|
||||
Some(draft)
|
||||
}
|
||||
fn rebuild(&mut self) {
|
||||
// Array/variant edits can change the meaning of a row path.
|
||||
self.value_browsers.clear();
|
||||
self.inputs.clear();
|
||||
fn visit(value: &Value, path: &str, out: &mut BTreeMap<String, Input>) {
|
||||
match value {
|
||||
@@ -229,13 +217,11 @@ impl Draft {
|
||||
} else if path.ends_with("bindings") {
|
||||
json!({"referenced": Id::nil(), "local": Id::nil()})
|
||||
} else if path.ends_with("values") {
|
||||
json!({"path": [], "field": Id::nil(), "expression": ""})
|
||||
json!({"field": Id::nil(), "expression": ""})
|
||||
} else if path.ends_with("routes") {
|
||||
json!({"edge": Id::nil(), "waypoints": [], "label": null})
|
||||
} else if path.ends_with("waypoints") {
|
||||
json!({"x": 0.0, "y": 0.0})
|
||||
} else if path.ends_with("port_anchors") {
|
||||
json!({"port": Id::nil(), "side": "Right", "offset": 0.5})
|
||||
} else {
|
||||
json!(Id::nil())
|
||||
}),
|
||||
@@ -253,33 +239,6 @@ impl Draft {
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn append(&mut self, path: &str, value: Value) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
candidate
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or("List no longer exists.")?
|
||||
.push(value);
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn value_path(
|
||||
&mut self,
|
||||
path: &str,
|
||||
key: crate::data_values::FieldPathKey,
|
||||
) -> Result<(), String> {
|
||||
let mut candidate = self.materialize()?;
|
||||
let row = candidate
|
||||
.pointer_mut(path)
|
||||
.and_then(Value::as_object_mut)
|
||||
.ok_or("Message value no longer exists.")?;
|
||||
row.insert("path".into(), json!(key.path));
|
||||
row.insert("field".into(), json!(key.field));
|
||||
self.value = candidate;
|
||||
self.rebuild();
|
||||
Ok(())
|
||||
}
|
||||
pub fn command(&self) -> Result<Command, String> {
|
||||
let mut value = self.materialize()?;
|
||||
let decode = |e: serde_json::Error| e.to_string();
|
||||
@@ -375,8 +334,6 @@ fn friendly(name: &str) -> String {
|
||||
"Named" => "Data type".into(),
|
||||
"body" => "Occurrence type".into(),
|
||||
"diagram" => "Diagram".into(),
|
||||
"StateMachine" => "State machine".into(),
|
||||
"machine" => "State machine context".into(),
|
||||
"uri" => "Artifact URI".into(),
|
||||
"definition" => "Data type definition".into(),
|
||||
"steps" | "occurrences" => "Ordered occurrences".into(),
|
||||
@@ -430,7 +387,6 @@ fn reference(path: &str) -> bool {
|
||||
| "represented"
|
||||
| "exchange"
|
||||
| "edge"
|
||||
| "port"
|
||||
| "sender"
|
||||
| "receiver"
|
||||
| "action"
|
||||
@@ -448,8 +404,6 @@ fn reference(path: &str) -> bool {
|
||||
| "sa"
|
||||
| "Named"
|
||||
| "diagram"
|
||||
| "machine"
|
||||
| "supertype"
|
||||
) || matches!(
|
||||
parent,
|
||||
"items"
|
||||
@@ -479,7 +433,6 @@ fn optional_reference(path: &str) -> bool {
|
||||
| "reply_to"
|
||||
| "lifeline"
|
||||
| "state"
|
||||
| "supertype"
|
||||
)
|
||||
}
|
||||
fn multiline(path: &str) -> bool {
|
||||
@@ -502,11 +455,9 @@ fn free_string(path: &str) -> bool {
|
||||
multiline(path)
|
||||
|| matches!(
|
||||
path.rsplit('/').next(),
|
||||
Some("name" | "uri" | "revision" | "fragment" | "unit" | "source_role" | "target_role")
|
||||
Some("name" | "uri" | "revision" | "fragment" | "unit")
|
||||
)
|
||||
|| path.split('/').rev().nth(1) == Some("literals")
|
||||
|| (path.contains("/default/")
|
||||
&& matches!(path.rsplit('/').next(), Some("String" | "Enumeration")))
|
||||
}
|
||||
|
||||
// These are choices for the closed model, not a second validation boundary.
|
||||
@@ -565,17 +516,6 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
|
||||
|
||||
fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
let value = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
if path.starts_with("/port_anchors/") && path.ends_with("/port") {
|
||||
let owner = value
|
||||
.get("element")
|
||||
.and_then(|id| serde_json::from_value::<Id>(id.clone()).ok());
|
||||
return project
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.kind() == Kind::Port && e.parent == owner)
|
||||
.map(|e| e.id)
|
||||
.collect();
|
||||
}
|
||||
let element = (draft.kind == RecordKind::Element)
|
||||
.then(|| serde_json::from_value::<Element>(value.clone()).ok())
|
||||
.flatten()
|
||||
@@ -587,12 +527,7 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
let layer = element
|
||||
.as_ref()
|
||||
.map(Element::layer)
|
||||
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())))
|
||||
.or_else(|| {
|
||||
(draft.kind == RecordKind::Diagram)
|
||||
.then(|| serde_json::from_value::<Layer>(value["layer"].clone()).ok())
|
||||
.flatten()
|
||||
});
|
||||
.or_else(|| relation.as_ref().and_then(|r| project.layer_of(r.source())));
|
||||
let field = path.rsplit('/').next().unwrap_or_default();
|
||||
if field == "edge" {
|
||||
return project
|
||||
@@ -617,8 +552,6 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
};
|
||||
|
||||
if field == "diagram" {
|
||||
// Keep destinations selectable before retargeting the element. The
|
||||
// element picker below uses the draft destination's membership rules.
|
||||
return project.diagrams.keys().copied().collect();
|
||||
}
|
||||
if field == "owner" || list == "constrained" {
|
||||
@@ -704,42 +637,21 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
.collect();
|
||||
}
|
||||
if field == "field" {
|
||||
let address_path = path
|
||||
.strip_suffix("/field")
|
||||
.and_then(|row| value.pointer(&format!("{row}/path")))
|
||||
.and_then(|v| serde_json::from_value::<Vec<Id>>(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
return element
|
||||
.as_ref()
|
||||
.and_then(|e| match e.shared()? {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(id),
|
||||
message_kind,
|
||||
..
|
||||
exchange: Some(id), ..
|
||||
},
|
||||
..
|
||||
} => project.relations.get(id).and_then(|r| {
|
||||
let scope = address_path.split_last().map(|(&field, path)| {
|
||||
crate::data_values::FieldPathKey {
|
||||
path: path.to_vec(),
|
||||
field,
|
||||
}
|
||||
});
|
||||
crate::data_values::at_scope(project, r, message_kind, scope.as_ref()).ok()
|
||||
}),
|
||||
} => project.relations.get(id),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.filter(|choice| choice.key.path == address_path)
|
||||
.map(|choice| choice.key.field)
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
.map(|r| validation::carried_fields(project, r).into_iter().collect())
|
||||
.unwrap_or_default();
|
||||
}
|
||||
|
||||
let scenario = element
|
||||
.as_ref()
|
||||
.and_then(|e| e.parent)
|
||||
@@ -761,50 +673,18 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| id.parse().ok())
|
||||
.unwrap_or(diagram);
|
||||
return project
|
||||
.diagrams
|
||||
.get(&diagram)
|
||||
.is_some_and(|d| placement_allowed(project, d, candidate, draft.original));
|
||||
return project.diagrams.get(&diagram).is_some_and(|d| {
|
||||
(d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
|
||||
&& !d
|
||||
.nodes
|
||||
.values()
|
||||
.any(|n| n.id != draft.original && n.element == candidate.id)
|
||||
});
|
||||
}
|
||||
if !same_layer(candidate) {
|
||||
return false;
|
||||
}
|
||||
match field {
|
||||
"supertype" => element.as_ref().is_some_and(|owner| {
|
||||
if candidate.id == owner.id
|
||||
|| candidate.kind() != owner.kind()
|
||||
|| (candidate.kind() == Kind::DataType
|
||||
&& !data::is_record(project, candidate.id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if let (
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind, mechanism, ..
|
||||
}),
|
||||
Some(SharedBody::ExchangeItem {
|
||||
kind: base_kind,
|
||||
mechanism: base_mechanism,
|
||||
..
|
||||
}),
|
||||
) = (owner.shared(), candidate.shared())
|
||||
&& (kind != base_kind
|
||||
|| (*base_mechanism != ExchangeMechanism::Unspecified
|
||||
&& mechanism != base_mechanism))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut next = Some(candidate.id);
|
||||
let mut seen = BTreeSet::new();
|
||||
while let Some(id) = next {
|
||||
if id == owner.id || !seen.insert(id) {
|
||||
return false;
|
||||
}
|
||||
next = project.elements.get(&id).and_then(data::supertype);
|
||||
}
|
||||
true
|
||||
}),
|
||||
"machine" => candidate.kind() == Kind::StateMachine,
|
||||
"parent" => element.as_ref().is_some_and(|child| {
|
||||
if candidate.id == draft.original
|
||||
|| candidate.id == child.id
|
||||
@@ -1107,18 +987,6 @@ 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
|
||||
.state_machine()
|
||||
.is_none_or(|machine| projection::state_machine_contains(project, machine, element.id))
|
||||
&& !diagram
|
||||
.nodes
|
||||
.values()
|
||||
.any(|node| node.id != current && node.element == element.id)
|
||||
}
|
||||
|
||||
fn unit_body(path: &str, value: &Value) -> bool {
|
||||
value.is_string()
|
||||
&& matches!(
|
||||
@@ -1130,45 +998,16 @@ fn unit_body(path: &str, value: &Value) -> bool {
|
||||
/// Closed choices for the concrete model enums. No runtime schema/plugin layer.
|
||||
fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)> {
|
||||
let field = path.rsplit('/').next().unwrap_or_default();
|
||||
if path == "/projection" && kind == RecordKind::Diagram {
|
||||
let mut choices = [
|
||||
let words: &[&str] = match field {
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"projection" => &[
|
||||
"Overview",
|
||||
"Decomposition",
|
||||
"Allocation",
|
||||
"Traceability",
|
||||
"FunctionalDataflow",
|
||||
"Capabilities",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| (friendly(name), json!(name)))
|
||||
.collect::<Vec<_>>();
|
||||
choices.push((
|
||||
"State machine".into(),
|
||||
if value.get("StateMachine").is_some() {
|
||||
value.clone()
|
||||
} else {
|
||||
json!({"StateMachine": {"machine": Id::nil()}})
|
||||
},
|
||||
));
|
||||
return choices;
|
||||
}
|
||||
let words: &[&str] = match field {
|
||||
"side" if path.starts_with("/port_anchors/") => &["Left", "Right", "Top", "Bottom"],
|
||||
"layer" => &["Oa", "Sa"],
|
||||
"mechanism" => &["Unspecified", "Flow", "Event", "Operation", "SharedData"],
|
||||
"storage" => &["Value", "Reference"],
|
||||
"direction" if path.contains("DataField") => {
|
||||
&["Unspecified", "In", "Out", "InOut", "Return"]
|
||||
}
|
||||
"transmission" => &[
|
||||
"Unspecified",
|
||||
"Synchronous",
|
||||
"Asynchronous",
|
||||
"Unicast",
|
||||
"Multicast",
|
||||
"Broadcast",
|
||||
],
|
||||
"acquisition" => &["Unspecified", "Read", "Accept"],
|
||||
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
|
||||
"viewpoint" if path.contains("/Oa/") => &[
|
||||
"OperationalActivity",
|
||||
@@ -1177,7 +1016,7 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
],
|
||||
"viewpoint" => &["Functional", "BoundaryInterface", "MixedLegacy"],
|
||||
"phase" => &["Entry", "Do", "Exit"],
|
||||
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy"],
|
||||
"kind" if path.contains("ExchangeItem") => &["Information", "Material", "Energy", "Event"],
|
||||
"kind" if path.contains("Control") => &["Choice", "Merge", "Fork", "Join", "Loop"],
|
||||
"kind" if path.contains("State") => &[
|
||||
"State",
|
||||
@@ -1243,20 +1082,11 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
|
||||
if field == "definition" {
|
||||
return vec![
|
||||
("Boolean".into(), json!("Boolean")),
|
||||
(
|
||||
"Integer".into(),
|
||||
json!({"Integer":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
(
|
||||
"Real".into(),
|
||||
json!({"Real":{"minimum":null,"maximum":null}}),
|
||||
),
|
||||
(
|
||||
"Text".into(),
|
||||
json!({"String":{"minimum_length":0,"maximum_length":null}}),
|
||||
),
|
||||
("Integer".into(), json!("Integer")),
|
||||
("Real".into(), json!("Real")),
|
||||
("Text".into(), json!("String")),
|
||||
("Enumeration".into(), json!({"Enumeration":{"literals":[]}})),
|
||||
("Record".into(), json!({"Record":{"supertype":null}})),
|
||||
("Record".into(), json!("Record")),
|
||||
];
|
||||
}
|
||||
if field == "type_ref" {
|
||||
@@ -1393,9 +1223,6 @@ fn property<'a>(
|
||||
label: &str,
|
||||
skip_variant: bool,
|
||||
) -> View<'a, Message> {
|
||||
if let Some(view) = data_fields::property(draft, project, value, path) {
|
||||
return view;
|
||||
}
|
||||
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
|
||||
if path.ends_with("/Port/legacy") {
|
||||
let mut content = column![].spacing(4);
|
||||
@@ -1672,162 +1499,6 @@ mod tests {
|
||||
reference_ids(draft, project, path)
|
||||
}
|
||||
|
||||
fn state_diagram_form_fixture() -> Project {
|
||||
language::parse(
|
||||
r#"arcadia 3; project state_forms "State forms" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
oa_region nested "Nested" { parent @ready; }
|
||||
oa_state leaf "Leaf" { parent @nested; }
|
||||
oa_state_machine other "Other modes" { parent @crew; }
|
||||
oa_region other_region "Other region" { parent @other; }
|
||||
oa_state foreign "Foreign" { parent @other_region; }
|
||||
sa_system system "System" {}
|
||||
sa_state_machine sa_modes "System modes" { parent @system; }
|
||||
diagram overview "Overview" {}
|
||||
diagram states "States" {
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
diagram other_states "Other states" {
|
||||
projection { StateMachine { machine @other; }; };
|
||||
}
|
||||
diagram duplicate "Duplicate" {
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
node ready_view { diagram @states; element @ready; }
|
||||
node region_view { diagram @states; element @main; }
|
||||
node duplicate_view { diagram @duplicate; element @ready; }
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn alias_id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, candidate)| *candidate == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_projection_form_keeps_string_variants_and_requires_a_typed_context() {
|
||||
let project = state_diagram_form_fixture();
|
||||
let diagram = alias_id(&project, "overview");
|
||||
let machine = alias_id(&project, "modes");
|
||||
let mut draft = Draft::from_project(&project, diagram).unwrap();
|
||||
let choices = variants("/projection", &draft.value["projection"], draft.kind);
|
||||
assert_eq!(choices.len(), 7);
|
||||
for (_, value) in &choices[..6] {
|
||||
let decoded = serde_json::from_value::<ProjectionKind>(value.clone()).unwrap();
|
||||
assert!(value.is_string());
|
||||
assert_eq!(json!(decoded), *value);
|
||||
}
|
||||
draft.shape("/projection", choices[6].1.clone()).unwrap();
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
let path = "/projection/StateMachine/machine";
|
||||
assert!(reference(path));
|
||||
assert!(!optional_reference(path));
|
||||
assert_eq!(
|
||||
ids(&draft, &project, path)
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>(),
|
||||
BTreeSet::from([machine, alias_id(&project, "other")])
|
||||
);
|
||||
draft.shape(path, json!(machine)).unwrap();
|
||||
let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
|
||||
assert_eq!(
|
||||
accepted.diagrams[&diagram].projection.state_machine(),
|
||||
Some(machine)
|
||||
);
|
||||
assert_eq!(accepted.elements, project.elements);
|
||||
assert!(accepted.diagrams[&diagram].nodes.is_empty());
|
||||
assert_eq!(
|
||||
variants("/projection", &draft.value["projection"], draft.kind)[6].1,
|
||||
json!({"StateMachine": {"machine": machine}})
|
||||
);
|
||||
|
||||
draft.shape("/layer", json!("Sa")).unwrap();
|
||||
assert_eq!(
|
||||
ids(&draft, &project, path),
|
||||
vec![alias_id(&project, "sa_modes")]
|
||||
);
|
||||
assert!(
|
||||
reference_choices(&draft, &project, path, machine)
|
||||
.iter()
|
||||
.any(|choice| {
|
||||
choice.id == machine && choice.label.starts_with("Current choice needs review")
|
||||
})
|
||||
);
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
assert_eq!(
|
||||
draft.materialize().unwrap().pointer(path),
|
||||
Some(&json!(machine))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_placement_form_uses_draft_destination_membership_without_changing_other_views() {
|
||||
let project = state_diagram_form_fixture();
|
||||
let node = alias_id(&project, "ready_view");
|
||||
let mut draft = Draft::from_project(&project, node).unwrap();
|
||||
let offered = ids(&draft, &project, "/element");
|
||||
for alias in ["modes", "ready", "nested", "leaf"] {
|
||||
assert!(offered.contains(&alias_id(&project, alias)), "{alias}");
|
||||
}
|
||||
for alias in [
|
||||
"main",
|
||||
"other",
|
||||
"other_region",
|
||||
"foreign",
|
||||
"crew",
|
||||
"sa_modes",
|
||||
] {
|
||||
assert!(!offered.contains(&alias_id(&project, alias)), "{alias}");
|
||||
}
|
||||
assert_eq!(
|
||||
ids(&draft, &project, "/diagram")
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<_>>(),
|
||||
project.diagrams.keys().copied().collect::<BTreeSet<_>>()
|
||||
);
|
||||
|
||||
// A destination must remain selectable before its compatible element;
|
||||
// the current element is retained visibly until the draft is complete.
|
||||
|
||||
draft
|
||||
.shape("/diagram", json!(alias_id(&project, "other_states")))
|
||||
.unwrap();
|
||||
let ready = alias_id(&project, "ready");
|
||||
assert!(!ids(&draft, &project, "/element").contains(&ready));
|
||||
assert!(
|
||||
reference_choices(&draft, &project, "/element", ready)
|
||||
.iter()
|
||||
.any(|choice| {
|
||||
choice.id == ready && choice.label.starts_with("Current choice needs review")
|
||||
})
|
||||
);
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err());
|
||||
draft
|
||||
.shape("/element", json!(alias_id(&project, "foreign")))
|
||||
.unwrap();
|
||||
let accepted = execute_batch(&project, &[draft.command().unwrap()]).unwrap();
|
||||
assert!(
|
||||
accepted.diagrams[&alias_id(&project, "other_states")]
|
||||
.nodes
|
||||
.contains_key(&node)
|
||||
);
|
||||
|
||||
draft
|
||||
.shape("/diagram", json!(alias_id(&project, "overview")))
|
||||
.unwrap();
|
||||
assert!(ids(&draft, &project, "/element").contains(&alias_id(&project, "crew")));
|
||||
assert!(execute_batch(&project, &[draft.command().unwrap()]).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editing_allocation_offers_other_performers_without_counting_itself_twice() {
|
||||
let mut p = Project::empty();
|
||||
@@ -2198,8 +1869,6 @@ mod tests {
|
||||
Layer::Oa,
|
||||
SharedBody::ExchangeItem {
|
||||
kind: ExchangeItemKind::Energy,
|
||||
mechanism: ExchangeMechanism::Flow,
|
||||
supertype: None,
|
||||
},
|
||||
);
|
||||
let mut draft = Draft::from_project(&p, item).unwrap();
|
||||
@@ -2216,12 +1885,8 @@ mod tests {
|
||||
let interface = p
|
||||
.add_element(Kind::Interface, Layer::Oa, "Power contract", None)
|
||||
.unwrap();
|
||||
p.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
p.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Oa, SharedBody::Interface { items: vec![item] });
|
||||
assert!(execute_batch(&p, &[draft.command().unwrap()]).is_err());
|
||||
assert!(draft.dirty());
|
||||
assert_eq!(p.elements[&item].layer(), Layer::Oa);
|
||||
|
||||
@@ -1,813 +0,0 @@
|
||||
//! Typed data contracts use the same draft and command boundary as other properties.
|
||||
use super::*;
|
||||
use iced::widget::column;
|
||||
|
||||
fn element(draft: &Draft, project: &Project) -> Option<Element> {
|
||||
serde_json::from_value(draft.materialize().unwrap_or_else(|_| draft.value.clone()))
|
||||
.ok()
|
||||
.or_else(|| project.elements.get(&draft.original).cloned())
|
||||
}
|
||||
|
||||
fn definition(project: &Project, reference: &TypeRef) -> Option<DataTypeDefinition> {
|
||||
Some(match reference {
|
||||
TypeRef::Boolean => DataTypeDefinition::Boolean,
|
||||
TypeRef::Integer => DataTypeDefinition::Integer {
|
||||
minimum: None,
|
||||
maximum: None,
|
||||
},
|
||||
TypeRef::Real => DataTypeDefinition::Real {
|
||||
minimum: None,
|
||||
maximum: None,
|
||||
},
|
||||
TypeRef::String => DataTypeDefinition::String {
|
||||
minimum_length: 0,
|
||||
maximum_length: None,
|
||||
},
|
||||
TypeRef::Named(id) => match project.elements.get(id)?.shared()? {
|
||||
SharedBody::DataType { definition, .. } => definition.clone(),
|
||||
_ => return None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn literal(definition: &DataTypeDefinition) -> Option<Value> {
|
||||
Some(match definition {
|
||||
DataTypeDefinition::Boolean => json!({"Boolean": false}),
|
||||
DataTypeDefinition::Integer { minimum, .. } => json!({"Integer": minimum.unwrap_or(0)}),
|
||||
DataTypeDefinition::Real { minimum, .. } => json!({"Real": minimum.unwrap_or(0.)}),
|
||||
DataTypeDefinition::String { .. } => json!({"String": ""}),
|
||||
DataTypeDefinition::Enumeration { literals } => json!({"Enumeration": literals.first()?}),
|
||||
DataTypeDefinition::Record { .. } => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn property<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
value: &'a Value,
|
||||
path: &str,
|
||||
) -> Option<View<'a, Message>> {
|
||||
if path.split('/').rev().nth(1) == Some("values") && value.is_object() {
|
||||
return Some(message_value(draft, project, value, path));
|
||||
}
|
||||
let leaf = path.rsplit('/').next()?;
|
||||
if leaf == "direction" && path.contains("/DataField/") {
|
||||
let operation = element(draft, project)
|
||||
.and_then(|e| e.parent)
|
||||
.and_then(|id| data::item_mechanism(project, id))
|
||||
== Some(ExchangeMechanism::Operation);
|
||||
if !operation {
|
||||
let mut out = column![
|
||||
text("Parameter direction").size(11).color(style::MUTED),
|
||||
text("Unspecified · direction applies to direct Operation parameters.").size(11)
|
||||
]
|
||||
.spacing(5);
|
||||
if value.as_str() != Some("Unspecified") {
|
||||
out = out
|
||||
.push(
|
||||
text(format!(
|
||||
"Current {:?} requires review.",
|
||||
value.as_str().unwrap_or_default()
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.push(
|
||||
button(text("Use Unspecified").size(11))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldShape(path.into(), json!("Unspecified"))),
|
||||
);
|
||||
}
|
||||
return Some(out.into());
|
||||
}
|
||||
}
|
||||
let bounded = (path.contains("/definition/Integer/") || path.contains("/definition/Real/"))
|
||||
&& matches!(leaf, "minimum" | "maximum");
|
||||
let count_max = matches!(leaf, "maximum_length" | "source_maximum" | "target_maximum")
|
||||
|| (leaf == "maximum" && path.contains("/DataField/"));
|
||||
if bounded || count_max {
|
||||
let label = match leaf {
|
||||
"minimum" => "Inclusive lower bound",
|
||||
"maximum" if bounded => "Inclusive upper bound",
|
||||
"maximum_length" => "Maximum Unicode characters",
|
||||
"source_maximum" => "Source maximum count",
|
||||
"target_maximum" => "Target maximum count",
|
||||
_ => "Maximum count",
|
||||
};
|
||||
let mut out = column![text(label).size(11).color(style::MUTED)].spacing(5);
|
||||
if value.is_null() {
|
||||
out = out.push(
|
||||
button(
|
||||
text(if bounded {
|
||||
"Unbounded · set bound"
|
||||
} else {
|
||||
"Unlimited · set maximum"
|
||||
})
|
||||
.size(12),
|
||||
)
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldShape(
|
||||
path.into(),
|
||||
if path.contains("/Real/") {
|
||||
json!(0.)
|
||||
} else if count_max {
|
||||
let minimum_path = path.replace("maximum", "minimum");
|
||||
json!(
|
||||
draft
|
||||
.materialize()
|
||||
.ok()
|
||||
.and_then(|v| v.pointer(&minimum_path).and_then(Value::as_u64))
|
||||
.unwrap_or(1)
|
||||
.max(1)
|
||||
)
|
||||
} else {
|
||||
json!(0)
|
||||
},
|
||||
)),
|
||||
);
|
||||
} else if let Some(input) = draft.inputs.get(path) {
|
||||
let owned = path.to_owned();
|
||||
out = out
|
||||
.push(
|
||||
text_input("", &input.raw)
|
||||
.on_input(move |s| Message::Field(owned.clone(), s))
|
||||
.size(12)
|
||||
.padding(8),
|
||||
)
|
||||
.push(
|
||||
button(text("Clear bound").size(10))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldShape(path.into(), Value::Null)),
|
||||
);
|
||||
}
|
||||
return Some(out.into());
|
||||
}
|
||||
if leaf == "default" && (path.contains("/DataField/") || path.contains("/DataType/")) {
|
||||
let owner = element(draft, project);
|
||||
let (definition, collection, reference, minimum) =
|
||||
match owner.as_ref().and_then(Element::shared) {
|
||||
Some(SharedBody::DataType { definition, .. }) => {
|
||||
(Some(definition.clone()), false, false, 0)
|
||||
}
|
||||
Some(SharedBody::DataField {
|
||||
type_ref,
|
||||
storage,
|
||||
minimum,
|
||||
..
|
||||
}) => (
|
||||
definition(project, type_ref),
|
||||
true,
|
||||
*storage == FieldStorage::Reference,
|
||||
*minimum,
|
||||
),
|
||||
_ => (None, false, false, 0),
|
||||
};
|
||||
let initial = definition.as_ref().and_then(literal);
|
||||
let mut out = column![text("Default").size(11).color(style::MUTED)].spacing(6);
|
||||
if reference {
|
||||
out = out.push(
|
||||
text("References have no literal default; they identify an existing record.")
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
if value.is_null() {
|
||||
out = out.push(text("Absent · no authored default").size(12));
|
||||
if !reference {
|
||||
if let Some(initial) = initial {
|
||||
out = out.push(
|
||||
button(text("Set literal default").size(11))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldShape(
|
||||
path.into(),
|
||||
if collection {
|
||||
json!([initial])
|
||||
} else {
|
||||
initial
|
||||
},
|
||||
)),
|
||||
);
|
||||
}
|
||||
if collection && minimum == 0 {
|
||||
out = out.push(
|
||||
button(text("Set explicit empty collection").size(11))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldShape(path.into(), json!([]))),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
out = out.push(
|
||||
button(text("Clear default · make absent").size(11))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldShape(path.into(), Value::Null)),
|
||||
);
|
||||
if let Some(values) = value.as_array() {
|
||||
if values.is_empty() {
|
||||
out = out.push(text("Explicit empty collection []").size(12));
|
||||
}
|
||||
for (i, value) in values.iter().enumerate() {
|
||||
out = out
|
||||
.push(literal_editor(
|
||||
draft,
|
||||
project,
|
||||
value,
|
||||
&format!("{path}/{i}"),
|
||||
definition.as_ref(),
|
||||
))
|
||||
.push(
|
||||
button(text("Remove value").size(10))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldArray(path.into(), Some(i), 0)),
|
||||
);
|
||||
}
|
||||
if let Some(initial) = initial
|
||||
&& !reference
|
||||
{
|
||||
out = out.push(
|
||||
button(text("+ Add literal").size(11))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldAppend(path.into(), initial)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
out = out.push(literal_editor(
|
||||
draft,
|
||||
project,
|
||||
value,
|
||||
path,
|
||||
definition.as_ref(),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Some(out.into());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn literal_editor<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
value: &'a Value,
|
||||
path: &str,
|
||||
definition: Option<&DataTypeDefinition>,
|
||||
) -> View<'a, Message> {
|
||||
if let Some(DataTypeDefinition::Enumeration { literals }) = definition {
|
||||
let current = draft
|
||||
.materialize()
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.pointer(&format!("{path}/Enumeration"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
})
|
||||
.or_else(|| {
|
||||
value
|
||||
.get("Enumeration")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
});
|
||||
let mut choices = literals.clone();
|
||||
if let Some(current) = ¤t
|
||||
&& !choices.contains(current)
|
||||
{
|
||||
choices.push(current.clone());
|
||||
}
|
||||
let path = path.to_owned();
|
||||
let picker = pick_list(choices, current, move |s| {
|
||||
Message::FieldShape(path.clone(), json!({"Enumeration": s}))
|
||||
})
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill);
|
||||
return if value.get("Enumeration").is_none() {
|
||||
column![text("Current default uses another literal type. Choose an enumeration literal or clear the default.").size(11).color(style::MUTED),picker].spacing(5).into()
|
||||
} else {
|
||||
picker.into()
|
||||
};
|
||||
}
|
||||
let mut out = column![].spacing(5);
|
||||
if let Some(fields) = value.as_object() {
|
||||
for (tag, payload) in fields {
|
||||
out = out.push(super::property(
|
||||
draft,
|
||||
project,
|
||||
payload,
|
||||
&format!("{path}/{tag}"),
|
||||
&friendly(tag),
|
||||
true,
|
||||
));
|
||||
}
|
||||
} else {
|
||||
out = out.push(
|
||||
text("Current literal requires review; clear it to choose a typed value.").size(11),
|
||||
);
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
fn message_value<'a>(
|
||||
draft: &'a Draft,
|
||||
project: &'a Project,
|
||||
value: &'a Value,
|
||||
path: &str,
|
||||
) -> View<'a, Message> {
|
||||
let edited = draft.materialize().unwrap_or_else(|_| draft.value.clone());
|
||||
let row = edited.pointer(path).unwrap_or(value).clone();
|
||||
let current = serde_json::from_value::<MessageValue>(row.clone()).ok();
|
||||
let owner = element(draft, project);
|
||||
let context = owner
|
||||
.as_ref()
|
||||
.and_then(Element::shared)
|
||||
.and_then(|body| match body {
|
||||
SharedBody::Occurrence {
|
||||
body:
|
||||
OccurrenceBody::Exchange {
|
||||
exchange: Some(id),
|
||||
message_kind,
|
||||
..
|
||||
},
|
||||
..
|
||||
} => project.relations.get(id).map(|r| (r, message_kind)),
|
||||
_ => None,
|
||||
});
|
||||
let current_key = current
|
||||
.as_ref()
|
||||
.map(|value| crate::data_values::FieldPathKey {
|
||||
path: value.path.clone(),
|
||||
field: value.field,
|
||||
});
|
||||
let selected = context.and_then(|(exchange, phase)| {
|
||||
current_key
|
||||
.as_ref()
|
||||
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
|
||||
});
|
||||
let browser = draft
|
||||
.value_browsers
|
||||
.get(path)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| ValueBrowser {
|
||||
scope: selected
|
||||
.as_ref()
|
||||
.and_then(|choice| crate::data_values::parent(&choice.key)),
|
||||
page: 0,
|
||||
});
|
||||
let mut out = column![text("Qualified carried field").size(11).color(style::MUTED)].spacing(6);
|
||||
if let Some(choice) = &selected {
|
||||
out = out.push(text(choice.label.clone()).size(11));
|
||||
} else if let Some(key) = ¤t_key
|
||||
&& !key.field.is_nil()
|
||||
{
|
||||
out = out.push(
|
||||
text(format!(
|
||||
"Current address needs review: {} / {}. Choose an explicit field.",
|
||||
key.path
|
||||
.iter()
|
||||
.map(|id| project.named(*id))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" → "),
|
||||
project.named(key.field)
|
||||
))
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
if let Some((exchange, phase)) = context {
|
||||
let location = browser
|
||||
.scope
|
||||
.as_ref()
|
||||
.and_then(|key| crate::data_values::resolve(project, exchange, phase, key))
|
||||
.map(|choice| choice.label)
|
||||
.unwrap_or_else(|| "Root carried fields".into());
|
||||
out = out.push(
|
||||
text(format!("Browse: {location}"))
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
if let Some(scope) = &browser.scope {
|
||||
let mut navigation = row![button(text("↑ Up").size(11)).style(style::nav).on_press(
|
||||
Message::FieldValueBrowse(path.into(), crate::data_values::parent(scope), 0)
|
||||
)]
|
||||
.spacing(4);
|
||||
if !scope.path.is_empty() {
|
||||
navigation = navigation.push(
|
||||
button(text("Root fields").size(11))
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldValueBrowse(path.into(), None, 0)),
|
||||
);
|
||||
}
|
||||
out = out.push(navigation);
|
||||
}
|
||||
match crate::data_values::at_scope(project, exchange, phase, browser.scope.as_ref()) {
|
||||
Ok(choices) => {
|
||||
let count = choices.len();
|
||||
let (visible, page, pages) = field_page(choices, browser.page);
|
||||
// Selection detail stays visible independently of browser location/page.
|
||||
let visible_selection = selected
|
||||
.clone()
|
||||
.filter(|choice| visible.iter().any(|c| c.key == choice.key));
|
||||
let owned = path.to_owned();
|
||||
out = out.push(
|
||||
pick_list(visible, visible_selection, move |choice| {
|
||||
Message::FieldValuePath(owned.clone(), choice.key)
|
||||
})
|
||||
.placeholder("Choose a field here…")
|
||||
.text_size(12)
|
||||
.padding(8)
|
||||
.width(Fill),
|
||||
);
|
||||
if pages > 1 {
|
||||
out = out.push(
|
||||
row![
|
||||
button(text("Previous").size(11))
|
||||
.style(style::nav)
|
||||
.on_press_maybe((page > 0).then(|| Message::FieldValueBrowse(
|
||||
path.into(),
|
||||
browser.scope.clone(),
|
||||
page - 1
|
||||
))),
|
||||
text(format!("{} / {pages} · {count} fields", page + 1)).size(11),
|
||||
button(text("Next").size(11))
|
||||
.style(style::nav)
|
||||
.on_press_maybe((page + 1 < pages).then(|| {
|
||||
Message::FieldValueBrowse(
|
||||
path.into(),
|
||||
browser.scope.clone(),
|
||||
page + 1,
|
||||
)
|
||||
}))
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center),
|
||||
);
|
||||
} else if count == 0 {
|
||||
out = out.push(
|
||||
text("No compatible fields at this location.")
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(error) => out = out.push(text(error).size(11).color(style::MUTED)),
|
||||
}
|
||||
if let Some(choice) = &selected
|
||||
&& browser.scope.as_ref() != Some(&choice.key)
|
||||
&& crate::data_values::children(project, exchange, phase, &choice.key)
|
||||
.is_ok_and(|children| !children.is_empty())
|
||||
{
|
||||
out = out.push(
|
||||
button(
|
||||
text(format!(
|
||||
"Browse fields in {} →",
|
||||
project.named(choice.key.field)
|
||||
))
|
||||
.size(11),
|
||||
)
|
||||
.style(style::nav)
|
||||
.on_press(Message::FieldValueBrowse(
|
||||
path.into(),
|
||||
Some(choice.key.clone()),
|
||||
0,
|
||||
)),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
out = out.push(
|
||||
text("Choose a message exchange to browse its carried fields.")
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
if let Some(expression) = value.get("expression") {
|
||||
out = out.push(super::property(
|
||||
draft,
|
||||
project,
|
||||
expression,
|
||||
&format!("{path}/expression"),
|
||||
"Value expression",
|
||||
false,
|
||||
));
|
||||
}
|
||||
out.into()
|
||||
}
|
||||
|
||||
const FIELDS_PER_PAGE: usize = 24;
|
||||
|
||||
fn field_page(
|
||||
choices: Vec<crate::data_values::FieldPathChoice>,
|
||||
requested: usize,
|
||||
) -> (Vec<crate::data_values::FieldPathChoice>, usize, usize) {
|
||||
let pages = choices.len().div_ceil(FIELDS_PER_PAGE).max(1);
|
||||
let page = requested.min(pages - 1);
|
||||
(
|
||||
choices
|
||||
.into_iter()
|
||||
.skip(page * FIELDS_PER_PAGE)
|
||||
.take(FIELDS_PER_PAGE)
|
||||
.collect(),
|
||||
page,
|
||||
pages,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn selecting_qualified_path_preserves_expression_typed_before_the_next_render() {
|
||||
let (mut f, next) = crate::data_values::tests::recursive_payload();
|
||||
if let Some(SharedBody::DataField { direction, .. }) = f
|
||||
.project
|
||||
.elements
|
||||
.get_mut(&f.destination)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
{
|
||||
*direction = ParameterDirection::InOut;
|
||||
}
|
||||
let call = f
|
||||
.project
|
||||
.add_element(Kind::Occurrence, Layer::Oa, "Call", Some(f.scenario))
|
||||
.unwrap();
|
||||
*f.project
|
||||
.elements
|
||||
.get_mut(&call)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(f.exchange),
|
||||
sender: None,
|
||||
receiver: None,
|
||||
message_kind: MessageKind::Call,
|
||||
reply_to: None,
|
||||
values: vec![MessageValue {
|
||||
path: vec![f.origin],
|
||||
field: f.leaf,
|
||||
expression: "old".into(),
|
||||
}],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
f.project
|
||||
.elements
|
||||
.get_mut(&f.scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.push(call);
|
||||
let mut app = crate::app::App::with_project(f.project.clone());
|
||||
let path = "/body/Oa/Shared/Occurrence/body/Exchange/values/0";
|
||||
let _ = app.update(Message::Select(call));
|
||||
let _ = app.update(Message::Field(
|
||||
format!("{path}/expression"),
|
||||
"newly typed".into(),
|
||||
));
|
||||
let before_browse = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
for (scope, page) in [
|
||||
(None, 0),
|
||||
(
|
||||
Some(crate::data_values::FieldPathKey {
|
||||
path: vec![f.origin],
|
||||
field: next,
|
||||
}),
|
||||
999,
|
||||
),
|
||||
] {
|
||||
let _ = app.update(Message::FieldValueBrowse(path.into(), scope, page));
|
||||
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().materialize().unwrap(),
|
||||
before_browse
|
||||
);
|
||||
assert_eq!(app.project(), &f.project);
|
||||
}
|
||||
let _ = app.update(Message::FieldValuePath(
|
||||
path.into(),
|
||||
crate::data_values::FieldPathKey {
|
||||
path: vec![f.destination],
|
||||
field: f.leaf,
|
||||
},
|
||||
));
|
||||
let _ = app.update(Message::Apply);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = app.project().elements[&call].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(
|
||||
values,
|
||||
&vec![MessageValue {
|
||||
path: vec![f.destination],
|
||||
field: f.leaf,
|
||||
expression: "newly typed".into()
|
||||
}],
|
||||
"{}",
|
||||
app.status
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(app.project(), &f.project);
|
||||
let nested = crate::data_values::children(
|
||||
app.project(),
|
||||
&app.project().relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
&crate::data_values::FieldPathKey {
|
||||
path: vec![f.origin],
|
||||
field: next,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.find(|c| c.key.field == f.leaf)
|
||||
.unwrap();
|
||||
let _ = app.update(Message::Select(call));
|
||||
let _ = app.update(Message::FieldValuePath(path.into(), nested.key.clone()));
|
||||
let _ = app.update(Message::Apply);
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = app.project().elements[&call].shared()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
assert_eq!(values[0].path, vec![f.origin, next], "{}", app.status);
|
||||
assert!(
|
||||
crate::data_values::resolve(
|
||||
app.project(),
|
||||
&app.project().relations[&f.exchange],
|
||||
&MessageKind::Call,
|
||||
&nested.key
|
||||
)
|
||||
.is_some()
|
||||
);
|
||||
let _ = super::super::view(app.draft.as_ref().unwrap(), app.project());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn field_browser_pages_every_direct_field_without_flattening_other_records() {
|
||||
let f = crate::data_values::tests::wide_payload();
|
||||
let exchange = &f.project.relations[&f.exchange];
|
||||
let roots =
|
||||
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, None).unwrap();
|
||||
assert!(roots.len() > FIELDS_PER_PAGE);
|
||||
assert!(roots.iter().all(|choice| choice.key.path.is_empty()));
|
||||
let scope = crate::data_values::FieldPathKey {
|
||||
path: vec![],
|
||||
field: f.origin,
|
||||
};
|
||||
let children =
|
||||
crate::data_values::at_scope(&f.project, exchange, &MessageKind::Call, Some(&scope))
|
||||
.unwrap();
|
||||
assert!(children.len() > FIELDS_PER_PAGE);
|
||||
assert!(
|
||||
children
|
||||
.iter()
|
||||
.all(|choice| choice.key.path == vec![f.origin])
|
||||
);
|
||||
for choices in [roots, children] {
|
||||
let (_, _, pages) = field_page(choices.clone(), 0);
|
||||
let visited: Vec<_> = (0..pages)
|
||||
.flat_map(|page| {
|
||||
let (visible, actual, _) = field_page(choices.clone(), page);
|
||||
assert_eq!(actual, page);
|
||||
assert!(visible.len() <= FIELDS_PER_PAGE);
|
||||
visible
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(visited, choices);
|
||||
assert_eq!(field_page(choices, usize::MAX).1, pages - 1);
|
||||
}
|
||||
assert_eq!(field_page(vec![], usize::MAX), (vec![], 0, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numeric_bound_drafts_keep_partial_and_full_precision_values_out_of_accepted_state() {
|
||||
let mut p = Project::empty();
|
||||
let id = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Counter", None)
|
||||
.unwrap();
|
||||
let mut draft = Draft::from_project(&p, id).unwrap();
|
||||
let definition = "/body/Oa/Shared/DataType/definition";
|
||||
draft
|
||||
.shape(
|
||||
definition,
|
||||
json!({"Integer":{"minimum":null,"maximum":i64::MAX}}),
|
||||
)
|
||||
.unwrap();
|
||||
let maximum = format!("{definition}/Integer/maximum");
|
||||
draft.input(&maximum, "-".into());
|
||||
assert!(draft.command().is_err());
|
||||
assert!(draft.shape(&maximum, Value::Null).is_err());
|
||||
assert_eq!(draft.inputs[&maximum].raw, "-");
|
||||
draft.input(&maximum, "9223372036854775808".into());
|
||||
assert!(draft.command().is_err());
|
||||
draft.input(&maximum, i64::MAX.to_string());
|
||||
draft
|
||||
.shape(
|
||||
"/body/Oa/Shared/DataType/default",
|
||||
json!({"Integer":i64::MAX}),
|
||||
)
|
||||
.unwrap();
|
||||
let accepted = execute_batch(&p, &[draft.command().unwrap()]).unwrap();
|
||||
assert!(matches!(
|
||||
accepted.elements[&id].shared(),
|
||||
Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Integer {
|
||||
maximum: Some(i64::MAX),
|
||||
..
|
||||
},
|
||||
default: Some(DataValue::Integer(i64::MAX)),
|
||||
..
|
||||
})
|
||||
));
|
||||
draft.input(&maximum, "10".into());
|
||||
assert!(execute_batch(&accepted, &[draft.command().unwrap()]).is_err());
|
||||
assert_eq!(draft.inputs[&maximum].raw, "10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_distinguish_absent_empty_and_typed_literals_with_escaped_unicode() {
|
||||
let mut p = Project::empty();
|
||||
let item = p
|
||||
.add_element(Kind::ExchangeItem, Layer::Oa, "Payload", None)
|
||||
.unwrap();
|
||||
let id = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "Optional names", Some(item))
|
||||
.unwrap();
|
||||
let mut draft = Draft::from_project(&p, id).unwrap();
|
||||
let root = "/body/Oa/Shared/DataField";
|
||||
draft.shape(&format!("{root}/minimum"), json!(0)).unwrap();
|
||||
draft
|
||||
.shape(&format!("{root}/maximum"), Value::Null)
|
||||
.unwrap();
|
||||
draft.shape(&format!("{root}/default"), json!([])).unwrap();
|
||||
let empty = execute_batch(&p, &[draft.command().unwrap()]).unwrap();
|
||||
assert!(
|
||||
matches!(empty.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v.is_empty())
|
||||
);
|
||||
draft
|
||||
.shape(&format!("{root}/default"), Value::Null)
|
||||
.unwrap();
|
||||
let absent = execute_batch(&empty, &[draft.command().unwrap()]).unwrap();
|
||||
assert!(matches!(
|
||||
absent.elements[&id].shared(),
|
||||
Some(SharedBody::DataField { default: None, .. })
|
||||
));
|
||||
draft
|
||||
.shape(&format!("{root}/default"), json!([{"String":"é🙂\n"}]))
|
||||
.unwrap();
|
||||
draft.input(&format!("{root}/default/0/String"), "é🙂\n".into());
|
||||
draft
|
||||
.append(&format!("{root}/default"), json!({"String":"next"}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
draft
|
||||
.materialize()
|
||||
.unwrap()
|
||||
.pointer(&format!("{root}/default/0/String")),
|
||||
Some(&json!("é🙂\n"))
|
||||
);
|
||||
draft.array(&format!("{root}/default"), Some(1), 0).unwrap();
|
||||
draft.set_exact(true).unwrap();
|
||||
let saved = execute_batch(&absent, &[draft.command().unwrap()]).unwrap();
|
||||
assert!(
|
||||
matches!(saved.elements[&id].shared(),Some(SharedBody::DataField {default:Some(v),..}) if v == &vec![DataValue::String("é🙂\n".into())])
|
||||
);
|
||||
let _ = super::super::view(&draft, &saved);
|
||||
draft
|
||||
.shape(&format!("{root}/storage"), json!("Reference"))
|
||||
.unwrap();
|
||||
assert!(execute_batch(&saved, &[draft.command().unwrap()]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supertype_picker_excludes_descendants_and_keeps_original_inherited_field_identity() {
|
||||
let mut p = Project::empty();
|
||||
let base = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Base", None)
|
||||
.unwrap();
|
||||
let child = p
|
||||
.add_element(Kind::DataType, Layer::Oa, "Child", None)
|
||||
.unwrap();
|
||||
for (id, supertype) in [(base, None), (child, Some(base))] {
|
||||
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype },
|
||||
default: None,
|
||||
unit: String::new(),
|
||||
};
|
||||
}
|
||||
let field = p
|
||||
.add_element(Kind::DataField, Layer::Oa, "Owned by base", Some(base))
|
||||
.unwrap();
|
||||
let draft = Draft::from_project(&p, base).unwrap();
|
||||
let path = "/body/Oa/Shared/DataType/definition/Record/supertype";
|
||||
assert!(!reference_ids(&draft, &p, path).contains(&child));
|
||||
let child_draft = Draft::from_project(&p, child).unwrap();
|
||||
assert!(reference_ids(&child_draft, &p, path).contains(&base));
|
||||
assert_eq!(data::effective_fields(&p, child)[0].id, field);
|
||||
assert_eq!(p.elements[&field].parent, Some(base));
|
||||
}
|
||||
}
|
||||
@@ -144,7 +144,13 @@ fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool {
|
||||
}
|
||||
let declared: Vec<_> = interfaces
|
||||
.iter()
|
||||
.flat_map(|id| data::interface_items(project, *id))
|
||||
.filter_map(|id| project.elements.get(id))
|
||||
.filter_map(|e| match e.shared() {
|
||||
Some(SharedBody::Interface { items }) => Some(items),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.copied()
|
||||
.collect();
|
||||
items_satisfy(project, items, &declared)
|
||||
}
|
||||
@@ -708,12 +714,8 @@ mod tests {
|
||||
interface,
|
||||
);
|
||||
assert!(reference_ids(&draft, &project, target).is_empty());
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
item,
|
||||
);
|
||||
project.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![item] });
|
||||
assert_eq!(reference_ids(&draft, &project, target), vec![output]);
|
||||
}
|
||||
|
||||
|
||||
@@ -299,9 +299,9 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Route connections around the model",
|
||||
&[
|
||||
"Select an architecture connection and drag a segment to add a bend. Right-click a line for Add bend here; right-click a circle for Remove bend. With the canvas focused, Insert adds a bend and Delete removes the selected bend. Drag the label independently. Escape cancels a gesture; Undo reverses one completed drag. Shift+F10 opens the selected diagram item's context menu; use arrow keys and Enter to choose an action.",
|
||||
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. In Select / move, drag a port to any border of its owner. Manual positions are per diagram; right-click Reset automatic position restores connection-aware placement. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
|
||||
"Right-click a connection and choose Reset routing to clear its pins and label position in this view. The Properties Actions menu provides the same commands. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
"Select an architecture connection. Drag a line segment to add a pinned bend, or choose Add bend; drag its circles to shape a detour. Select a circle and choose Remove bend to release that pin. Drag the connection label independently. Escape cancels a gesture; Undo reverses one completed drag.",
|
||||
"Automatic orthogonal routing avoids cards and container headers where space permits. Amber connections need attention: move an obstructing element or a bend. Ports remain attached to their actual owners. Manual routes are saved separately for each diagram and appear in source, API and SVG export.",
|
||||
"Reset routing clears the selected connection’s pins and label position in this view. Arrange preserves pins. Moving one endpoint keeps pins fixed; moving a container carrying both endpoints translates their pins. When folding promotes an endpoint, expand it again to resume and edit its saved route.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -333,7 +333,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Include, extend or specialize a capability",
|
||||
&[
|
||||
"Select a capability in the diagram or explorer, then open Actions at the top of its properties. Include capability identifies behavior that is always part of it; choose the included capability as the target.",
|
||||
"Select a capability in the diagram or explorer, then open Modeling actions at the top of its properties. Include capability identifies behavior that is always part of it; choose the included capability as the target.",
|
||||
"Extend capability connects this extending capability to the extended capability. Enter the condition under which the extension adds behavior. An empty condition remains editable and appears in Model checks.",
|
||||
"Specialize capability connects a specific capability to a more general one. Inclusion and specialization cannot form cycles.",
|
||||
"The relationship preview names both ends and generates a readable name. Apply adds one undoable change; Cancel preserves the model. Include and extend labels remain visible in the diagram.",
|
||||
@@ -368,10 +368,10 @@ fn article(topic: Topic) -> Article {
|
||||
"Create a typed exchange",
|
||||
&[
|
||||
"Create the source and target activities/functions in one perspective. Give each an engineering name and allocate leaf work to its responsible participant.",
|
||||
"Choose Interaction or Functional exchange in the diagram palette. Click the source, then a highlighted compatible target, or drag between them. Functional exchanges connect output ports to input ports; function cards create or reuse real ports in the same transaction. Middle-button drag pans while keeping the source; arrow keys also pan when the drawing canvas has focus. Click empty space to restart, or Escape to cancel.",
|
||||
"Create an Exchange item under Data & interfaces. Choose its nature (Information, Material or Energy) and its mechanism (Flow, Event, Operation or Shared data). Describe the payload independently of the connection.",
|
||||
"Select the source, choose + Relationship, then Interaction for OA activities or Functional exchange for SA functions. Choose the compatible target and apply the relationship.",
|
||||
"Create an Exchange item under Data & interfaces. Choose its kind (Information, Material, Energy or Event) and describe its payload.",
|
||||
"Select the exchange in Model explorer or the diagram. Edit its properties to reference the appropriate exchange items and available ports; Apply changes validates the complete record.",
|
||||
"Choose Boundary exchange and connect compatible component ports. Flow goes from Out to In; Standard is unoriented. Select / move returns to arranging. Exchange allocation associates a boundary exchange with the functional exchange it carries.",
|
||||
"For a participant boundary, create compatible ports and a Boundary exchange. Exchange allocation associates that boundary exchange with the functional exchange it carries.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
@@ -380,7 +380,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Use engineering payloads",
|
||||
"Choose Information for data such as a distress report, Energy for delivered power and Material for a transferred substance. Mechanism separately describes how it is exchanged: an Event is a notification, an Operation has parameters, and a Flow carries values or a physical quantity. Data types and fields make payloads precise. Artifact links can point to interface-control documents.",
|
||||
"Choose Information for data such as a distress report, Energy for delivered power, Material for a transferred substance and Event for a notification. Data types and fields can make structured payloads precise. URI artifact links can point to detailed interface-control documents.",
|
||||
),
|
||||
],
|
||||
related: &[
|
||||
@@ -467,26 +467,18 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "Describe the conditions in which the operation or system behaves differently. States and modes belong to regions within a state machine.",
|
||||
keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal diagram draw reconnect local internal timed fold unfold placement pan zoom undo",
|
||||
keywords: "state machine mode region initial final transition trigger guard effect entry exit do availability degraded nominal",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Build a state machine",
|
||||
&[
|
||||
"In Behavior & scenarios, create or select a State machine, then choose Create diagram. This places its existing regions and vertices in a saved view. + View creates another independent layout.",
|
||||
"Choose + Region and click the highlighted machine, or a State/Mode for a nested region. Choose Initial, State, Mode or another kind from + State / symbol, then click its highlighted owning region. Rename the new element in Properties.",
|
||||
"Choose Transition and drag from a source vertex to a compatible target, or click the source and then the target. In Select / move, drag states or containers to arrange them; a container moves its descendant placements without changing ownership.",
|
||||
"Select a drawn transition and drag its endpoint handle to reconnect it. Its right-click menu also offers Reconnect source and Reconnect target; choose one, then click a compatible vertex.",
|
||||
"Right-click a state or the background for New transition with details… to create Local, Internal or timed behavior. Right-click an existing transition and choose Edit transition to edit its kind, triggers, guard and effects. EntryPoint outgoing transitions use Local. Apply accepts the complete form; invalid drafts remain available for correction.",
|
||||
"In Behavior & scenarios, create a State machine and select it. Use + Region to give its state structure an owner.",
|
||||
"Use + State for conditions such as Nominal and Degraded, and + Initial for the starting pseudostate. Select a region and use Create element for other supported state kinds.",
|
||||
"Select a source state and choose + Relationship → State transition. Pick a compatible target in the same state machine.",
|
||||
"Edit the transition's Trigger, Guard and Effect in Properties. These distinguish what initiates the transition, what must be true, and what follows.",
|
||||
"Review the rendered state view and numbered transition list. Model checks calls out unfinished structures so they can be corrected during authoring.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Navigate and recover a view",
|
||||
"Middle-button drag pans; scroll or use −/+ to zoom, and Fit frames the diagram. Right-click a container for Fold contents or Unfold contents, or choose Placement properties for exact coordinates and size. Hide from this diagram removes only its placement; Show an element not yet in this view restores missing elements. Escape cancels a drawing gesture. Undo restores each completed gesture or menu edit, and Model checks identifies unfinished structures.",
|
||||
),
|
||||
explain(
|
||||
"One saved diagram across editors",
|
||||
"Each state view stores a reference to its state machine and its own placements. Text, JSON and REST edit those same diagram records, including the persisted machine context; saving and reopening retains them. Merely opening the behavior page does not create a diagram. State-view edits preserve other diagram layouts.",
|
||||
),
|
||||
explain(
|
||||
"Availability and state actions",
|
||||
"Availability links a participant or behavior to a State/Mode in which it is available. State action links a State/Mode to an Activity/Function and records its entry, do or exit phase. Use these typed relationships when work depends on operating conditions.",
|
||||
@@ -510,40 +502,23 @@ fn article(topic: Topic) -> Article {
|
||||
const {
|
||||
Article {
|
||||
summary: "Define exchanged payloads independently of the ports and participants that carry them. Reuse the same data definitions in related exchanges.",
|
||||
keywords: "data interface port direction input output inout return contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum bounds default unit specialization inheritance association storage reference recursive path transmission acquisition protocol",
|
||||
keywords: "data interface port direction input output inout contract provision requirement exchange item enum enumeration record primitive field value type multiplicity minimum maximum",
|
||||
sections: &[
|
||||
walkthrough(
|
||||
"Define a reusable payload",
|
||||
&[
|
||||
"Open Data & interfaces and create an Exchange item. Choose Information, Material or Energy for nature; choose Flow, Event, Operation or Shared data for mechanism. Keep Unspecified when the engineering decision is still open.",
|
||||
"Create a Data type for a reusable definition. Choose Boolean, Integer, Real, String, Enumeration or Record. Numeric bounds are inclusive; String bounds count Unicode characters. A typed default must satisfy those bounds or enumeration literals. Units annotate quantities without converting them.",
|
||||
"Create fields within an Exchange item or Record type. Set Value type, minimum/maximum count, unit and optional default. A default can be absent, explicitly empty when minimum is zero, or a list of typed scalar values. A type default describes one element; it does not create a required collection.",
|
||||
"Open Data & interfaces and create an Exchange item. Select Information, Material, Energy or Event to describe the nature of the exchange.",
|
||||
"Create a Data type where the payload needs a named definition. Choose its Data type definition and edit the corresponding values in Properties.",
|
||||
"For structured definitions, create Data fields under a legal Data type or Exchange item owner. Set Value type and the minimum/maximum count as required by the contract.",
|
||||
"Select the exchange and reference the payload items. Apply changes and check the result in Model checks.",
|
||||
],
|
||||
),
|
||||
explain(
|
||||
"Operation parameters and message values",
|
||||
"An Operation's direct fields are parameters. In and InOut supply call values; Out, InOut and Return supply reply values, from the provider's perspective. There is at most one Return parameter. Qualified paths distinguish origin.latitude from destination.latitude when both use the same Coordinate record. Nested fields inherit the parameter direction. Message expressions are annotations, not evaluated values.",
|
||||
),
|
||||
explain(
|
||||
"Reuse, ownership and references",
|
||||
"A Record or Exchange item can specialize one compatible base. Inherited fields keep their original owner and identity; edit the base to change them. Value fields compose a payload. Reference fields refer to separately identified Record objects and stop payload expansion. Optional recursive values and reference cycles are valid; a mandatory value cycle warns that no finite complete value exists. Data associations describe named reference ends, multiplicities and navigation between Record types without creating contained fields.",
|
||||
),
|
||||
walkthrough(
|
||||
"Allocate an item to interfaces",
|
||||
&[
|
||||
"Create an Interface and allocate an Exchange item. Select its allocation row to edit transmission, acquisition and order; Open item navigates to the shared payload definition.",
|
||||
"Two interfaces may allocate the same item with different protocols. Attach CAD, code or document evidence to the allocation when it concerns that particular contract.",
|
||||
"Removing an allocation keeps the shared item and other allocations. If a live boundary depends on it, first revise that boundary contract. Undo restores an accepted removal.",
|
||||
"An Interface may specialize one base and inherit its allocations. Duplicate allocation of an already inherited item is rejected. A specialized item satisfies its base contract; a base item does not satisfy a more specific contract.",
|
||||
],
|
||||
),
|
||||
walkthrough(
|
||||
"Connect participant boundaries",
|
||||
&[
|
||||
"In System Analysis, select a System or System Actor and choose Add port in Model explorer or Properties. Name it, choose Standard or Flow, then Create port. Standard ports describe interfaces; Flow ports have their own input/output orientation. Functions use separate input/output port actions.",
|
||||
"Use Interface provision and Interface requirement relationships to state who provides or needs the contract.",
|
||||
"Select a visible component port and choose Draw boundary exchange, then click or drag to a highlighted compatible port. The diagram palette offers the same tool. If the endpoints are outside the diagram, Configure boundary exchange opens the form with owner names and aliases. Create the matching port first if needed. Exchange orientation is separate from Flow port orientation. Exchange allocation associates carried functional exchanges with the boundary connection.",
|
||||
"Select the component port and choose Connect boundary exchange. Choose the other participant's compatible port; owner names and aliases distinguish repeated port names. Create the matching port first if none exists. Exchange orientation is separate from Flow port orientation. Connect carried functional exchanges using Exchange allocation.",
|
||||
"Link the detailed interface-control document, CAD connector or schematic through an Artifact reference when an external artifact supplies additional evidence.",
|
||||
],
|
||||
),
|
||||
@@ -638,7 +613,7 @@ fn article(topic: Topic) -> Article {
|
||||
walkthrough(
|
||||
"Edit and review source",
|
||||
&[
|
||||
"Open Textual model and expand folders to choose a source file, or choose Actions > Open source in Properties to locate the selected record. Switching files preserves each draft. Collapsed folders show modified-file and diagnostic counts; Locate reveals the active file. Right-click a file for its actions.",
|
||||
"Open Textual model and choose a source file, or choose Source in Properties to locate the selected record in its owning file. Switching files preserves each draft.",
|
||||
"Use Find / go to, Ctrl+F or Ctrl+G to navigate. Literal search is case sensitive. Enter/F3 selects the next match, Shift+F3 selects the previous match, and results wrap. Go to line accepts the displayed one-based line number.",
|
||||
"Edit the draft. Syntax highlighting distinguishes source structure; live lint reports errors with line and column information.",
|
||||
"Choose Apply all to parse and validate all reachable included files. A successful application is one model change; graphical views and Properties then reflect it. Finish or revert source drafts before editing graphical properties.",
|
||||
@@ -740,7 +715,7 @@ fn article(topic: Topic) -> Article {
|
||||
),
|
||||
explain(
|
||||
"Open and migration",
|
||||
"Open validates a complete candidate before replacing the project. Versions 1, 2 and 3 migrate explicitly to version 4; Model checks shows preserved ports, supplied data defaults, generated interface allocations and unresolved message paths. Old Event items become Information notifications; unknown physical mechanisms remain Unspecified. Source3 upgrades retain files, includes, nested ownership and comments. Opening does not overwrite the original: use Save as if it must remain in its older format. Invalid input leaves the accepted project unchanged.",
|
||||
"Open reads and validates a complete candidate before replacing the current project. Supported legacy files are migrated explicitly; save the resulting current format under a new name if the legacy original must remain available. Unsupported or invalid input does not replace the accepted project.",
|
||||
),
|
||||
],
|
||||
related: &[Topic::Source, Topic::Recovery, Topic::Artifacts],
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
mod app;
|
||||
mod behavior;
|
||||
mod connection_edit;
|
||||
mod connection_tool;
|
||||
mod context_menu;
|
||||
mod data_values;
|
||||
mod data_view;
|
||||
mod diagram;
|
||||
mod document;
|
||||
mod explorer;
|
||||
@@ -13,14 +8,11 @@ mod explorer_view;
|
||||
mod forms;
|
||||
mod guide;
|
||||
mod highlight;
|
||||
mod model_menu;
|
||||
mod navigation;
|
||||
mod port_edit;
|
||||
mod port_preview;
|
||||
mod port_workflow;
|
||||
mod route_edit;
|
||||
mod source_navigation;
|
||||
mod source_tree;
|
||||
mod source_workspace;
|
||||
mod style;
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -1,866 +0,0 @@
|
||||
//! Contextual actions reuse the ordinary guarded application commands.
|
||||
use crate::{
|
||||
app::{App, Message, Page},
|
||||
context_menu::{Entry, Menu},
|
||||
port_workflow, route_edit,
|
||||
};
|
||||
use iced::Point;
|
||||
use oa_core::*;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Request {
|
||||
pub anchor: Point,
|
||||
pub object: Id,
|
||||
pub placement: Option<Id>,
|
||||
pub bend: Option<usize>,
|
||||
pub route_point: Option<Position>,
|
||||
pub canvas: Option<route_edit::Context>,
|
||||
pub source_file: Option<String>,
|
||||
}
|
||||
impl Request {
|
||||
pub fn object(object: Id, anchor: Point) -> Self {
|
||||
Self {
|
||||
anchor,
|
||||
object,
|
||||
placement: None,
|
||||
bend: None,
|
||||
route_point: None,
|
||||
canvas: None,
|
||||
source_file: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Open(Request),
|
||||
Invoke(u64, Box<Message>),
|
||||
Close,
|
||||
}
|
||||
|
||||
pub struct Opened {
|
||||
pub token: u64,
|
||||
pub request: Request,
|
||||
document: Id,
|
||||
revision: u64,
|
||||
epoch: u64,
|
||||
page: Page,
|
||||
layer: Layer,
|
||||
diagram: Option<Id>,
|
||||
}
|
||||
impl Opened {
|
||||
pub fn new(app: &App, request: Request, token: u64) -> Option<Self> {
|
||||
if !app.project().contains(request.object)
|
||||
|| request
|
||||
.canvas
|
||||
.is_some_and(|c| Some(c) != current_canvas(app))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(Self {
|
||||
token,
|
||||
request,
|
||||
document: app.canvas_document,
|
||||
revision: app.document.revision(),
|
||||
epoch: app.canvas_epoch,
|
||||
page: app.page,
|
||||
layer: app.layer,
|
||||
diagram: current_diagram(app),
|
||||
})
|
||||
}
|
||||
pub fn valid(&self, app: &App) -> bool {
|
||||
self.document == app.canvas_document
|
||||
&& self.revision == app.document.revision()
|
||||
&& self.epoch == app.canvas_epoch
|
||||
&& self.page == app.page
|
||||
&& self.layer == app.layer
|
||||
&& self.diagram == current_diagram(app)
|
||||
&& app.project().contains(self.request.object)
|
||||
&& self
|
||||
.request
|
||||
.canvas
|
||||
.is_none_or(|c| Some(c) == current_canvas(app))
|
||||
&& self
|
||||
.request
|
||||
.source_file
|
||||
.as_ref()
|
||||
.is_none_or(|path| app.source_workspace.files.contains_key(path))
|
||||
}
|
||||
}
|
||||
|
||||
fn current_canvas(app: &App) -> Option<route_edit::Context> {
|
||||
if app.page == Page::Behavior {
|
||||
app.state_context()
|
||||
} else {
|
||||
app.route_context()
|
||||
}
|
||||
}
|
||||
|
||||
fn current_diagram(app: &App) -> Option<Id> {
|
||||
if app.page == Page::Behavior {
|
||||
app.state_context().map(|context| context.diagram)
|
||||
} else {
|
||||
app.active_diagram
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(object: Id, anchor: Point) -> Message {
|
||||
Message::Menu(Event::Open(Request::object(object, anchor)))
|
||||
}
|
||||
|
||||
pub fn trigger(app: &App) -> iced::Element<'_, Message> {
|
||||
crate::context_menu::Area::left(
|
||||
crate::views::action("Actions ▾", Message::Menu(Event::Close)),
|
||||
move |anchor| {
|
||||
let mut request = Request::object(app.selected, anchor);
|
||||
if app.page == Page::Diagram {
|
||||
request.placement = app.selected_node;
|
||||
request.bend = app
|
||||
.selected_bend
|
||||
.filter(|(id, _)| *id == app.selected)
|
||||
.map(|(_, i)| i);
|
||||
request.canvas = app.route_context();
|
||||
} else if app.page == Page::Behavior {
|
||||
request.canvas = app.state_context();
|
||||
if let Some(context) = request.canvas
|
||||
&& let Some(node) = app.project().diagrams[&context.diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == app.selected || node.id == app.selected)
|
||||
{
|
||||
request.object = node.element;
|
||||
request.placement = Some(node.id);
|
||||
}
|
||||
}
|
||||
Message::Menu(Event::Open(request))
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
pub fn popup(app: &App) -> Option<Menu<Message>> {
|
||||
let opened = app.model_menu.as_ref().filter(|menu| menu.valid(app))?;
|
||||
let request = &opened.request;
|
||||
let p = app.project();
|
||||
let id = request.object;
|
||||
let clean = !app.busy
|
||||
&& (request.source_file.is_some()
|
||||
|| (!app.draft_dirty() && !app.source_dirty() && !app.behavior.has_pending()));
|
||||
let item = |caption: &str, message: Message, enabled: bool| {
|
||||
if clean && enabled {
|
||||
Entry::action(
|
||||
caption,
|
||||
Message::Menu(Event::Invoke(opened.token, Box::new(message))),
|
||||
)
|
||||
} else {
|
||||
Entry::disabled(caption)
|
||||
}
|
||||
};
|
||||
if let Some(path) = &request.source_file {
|
||||
let buffer = app.source_workspace.files.get(path)?;
|
||||
let entries = vec![
|
||||
Entry::title(path),
|
||||
item("Open file", Message::SourceFile(path.clone()), true),
|
||||
item("Reveal active file", Message::SourceTreeLocate, true),
|
||||
Entry::separator(),
|
||||
item(
|
||||
"Close detached buffer",
|
||||
Message::SourceCloseFile(path.clone()),
|
||||
!app.source_workspace.reachable.contains(path) && !buffer.dirty(),
|
||||
),
|
||||
Entry::title(if app.source_workspace.reachable.contains(path) {
|
||||
"Included files remain part of the project"
|
||||
} else if buffer.dirty() {
|
||||
"Apply or revert this file before closing"
|
||||
} else {
|
||||
"Closing leaves the file on disk"
|
||||
}),
|
||||
];
|
||||
return Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
});
|
||||
}
|
||||
if app.page == Page::Behavior
|
||||
&& let Some(menu) = crate::behavior::state_menu::popup(app, opened, clean)
|
||||
{
|
||||
return Some(menu);
|
||||
}
|
||||
let mut entries = vec![Entry::title(crate::views::short(p.named(id), 38))];
|
||||
if !clean {
|
||||
entries.push(Entry::title("Apply or revert pending edits first"));
|
||||
}
|
||||
|
||||
if request.canvas.is_some() && app.active_diagram == Some(id) {
|
||||
entries.extend([
|
||||
item("View properties", Message::EditProperties(id), true),
|
||||
item("Fit diagram", Message::Fit, true),
|
||||
item(
|
||||
"Arrange elements",
|
||||
Message::Command(Command::Layout(id)),
|
||||
true,
|
||||
),
|
||||
item("Export SVG…", Message::Export, true),
|
||||
Entry::separator(),
|
||||
item(
|
||||
&format!("Create {} at root", app.create_kind.label()),
|
||||
Message::Add(false),
|
||||
validation::can_parent(p, app.create_kind, app.layer, None),
|
||||
),
|
||||
item("Browse existing elements…", Message::Explore(None), true),
|
||||
]);
|
||||
} else {
|
||||
if request.route_point.is_some()
|
||||
|| request.bend.is_some()
|
||||
|| (request.canvas.is_some() && p.relations.contains_key(&id))
|
||||
{
|
||||
let diagram = app.active_diagram.and_then(|d| p.diagrams.get(&d));
|
||||
let view = diagram.map(|d| projection::project_view(p, d.id));
|
||||
let projected = view
|
||||
.as_ref()
|
||||
.and_then(|v| v.edges.iter().find(|e| e.id == id));
|
||||
let editable = diagram.zip(projected).is_some_and(|(d, e)| {
|
||||
projection::edge_endpoint_placements(p, d, id)
|
||||
.is_none_or(|(s, t)| s == e.source && t == e.target)
|
||||
});
|
||||
let route = diagram.and_then(|d| d.routes.iter().find(|r| r.edge == id));
|
||||
if let Some(point) = request.route_point {
|
||||
entries.push(
|
||||
item(
|
||||
"Add bend here",
|
||||
Message::Route(route_edit::Event::AddBendAt(point)),
|
||||
editable && route.is_none_or(|r| r.waypoints.len() < 128),
|
||||
)
|
||||
.shortcut("Insert"),
|
||||
);
|
||||
} else {
|
||||
entries.push(
|
||||
item(
|
||||
"Add bend",
|
||||
Message::Route(route_edit::Event::AddBend),
|
||||
editable && route.is_none_or(|r| r.waypoints.len() < 128),
|
||||
)
|
||||
.shortcut("Insert"),
|
||||
);
|
||||
}
|
||||
if request.bend.is_some() {
|
||||
entries.push(
|
||||
item(
|
||||
"Remove bend",
|
||||
Message::Route(route_edit::Event::RemoveBend),
|
||||
editable,
|
||||
)
|
||||
.shortcut("Delete"),
|
||||
);
|
||||
}
|
||||
entries.push(item(
|
||||
"Reset routing",
|
||||
Message::Route(route_edit::Event::Reset),
|
||||
editable && route.is_some(),
|
||||
));
|
||||
if !editable {
|
||||
entries.push(Entry::title("Expand endpoint containers to edit routing"));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
}
|
||||
entries.push(item("Properties", Message::EditProperties(id), true));
|
||||
entries.push(item("Open source", Message::SourceForSelection, true));
|
||||
entries.push(item(
|
||||
"Connections & references",
|
||||
Message::InspectorTab(crate::navigation::InspectorTab::Connections),
|
||||
true,
|
||||
));
|
||||
if let Some(e) = p.elements.get(&id) {
|
||||
if e.kind() == Kind::Port
|
||||
&& app.page == Page::Diagram
|
||||
&& let Some(context) = app.route_context()
|
||||
&& let Some(port) = projection::project_view(p, context.diagram)
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| port.element == id)
|
||||
{
|
||||
let manual = p.diagrams[&context.diagram].nodes[&port.owner]
|
||||
.port_anchors
|
||||
.iter()
|
||||
.any(|anchor| anchor.port == id);
|
||||
entries.push(item(
|
||||
"Reset automatic position",
|
||||
Message::PortAnchor(crate::port_edit::Event::Reset(context, port.owner, id)),
|
||||
manual,
|
||||
));
|
||||
}
|
||||
if app.page == Page::Diagram {
|
||||
for tool in crate::connection_tool::Tool::for_layer(e.layer()) {
|
||||
if tool.can_start(p, id) {
|
||||
entries.push(item(
|
||||
&format!("Draw {}", tool.label().to_lowercase()),
|
||||
Message::Connection(crate::connection_edit::Event::Begin(*tool, id)),
|
||||
app.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::visible(&projection::project_view(
|
||||
p, diagram,
|
||||
))
|
||||
.any(|element| element == id)
|
||||
}),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(owner) = e.parent {
|
||||
entries.push(item("Open owner", Message::Select(owner), true));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
for action in port_workflow::actions(p, id) {
|
||||
if matches!(
|
||||
action,
|
||||
port_workflow::Action::Flow | port_workflow::Action::Standard
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let caption = match action {
|
||||
port_workflow::Action::Component => "Add port…",
|
||||
port_workflow::Action::Input => "Add input port…",
|
||||
port_workflow::Action::Output => "Add output port…",
|
||||
_ => action.label(),
|
||||
};
|
||||
entries.push(item(caption, Message::PortAction(action), true));
|
||||
}
|
||||
if let Some(PortContract::Component { kind, orientation }) = port_contract(e) {
|
||||
entries.push(item(
|
||||
"Configure boundary exchange…",
|
||||
Message::AddRelationKind(
|
||||
RelationKind::BoundaryExchange,
|
||||
*kind == ComponentPortKind::Flow
|
||||
&& *orientation == ComponentPortOrientation::In,
|
||||
),
|
||||
true,
|
||||
));
|
||||
}
|
||||
let work = if e.layer() == Layer::Oa {
|
||||
Kind::Activity
|
||||
} else {
|
||||
Kind::Function
|
||||
};
|
||||
if e.kind().is_performer() || e.kind().is_work() {
|
||||
entries.push(item(
|
||||
if e.kind().is_performer() {
|
||||
"Create allocated work"
|
||||
} else {
|
||||
"Create child work"
|
||||
},
|
||||
Message::CreateContextElement(work, true),
|
||||
true,
|
||||
));
|
||||
} else if validation::can_parent(p, app.create_kind, e.layer(), Some(id))
|
||||
&& app.create_kind != Kind::Port
|
||||
{
|
||||
entries.push(item(
|
||||
&format!("Create child {}", app.create_kind.label()),
|
||||
Message::CreateContextElement(app.create_kind, true),
|
||||
true,
|
||||
));
|
||||
}
|
||||
for (caption, kind, incoming) in relation_actions(e.kind()) {
|
||||
entries.push(item(
|
||||
caption,
|
||||
Message::AddRelationKind(*kind, *incoming),
|
||||
true,
|
||||
));
|
||||
}
|
||||
if let Some(node) = request.placement.and_then(|node| {
|
||||
app.active_diagram
|
||||
.and_then(|d| p.diagrams.get(&d))?
|
||||
.nodes
|
||||
.get(&node)
|
||||
}) {
|
||||
entries.push(Entry::separator());
|
||||
entries.push(item(
|
||||
if node.collapsed {
|
||||
"Unfold contents"
|
||||
} else {
|
||||
"Fold contents"
|
||||
},
|
||||
Message::ToggleCollapse,
|
||||
true,
|
||||
));
|
||||
entries.push(item("Placement properties", Message::Select(node.id), true));
|
||||
entries.push(item("Hide from this diagram", Message::HideSelected, true));
|
||||
} else if e.kind() != Kind::Port
|
||||
&& let Some(d) = app.active_diagram.and_then(|d| p.diagrams.get(&d))
|
||||
{
|
||||
entries.push(item(
|
||||
"Show in this diagram",
|
||||
Message::PlaceSelected,
|
||||
d.layer == e.layer() && d.projection.includes(e.kind()),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(r) = p.relations.get(&id) {
|
||||
for action in port_workflow::actions(p, id) {
|
||||
entries.push(item(action.label(), Message::PortAction(action), true));
|
||||
}
|
||||
entries.push(item(
|
||||
"Open source endpoint",
|
||||
Message::Select(r.source()),
|
||||
true,
|
||||
));
|
||||
entries.push(item(
|
||||
"Open target endpoint",
|
||||
Message::Select(r.target()),
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(Entry::separator());
|
||||
if p.layer_of(id).is_some() {
|
||||
entries.push(item("Create relationship…", Message::AddRelation, true));
|
||||
entries.push(item(
|
||||
"Create / update counterpart…",
|
||||
Message::StartTransition,
|
||||
true,
|
||||
));
|
||||
}
|
||||
entries.push(item(
|
||||
"Attach artifact reference…",
|
||||
Message::AddArtifact,
|
||||
true,
|
||||
));
|
||||
if id != p.id {
|
||||
entries.push(item("Delete from model…", Message::Delete, true));
|
||||
}
|
||||
}
|
||||
Some(Menu {
|
||||
token: opened.token,
|
||||
anchor: request.anchor,
|
||||
entries,
|
||||
on_close: Message::Menu(Event::Close),
|
||||
})
|
||||
}
|
||||
|
||||
fn relation_actions(kind: Kind) -> &'static [(&'static str, RelationKind, bool)] {
|
||||
match kind {
|
||||
Kind::OperationalMission | Kind::Mission => &[
|
||||
(
|
||||
"Exploit capability…",
|
||||
RelationKind::MissionCapability,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Involve participant…",
|
||||
RelationKind::MissionInvolvement,
|
||||
true,
|
||||
),
|
||||
],
|
||||
Kind::Capability | Kind::SystemCapability => &[
|
||||
(
|
||||
"Include capability…",
|
||||
RelationKind::CapabilityInclude,
|
||||
false,
|
||||
),
|
||||
("Extend capability…", RelationKind::CapabilityExtend, false),
|
||||
(
|
||||
"Specialize capability…",
|
||||
RelationKind::CapabilityGeneralization,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Involve participant or work…",
|
||||
RelationKind::CapabilityInvolvement,
|
||||
true,
|
||||
),
|
||||
],
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn send(app: &mut App, message: Message) {
|
||||
let _ = app.update(message);
|
||||
}
|
||||
fn choose(app: &App, caption: &str) -> Message {
|
||||
popup(app)
|
||||
.unwrap()
|
||||
.entries
|
||||
.into_iter()
|
||||
.find_map(|entry| match entry {
|
||||
Entry::Action { label, message, .. } if label == caption => message,
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("Expected enabled menu action {caption}"))
|
||||
}
|
||||
#[test]
|
||||
fn state_menu_folds_and_hides_only_its_captured_placement_and_rejects_stale_views() {
|
||||
use crate::behavior::state_edit;
|
||||
let mut project = Project::empty();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let state = project
|
||||
.add_element(Kind::State, Layer::Oa, "Ready", Some(region))
|
||||
.unwrap();
|
||||
let architecture = project.ensure_diagram(Layer::Oa);
|
||||
project.place(architecture, state, Position { x: 80., y: 90. });
|
||||
let first = state_edit::create_view(&project, machine).unwrap();
|
||||
let first_id = first.diagram;
|
||||
project = execute_batch(&project, &[first.command]).unwrap();
|
||||
let second = state_edit::create_view(&project, machine).unwrap();
|
||||
let second_id = second.diagram;
|
||||
project = execute_batch(&project, &[second.command]).unwrap();
|
||||
let placement = project.diagrams[&first_id]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|node| node.element == state)
|
||||
.unwrap()
|
||||
.id;
|
||||
let mut app = App::with_project(project.clone());
|
||||
send(&mut app, Message::Select(machine));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
app.behavior.diagram.diagram = Some(first_id);
|
||||
app.active_diagram = Some(architecture);
|
||||
let open_state = |app: &mut App| {
|
||||
let mut request = Request::object(state, Point::ORIGIN);
|
||||
request.canvas = app.state_context();
|
||||
request.placement = Some(placement);
|
||||
send(app, Message::Menu(Event::Open(request)));
|
||||
};
|
||||
|
||||
// A canvas menu from Architecture is stale on the state page even when
|
||||
// the architecture diagram itself remains the active architecture view.
|
||||
let mut wrong = Request::object(state, Point::ORIGIN);
|
||||
wrong.canvas = app.route_context();
|
||||
assert!(Opened::new(&app, wrong, 1).is_none());
|
||||
open_state(&mut app);
|
||||
let fold = choose(&app, "Fold contents");
|
||||
send(&mut app, fold);
|
||||
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
|
||||
assert_eq!(app.selected, state, "Folding retains semantic selection");
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, first_id);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
assert_eq!(
|
||||
app.project().diagrams[&architecture],
|
||||
project.diagrams[&architecture]
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&second_id],
|
||||
project.diagrams[&second_id]
|
||||
);
|
||||
assert_eq!(app.project().elements, project.elements);
|
||||
open_state(&mut app);
|
||||
let unfold = choose(&app, "Unfold contents");
|
||||
send(&mut app, unfold);
|
||||
assert_eq!(app.selected, state, "Unfolding retains semantic selection");
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
send(&mut app, Message::Undo);
|
||||
assert!(app.project().diagrams[&first_id].nodes[&placement].collapsed);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &project);
|
||||
|
||||
open_state(&mut app);
|
||||
let stale = choose(&app, "Fold contents");
|
||||
app.behavior.diagram.diagram = Some(second_id);
|
||||
send(&mut app, stale);
|
||||
assert_eq!(app.project(), &project);
|
||||
app.behavior.diagram.diagram = Some(first_id);
|
||||
open_state(&mut app);
|
||||
let hide = choose(&app, "Hide from this diagram");
|
||||
send(&mut app, hide);
|
||||
assert!(
|
||||
!app.project().diagrams[&first_id]
|
||||
.nodes
|
||||
.contains_key(&placement)
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&architecture],
|
||||
project.diagrams[&architecture]
|
||||
);
|
||||
assert_eq!(
|
||||
app.project().diagrams[&second_id],
|
||||
project.diagrams[&second_id]
|
||||
);
|
||||
assert_eq!(app.project().elements, project.elements);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &project);
|
||||
|
||||
// Placement properties deliberately select a placement record, but an
|
||||
// accepted numeric edit must keep the Behavior view's own context.
|
||||
let source_before = app.document.bundle().clone();
|
||||
open_state(&mut app);
|
||||
let properties = choose(&app, "Placement properties");
|
||||
send(&mut app, properties);
|
||||
assert_eq!(app.selected, placement);
|
||||
assert_eq!(
|
||||
app.draft.as_ref().unwrap().kind,
|
||||
crate::forms::RecordKind::Node(first_id)
|
||||
);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
let x = project.diagrams[&first_id].nodes[&placement].position.x + 37.25;
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/position/x".into(), x.to_string()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
send(&mut app, Message::Apply);
|
||||
assert!(!app.draft_dirty());
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.behavior.active_owner, Some(machine));
|
||||
assert_eq!(app.state_context().unwrap().diagram, first_id);
|
||||
assert_eq!(
|
||||
app.active_diagram,
|
||||
Some(architecture),
|
||||
"Placement Apply must not replace the Architecture view"
|
||||
);
|
||||
let mut expected = project.clone();
|
||||
expected
|
||||
.diagrams
|
||||
.get_mut(&first_id)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&placement)
|
||||
.unwrap()
|
||||
.position
|
||||
.x = x;
|
||||
assert_eq!(app.project(), &expected);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.project(), &project);
|
||||
assert_eq!(app.document.bundle(), &source_before);
|
||||
assert_eq!(app.page, Page::Behavior);
|
||||
assert_eq!(app.state_context().unwrap().diagram, first_id);
|
||||
assert_eq!(app.active_diagram, Some(architecture));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_menu_keeps_typed_creation_reachable_and_preserves_pending_properties() {
|
||||
let mut project = Project::empty();
|
||||
let machine = project
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Modes", None)
|
||||
.unwrap();
|
||||
let region = project
|
||||
.add_element(Kind::Region, Layer::Oa, "Region", Some(machine))
|
||||
.unwrap();
|
||||
let entry = project
|
||||
.add_element(Kind::EntryPoint, Layer::Oa, "Entry", Some(region))
|
||||
.unwrap();
|
||||
let mut app = App::with_project(project);
|
||||
send(&mut app, Message::Select(entry));
|
||||
send(&mut app, Message::Page(Page::Behavior));
|
||||
send(&mut app, open(entry, Point::ORIGIN));
|
||||
let start = choose(&app, "New transition with details…");
|
||||
send(&mut app, start);
|
||||
assert_eq!(app.behavior.pending_owner(), Some(machine));
|
||||
send(
|
||||
&mut app,
|
||||
Message::Behavior(crate::behavior::Event::States(
|
||||
crate::behavior::states::Event::Cancel,
|
||||
)),
|
||||
);
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Unapplied entry".into()),
|
||||
);
|
||||
let before = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
send(&mut app, open(entry, Point::ORIGIN));
|
||||
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
||||
entry,
|
||||
Entry::Action {
|
||||
message: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn right_click_targets_actor_without_changing_selection_until_action() {
|
||||
let mut app = App::new();
|
||||
let actor = app
|
||||
.project()
|
||||
.elements
|
||||
.values()
|
||||
.find(|e| e.kind() == Kind::SystemActor)
|
||||
.unwrap()
|
||||
.id;
|
||||
let selected = app.selected;
|
||||
let model = app.project().clone();
|
||||
send(&mut app, open(actor, Point::new(240., 180.)));
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.project(), &model);
|
||||
let action = choose(&app, "Add port…");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.selected, actor);
|
||||
assert!(matches!(
|
||||
&app.workflow,
|
||||
Some(crate::workflow::Workflow::Ports(_))
|
||||
));
|
||||
assert!(app.model_menu.is_none());
|
||||
assert_eq!(
|
||||
app.project(),
|
||||
&model,
|
||||
"Opening the existing creation workflow does not create a port"
|
||||
);
|
||||
send(&mut app, Message::Workflow(crate::workflow::Event::Apply));
|
||||
assert_eq!(app.project().elements.len(), model.elements.len() + 1);
|
||||
assert_eq!(app.project().elements[&app.selected].parent, Some(actor));
|
||||
assert_eq!(app.project().elements[&app.selected].kind(), Kind::Port);
|
||||
}
|
||||
#[test]
|
||||
fn menu_preserves_pending_properties_and_rejects_delayed_mutations() {
|
||||
let mut app = App::new();
|
||||
let target = app.project().elements.keys().next().copied().unwrap();
|
||||
let selected = app.selected;
|
||||
let model = app.project().clone();
|
||||
send(
|
||||
&mut app,
|
||||
Message::Field("/name".into(), "Unapplied project title".into()),
|
||||
);
|
||||
assert!(app.draft_dirty());
|
||||
let draft = app.draft.as_ref().unwrap().materialize().unwrap();
|
||||
send(&mut app, open(target, Point::ORIGIN));
|
||||
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
||||
entry,
|
||||
Entry::Action {
|
||||
message: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
let token = app.model_menu.as_ref().unwrap().token;
|
||||
send(
|
||||
&mut app,
|
||||
Message::Menu(Event::Invoke(token, Box::new(Message::Delete))),
|
||||
);
|
||||
assert_eq!(app.selected, selected);
|
||||
assert_eq!(app.project(), &model);
|
||||
assert_eq!(app.draft.as_ref().unwrap().materialize().unwrap(), draft);
|
||||
assert!(!app.delete_pending);
|
||||
}
|
||||
#[test]
|
||||
fn late_menu_actions_do_not_dismiss_new_menu_or_cross_view_context() {
|
||||
let mut app = App::new();
|
||||
let id = app.selected;
|
||||
send(&mut app, open(id, Point::ORIGIN));
|
||||
let old = choose(&app, "Open source");
|
||||
send(&mut app, open(id, Point::new(200., 80.)));
|
||||
let current = app.model_menu.as_ref().unwrap().token;
|
||||
send(&mut app, old);
|
||||
assert_eq!(app.model_menu.as_ref().unwrap().token, current);
|
||||
let stale = choose(&app, "Open source");
|
||||
app.canvas_epoch += 1;
|
||||
send(&mut app, stale);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert!(app.model_menu.is_none());
|
||||
}
|
||||
#[test]
|
||||
fn pending_source_allows_file_navigation_but_disables_model_actions() {
|
||||
let mut app = App::new();
|
||||
send(&mut app, Message::Page(Page::Text));
|
||||
send(
|
||||
&mut app,
|
||||
Message::SourceEdit(iced::widget::text_editor::Action::Edit(
|
||||
iced::widget::text_editor::Edit::Insert('x'),
|
||||
)),
|
||||
);
|
||||
assert!(app.source_dirty());
|
||||
let original = app.source_editor.text();
|
||||
let file = app.source_workspace.active.clone();
|
||||
let mut request = Request::object(app.project().id, Point::ORIGIN);
|
||||
request.source_file = Some(file.clone());
|
||||
send(&mut app, Message::Menu(Event::Open(request)));
|
||||
let action = choose(&app, "Open file");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.source_editor.text(), original);
|
||||
assert_eq!(app.source_workspace.active, file);
|
||||
let id = app.project().id;
|
||||
send(&mut app, open(id, Point::ORIGIN));
|
||||
assert!(popup(&app).unwrap().entries.iter().all(|entry| !matches!(
|
||||
entry,
|
||||
Entry::Action {
|
||||
message: Some(_),
|
||||
..
|
||||
}
|
||||
)));
|
||||
}
|
||||
#[test]
|
||||
fn add_bend_here_uses_context_point_and_one_source_aware_history_change() {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Activity, "Send", 0., 0.);
|
||||
let b = p.add(Kind::Activity, "Receive", 650., 0.);
|
||||
let edge = p.relate(RelationKind::Interaction, "Report", a, b);
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let mut app = App::with_project(p);
|
||||
let original = app.document.bundle().clone();
|
||||
let point = Position { x: 420., y: -80. };
|
||||
let mut request = Request::object(edge, Point::new(540., 360.));
|
||||
request.canvas = app.route_context();
|
||||
request.route_point = Some(point);
|
||||
send(&mut app, Message::Menu(Event::Open(request)));
|
||||
let action = choose(&app, "Add bend here");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.selected, edge);
|
||||
assert_eq!(app.selected_bend, Some((edge, 0)));
|
||||
assert_eq!(
|
||||
app.project().diagrams[&diagram].routes[0].waypoints,
|
||||
vec![point]
|
||||
);
|
||||
let changed = app.document.bundle().clone();
|
||||
assert_ne!(original, changed);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(app.document.bundle(), &original);
|
||||
send(&mut app, Message::Redo);
|
||||
assert_eq!(app.document.bundle(), &changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_from_explorer_places_unfolds_and_keeps_semantic_selection() {
|
||||
let mut p = Project::empty();
|
||||
let parent = p.add(Kind::Activity, "Respond", 0., 0.);
|
||||
let child = p
|
||||
.add_element(Kind::Activity, Layer::Oa, "Assess", Some(parent))
|
||||
.unwrap();
|
||||
let diagram = p.ensure_diagram(Layer::Oa);
|
||||
let parent_node = p.diagrams[&diagram]
|
||||
.nodes
|
||||
.values()
|
||||
.find(|n| n.element == parent)
|
||||
.unwrap()
|
||||
.id;
|
||||
p.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&parent_node)
|
||||
.unwrap()
|
||||
.collapsed = true;
|
||||
let mut app = App::with_project(p);
|
||||
let original = app.document.bundle().clone();
|
||||
send(&mut app, Message::Explore(Some(child)));
|
||||
send(&mut app, open(child, Point::new(90., 300.)));
|
||||
let action = choose(&app, "Show in this diagram");
|
||||
send(&mut app, action);
|
||||
assert_eq!(app.page, Page::Diagram);
|
||||
assert_eq!(app.selected, child);
|
||||
assert!(
|
||||
app.selected_node
|
||||
.is_some_and(|node| app.project().diagrams[&diagram].nodes[&node].element == child)
|
||||
);
|
||||
assert!(!app.project().diagrams[&diagram].nodes[&parent_node].collapsed);
|
||||
assert!(
|
||||
projection::project_view(app.project(), diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.any(|n| n.element == child)
|
||||
);
|
||||
send(&mut app, Message::Undo);
|
||||
assert_eq!(
|
||||
app.document.bundle(),
|
||||
&original,
|
||||
"One Undo restores both placement and folded ancestors"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,7 @@ pub fn view(p: &Project, selected: Id) -> Element<'_, Message> {
|
||||
);
|
||||
}
|
||||
if count == 0 {
|
||||
content = content.push(text("No semantic connections yet. Use Actions or right-click an element to add relationships.").size(12).color(style::MUTED));
|
||||
content = content.push(text("No semantic connections yet. Add relationships from Properties as the model develops.").size(12).color(style::MUTED));
|
||||
}
|
||||
content.into()
|
||||
}
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
//! Border dragging changes diagram presentation, never semantic ownership.
|
||||
use crate::route_edit::{Context, point, position};
|
||||
use iced::Point;
|
||||
use oa_core::{projection::*, *};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Commit(Context, Id, PortAnchor),
|
||||
Reset(Context, Id, Id),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Gesture {
|
||||
pub context: Context,
|
||||
pub owner: ProjectedNode,
|
||||
pub anchor: PortAnchor,
|
||||
pub start: Point,
|
||||
pub changed: bool,
|
||||
}
|
||||
impl Gesture {
|
||||
pub fn new(context: Context, owner: ProjectedNode, port: &ProjectedPort, start: Point) -> Self {
|
||||
let anchor = PortAnchor {
|
||||
port: port.element,
|
||||
side: port.side,
|
||||
offset: port_anchor_offset(&owner, port.side, port.position),
|
||||
};
|
||||
Self {
|
||||
context,
|
||||
owner,
|
||||
anchor,
|
||||
start,
|
||||
changed: false,
|
||||
}
|
||||
}
|
||||
pub fn move_to(&mut self, cursor: Point, zoom: f32) {
|
||||
if !self.changed && cursor.distance(self.start) * zoom < 3. {
|
||||
return;
|
||||
}
|
||||
self.changed = true;
|
||||
let distance = |side| {
|
||||
let offset = port_anchor_offset(&self.owner, side, position(cursor));
|
||||
cursor.distance(point(port_anchor_position(&self.owner, side, offset)))
|
||||
};
|
||||
let nearest = [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
]
|
||||
.into_iter()
|
||||
.min_by(|a, b| distance(*a).total_cmp(&distance(*b)))
|
||||
.unwrap();
|
||||
// Six screen pixels of hysteresis avoids flickering between corner sides.
|
||||
if distance(self.anchor.side) > distance(nearest) + 6. / zoom {
|
||||
self.anchor.side = nearest;
|
||||
}
|
||||
self.anchor.offset =
|
||||
(port_anchor_offset(&self.owner, self.anchor.side, position(cursor)) * 100.).round()
|
||||
/ 100.;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_anchor(diagram: &mut Diagram, node: Id, anchor: PortAnchor) {
|
||||
if let Some(node) = diagram.nodes.get_mut(&node) {
|
||||
if let Some(previous) = node.port_anchors.iter_mut().find(|a| a.port == anchor.port) {
|
||||
*previous = anchor;
|
||||
} else {
|
||||
node.port_anchors.push(anchor);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Graphical ownership edits intentionally reset anchors left on the old owner.
|
||||
/// Raw source/API edits remain strict and must explicitly repair their anchors.
|
||||
pub fn repair_owner_change(project: &Project, command: Command) -> Command {
|
||||
let change = match &command {
|
||||
Command::Reparent { element, parent } => Some((*element, *parent)),
|
||||
Command::EditElement(original, element) => Some((*original, element.parent)),
|
||||
_ => None,
|
||||
};
|
||||
let Some((port, parent)) = change.filter(|(id, parent)| {
|
||||
project
|
||||
.elements
|
||||
.get(id)
|
||||
.is_some_and(|e| e.kind() == Kind::Port && e.parent != *parent)
|
||||
}) else {
|
||||
return command;
|
||||
};
|
||||
let mut commands: Vec<_> = project
|
||||
.diagrams
|
||||
.values()
|
||||
.flat_map(|d| {
|
||||
d.nodes
|
||||
.values()
|
||||
.filter(|n| {
|
||||
Some(n.element) != parent && n.port_anchors.iter().any(|a| a.port == port)
|
||||
})
|
||||
.map(move |n| Command::RemovePortAnchor {
|
||||
diagram: d.id,
|
||||
node: n.id,
|
||||
port,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if commands.is_empty() {
|
||||
return command;
|
||||
}
|
||||
commands.push(command);
|
||||
Command::Batch(commands)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::app::{App, Message};
|
||||
|
||||
fn fixture() -> (App, Id, Id, Id) {
|
||||
let mut p = Project::empty();
|
||||
let a = p.add(Kind::Function, "Transmit", 100., 100.);
|
||||
let b = p.add(Kind::Function, "Alternate owner", 500., 100.);
|
||||
let port = Id::new_v4();
|
||||
let element = make_port(
|
||||
&p,
|
||||
port,
|
||||
a,
|
||||
"Output",
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![],
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
p.elements.insert(port, element);
|
||||
(App::with_project(p), a, b, port)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_border_drags_commit_exact_preview_and_restore_automatic_anchors_in_history() {
|
||||
for side in [
|
||||
PortAnchorSide::Left,
|
||||
PortAnchorSide::Right,
|
||||
PortAnchorSide::Top,
|
||||
PortAnchorSide::Bottom,
|
||||
] {
|
||||
let (mut app, owner, _, port_id) = fixture();
|
||||
let context = app.route_context().unwrap();
|
||||
let view = project_view(app.project(), context.diagram);
|
||||
let node = view.nodes.iter().find(|n| n.element == owner).unwrap();
|
||||
let port = view.ports.iter().find(|p| p.element == port_id).unwrap();
|
||||
let mut gesture = Gesture::new(context, node.clone(), port, point(port.position));
|
||||
let end = port_anchor_position(node, side, 0.25);
|
||||
gesture.move_to(point(end), 0.7);
|
||||
assert_eq!(gesture.anchor.side, side);
|
||||
assert_eq!(gesture.anchor.offset, 0.25);
|
||||
let before = app.document.bundle().clone();
|
||||
let _ = app.update(Message::PortAnchor(Event::Commit(
|
||||
context,
|
||||
node.id,
|
||||
gesture.anchor,
|
||||
)));
|
||||
let after = app.document.bundle().clone();
|
||||
let projected = project_view(app.project(), context.diagram);
|
||||
assert_eq!(
|
||||
projected.nodes.iter().find(|n| n.id == node.id).unwrap(),
|
||||
node
|
||||
);
|
||||
assert_eq!(
|
||||
projected
|
||||
.ports
|
||||
.iter()
|
||||
.find(|p| p.element == port_id)
|
||||
.unwrap()
|
||||
.position,
|
||||
end
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(*app.document.bundle(), before);
|
||||
let _ = app.update(Message::Redo);
|
||||
assert_eq!(*app.document.bundle(), after);
|
||||
let latest = app.route_context().unwrap();
|
||||
let _ = app.update(Message::PortAnchor(Event::Reset(latest, node.id, port_id)));
|
||||
assert!(
|
||||
app.project().diagrams[&context.diagram].nodes[&node.id]
|
||||
.port_anchors
|
||||
.is_empty()
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(*app.document.bundle(), after);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graphical_port_reparent_clears_old_anchors_in_the_same_history_operation() {
|
||||
let (mut app, owner, next, port) = fixture();
|
||||
let context = app.route_context().unwrap();
|
||||
let node = project_view(app.project(), context.diagram)
|
||||
.nodes
|
||||
.iter()
|
||||
.find(|n| n.element == owner)
|
||||
.unwrap()
|
||||
.id;
|
||||
let _ = app.update(Message::PortAnchor(Event::Commit(
|
||||
context,
|
||||
node,
|
||||
PortAnchor {
|
||||
port,
|
||||
side: PortAnchorSide::Top,
|
||||
offset: 0.5,
|
||||
},
|
||||
)));
|
||||
let before = app.document.bundle().clone();
|
||||
let revision = app.document.revision();
|
||||
let _ = app.update(Message::Command(Command::Reparent {
|
||||
element: port,
|
||||
parent: Some(next),
|
||||
}));
|
||||
assert_eq!(app.document.revision(), revision + 1, "{}", app.status);
|
||||
assert_eq!(app.project().elements[&port].parent, Some(next));
|
||||
assert!(
|
||||
app.project().diagrams[&context.diagram].nodes[&node]
|
||||
.port_anchors
|
||||
.is_empty()
|
||||
);
|
||||
let _ = app.update(Message::Undo);
|
||||
assert_eq!(*app.document.bundle(), before);
|
||||
}
|
||||
}
|
||||
@@ -157,9 +157,12 @@ pub fn port_context_rows(project: &Project, port: Id) -> Vec<String> {
|
||||
}
|
||||
if let Some(legacy) = legacy {
|
||||
rows.push(legacy_line(&context, legacy));
|
||||
if let Some(interface) = legacy.interface {
|
||||
for item in data::interface_items(project, interface) {
|
||||
origins.entry(item).or_default().push(format!(
|
||||
if let Some(interface) = legacy.interface
|
||||
&& let Some(SharedBody::Interface { items }) =
|
||||
context.element(interface).and_then(Element::shared)
|
||||
{
|
||||
for item in items {
|
||||
origins.entry(*item).or_default().push(format!(
|
||||
"preserved interface {} (suggestion only; not an active typed contract)",
|
||||
context.label(interface)
|
||||
));
|
||||
@@ -708,17 +711,11 @@ mod tests {
|
||||
let (other_fe, commands) = exchange(&mut project, source, target, vec![second]);
|
||||
project = execute_batch(&project, &commands).unwrap();
|
||||
let interface = add(&mut project, Kind::Interface, "Incident service");
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"First item",
|
||||
interface,
|
||||
first,
|
||||
);
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Second item",
|
||||
interface,
|
||||
second,
|
||||
project.elements.get_mut(&interface).unwrap().body = ElementBody::shared_in(
|
||||
Layer::Sa,
|
||||
SharedBody::Interface {
|
||||
items: vec![first, second],
|
||||
},
|
||||
);
|
||||
let connection = &project.relations[&boundary];
|
||||
let (a, b) = (connection.source(), connection.target());
|
||||
@@ -793,12 +790,8 @@ mod tests {
|
||||
..
|
||||
} = fixture();
|
||||
let interface = add(&mut project, Kind::Interface, "Preserved contract");
|
||||
project.relate(
|
||||
RelationKind::InterfaceItemAllocation,
|
||||
"Allocated item",
|
||||
interface,
|
||||
first,
|
||||
);
|
||||
project.elements.get_mut(&interface).unwrap().body =
|
||||
ElementBody::shared_in(Layer::Sa, SharedBody::Interface { items: vec![first] });
|
||||
let legacy = port(
|
||||
&mut project,
|
||||
source,
|
||||
|
||||
@@ -168,9 +168,12 @@ impl State {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(interface) = old.and_then(|old| old.interface) {
|
||||
let items = data::interface_items(p, interface);
|
||||
incoming.extend(items.iter().copied());
|
||||
if let Some(SharedBody::Interface { items }) = old
|
||||
.and_then(|old| old.interface)
|
||||
.and_then(|id| p.elements.get(&id))
|
||||
.and_then(|e| e.shared())
|
||||
{
|
||||
incoming.extend(items);
|
||||
outgoing.extend(items);
|
||||
}
|
||||
for r in p.relations.values() {
|
||||
|
||||
@@ -15,7 +15,6 @@ pub enum Event {
|
||||
SelectBend(Id, usize),
|
||||
Commit(Context, EdgeRoute, Option<usize>),
|
||||
AddBend,
|
||||
AddBendAt(oa_core::Position),
|
||||
RemoveBend,
|
||||
Reset,
|
||||
}
|
||||
|
||||
@@ -1,669 +0,0 @@
|
||||
//! Source paths are navigation groups, not model containment or include ownership.
|
||||
//! The workspace retains every buffer; expanding folders never changes its files.
|
||||
use crate::{context_menu, source_workspace::Workspace, style};
|
||||
use iced::{
|
||||
Element, Fill, Point,
|
||||
widget::{Space, button, column, container, row, scrollable, text, tooltip},
|
||||
};
|
||||
use oa_core::source_project::SourceDiagnostic;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
const ROW_HEIGHT: f32 = 32.;
|
||||
const INDENT: f32 = 16.;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
ToggleFolder(String),
|
||||
OpenFile(String),
|
||||
FileMenu(String, Point),
|
||||
}
|
||||
|
||||
/// Presentation only. Folder keys are exact logical prefixes without a final `/`.
|
||||
/// Start compact; explicit file navigation reveals that file's ancestors.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
expanded: BTreeSet<String>,
|
||||
}
|
||||
|
||||
impl State {
|
||||
pub fn toggle(&mut self, path: &str) {
|
||||
if !self.expanded.remove(path) {
|
||||
self.expanded.insert(path.into());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reveal(&mut self, file: &str) {
|
||||
for (offset, _) in file.match_indices('/') {
|
||||
self.expanded.insert(file[..offset].into());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn collapse_all(&mut self) {
|
||||
self.expanded.clear();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RowKind {
|
||||
Folder {
|
||||
expanded: bool,
|
||||
file_count: usize,
|
||||
},
|
||||
File {
|
||||
entry: bool,
|
||||
detached: bool,
|
||||
closable: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Row {
|
||||
/// Original logical file path or exact folder prefix. Never normalized here.
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub depth: usize,
|
||||
pub kind: RowKind,
|
||||
pub selected: bool,
|
||||
pub contains_active: bool,
|
||||
pub dirty_files: usize,
|
||||
pub diagnostics: usize,
|
||||
}
|
||||
|
||||
impl Row {
|
||||
pub fn tooltip(&self) -> String {
|
||||
let mut lines = vec![match &self.kind {
|
||||
RowKind::Folder { .. } => format!("{}/", self.path),
|
||||
RowKind::File { .. } => self.path.clone(),
|
||||
}];
|
||||
match self.kind {
|
||||
RowKind::Folder { file_count, .. } => {
|
||||
lines.push(format!(
|
||||
"{file_count} open source file{}",
|
||||
if file_count == 1 { "" } else { "s" }
|
||||
));
|
||||
if self.contains_active {
|
||||
lines.push("Contains the active source file".into());
|
||||
}
|
||||
}
|
||||
RowKind::File {
|
||||
entry, detached, ..
|
||||
} => {
|
||||
lines.push(if detached {
|
||||
"Detached buffer · closing never deletes its file".into()
|
||||
} else if entry {
|
||||
"Project entry".into()
|
||||
} else {
|
||||
"Included source file".into()
|
||||
});
|
||||
if self.selected {
|
||||
lines.push("Active source file".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if self.dirty_files > 0 {
|
||||
lines.push(format!(
|
||||
"{} modified source draft{}",
|
||||
self.dirty_files,
|
||||
if self.dirty_files == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
if self.diagnostics > 0 {
|
||||
lines.push(format!(
|
||||
"{} source diagnostic{}",
|
||||
self.diagnostics,
|
||||
if self.diagnostics == 1 { "" } else { "s" }
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Folder {
|
||||
name: String,
|
||||
folders: BTreeMap<String, usize>,
|
||||
files: Vec<usize>,
|
||||
file_count: usize,
|
||||
dirty_files: usize,
|
||||
diagnostics: usize,
|
||||
contains_active: bool,
|
||||
}
|
||||
|
||||
enum Pending {
|
||||
Folder(usize, String, usize),
|
||||
File(usize, usize),
|
||||
}
|
||||
|
||||
/// Build a flat arena and traverse only expanded folders. The arena avoids
|
||||
/// recursive construction, traversal and drop for deeply nested logical paths.
|
||||
pub fn rows(workspace: &Workspace, diagnostics: &[SourceDiagnostic], state: &State) -> Vec<Row> {
|
||||
let mut issue_counts = BTreeMap::<&str, usize>::new();
|
||||
for issue in diagnostics {
|
||||
*issue_counts.entry(&issue.path).or_default() += 1;
|
||||
}
|
||||
let mut folders = vec![Folder::default()];
|
||||
let mut files = Vec::with_capacity(workspace.files.len());
|
||||
for (path, buffer) in &workspace.files {
|
||||
let mut parts = path.split('/').peekable();
|
||||
let mut current = 0;
|
||||
let mut ancestors = vec![0];
|
||||
let mut name = "";
|
||||
while let Some(part) = parts.next() {
|
||||
if parts.peek().is_none() {
|
||||
name = part;
|
||||
break;
|
||||
}
|
||||
current = match folders[current].folders.get(part).copied() {
|
||||
Some(index) => index,
|
||||
None => {
|
||||
let index = folders.len();
|
||||
folders.push(Folder {
|
||||
name: part.into(),
|
||||
..Default::default()
|
||||
});
|
||||
folders[current].folders.insert(part.into(), index);
|
||||
index
|
||||
}
|
||||
};
|
||||
ancestors.push(current);
|
||||
}
|
||||
let detached = !workspace.reachable.contains(path);
|
||||
let dirty_files = usize::from(buffer.dirty());
|
||||
let diagnostics = issue_counts.get(path.as_str()).copied().unwrap_or(0);
|
||||
let selected = workspace.active == *path;
|
||||
for index in ancestors {
|
||||
let folder = &mut folders[index];
|
||||
folder.file_count += 1;
|
||||
folder.dirty_files += dirty_files;
|
||||
folder.diagnostics += diagnostics;
|
||||
folder.contains_active |= selected;
|
||||
}
|
||||
folders[current].files.push(files.len());
|
||||
files.push(Row {
|
||||
path: path.clone(),
|
||||
name: name.into(),
|
||||
depth: 0,
|
||||
kind: RowKind::File {
|
||||
entry: workspace.entry == *path,
|
||||
detached,
|
||||
closable: detached && !buffer.dirty(),
|
||||
},
|
||||
selected,
|
||||
contains_active: selected,
|
||||
dirty_files,
|
||||
diagnostics,
|
||||
});
|
||||
}
|
||||
|
||||
let mut pending = Vec::new();
|
||||
append_children(&mut pending, &folders, &files, 0, "", 0);
|
||||
let mut result = Vec::new();
|
||||
while let Some(next) = pending.pop() {
|
||||
match next {
|
||||
Pending::File(index, depth) => {
|
||||
let mut row = files[index].clone();
|
||||
row.depth = depth;
|
||||
result.push(row);
|
||||
}
|
||||
Pending::Folder(index, path, depth) => {
|
||||
let folder = &folders[index];
|
||||
let expanded = state.expanded.contains(&path);
|
||||
result.push(Row {
|
||||
path: path.clone(),
|
||||
name: folder.name.clone(),
|
||||
depth,
|
||||
kind: RowKind::Folder {
|
||||
expanded,
|
||||
file_count: folder.file_count,
|
||||
},
|
||||
selected: false,
|
||||
contains_active: folder.contains_active,
|
||||
dirty_files: folder.dirty_files,
|
||||
diagnostics: folder.diagnostics,
|
||||
});
|
||||
if expanded {
|
||||
append_children(&mut pending, &folders, &files, index, &path, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Locate the active file after `State::reveal`, retaining one ancestor row of
|
||||
/// context. If it is intentionally collapsed, locate its visible folder instead.
|
||||
pub fn active_offset(
|
||||
workspace: &Workspace,
|
||||
diagnostics: &[SourceDiagnostic],
|
||||
state: &State,
|
||||
) -> iced::widget::operation::AbsoluteOffset {
|
||||
let rows = rows(workspace, diagnostics, state);
|
||||
let active = rows
|
||||
.iter()
|
||||
.position(|row| row.selected)
|
||||
.or_else(|| rows.iter().rposition(|row| row.contains_active));
|
||||
active.map_or_else(iced::widget::operation::AbsoluteOffset::default, |index| {
|
||||
iced::widget::operation::AbsoluteOffset {
|
||||
x: rows[index].depth.saturating_sub(1) as f32 * INDENT,
|
||||
y: (index as f32 * ROW_HEIGHT - ROW_HEIGHT).max(0.),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn append_children(
|
||||
pending: &mut Vec<Pending>,
|
||||
folders: &[Folder],
|
||||
files: &[Row],
|
||||
parent: usize,
|
||||
prefix: &str,
|
||||
depth: usize,
|
||||
) {
|
||||
let folder = &folders[parent];
|
||||
let mut children = folder.files.clone();
|
||||
children.sort_by_cached_key(|index| {
|
||||
(
|
||||
files[*index].name.to_lowercase(),
|
||||
files[*index].path.clone(),
|
||||
)
|
||||
});
|
||||
pending.extend(
|
||||
children
|
||||
.into_iter()
|
||||
.rev()
|
||||
.map(|index| Pending::File(index, depth)),
|
||||
);
|
||||
let mut children: Vec<_> = folder.folders.values().copied().collect();
|
||||
children.sort_by_cached_key(|index| {
|
||||
(
|
||||
folders[*index].name.to_lowercase(),
|
||||
folders[*index].name.clone(),
|
||||
)
|
||||
});
|
||||
pending.extend(children.into_iter().rev().map(|index| {
|
||||
let name = &folders[index].name;
|
||||
let path = if prefix.is_empty() {
|
||||
name.clone()
|
||||
} else {
|
||||
format!("{prefix}/{name}")
|
||||
};
|
||||
Pending::Folder(index, path, depth)
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn view<'a>(
|
||||
workspace: &Workspace,
|
||||
diagnostics: &[SourceDiagnostic],
|
||||
state: &State,
|
||||
) -> Element<'a, Event> {
|
||||
let mut content = column![].spacing(0);
|
||||
for entry in rows(workspace, diagnostics, state) {
|
||||
let hint = entry.tooltip();
|
||||
let (symbol, action, muted) = match entry.kind {
|
||||
RowKind::Folder { expanded, .. } => (
|
||||
if expanded { "⌄" } else { "›" },
|
||||
Event::ToggleFolder(entry.path.clone()),
|
||||
false,
|
||||
),
|
||||
RowKind::File {
|
||||
entry: is_entry,
|
||||
detached,
|
||||
..
|
||||
} => (
|
||||
if is_entry {
|
||||
"◆"
|
||||
} else if detached {
|
||||
"○"
|
||||
} else {
|
||||
"·"
|
||||
},
|
||||
Event::OpenFile(entry.path.clone()),
|
||||
detached,
|
||||
),
|
||||
};
|
||||
let caption = row![
|
||||
text(symbol).size(15).width(18),
|
||||
text(entry.name)
|
||||
.size(12)
|
||||
.width(Fill)
|
||||
.wrapping(text::Wrapping::None)
|
||||
.color(if muted { style::MUTED } else { style::INK }),
|
||||
text(if entry.dirty_files > 0 { "*" } else { "" })
|
||||
.size(12)
|
||||
.color(style::TEAL),
|
||||
text(if entry.diagnostics > 0 { "!" } else { "" })
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
text(match entry.kind {
|
||||
RowKind::Folder { file_count, .. } => file_count.to_string(),
|
||||
RowKind::File { .. } => String::new(),
|
||||
})
|
||||
.size(11)
|
||||
.color(style::MUTED),
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Center);
|
||||
let selected = entry.selected
|
||||
|| (entry.contains_active
|
||||
&& matches!(
|
||||
entry.kind,
|
||||
RowKind::Folder {
|
||||
expanded: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
let control = button(caption)
|
||||
.width(210. - entry.depth.min(2) as f32 * INDENT)
|
||||
.height(ROW_HEIGHT)
|
||||
.padding([7, 6])
|
||||
.clip(true)
|
||||
.on_press(action)
|
||||
.style(if selected {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
});
|
||||
let control: Element<'a, Event> = if matches!(entry.kind, RowKind::File { .. }) {
|
||||
let path = entry.path;
|
||||
context_menu::Area::new(control, move |point| Event::FileMenu(path.clone(), point))
|
||||
.into()
|
||||
} else {
|
||||
control.into()
|
||||
};
|
||||
content = content.push(
|
||||
row![
|
||||
Space::new().width(entry.depth as f32 * INDENT),
|
||||
Space::new().width(8),
|
||||
container(Space::new())
|
||||
.width(1)
|
||||
.height(ROW_HEIGHT)
|
||||
.style(|_| container::Style {
|
||||
background: Some(style::LINE.into()),
|
||||
..Default::default()
|
||||
}),
|
||||
Space::new().width(7),
|
||||
tooltip(
|
||||
control,
|
||||
container(text(hint).size(12)).padding(8).style(style::card),
|
||||
tooltip::Position::Right,
|
||||
),
|
||||
]
|
||||
.height(ROW_HEIGHT)
|
||||
.align_y(iced::Center),
|
||||
);
|
||||
}
|
||||
if workspace.files.is_empty() {
|
||||
content = content.push(
|
||||
text("No source files are open.")
|
||||
.size(12)
|
||||
.color(style::MUTED),
|
||||
);
|
||||
}
|
||||
scrollable(content)
|
||||
.id("source-files")
|
||||
.height(Fill)
|
||||
.width(Fill)
|
||||
.direction(scrollable::Direction::Both {
|
||||
vertical: scrollable::Scrollbar::default(),
|
||||
horizontal: scrollable::Scrollbar::default(),
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use oa_core::{language, source_project::SourceBundle, validation::Severity};
|
||||
|
||||
fn workspace(paths: &[&str]) -> Workspace {
|
||||
Workspace::new(
|
||||
&SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: paths
|
||||
.iter()
|
||||
.map(|p| ((*p).into(), "// source\n".into()))
|
||||
.collect(),
|
||||
},
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn diagnostic(path: &str) -> SourceDiagnostic {
|
||||
SourceDiagnostic {
|
||||
path: path.into(),
|
||||
diagnostic: language::Diagnostic {
|
||||
severity: Severity::Error,
|
||||
code: "test".into(),
|
||||
object_id: None,
|
||||
field: None,
|
||||
line: 1,
|
||||
column: 1,
|
||||
message: "Source needs repair".into(),
|
||||
span: language::Span::default(),
|
||||
},
|
||||
related: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folders_are_compact_and_sorted_before_files_without_losing_duplicate_basenames() {
|
||||
let workspace = workspace(&[
|
||||
"project.arcadia",
|
||||
"z.arcadia",
|
||||
"A.arcadia",
|
||||
"sa/parts/shared.arcadia",
|
||||
"oa/shared.arcadia",
|
||||
"sa/other.arcadia",
|
||||
"sa/Parts/another.arcadia",
|
||||
]);
|
||||
let mut state = State::default();
|
||||
let compact = rows(&workspace, &[], &state);
|
||||
assert_eq!(
|
||||
compact
|
||||
.iter()
|
||||
.map(|row| row.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["oa", "sa", "A.arcadia", "project.arcadia", "z.arcadia"]
|
||||
);
|
||||
state.reveal("sa/parts/shared.arcadia");
|
||||
state.reveal("oa/shared.arcadia");
|
||||
let expanded = rows(&workspace, &[], &state);
|
||||
assert_eq!(
|
||||
expanded
|
||||
.iter()
|
||||
.map(|row| row.path.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
[
|
||||
"oa",
|
||||
"oa/shared.arcadia",
|
||||
"sa",
|
||||
"sa/Parts",
|
||||
"sa/parts",
|
||||
"sa/parts/shared.arcadia",
|
||||
"sa/other.arcadia",
|
||||
"A.arcadia",
|
||||
"project.arcadia",
|
||||
"z.arcadia"
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
expanded
|
||||
.iter()
|
||||
.filter(|row| row.name == "shared.arcadia")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
expanded
|
||||
.iter()
|
||||
.find(|row| row.path == "sa/parts/shared.arcadia")
|
||||
.unwrap()
|
||||
.depth,
|
||||
2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_folders_retain_active_dirty_and_diagnostic_markers() {
|
||||
let mut workspace = workspace(&[
|
||||
"project.arcadia",
|
||||
"sa/parts/first.arcadia",
|
||||
"sa/second.arcadia",
|
||||
]);
|
||||
workspace.active = "sa/parts/first.arcadia".into();
|
||||
workspace
|
||||
.files
|
||||
.get_mut(&workspace.active)
|
||||
.unwrap()
|
||||
.text
|
||||
.push_str("// draft\n");
|
||||
let issues = [
|
||||
diagnostic(&workspace.active),
|
||||
diagnostic("sa/second.arcadia"),
|
||||
diagnostic("missing.arcadia"),
|
||||
];
|
||||
let mut state = State::default();
|
||||
let compact = rows(&workspace, &issues, &state);
|
||||
let folder = compact.iter().find(|row| row.path == "sa").unwrap();
|
||||
assert!(folder.contains_active);
|
||||
assert!(!folder.selected);
|
||||
assert_eq!((folder.dirty_files, folder.diagnostics), (1, 2));
|
||||
assert!(folder.tooltip().contains("Contains the active source file"));
|
||||
state.reveal(&workspace.active);
|
||||
let expanded = rows(&workspace, &issues, &state);
|
||||
let active = expanded.iter().find(|row| row.selected).unwrap();
|
||||
assert_eq!(active.path, workspace.active);
|
||||
assert_eq!((active.dirty_files, active.diagnostics), (1, 1));
|
||||
assert!(active.tooltip().contains("1 modified source draft"));
|
||||
state.collapse_all();
|
||||
assert_eq!(rows(&workspace, &issues, &state), compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detached_buffers_keep_paths_and_only_clean_buffers_are_closable() {
|
||||
let mut workspace = workspace(&[
|
||||
"project.arcadia",
|
||||
"archive/clean.arcadia",
|
||||
"archive/draft.arcadia",
|
||||
]);
|
||||
workspace.reachable.retain(|path| path == "project.arcadia");
|
||||
workspace
|
||||
.files
|
||||
.get_mut("archive/draft.arcadia")
|
||||
.unwrap()
|
||||
.text
|
||||
.push_str("// retain\n");
|
||||
let mut state = State::default();
|
||||
state.toggle("archive");
|
||||
let all = rows(&workspace, &[], &state);
|
||||
assert_eq!(
|
||||
all.iter()
|
||||
.find(|row| row.path == "archive/clean.arcadia")
|
||||
.unwrap()
|
||||
.kind,
|
||||
RowKind::File {
|
||||
entry: false,
|
||||
detached: true,
|
||||
closable: true
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
all.iter()
|
||||
.find(|row| row.path == "archive/draft.arcadia")
|
||||
.unwrap()
|
||||
.kind,
|
||||
RowKind::File {
|
||||
entry: false,
|
||||
detached: true,
|
||||
closable: false
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
all.iter()
|
||||
.find(|row| row.path == "project.arcadia")
|
||||
.unwrap()
|
||||
.kind,
|
||||
RowKind::File {
|
||||
entry: true,
|
||||
detached: false,
|
||||
closable: false
|
||||
}
|
||||
);
|
||||
state.toggle("archive");
|
||||
assert_eq!(rows(&workspace, &[], &state).len(), 2);
|
||||
assert_eq!(workspace.files.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_unicode_spaces_and_file_folder_name_collisions_are_not_normalized() {
|
||||
let paths = [
|
||||
"project.arcadia",
|
||||
"Réponse équipe/États du système.arcadia",
|
||||
"same.arcadia",
|
||||
"same.arcadia/nested.arcadia",
|
||||
];
|
||||
let workspace = workspace(&paths);
|
||||
let mut state = State::default();
|
||||
for path in paths {
|
||||
state.reveal(path);
|
||||
}
|
||||
let all = rows(&workspace, &[], &state);
|
||||
let file_paths: BTreeSet<_> = all
|
||||
.iter()
|
||||
.filter(|row| matches!(row.kind, RowKind::File { .. }))
|
||||
.map(|row| row.path.as_str())
|
||||
.collect();
|
||||
assert_eq!(file_paths, paths.into_iter().collect());
|
||||
assert_eq!(
|
||||
all.iter().filter(|row| row.path == "same.arcadia").count(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
all.iter()
|
||||
.find(|row| row.name == "États du système.arcadia")
|
||||
.unwrap()
|
||||
.tooltip()
|
||||
.starts_with("Réponse équipe/États du système.arcadia\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_paths_build_expand_and_drop_on_a_small_stack() {
|
||||
std::thread::Builder::new()
|
||||
.stack_size(128 * 1024)
|
||||
.spawn(|| {
|
||||
let file = format!("{}leaf.arcadia", "folder/".repeat(600));
|
||||
let workspace = workspace(&["project.arcadia", &file]);
|
||||
let mut state = State::default();
|
||||
assert_eq!(rows(&workspace, &[], &state).len(), 2);
|
||||
state.reveal(&file);
|
||||
let all = rows(&workspace, &[], &state);
|
||||
let leaf = all.iter().find(|row| row.path == file).unwrap();
|
||||
assert_eq!(leaf.depth, 600);
|
||||
assert_eq!(all.len(), 602);
|
||||
})
|
||||
.unwrap()
|
||||
.join()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_scroll_offset_uses_the_visible_order_and_retains_ancestor_context() {
|
||||
let mut workspace = workspace(&[
|
||||
"project.arcadia",
|
||||
"a/first.arcadia",
|
||||
"b/inner/deeper/active.arcadia",
|
||||
]);
|
||||
workspace.active = "b/inner/deeper/active.arcadia".into();
|
||||
let mut state = State::default();
|
||||
state.reveal("a/first.arcadia");
|
||||
state.reveal(&workspace.active);
|
||||
let offset = active_offset(&workspace, &[], &state);
|
||||
assert_eq!((offset.x, offset.y), (32., 128.));
|
||||
state.toggle("b/inner");
|
||||
let offset = active_offset(&workspace, &[], &state);
|
||||
assert_eq!((offset.x, offset.y), (0., 64.));
|
||||
workspace.files.clear();
|
||||
let offset = active_offset(&workspace, &[], &state);
|
||||
assert_eq!((offset.x, offset.y), (0., 0.));
|
||||
}
|
||||
}
|
||||
@@ -250,7 +250,7 @@ fn opening_legacy_source_surfaces_report_without_modifying_original_bytes() {
|
||||
assert_eq!(app.page, Page::Validation);
|
||||
assert_eq!(app.project(), &original);
|
||||
assert!(!app.dirty());
|
||||
assert!(app.source_snapshot.starts_with("arcadia 4;"));
|
||||
assert!(app.source_snapshot.starts_with("arcadia 3;"));
|
||||
assert!(serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["schema_version"] == 2);
|
||||
}
|
||||
|
||||
@@ -1648,15 +1648,6 @@ fn adding_bend_between_close_ports_stays_outside_owner_clearance() {
|
||||
);
|
||||
assert_eq!(
|
||||
p.diagrams[&diagram].routes[0].waypoints,
|
||||
vec![Position {
|
||||
x: 242.,
|
||||
y: view
|
||||
.ports
|
||||
.iter()
|
||||
.find(|port| p.elements[&port.element].parent == Some(a))
|
||||
.unwrap()
|
||||
.position
|
||||
.y
|
||||
}]
|
||||
vec![Position { x: 242., y: 192. }]
|
||||
);
|
||||
}
|
||||
|
||||
+376
-150
@@ -41,6 +41,22 @@ pub fn heading<'a>(title: &str, subtitle: &str) -> View<'a, Message> {
|
||||
.into()
|
||||
}
|
||||
|
||||
fn port_role(element: &oa_core::Element) -> String {
|
||||
match port_contract(element) {
|
||||
Some(PortContract::FunctionInput { .. }) => "Input".into(),
|
||||
Some(PortContract::FunctionOutput { .. }) => "Output".into(),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Standard,
|
||||
..
|
||||
}) => "Standard".into(),
|
||||
Some(PortContract::Component {
|
||||
kind: ComponentPortKind::Flow,
|
||||
orientation,
|
||||
}) => format!("Flow · {orientation:?}"),
|
||||
_ => "Legacy".into(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn view(app: &App) -> View<'_, Message> {
|
||||
let p = app.project();
|
||||
let header = container(
|
||||
@@ -382,26 +398,23 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.spacing(10)
|
||||
.align_y(iced::Center);
|
||||
let canvas: View<'_, Message> = if let Some(id) = app.active_diagram {
|
||||
let program = DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: id,
|
||||
selected: app.selected,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: app
|
||||
.selected_bend
|
||||
.filter(|(edge, _)| *edge == app.selected)
|
||||
.map(|(_, i)| i),
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
connection: app.connection.as_ref(),
|
||||
};
|
||||
widget::sensor(widget::stack![
|
||||
widget::canvas(program).width(Fill).height(Fill),
|
||||
widget::canvas(crate::diagram::PortLabels(program))
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
])
|
||||
widget::sensor(
|
||||
widget::canvas(DiagramCanvas {
|
||||
project: app.project(),
|
||||
diagram: id,
|
||||
selected: app.selected,
|
||||
zoom: app.zoom,
|
||||
pan: app.pan,
|
||||
context: app.route_context().unwrap(),
|
||||
selected_bend: app
|
||||
.selected_bend
|
||||
.filter(|(edge, _)| *edge == app.selected)
|
||||
.map(|(_, i)| i),
|
||||
editable: !app.draft_dirty() && !app.source_dirty(),
|
||||
})
|
||||
.width(Fill)
|
||||
.height(Fill),
|
||||
)
|
||||
.on_show(Message::CanvasResized)
|
||||
.on_resize(Message::CanvasResized)
|
||||
.into()
|
||||
@@ -426,20 +439,7 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
.center(Fill)
|
||||
.into()
|
||||
};
|
||||
let choose = |tool: Option<crate::connection_tool::Tool>| {
|
||||
let active = app.connection.as_ref().map(|s| s.tool) == tool;
|
||||
button(text(tool.map_or("Select / move", |t| t.label())).size(12))
|
||||
.padding([8, 10])
|
||||
.on_press(Message::Connection(crate::connection_edit::Event::Choose(
|
||||
tool,
|
||||
)))
|
||||
.style(if active { button::primary } else { style::nav })
|
||||
};
|
||||
let mut palette = row![choose(None)].spacing(4);
|
||||
for tool in crate::connection_tool::Tool::for_layer(app.layer) {
|
||||
palette = palette.push(choose(Some(*tool)));
|
||||
}
|
||||
let mut tools = column![toolbar, modes, palette].spacing(8);
|
||||
let mut tools = column![toolbar, modes].spacing(8);
|
||||
if let Some(diagram) = app.active_diagram {
|
||||
let view = oa_core::projection::project_view(app.project(), diagram);
|
||||
let edge = view.edges.iter().find(|edge| edge.id == app.selected);
|
||||
@@ -451,24 +451,39 @@ fn diagram(app: &App) -> View<'_, Message> {
|
||||
)
|
||||
.is_some_and(|(source, target)| source != edge.source || target != edge.target)
|
||||
});
|
||||
let hint = if let Some(session) = &app.connection {
|
||||
if session.source.is_some() {
|
||||
if session.candidates.is_empty() {
|
||||
"No compatible targets are visible. Expand containers or show an endpoint · Esc cancels"
|
||||
} else {
|
||||
"Choose a highlighted target · middle-drag or arrow keys pan · Esc cancels"
|
||||
}
|
||||
} else {
|
||||
session.tool.hint()
|
||||
}
|
||||
} else if promoted {
|
||||
let enabled = edge.is_some() && !promoted && !app.draft_dirty() && !app.source_dirty();
|
||||
let route = app.project().diagrams[&diagram]
|
||||
.routes
|
||||
.iter()
|
||||
.find(|r| r.edge == app.selected);
|
||||
let bend = app.selected_bend.filter(|(edge, index)| {
|
||||
*edge == app.selected && route.is_some_and(|r| *index < r.waypoints.len())
|
||||
});
|
||||
// Keep canvas origin/size stable when switching between a node and edge.
|
||||
tools = tools.push(
|
||||
row![
|
||||
button("Add bend").padding(8).on_press_maybe(
|
||||
(enabled && route.is_none_or(|r| r.waypoints.len() < 128))
|
||||
.then_some(Message::Route(crate::route_edit::Event::AddBend))
|
||||
),
|
||||
button("Remove bend").padding(8).on_press_maybe(
|
||||
(enabled && bend.is_some())
|
||||
.then_some(Message::Route(crate::route_edit::Event::RemoveBend))
|
||||
),
|
||||
button("Reset routing").padding(8).on_press_maybe(
|
||||
(enabled && route.is_some())
|
||||
.then_some(Message::Route(crate::route_edit::Event::Reset))
|
||||
),
|
||||
]
|
||||
.spacing(7),
|
||||
);
|
||||
tools = tools.push(label(if promoted {
|
||||
"Expand endpoint containers to edit this connection route"
|
||||
} else if edge.is_some() {
|
||||
"Right-click for routing · drag line / bend / label · Insert adds a bend · Esc cancels"
|
||||
"Drag a line to add a bend · drag circles or the label · Esc cancels"
|
||||
} else {
|
||||
"Right-click for actions · drag to arrange · scroll to zoom"
|
||||
};
|
||||
tools = tools.push(container(label(hint)).height(30));
|
||||
"Drag to arrange · select a connection to edit its route · scroll to zoom"
|
||||
}));
|
||||
}
|
||||
column![container(tools).padding(14), canvas].into()
|
||||
}
|
||||
@@ -539,7 +554,7 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
row![
|
||||
label("PROPERTIES"),
|
||||
Space::new().width(Fill),
|
||||
crate::model_menu::trigger(app)
|
||||
action("Source", Message::SourceForSelection)
|
||||
]
|
||||
.align_y(iced::Center)
|
||||
]
|
||||
@@ -607,47 +622,83 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if e.kind() == Kind::Port {
|
||||
body = body.push(label(
|
||||
"Shown automatically on the boundary of its visible owner.",
|
||||
));
|
||||
} else if app
|
||||
.active_diagram
|
||||
.and_then(|id| p.diagrams.get(&id))
|
||||
.is_some_and(|d| !d.projection.includes(e.kind()))
|
||||
{
|
||||
body = body.push(label(
|
||||
"This element is outside the selected view's content.",
|
||||
));
|
||||
body = body.push(action(
|
||||
"Use all-relationships view",
|
||||
Message::Projection(ProjectionKind::Overview),
|
||||
));
|
||||
} else if let Some(node) = app.selected_node {
|
||||
body = body.push(
|
||||
row![
|
||||
action("Placement", Message::Select(node)),
|
||||
action("Fold / unfold", Message::ToggleCollapse),
|
||||
action("Hide", Message::HideSelected)
|
||||
]
|
||||
.spacing(2),
|
||||
);
|
||||
} else {
|
||||
body = body.push(action("Show in this diagram", Message::PlaceSelected));
|
||||
}
|
||||
}
|
||||
// Keep a discoverable primary action; secondary actions live in Actions.
|
||||
if p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| matches!(e.kind(), Kind::System | Kind::SystemActor))
|
||||
{
|
||||
body = body.push(
|
||||
button(text("+ Add port…").size(12))
|
||||
.padding(9)
|
||||
.width(Fill)
|
||||
.on_press(Message::PortAction(crate::port_workflow::Action::Component)),
|
||||
);
|
||||
let kind = p.elements.get(&app.selected).map(Element::kind);
|
||||
let port_actions = crate::port_workflow::actions(p, app.selected);
|
||||
if !port_actions.is_empty() {
|
||||
body = body.push(label("PORTS & EXCHANGES"));
|
||||
for port_action in port_actions {
|
||||
use crate::port_workflow::Action;
|
||||
if matches!(port_action, Action::Flow | Action::Standard) {
|
||||
continue;
|
||||
}
|
||||
let caption = match port_action {
|
||||
Action::Component => "+ Add port…",
|
||||
Action::Input => "+ Input port…",
|
||||
Action::Output => "+ Output port…",
|
||||
_ => port_action.label(),
|
||||
};
|
||||
body = body.push(action(caption, Message::PortAction(port_action)).width(Fill));
|
||||
}
|
||||
if matches!(kind, Some(Kind::System | Kind::SystemActor)) {
|
||||
body = body.push(label(
|
||||
"Ports belong to this participant and connect boundary exchanges.",
|
||||
));
|
||||
}
|
||||
}
|
||||
let ports: Vec<_> = p
|
||||
.children(app.selected)
|
||||
.filter(|e| e.kind() == Kind::Port)
|
||||
.collect();
|
||||
if !ports.is_empty() {
|
||||
body = body.push(label(format!("OWNED PORTS · {}", ports.len())));
|
||||
for port in ports {
|
||||
body = body.push(
|
||||
action(
|
||||
&format!("{} · {}", port.name, port_role(port)),
|
||||
Message::Select(port.id),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
if p.elements
|
||||
.get(&app.selected)
|
||||
.is_some_and(|e| matches!(port_contract(e), Some(PortContract::Component { .. })))
|
||||
{
|
||||
let draw = app.page == Page::Diagram
|
||||
&& crate::connection_tool::Tool::BoundaryExchange.can_start(p, app.selected)
|
||||
&& app.active_diagram.is_some_and(|diagram| {
|
||||
crate::connection_edit::visible(&projection::project_view(p, diagram))
|
||||
.any(|id| id == app.selected)
|
||||
});
|
||||
body = body.push(
|
||||
button(
|
||||
text(if draw {
|
||||
"Draw boundary exchange"
|
||||
} else {
|
||||
"Configure boundary exchange…"
|
||||
})
|
||||
.size(12),
|
||||
)
|
||||
.padding(9)
|
||||
.width(Fill)
|
||||
.on_press(if draw {
|
||||
Message::Connection(crate::connection_edit::Event::Begin(
|
||||
crate::connection_tool::Tool::BoundaryExchange,
|
||||
app.selected,
|
||||
))
|
||||
} else {
|
||||
Message::AddRelationKind(
|
||||
button(text("Connect boundary exchange…").size(12))
|
||||
.padding(10)
|
||||
.width(Fill)
|
||||
.on_press(Message::AddRelationKind(
|
||||
RelationKind::BoundaryExchange,
|
||||
p.elements.get(&app.selected).is_some_and(|e| {
|
||||
matches!(
|
||||
@@ -658,8 +709,62 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
})
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)),
|
||||
);
|
||||
body = body.push(label(
|
||||
"Choose the other participant's port in the next step.",
|
||||
));
|
||||
}
|
||||
let contextual: &[(&str, RelationKind, bool)] = match kind {
|
||||
Some(Kind::OperationalMission | Kind::Mission) => &[
|
||||
(
|
||||
"Exploit capability…",
|
||||
RelationKind::MissionCapability,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Involve participant…",
|
||||
RelationKind::MissionInvolvement,
|
||||
true,
|
||||
),
|
||||
],
|
||||
Some(Kind::Capability | Kind::SystemCapability) => &[
|
||||
(
|
||||
"Include capability…",
|
||||
RelationKind::CapabilityInclude,
|
||||
false,
|
||||
),
|
||||
("Extend capability…", RelationKind::CapabilityExtend, false),
|
||||
(
|
||||
"Specialize capability…",
|
||||
RelationKind::CapabilityGeneralization,
|
||||
false,
|
||||
),
|
||||
(
|
||||
"Involve participant or work…",
|
||||
RelationKind::CapabilityInvolvement,
|
||||
true,
|
||||
),
|
||||
],
|
||||
_ => &[],
|
||||
};
|
||||
if !contextual.is_empty() {
|
||||
let choices = contextual
|
||||
.iter()
|
||||
.map(|(label, kind, incoming)| crate::workflow::RelationAction {
|
||||
label,
|
||||
kind: *kind,
|
||||
incoming: *incoming,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
body = body.push(
|
||||
pick_list(choices, None::<crate::workflow::RelationAction>, |choice| {
|
||||
Message::AddRelationKind(choice.kind, choice.incoming)
|
||||
})
|
||||
.placeholder("Modeling actions…")
|
||||
.padding(9)
|
||||
.text_size(12)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if let Some(r) = p.relations.get(&app.selected) {
|
||||
@@ -683,6 +788,82 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
if let Some(d) = &app.draft {
|
||||
body = body.push(forms::view(d, p));
|
||||
}
|
||||
let links = p
|
||||
.relations
|
||||
.values()
|
||||
.filter(|r| r.source() == app.selected || r.target() == app.selected)
|
||||
.collect::<Vec<_>>();
|
||||
if !links.is_empty() {
|
||||
body = body
|
||||
.push(Space::new().height(6))
|
||||
.push(label("RELATED ELEMENTS"));
|
||||
for r in links {
|
||||
let other = if r.source() == app.selected {
|
||||
r.target()
|
||||
} else {
|
||||
r.source()
|
||||
};
|
||||
body = body.push(
|
||||
container(
|
||||
column![
|
||||
action(
|
||||
&format!("{} · {}", r.kind(), short(&r.name, 24)),
|
||||
Message::Select(r.id)
|
||||
)
|
||||
.width(Fill),
|
||||
action(
|
||||
&format!("→ {}", short(p.named(other), 34)),
|
||||
Message::Select(other)
|
||||
)
|
||||
.width(Fill)
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.style(style::subtle),
|
||||
);
|
||||
}
|
||||
}
|
||||
for t in p
|
||||
.traces
|
||||
.values()
|
||||
.filter(|t| t.oa == app.selected || t.sa == app.selected)
|
||||
{
|
||||
let other = if t.oa == app.selected { t.sa } else { t.oa };
|
||||
body = body.push(
|
||||
action(
|
||||
&format!(
|
||||
"{}: {}",
|
||||
if t.oa == app.selected {
|
||||
"Realized by"
|
||||
} else {
|
||||
"Realizes"
|
||||
},
|
||||
short(p.named(other), 29)
|
||||
),
|
||||
Message::Select(other),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
if p.layer_of(app.selected).is_some() {
|
||||
body = body
|
||||
.push(action("+ Relationship", Message::AddRelation).width(Fill))
|
||||
.push(
|
||||
button(text("Create / update counterpart…").size(12))
|
||||
.padding(10)
|
||||
.on_press(Message::StartTransition)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
body = body.push(action("+ Artifact reference", Message::AddArtifact).width(Fill));
|
||||
if app.selected != p.id {
|
||||
body = body.push(
|
||||
button(text("Delete from model…").size(11))
|
||||
.padding(9)
|
||||
.style(button::danger)
|
||||
.on_press(Message::Delete),
|
||||
);
|
||||
}
|
||||
container(
|
||||
column![
|
||||
tabs,
|
||||
@@ -703,7 +884,68 @@ fn inspector(app: &App) -> View<'_, Message> {
|
||||
}
|
||||
|
||||
fn data_catalog(app: &App) -> View<'_, Message> {
|
||||
crate::data_view::view(app)
|
||||
let p = app.project();
|
||||
let mut content = column![heading(
|
||||
"Data & interfaces",
|
||||
"Define information, material and energy at the system boundary."
|
||||
)]
|
||||
.spacing(15);
|
||||
for kind in Kind::ALL {
|
||||
let is_data = matches!(
|
||||
kind,
|
||||
Kind::ExchangeItem | Kind::DataType | Kind::DataField | Kind::Interface | Kind::Port
|
||||
);
|
||||
if !is_data {
|
||||
continue;
|
||||
}
|
||||
let elements = p
|
||||
.elements
|
||||
.values()
|
||||
.filter(|e| e.layer() == app.layer && e.kind() == kind)
|
||||
.collect::<Vec<_>>();
|
||||
if elements.is_empty() {
|
||||
continue;
|
||||
}
|
||||
content = content.push(
|
||||
text(format!("{} · {}", kind.label(), elements.len()))
|
||||
.size(15)
|
||||
.color(style::kind_color(kind)),
|
||||
);
|
||||
for e in elements {
|
||||
content = content.push(
|
||||
button(
|
||||
row![
|
||||
column![
|
||||
text(&e.name).size(14),
|
||||
label(format!(
|
||||
"@{}{}",
|
||||
e.alias,
|
||||
e.parent
|
||||
.map(|id| format!(" · in {}", p.named(id)))
|
||||
.unwrap_or_default()
|
||||
)),
|
||||
text(short(&e.description, 125))
|
||||
.size(12)
|
||||
.color(style::MUTED)
|
||||
]
|
||||
.spacing(5)
|
||||
.width(Fill),
|
||||
text("→").size(17)
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
)
|
||||
.padding(14)
|
||||
.width(Fill)
|
||||
.style(if e.id == app.selected {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
})
|
||||
.on_press(Message::Select(e.id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
scrollable(content.padding(24)).into()
|
||||
}
|
||||
|
||||
fn traces(app: &App) -> View<'_, Message> {
|
||||
@@ -794,29 +1036,58 @@ fn traces(app: &App) -> View<'_, Message> {
|
||||
|
||||
fn source_files(app: &App) -> View<'_, Message> {
|
||||
let workspace = &app.source_workspace;
|
||||
let mut files = column![].spacing(3);
|
||||
for (path, buffer) in &workspace.files {
|
||||
let detached = !workspace.reachable.contains(path);
|
||||
let errors = app
|
||||
.source_diagnostics
|
||||
.iter()
|
||||
.filter(|issue| issue.path == *path)
|
||||
.count();
|
||||
let filename = path.rsplit('/').next().unwrap_or(path);
|
||||
let detail: String = if detached {
|
||||
"Detached · retained on disk".into()
|
||||
} else if path == &workspace.entry {
|
||||
"Project entry".into()
|
||||
} else {
|
||||
path.rsplit_once('/')
|
||||
.map_or("Included source", |(parent, _)| parent)
|
||||
.into()
|
||||
};
|
||||
let caption = format!(
|
||||
"{}{}{}",
|
||||
if buffer.dirty() { "* " } else { "" },
|
||||
short(filename, 27),
|
||||
if errors > 0 { " !" } else { "" }
|
||||
);
|
||||
files = files.push(
|
||||
button(column![text(caption).size(12), label(detail)].spacing(3))
|
||||
.padding(8)
|
||||
.width(Fill)
|
||||
.on_press(Message::SourceFile(path.clone()))
|
||||
.style(if &workspace.active == path {
|
||||
style::selected
|
||||
} else {
|
||||
style::nav
|
||||
}),
|
||||
);
|
||||
if detached && !buffer.dirty() {
|
||||
files = files.push(
|
||||
action(
|
||||
"Close detached buffer",
|
||||
Message::SourceCloseFile(path.clone()),
|
||||
)
|
||||
.width(Fill),
|
||||
);
|
||||
}
|
||||
}
|
||||
let panel = column![
|
||||
row![
|
||||
text("SOURCE FILES").size(13).width(Fill),
|
||||
crate::context_menu::Area::left(
|
||||
action("Actions ▾", Message::Menu(crate::model_menu::Event::Close)),
|
||||
move |anchor| Message::SourceTree(crate::source_tree::Event::FileMenu(
|
||||
workspace.active.clone(),
|
||||
anchor
|
||||
))
|
||||
)
|
||||
]
|
||||
.align_y(iced::Center),
|
||||
text("SOURCE FILES").size(13),
|
||||
label(format!(
|
||||
"{} files · one system model",
|
||||
workspace.files.len()
|
||||
)),
|
||||
row![
|
||||
action("Collapse all", Message::SourceTreeCollapse),
|
||||
action("Locate", Message::SourceTreeLocate)
|
||||
]
|
||||
.spacing(3),
|
||||
crate::source_tree::view(workspace, &app.source_diagnostics, &app.source_tree)
|
||||
.map(Message::SourceTree),
|
||||
scrollable(files).height(Fill),
|
||||
action("Load includes", Message::SourceLoadIncludes).width(Fill),
|
||||
text_input("oa/new-file.arcadia", &workspace.new_path)
|
||||
.size(12)
|
||||
@@ -1037,51 +1308,6 @@ fn checks(app: &App) -> View<'_, Message> {
|
||||
app.project().named(endpoint.exchange)
|
||||
)));
|
||||
}
|
||||
card=card.push(label(format!("Data migration: {} semantic defaults · {} interface allocations · {} unresolved value addresses",report.semantic_defaults.len(),report.generated_interface_allocations.len(),report.unresolved_message_values.len())));
|
||||
for default in report.semantic_defaults.iter().take(6) {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!(
|
||||
"{} · {}",
|
||||
app.project().named(default.object),
|
||||
default.field
|
||||
),
|
||||
Message::Select(default.object),
|
||||
))
|
||||
.push(label(&default.description));
|
||||
}
|
||||
for allocation in report.generated_interface_allocations.iter().take(6) {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!(
|
||||
"Created item allocation · @{} · order {}",
|
||||
allocation.alias, allocation.order
|
||||
),
|
||||
Message::Select(allocation.id),
|
||||
))
|
||||
.push(label(format!(
|
||||
"{} → {}",
|
||||
app.project().named(allocation.source),
|
||||
app.project().named(allocation.target)
|
||||
)));
|
||||
}
|
||||
for value in report.unresolved_message_values.iter().take(6) {
|
||||
card = card
|
||||
.push(action(
|
||||
&format!(
|
||||
"Review qualified value · {}",
|
||||
app.project().named(value.occurrence)
|
||||
),
|
||||
Message::EditProperties(value.occurrence),
|
||||
))
|
||||
.push(label(&value.reason));
|
||||
}
|
||||
if report.semantic_defaults.len() > 6
|
||||
|| report.generated_interface_allocations.len() > 6
|
||||
|| report.unresolved_message_values.len() > 6
|
||||
{
|
||||
card=card.push(label("Showing the first six entries in each data migration category. Model checks below link remaining unresolved contracts."));
|
||||
}
|
||||
for boundary in &report.orientation_defaults {
|
||||
card = card.push(action(
|
||||
&format!(
|
||||
|
||||
@@ -13,6 +13,18 @@ use iced::{
|
||||
use oa_core::{transitions::*, *};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RelationAction {
|
||||
pub label: &'static str,
|
||||
pub kind: RelationKind,
|
||||
pub incoming: bool,
|
||||
}
|
||||
impl std::fmt::Display for RelationAction {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(self.label)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Event {
|
||||
Port(crate::port_workflow::Event),
|
||||
|
||||
@@ -231,7 +231,7 @@ fn parse_source(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
|
||||
}
|
||||
|
||||
fn import_json(body: &[u8]) -> ApiResult<oa_core::persistence::DecodeResult> {
|
||||
// Format is chosen by HTTP. The shared decoder owns supported older-schema migration.
|
||||
// Format is chosen by HTTP. The shared decoder owns explicit schema-1 and schema-2 migration.
|
||||
let _: Value = json_body(body)?;
|
||||
oa_core::persistence::decode_with_report_limited(body, MODEL_LIMITS).map_err(
|
||||
|error| match error {
|
||||
@@ -525,7 +525,7 @@ async fn put_source_project(
|
||||
StatusCode::OK,
|
||||
json!({
|
||||
"revision": stored.revision, "changed": changed, "issues": diagnostics,
|
||||
"source_project": stored.source_project, "migration": stored.migration
|
||||
"source_project": stored.source_project
|
||||
}),
|
||||
))
|
||||
})
|
||||
@@ -947,16 +947,14 @@ async fn validate_candidate(
|
||||
// Advisory locations refer to submitted bytes, before Apply
|
||||
// would insert missing persistent identities.
|
||||
let diagnostics: Vec<_> = source_project::lint_bundle(&source_project, SOURCE_LIMITS).into_iter().map(source_issue).collect();
|
||||
let migration = if !diagnostics.iter().any(|issue| issue.severity == "error") {
|
||||
PreparedProject::from_bundle(source_project)?.migration().cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((diagnostics, migration))
|
||||
if !diagnostics.iter().any(|issue| issue.severity == "error") {
|
||||
PreparedProject::from_bundle(source_project)?;
|
||||
}
|
||||
Ok(diagnostics)
|
||||
});
|
||||
let (diagnostics, migration) = result.unwrap_or_else(|error| (candidate_issues(error), None));
|
||||
let diagnostics = result.unwrap_or_else(candidate_issues);
|
||||
let valid = !diagnostics.iter().any(|issue| issue.severity == "error");
|
||||
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":migration})));
|
||||
return Ok(response(stored, StatusCode::OK, json!({"revision":stored.revision,"valid":valid,"issues":diagnostics,"migration":null})));
|
||||
}
|
||||
_ => return Err(ApiError::bad_request("Validation requires one matching format/payload: json with model, arcadia with source, or source-project with source_project.")),
|
||||
};
|
||||
|
||||
@@ -128,10 +128,9 @@ impl PreparedProject {
|
||||
fn from_compiled(compiled: CompiledSourceProject) -> ApiResult<Self> {
|
||||
check_model_size(&compiled.decoded.project)?;
|
||||
check_source_size(&compiled.bundle)?;
|
||||
let migration = compiled.decoded.migration.clone();
|
||||
Ok(Self {
|
||||
compiled,
|
||||
migration,
|
||||
migration: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,10 +173,6 @@ impl PreparedProject {
|
||||
&self.compiled.bundle
|
||||
}
|
||||
|
||||
pub(crate) fn migration(&self) -> Option<&MigrationReport> {
|
||||
self.migration.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn source_issues(&self) -> Vec<crate::error::IssueDto> {
|
||||
self.compiled
|
||||
.diagnostics
|
||||
@@ -334,12 +329,7 @@ impl Store {
|
||||
let prepared = if let Some(bundle) = envelope.source_project {
|
||||
let prepared = PreparedProject::from_bundle(bundle.clone())
|
||||
.map_err(|error| corrupt(error.detail))?;
|
||||
// Explicit source upgrades may change tokens and generate new
|
||||
// records. They must still agree with independently migrated
|
||||
// JSON. Current-format unstabilized/inconsistent pairs reject.
|
||||
if (prepared.bundle() != &bundle && prepared.migration.is_none())
|
||||
|| prepared.model() != &decoded.project
|
||||
{
|
||||
if prepared.bundle() != &bundle || prepared.model() != &decoded.project {
|
||||
return Err(corrupt(
|
||||
"Stored source and model disagree, or source lacks stable identities."
|
||||
.into(),
|
||||
@@ -357,7 +347,7 @@ impl Store {
|
||||
revision: envelope.revision,
|
||||
model: decoded.project,
|
||||
source_project: prepared.compiled.bundle,
|
||||
migration: decoded.migration.or(prepared.migration),
|
||||
migration: decoded.migration,
|
||||
};
|
||||
if stored.resource_id != filename_id || stored.revision == 0 {
|
||||
return Err(corrupt(
|
||||
|
||||
@@ -8,10 +8,6 @@ use serde_json::{Value, json};
|
||||
use std::{path::Path, sync::Arc};
|
||||
use tokio::{sync::oneshot, task::JoinHandle};
|
||||
|
||||
#[path = "http/data_contracts.rs"]
|
||||
mod data_contracts;
|
||||
#[path = "http/port_anchors.rs"]
|
||||
mod port_anchors;
|
||||
#[path = "http/source_projects.rs"]
|
||||
mod source_projects;
|
||||
|
||||
@@ -1931,7 +1927,7 @@ async fn legacy_json_import_and_model_reidentification_keep_the_service_address(
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_state() {
|
||||
async fn schema3_migration_reports_and_invalid_typed_transaction_preserve_http_state() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let old = v2_missing_ports();
|
||||
@@ -1947,7 +1943,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
|
||||
assert_eq!(imported.status(), StatusCode::CREATED);
|
||||
let etag = imported.headers()["etag"].to_str().unwrap().to_owned();
|
||||
let summary: Value = imported.json().await.unwrap();
|
||||
assert_eq!(summary["schema_version"], oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(summary["schema_version"], 3);
|
||||
assert_eq!(
|
||||
summary["migration"],
|
||||
serde_json::to_value(&expected.migration).unwrap()
|
||||
@@ -1960,7 +1956,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
|
||||
let before = std::fs::read(&filename).unwrap();
|
||||
let envelope: Value = serde_json::from_slice(&before).unwrap();
|
||||
assert!(envelope.get("migration").is_none());
|
||||
assert_eq!(envelope["model"]["schema_version"], oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(envelope["model"]["schema_version"], 3);
|
||||
let validation = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{id}/validation")))
|
||||
@@ -2002,7 +1998,7 @@ async fn current_migration_reports_and_invalid_typed_transaction_preserve_http_s
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 4;"));
|
||||
assert!(source.starts_with("arcadia 3;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), stored.model);
|
||||
let no_op = server
|
||||
.client
|
||||
@@ -2051,7 +2047,7 @@ async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_rev
|
||||
let migrated = server.get(id).await;
|
||||
assert_eq!(migrated.resource_id, id);
|
||||
assert_eq!(migrated.revision, 7);
|
||||
assert_eq!(migrated.model.schema_version, oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(migrated.model.schema_version, 3);
|
||||
assert_eq!(
|
||||
migrated
|
||||
.migration
|
||||
@@ -2076,7 +2072,7 @@ async fn reopening_v2_envelope_reports_migration_without_saving_or_advancing_rev
|
||||
assert_eq!(current.model.name, "Reviewed migrated model");
|
||||
let saved: Value = serde_json::from_slice(&std::fs::read(&filename).unwrap()).unwrap();
|
||||
assert_eq!(saved["revision"], 8);
|
||||
assert_eq!(saved["model"]["schema_version"], oa_core::SCHEMA_VERSION);
|
||||
assert_eq!(saved["model"]["schema_version"], 3);
|
||||
assert!(saved.get("migration").is_none());
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
@@ -1,734 +0,0 @@
|
||||
//! Transport evidence for the published R6 contract, not a second model fixture.
|
||||
use super::*;
|
||||
use oa_core::source_project::{ProjectLimits, SourceBundle, compile_bundle};
|
||||
use oa_core::{DataTypeDefinition, DataValue, SharedBody, TransmissionProtocol};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn documented_data() -> Project {
|
||||
let contract = include_str!("../../../../docs/language.rst");
|
||||
let example = contract
|
||||
.split("This complete data contract example is also exercised through the REST API:")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(".. code-block:: text")
|
||||
.nth(1)
|
||||
.unwrap();
|
||||
let source = example
|
||||
.lines()
|
||||
.skip_while(|line| line.trim().is_empty())
|
||||
.take_while(|line| line.starts_with(" ") || line.is_empty())
|
||||
.map(|line| line.strip_prefix(" ").unwrap_or(line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let model = oa_core::language::parse(&source).unwrap();
|
||||
oa_core::validate_candidate(&model).unwrap();
|
||||
model
|
||||
}
|
||||
fn id(model: &Project, alias: &str) -> Id {
|
||||
model
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
fn assert_pair(stored: &StoredProject) {
|
||||
let compiled = compile_bundle(&stored.source_project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, stored.model);
|
||||
assert_eq!(compiled.bundle, stored.source_project);
|
||||
}
|
||||
async fn commands(
|
||||
server: &TestServer,
|
||||
current: &StoredProject,
|
||||
commands: Vec<Command>,
|
||||
) -> reqwest::Response {
|
||||
server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&json!({"commands":commands}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn published_data_contract_queries_atomic_edits_identity_and_restart() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let model = documented_data();
|
||||
let (resource, _) = server.import(&model).await;
|
||||
let before = server.get(resource).await;
|
||||
assert_eq!(before.model, model);
|
||||
assert_pair(&before);
|
||||
for (kind, count) in [("InterfaceItemAllocation", 2), ("DataAssociation", 1)] {
|
||||
let result: Value = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/relations?kind={kind}")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result["items"].as_array().unwrap().len(), count);
|
||||
}
|
||||
let voltage = id(&model, "voltage");
|
||||
let mut invalid = model.elements[&voltage].clone();
|
||||
let Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Real { maximum, .. },
|
||||
..
|
||||
}) = invalid.shared_mut()
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*maximum = Some(12.0); // Existing default24V is outside this bound.
|
||||
let filename = directory.path().join(format!("{resource}.json"));
|
||||
let disk_before = std::fs::read(&filename).unwrap();
|
||||
assert_eq!(
|
||||
commands(
|
||||
&server,
|
||||
&before,
|
||||
vec![Command::EditElement(voltage, invalid.clone())]
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert_eq!(server.get(resource).await.etag(), before.etag());
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), disk_before);
|
||||
|
||||
let Some(SharedBody::DataType { default, .. }) = invalid.shared_mut() else {
|
||||
panic!()
|
||||
};
|
||||
*default = Some(DataValue::Real(12.0));
|
||||
let nav = id(&model, "nav_operation");
|
||||
let other = id(&model, "monitor_operation");
|
||||
let mut allocation = model.relations[&nav].clone();
|
||||
let oa_core::RelationBody::InterfaceItemAllocation { transmission, .. } = &mut allocation.body
|
||||
else {
|
||||
panic!()
|
||||
};
|
||||
*transmission = TransmissionProtocol::Unicast;
|
||||
let artifact = oa_core::ArtifactLink {
|
||||
id: Id::new_v4(),
|
||||
alias: "navigation_evidence".into(),
|
||||
name: "Navigation CAD evidence".into(),
|
||||
description: "Independent allocation evidence".into(),
|
||||
owner: nav,
|
||||
relation: oa_core::ArtifactRelation::Verifies,
|
||||
uri: "cad://navigation/connector".into(),
|
||||
revision: Some("review-17".into()),
|
||||
fragment: Some("pin-3".into()),
|
||||
};
|
||||
let response = commands(
|
||||
&server,
|
||||
&before,
|
||||
vec![
|
||||
Command::EditElement(voltage, invalid),
|
||||
Command::EditRelation(nav, allocation.clone()),
|
||||
Command::CreateArtifact(artifact.clone()),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let changed = server.get(resource).await;
|
||||
assert_eq!(changed.revision, before.revision + 1);
|
||||
assert_eq!(changed.model.relations[&other], model.relations[&other]);
|
||||
assert_eq!(changed.model.relations[&nav], allocation);
|
||||
assert_eq!(changed.model.artifact_links[&artifact.id], artifact);
|
||||
assert_pair(&changed);
|
||||
assert_eq!(
|
||||
commands(&server, &before, vec![Command::Rename(nav, "Stale".into())])
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
|
||||
let renamed = Id::new_v4();
|
||||
assert_eq!(
|
||||
commands(&server, &changed, vec![Command::Reidentify(nav, renamed)])
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let reidentified = server.get(resource).await;
|
||||
assert_eq!(
|
||||
reidentified.model.artifact_links[&artifact.id].owner,
|
||||
renamed
|
||||
);
|
||||
// This allocation is not enforcing a live boundary, so removal is valid.
|
||||
assert_eq!(
|
||||
commands(&server, &reidentified, vec![Command::Delete(renamed)])
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let removed = server.get(resource).await;
|
||||
assert!(removed.model.elements.contains_key(&id(&model, "navigate")));
|
||||
assert_eq!(removed.model.relations[&other], model.relations[&other]);
|
||||
assert!(!removed.model.artifact_links.contains_key(&artifact.id));
|
||||
assert_pair(&removed);
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 4;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), removed.model);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(reopened.model, removed.model);
|
||||
assert_eq!(reopened.etag(), removed.etag());
|
||||
assert_pair(&reopened);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn authored_schema3_bundle_upgrade_reports_data_defaults_and_preserves_files() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let old = SourceBundle {
|
||||
entry: "legacy.arcadia".into(), files: BTreeMap::from([
|
||||
("legacy.arcadia".into(), "// Entry rationale\narcadia 3; project old_data \"Old data\" { include \"dictionary.arcadia\"; }\n".into()),
|
||||
("dictionary.arcadia".into(), "// Event rationale\nsa_exchange_item notification \"Notification\" { kind Event; }\n// Interface rationale\nsa_interface contract \"Contract\" { items [@notification]; }\n".into())
|
||||
])
|
||||
};
|
||||
let response = server
|
||||
.client
|
||||
.post(server.url("/projects/import-source-project"))
|
||||
.json(&old)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let summary: Value = response.json().await.unwrap();
|
||||
let resource = summary["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
let stored = server.get(resource).await;
|
||||
let report = stored.migration.as_ref().unwrap();
|
||||
assert_eq!((report.from_schema, report.to_schema), (3, 4));
|
||||
assert_eq!(report.generated_interface_allocations.len(), 1);
|
||||
assert!(!report.semantic_defaults.is_empty());
|
||||
let validated: Value = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{resource}/validation")))
|
||||
.json(&json!({"format":"source-project","source_project":old}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(validated["valid"], true);
|
||||
assert_eq!(validated["migration"]["from_schema"], 3);
|
||||
assert_eq!(
|
||||
validated["migration"]["generated_interface_allocations"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(stored.source_project.files.len(), 2);
|
||||
assert!(stored.source_project.files["legacy.arcadia"].contains("arcadia 4;"));
|
||||
for text in [
|
||||
"// Event rationale",
|
||||
"// Interface rationale",
|
||||
"interface_item_allocation",
|
||||
] {
|
||||
assert!(stored.source_project.files["dictionary.arcadia"].contains(text));
|
||||
}
|
||||
assert_pair(&stored);
|
||||
let filename = directory.path().join(format!("{resource}.json"));
|
||||
let bytes = std::fs::read(&filename).unwrap();
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(reopened.model, stored.model);
|
||||
assert_eq!(reopened.source_project, stored.source_project);
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(std::fs::read(filename).unwrap(), bytes);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn existing_schema3_coupled_envelope_upgrades_in_memory_until_explicit_save() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let resource = Id::new_v4();
|
||||
let filename = directory.path().join(format!("{resource}.json"));
|
||||
let old_model: Value = serde_json::from_str(include_str!(
|
||||
"../../../oa-core/tests/fixtures/v3-data-contract.json"
|
||||
))
|
||||
.unwrap();
|
||||
let old_bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([(
|
||||
"project.arcadia".into(),
|
||||
include_str!("../../../oa-core/tests/fixtures/v3-data-contract.arcadia").into(),
|
||||
)]),
|
||||
};
|
||||
let original = serde_json::to_vec(&json!({"resource_id":resource,"revision":17,"model":old_model,"source_project":old_bundle})).unwrap();
|
||||
std::fs::write(&filename, &original).unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let opened = server.get(resource).await;
|
||||
assert_eq!(opened.revision, 17);
|
||||
assert_eq!(opened.model.schema_version, 4);
|
||||
assert_eq!(opened.migration.as_ref().unwrap().from_schema, 3);
|
||||
assert!(opened.source_project.files["project.arcadia"].contains("arcadia 4;"));
|
||||
assert_pair(&opened);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), original);
|
||||
assert_eq!(
|
||||
commands(
|
||||
&server,
|
||||
&opened,
|
||||
vec![Command::Rename(
|
||||
opened.model.id,
|
||||
"Reviewed data migration".into()
|
||||
)]
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let saved = server.get(resource).await;
|
||||
assert_eq!(saved.revision, 18);
|
||||
let bytes = std::fs::read(&filename).unwrap();
|
||||
assert_ne!(bytes, original);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(reopened.model, saved.model);
|
||||
assert_eq!(reopened.source_project, saved.source_project);
|
||||
assert_eq!(reopened.etag(), saved.etag());
|
||||
assert!(reopened.migration.is_none());
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn qualified_call_reply_values_reject_invalid_edits_and_survive_identity_source_and_restart()
|
||||
{
|
||||
use oa_core::{
|
||||
Element, FieldStorage, MessageKind, MessageValue, OccurrenceBody, ParameterDirection,
|
||||
PortContract, RelationBody, ScenarioViewpoint, TypeRef,
|
||||
};
|
||||
fn values(model: &Project, occurrence: Id) -> &[MessageValue] {
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = model.elements[&occurrence].shared()
|
||||
else {
|
||||
panic!("Expected message");
|
||||
};
|
||||
values
|
||||
}
|
||||
fn edited_values(model: &Project, occurrence: Id, next: Vec<MessageValue>) -> Element {
|
||||
let mut element = model.elements[&occurrence].clone();
|
||||
let Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { values, .. },
|
||||
..
|
||||
}) = element.shared_mut()
|
||||
else {
|
||||
panic!("Expected message");
|
||||
};
|
||||
*values = next;
|
||||
element
|
||||
}
|
||||
let mut model = documented_data();
|
||||
let operation = id(&model, "navigate");
|
||||
let coordinate = id(&model, "coordinate");
|
||||
let located = id(&model, "located");
|
||||
let latitude = id(&model, "latitude");
|
||||
let origin = id(&model, "origin");
|
||||
let destination = id(&model, "destination");
|
||||
let accepted = id(&model, "accepted");
|
||||
// The second parameter inherits the very same latitude identity.
|
||||
let Some(SharedBody::DataField { type_ref, .. }) =
|
||||
model.elements.get_mut(&destination).unwrap().shared_mut()
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
*type_ref = TypeRef::Named(located);
|
||||
let next = model
|
||||
.add_element(
|
||||
Kind::DataField,
|
||||
Layer::Sa,
|
||||
"Next coordinate",
|
||||
Some(coordinate),
|
||||
)
|
||||
.unwrap();
|
||||
*model.elements.get_mut(&next).unwrap().shared_mut().unwrap() = SharedBody::DataField {
|
||||
type_ref: TypeRef::Named(coordinate),
|
||||
minimum: 0,
|
||||
maximum: Some(1),
|
||||
direction: ParameterDirection::Unspecified,
|
||||
storage: FieldStorage::Value,
|
||||
unit: String::new(),
|
||||
default: None,
|
||||
};
|
||||
let requester = model
|
||||
.add_element(Kind::Function, Layer::Sa, "Request navigation", None)
|
||||
.unwrap();
|
||||
let provider = model
|
||||
.add_element(Kind::Function, Layer::Sa, "Provide navigation", None)
|
||||
.unwrap();
|
||||
let output = Id::new_v4();
|
||||
let input = Id::new_v4();
|
||||
for (port, contract) in [
|
||||
(
|
||||
output,
|
||||
PortContract::FunctionOutput {
|
||||
outgoing_items: vec![operation],
|
||||
},
|
||||
),
|
||||
(
|
||||
input,
|
||||
PortContract::FunctionInput {
|
||||
incoming_items: vec![operation],
|
||||
},
|
||||
),
|
||||
] {
|
||||
let (owner, name) = if port == output {
|
||||
(requester, "Navigation output")
|
||||
} else {
|
||||
(provider, "Navigation input")
|
||||
};
|
||||
let element = oa_core::ports::make_port(&model, port, owner, name, contract).unwrap();
|
||||
model.elements.insert(port, element);
|
||||
}
|
||||
let exchange = model.relate(
|
||||
RelationKind::FunctionalExchange,
|
||||
"Navigation request",
|
||||
requester,
|
||||
provider,
|
||||
);
|
||||
model.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
|
||||
source: requester,
|
||||
target: provider,
|
||||
source_port: output,
|
||||
target_port: input,
|
||||
items: vec![operation],
|
||||
};
|
||||
let scenario = model
|
||||
.add_element(Kind::Scenario, Layer::Sa, "Navigation conversation", None)
|
||||
.unwrap();
|
||||
let Some(SharedBody::Scenario { viewpoint, .. }) =
|
||||
model.elements.get_mut(&scenario).unwrap().shared_mut()
|
||||
else {
|
||||
panic!();
|
||||
};
|
||||
*viewpoint = ScenarioViewpoint::Functional;
|
||||
let sender = model
|
||||
.add_element(Kind::Lifeline, Layer::Sa, "Requester line", Some(scenario))
|
||||
.unwrap();
|
||||
let receiver = model
|
||||
.add_element(Kind::Lifeline, Layer::Sa, "Provider line", Some(scenario))
|
||||
.unwrap();
|
||||
for (line, represented) in [(sender, requester), (receiver, provider)] {
|
||||
*model.elements.get_mut(&line).unwrap().shared_mut().unwrap() = SharedBody::Lifeline {
|
||||
represented: Some(represented),
|
||||
};
|
||||
}
|
||||
let call = model
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Sa,
|
||||
"Navigation call",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
let reply = model
|
||||
.add_element(
|
||||
Kind::Occurrence,
|
||||
Layer::Sa,
|
||||
"Navigation reply",
|
||||
Some(scenario),
|
||||
)
|
||||
.unwrap();
|
||||
for (occurrence, message_kind, from, to, reply_to) in [
|
||||
(call, MessageKind::Call, sender, receiver, None),
|
||||
(reply, MessageKind::Reply, receiver, sender, Some(call)),
|
||||
] {
|
||||
*model
|
||||
.elements
|
||||
.get_mut(&occurrence)
|
||||
.unwrap()
|
||||
.shared_mut()
|
||||
.unwrap() = SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange {
|
||||
exchange: Some(exchange),
|
||||
sender: Some(from),
|
||||
receiver: Some(to),
|
||||
message_kind,
|
||||
reply_to,
|
||||
values: vec![],
|
||||
},
|
||||
precondition: String::new(),
|
||||
postcondition: String::new(),
|
||||
};
|
||||
}
|
||||
model
|
||||
.elements
|
||||
.get_mut(&scenario)
|
||||
.unwrap()
|
||||
.steps_mut()
|
||||
.unwrap()
|
||||
.extend([call, reply]);
|
||||
oa_core::validate_candidate(&model).unwrap();
|
||||
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let (resource, _) = server.import(&model).await;
|
||||
let before = server.get(resource).await;
|
||||
let call_values = vec![
|
||||
MessageValue {
|
||||
path: vec![origin],
|
||||
field: latitude,
|
||||
expression: " origin.latitude\n+ 0.25 ".into(),
|
||||
},
|
||||
MessageValue {
|
||||
path: vec![destination],
|
||||
field: latitude,
|
||||
expression: "destination.latitude".into(),
|
||||
},
|
||||
MessageValue {
|
||||
path: vec![origin, next],
|
||||
field: latitude,
|
||||
expression: "previous.latitude".into(),
|
||||
},
|
||||
];
|
||||
let reply_values = vec![MessageValue {
|
||||
path: vec![],
|
||||
field: accepted,
|
||||
expression: "true".into(),
|
||||
}];
|
||||
let response = commands(
|
||||
&server,
|
||||
&before,
|
||||
vec![
|
||||
Command::EditElement(call, edited_values(&model, call, call_values.clone())),
|
||||
Command::EditElement(reply, edited_values(&model, reply, reply_values.clone())),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let authored = server.get(resource).await;
|
||||
assert_eq!(authored.revision, before.revision + 1);
|
||||
assert_eq!(values(&authored.model, call), call_values);
|
||||
assert_eq!(values(&authored.model, reply), reply_values);
|
||||
assert_eq!(
|
||||
authored.model.elements[&latitude],
|
||||
model.elements[&latitude]
|
||||
);
|
||||
assert_pair(&authored);
|
||||
let filename = directory.path().join(format!("{resource}.json"));
|
||||
let bytes = std::fs::read(&filename).unwrap();
|
||||
for (occurrence, value, code) in [
|
||||
(
|
||||
call,
|
||||
MessageValue {
|
||||
path: vec![],
|
||||
field: accepted,
|
||||
expression: "wrong call phase".into(),
|
||||
},
|
||||
"message_parameter_phase",
|
||||
),
|
||||
(
|
||||
reply,
|
||||
MessageValue {
|
||||
path: vec![origin],
|
||||
field: latitude,
|
||||
expression: "wrong reply phase".into(),
|
||||
},
|
||||
"message_parameter_phase",
|
||||
),
|
||||
(
|
||||
call,
|
||||
MessageValue {
|
||||
path: vec![origin, accepted],
|
||||
field: latitude,
|
||||
expression: "unrelated hop".into(),
|
||||
},
|
||||
"message_value_field",
|
||||
),
|
||||
] {
|
||||
let invalid = edited_values(&authored.model, occurrence, vec![value]);
|
||||
let mut invalid_model = authored.model.clone();
|
||||
invalid_model.elements.insert(occurrence, invalid.clone());
|
||||
let response = commands(
|
||||
&server,
|
||||
&authored,
|
||||
vec![Command::EditElement(occurrence, invalid)],
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let problem: Value = response.json().await.unwrap();
|
||||
assert_eq!(problem["code"], "command_rejected", "{problem}");
|
||||
assert!(
|
||||
problem["detail"].as_str().unwrap().starts_with(code),
|
||||
"{problem}"
|
||||
);
|
||||
// Transactions report the first rejection; validation supplies located issues.
|
||||
let validation: Value = server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{resource}/validation")))
|
||||
.json(&json!({"format":"json", "model":invalid_model}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(validation["valid"], false);
|
||||
assert!(
|
||||
validation["issues"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|issue| issue["code"] == code
|
||||
&& issue["object_id"] == json!(occurrence)
|
||||
&& issue["field"] == "values.path"),
|
||||
"{problem}"
|
||||
);
|
||||
let unchanged = server.get(resource).await;
|
||||
assert_eq!(unchanged.etag(), authored.etag());
|
||||
assert_eq!(unchanged.model, authored.model);
|
||||
assert_eq!(unchanged.source_project, authored.source_project);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), bytes);
|
||||
}
|
||||
|
||||
// Source transport changes one argument without conflating the shared leaf.
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
let edited_source = source.replace(
|
||||
"\"destination.latitude\"",
|
||||
"\"destination.corrected_latitude\"",
|
||||
);
|
||||
assert_ne!(edited_source, source);
|
||||
let response = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{resource}/source")))
|
||||
.header("if-match", authored.etag())
|
||||
.header("content-type", "text/plain")
|
||||
.body(edited_source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let source_edited = server.get(resource).await;
|
||||
assert_eq!(source_edited.revision, authored.revision + 1);
|
||||
let mut expected_call = call_values.clone();
|
||||
expected_call[1].expression = "destination.corrected_latitude".into();
|
||||
assert_eq!(values(&source_edited.model, call), expected_call);
|
||||
assert_eq!(values(&source_edited.model, reply), reply_values);
|
||||
assert_pair(&source_edited);
|
||||
|
||||
// Reidentify every kind of enclosing hop, the shared terminal, and its base type.
|
||||
let mapping = [origin, destination, next, latitude, coordinate]
|
||||
.into_iter()
|
||||
.map(|old| (old, Id::new_v4()))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let response = commands(
|
||||
&server,
|
||||
&source_edited,
|
||||
mapping
|
||||
.iter()
|
||||
.map(|(old, new)| Command::Reidentify(*old, *new))
|
||||
.collect(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
let changed = server.get(resource).await;
|
||||
assert_eq!(changed.revision, source_edited.revision + 1);
|
||||
for value in &mut expected_call {
|
||||
value.path = value.path.iter().map(|hop| mapping[hop]).collect();
|
||||
value.field = mapping[&value.field];
|
||||
}
|
||||
assert_eq!(values(&changed.model, call), expected_call);
|
||||
assert_eq!(values(&changed.model, reply), reply_values);
|
||||
assert_eq!(
|
||||
changed.model.elements[&mapping[&latitude]].parent,
|
||||
Some(mapping[&coordinate])
|
||||
);
|
||||
assert!(
|
||||
matches!(changed.model.elements[&located].shared(), Some(SharedBody::DataType {
|
||||
definition: DataTypeDefinition::Record { supertype: Some(base) }, ..
|
||||
}) if *base == mapping[&coordinate])
|
||||
);
|
||||
assert!(
|
||||
oa_core::data::effective_fields(&changed.model, located)
|
||||
.iter()
|
||||
.any(|field| field.id == mapping[&latitude])
|
||||
);
|
||||
for old in mapping.keys() {
|
||||
assert!(!changed.model.contains(*old));
|
||||
}
|
||||
assert!(
|
||||
matches!(changed.model.elements[&reply].shared(), Some(SharedBody::Occurrence {
|
||||
body: OccurrenceBody::Exchange { reply_to: Some(correlation), .. }, ..
|
||||
}) if *correlation == call)
|
||||
);
|
||||
assert_pair(&changed);
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{resource}/source")))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.starts_with("arcadia 4;"));
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), changed.model);
|
||||
let saved = std::fs::read(&filename).unwrap();
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(resource).await;
|
||||
assert_eq!(reopened.etag(), changed.etag());
|
||||
assert_eq!(reopened.model, changed.model);
|
||||
assert_eq!(reopened.source_project, changed.source_project);
|
||||
assert_eq!(values(&reopened.model, call), expected_call);
|
||||
assert_eq!(values(&reopened.model, reply), reply_values);
|
||||
assert_pair(&reopened);
|
||||
assert_eq!(std::fs::read(&filename).unwrap(), saved);
|
||||
server.stop().await;
|
||||
}
|
||||
@@ -1,447 +0,0 @@
|
||||
use super::*;
|
||||
use oa_core::source_project::{ProjectLimits, SourceBundle, compile_bundle};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn source_bundle() -> SourceBundle {
|
||||
let bundle = SourceBundle {
|
||||
entry: "project.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("project.arcadia".into(), "// Project rationale\narcadia 3; project anchors \"Port positions\" { include \"participants.arcadia\"; include \"views.arcadia\"; }\n".into()),
|
||||
("participants.arcadia".into(), r#"// Port ownership and contracts stay in this file.
|
||||
sa_system system "System" {}
|
||||
sa_actor vessel "Vessel" {
|
||||
sa_port telemetry "Telemetry" { contract { Component { kind Flow; orientation Out; }; }; }
|
||||
sa_port health "Health" { contract { Component { kind Flow; orientation Out; }; }; }
|
||||
}
|
||||
sa_port inbound "Receive" { parent @system; contract { Component { kind Flow; orientation In; }; }; }
|
||||
"#.into()),
|
||||
("views.arcadia".into(), r#"// Placement rationale stays in the view file.
|
||||
diagram architecture "Architecture" { layer Sa; projection Allocation; }
|
||||
diagram review "Review" { layer Sa; projection Allocation; }
|
||||
node actor_view { diagram @architecture; element @vessel; }
|
||||
node review_view { diagram @review; element @vessel; }
|
||||
"#.into()),
|
||||
]),
|
||||
};
|
||||
compile_bundle(&bundle, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.bundle
|
||||
}
|
||||
|
||||
fn id(model: &Project, alias: &str) -> Id {
|
||||
model
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
async fn import(server: &TestServer) -> StoredProject {
|
||||
let response = server
|
||||
.client
|
||||
.post(server.url("/projects/import-source-project"))
|
||||
.json(&source_bundle())
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let value: Value = response.json().await.unwrap();
|
||||
assert_eq!(status, StatusCode::CREATED, "{value}");
|
||||
server
|
||||
.get(value["resource_id"].as_str().unwrap().parse().unwrap())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn transact(
|
||||
server: &TestServer,
|
||||
current: &StoredProject,
|
||||
commands: Value,
|
||||
dry_run: bool,
|
||||
) -> reqwest::Response {
|
||||
server
|
||||
.client
|
||||
.post(server.url(&format!("/projects/{}/transactions", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&json!({"commands":commands,"dry_run":dry_run}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_pair(current: &StoredProject) {
|
||||
let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, current.model);
|
||||
assert_eq!(compiled.bundle, current.source_project);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn port_anchor_http_commands_and_source_edits_preserve_per_view_state_after_restart() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let original = import(&server).await;
|
||||
let model = &original.model;
|
||||
let (diagram, node, port, other, other_node) = (
|
||||
id(model, "architecture"),
|
||||
id(model, "actor_view"),
|
||||
id(model, "telemetry"),
|
||||
id(model, "review"),
|
||||
id(model, "review_view"),
|
||||
);
|
||||
assert!(
|
||||
serde_json::to_value(&model.diagrams[&diagram].nodes[&node])
|
||||
.unwrap()
|
||||
.get("port_anchors")
|
||||
.is_none()
|
||||
);
|
||||
let commands = json!([
|
||||
{"SetPortAnchor":{"diagram":diagram,"node":node,"anchor":{"port":port,"side":"Top","offset":0.25}}},
|
||||
{"SetPortAnchor":{"diagram":other,"node":other_node,"anchor":{"port":port,"side":"Left","offset":0.75}}}
|
||||
]);
|
||||
let expected = oa_core::execute_batch(
|
||||
model,
|
||||
&serde_json::from_value::<Vec<Command>>(commands.clone()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let preview = transact(&server, &original, commands.clone(), true).await;
|
||||
assert_eq!(preview.status(), StatusCode::OK);
|
||||
let preview: Value = preview.json().await.unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Project>(preview["model"].clone()).unwrap(),
|
||||
expected
|
||||
);
|
||||
let proposed: SourceBundle = serde_json::from_value(preview["source_project"].clone()).unwrap();
|
||||
assert_eq!(
|
||||
compile_bundle(&proposed, ProjectLimits::default())
|
||||
.unwrap()
|
||||
.decoded
|
||||
.project,
|
||||
expected
|
||||
);
|
||||
assert_eq!(
|
||||
server.get(original.resource_id).await.source_project,
|
||||
original.source_project
|
||||
);
|
||||
|
||||
let response = transact(&server, &original, commands.clone(), false).await;
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let mut current = server.get(original.resource_id).await;
|
||||
assert_eq!(current.revision, original.revision + 1);
|
||||
assert_eq!(current.model, expected);
|
||||
assert_eq!(current.model.elements, original.model.elements);
|
||||
assert_pair(¤t);
|
||||
for path in ["project.arcadia", "participants.arcadia"] {
|
||||
assert_eq!(
|
||||
current.source_project.files[path],
|
||||
original.source_project.files[path]
|
||||
);
|
||||
}
|
||||
assert!(current.source_project.files["views.arcadia"].contains("// Placement rationale"));
|
||||
let no_op = transact(&server, ¤t, commands.clone(), false).await;
|
||||
assert_eq!(no_op.headers()["etag"], current.etag());
|
||||
assert_eq!(no_op.json::<Value>().await.unwrap()["changed"], false);
|
||||
assert_eq!(
|
||||
transact(&server, &original, commands, false).await.status(),
|
||||
StatusCode::PRECONDITION_FAILED
|
||||
);
|
||||
|
||||
// Source editing changes exactly the same accepted presentation record.
|
||||
let mut edited = current.source_project.clone();
|
||||
let source = edited.files.get_mut("views.arcadia").unwrap();
|
||||
assert!(source.contains("offset 0.25;"));
|
||||
*source = source.replace("offset 0.25;", "offset 0.375;");
|
||||
let written = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{}/source-project", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&edited)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
written.status(),
|
||||
StatusCode::OK,
|
||||
"{}",
|
||||
written.text().await.unwrap()
|
||||
);
|
||||
current = server.get(current.resource_id).await;
|
||||
assert_eq!(
|
||||
current.model.diagrams[&diagram].nodes[&node].port_anchors[0].offset,
|
||||
0.375
|
||||
);
|
||||
assert_eq!(current.model.diagrams[&other], expected.diagrams[&other]);
|
||||
assert_eq!(current.source_project, edited);
|
||||
assert_pair(¤t);
|
||||
|
||||
let source = server
|
||||
.client
|
||||
.get(server.url(&format!("/projects/{}/source", current.resource_id)))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(oa_core::language::parse(&source).unwrap(), current.model);
|
||||
server.stop().await;
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let reopened = server.get(current.resource_id).await;
|
||||
assert_eq!(
|
||||
serde_json::to_value(&reopened).unwrap(),
|
||||
serde_json::to_value(¤t).unwrap()
|
||||
);
|
||||
let reset = json!([{"RemovePortAnchor":{"diagram":diagram,"node":node,"port":port}}]);
|
||||
assert_eq!(
|
||||
transact(&server, &reopened, reset.clone(), false)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let reset_state = server.get(current.resource_id).await;
|
||||
assert!(
|
||||
reset_state.model.diagrams[&diagram].nodes[&node]
|
||||
.port_anchors
|
||||
.is_empty()
|
||||
);
|
||||
assert_eq!(
|
||||
reset_state.model.diagrams[&other],
|
||||
current.model.diagrams[&other]
|
||||
);
|
||||
assert_pair(&reset_state);
|
||||
let again = transact(&server, &reset_state, reset, false).await;
|
||||
assert_eq!(again.headers()["etag"], reset_state.etag());
|
||||
assert_eq!(again.json::<Value>().await.unwrap()["changed"], false);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn port_anchor_http_rejects_invalid_records_and_stale_owners_atomically() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let original = import(&server).await;
|
||||
let model = &original.model;
|
||||
let (diagram, node, port, actor, system, inbound) = (
|
||||
id(model, "architecture"),
|
||||
id(model, "actor_view"),
|
||||
id(model, "telemetry"),
|
||||
id(model, "vessel"),
|
||||
id(model, "system"),
|
||||
id(model, "inbound"),
|
||||
);
|
||||
let anchor = json!({"port":port,"side":"Bottom","offset":1});
|
||||
assert_eq!(
|
||||
transact(
|
||||
&server,
|
||||
&original,
|
||||
json!([{"SetPortAnchor":{"diagram":diagram,"node":node,"anchor":anchor}}]),
|
||||
false
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let current = server.get(original.resource_id).await;
|
||||
for (bad, status) in [
|
||||
(
|
||||
json!({"port":port,"side":"Top","offset":-0.01}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"port":port,"side":"Top","offset":1.01}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"port":inbound,"side":"Top","offset":0.5}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"port":actor,"side":"Top","offset":0.5}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"port":Id::new_v4(),"side":"Top","offset":0.5}),
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
),
|
||||
(
|
||||
json!({"port":port,"side":"Diagonal","offset":0.5}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(json!({"port":port,"side":"Top"}), StatusCode::BAD_REQUEST),
|
||||
(
|
||||
json!({"port":port,"side":"Top","offset":null}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
(
|
||||
json!({"port":port,"side":"Top","offset":0.5,"unknown":true}),
|
||||
StatusCode::BAD_REQUEST,
|
||||
),
|
||||
] {
|
||||
let response = transact(
|
||||
&server,
|
||||
¤t,
|
||||
json!([
|
||||
Command::Rename(actor, "Must roll back".into()),
|
||||
{"SetPortAnchor":{"diagram":diagram,"node":node,"anchor":bad}}
|
||||
]),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
status,
|
||||
"{bad}: {}",
|
||||
response.text().await.unwrap()
|
||||
);
|
||||
}
|
||||
let mut duplicate = current.model.clone();
|
||||
let anchors = &mut duplicate
|
||||
.diagrams
|
||||
.get_mut(&diagram)
|
||||
.unwrap()
|
||||
.nodes
|
||||
.get_mut(&node)
|
||||
.unwrap()
|
||||
.port_anchors;
|
||||
anchors.push(anchors[0].clone());
|
||||
let rejected = server
|
||||
.client
|
||||
.put(server.url(&format!("/projects/{}/model", current.resource_id)))
|
||||
.header("if-match", current.etag())
|
||||
.json(&duplicate)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rejected.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let reparent = Command::Reparent {
|
||||
element: port,
|
||||
parent: Some(system),
|
||||
};
|
||||
assert_eq!(
|
||||
transact(&server, ¤t, json!([reparent]), false)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert_eq!(
|
||||
transact(
|
||||
&server,
|
||||
¤t,
|
||||
json!([{"RemovePortAnchor":{"diagram":diagram,"node":node,"port":inbound}}]),
|
||||
false
|
||||
)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(server.get(current.resource_id).await).unwrap(),
|
||||
serde_json::to_value(¤t).unwrap()
|
||||
);
|
||||
let remove = json!({"RemovePortAnchor":{"diagram":diagram,"node":node,"port":port}});
|
||||
assert_eq!(
|
||||
transact(&server, ¤t, json!([remove, reparent]), false)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let moved = server.get(current.resource_id).await;
|
||||
assert_eq!(moved.model.elements[&port].parent, Some(system));
|
||||
assert!(
|
||||
moved.model.diagrams[&diagram].nodes[&node]
|
||||
.port_anchors
|
||||
.is_empty()
|
||||
);
|
||||
assert_pair(&moved);
|
||||
assert_eq!(
|
||||
transact(&server, &moved, json!([remove]), false)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::UNPROCESSABLE_ENTITY
|
||||
);
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn port_anchor_served_openapi_matches_serialized_records_and_commands() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = TestServer::start(directory.path(), None).await;
|
||||
let spec: Value = server
|
||||
.client
|
||||
.get(server.url("/openapi.json"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let schemas = &spec["components"]["schemas"];
|
||||
assert_eq!(
|
||||
schemas["PortAnchorSide"]["enum"],
|
||||
json!(["Left", "Right", "Top", "Bottom"])
|
||||
);
|
||||
assert_eq!(
|
||||
schemas["PortAnchor"]["required"],
|
||||
json!(["port", "side", "offset"])
|
||||
);
|
||||
assert_eq!(schemas["PortAnchor"]["additionalProperties"], false);
|
||||
assert_eq!(schemas["PortAnchor"]["properties"]["offset"]["minimum"], 0);
|
||||
assert_eq!(schemas["PortAnchor"]["properties"]["offset"]["maximum"], 1);
|
||||
assert_eq!(
|
||||
schemas["NodePlacement"]["properties"]["port_anchors"]["default"],
|
||||
json!([])
|
||||
);
|
||||
assert!(
|
||||
!schemas["NodePlacement"]["required"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("port_anchors"))
|
||||
);
|
||||
let anchor = oa_core::PortAnchor {
|
||||
port: Id::new_v4(),
|
||||
side: oa_core::PortAnchorSide::Right,
|
||||
offset: 0.5,
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::to_value(&anchor).unwrap(),
|
||||
json!({"port":anchor.port,"side":"Right","offset":0.5})
|
||||
);
|
||||
for (command, name, fields) in [
|
||||
(
|
||||
Command::SetPortAnchor {
|
||||
diagram: Id::new_v4(),
|
||||
node: Id::new_v4(),
|
||||
anchor: anchor.clone(),
|
||||
},
|
||||
"SetPortAnchor",
|
||||
json!(["diagram", "node", "anchor"]),
|
||||
),
|
||||
(
|
||||
Command::RemovePortAnchor {
|
||||
diagram: Id::new_v4(),
|
||||
node: Id::new_v4(),
|
||||
port: anchor.port,
|
||||
},
|
||||
"RemovePortAnchor",
|
||||
json!(["diagram", "node", "port"]),
|
||||
),
|
||||
] {
|
||||
let record = serde_json::to_value(&command).unwrap();
|
||||
assert_eq!(
|
||||
serde_json::from_value::<Command>(record.clone()).unwrap(),
|
||||
command
|
||||
);
|
||||
let schema = schemas["Command"]["oneOf"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|entry| entry["properties"].get(name).is_some())
|
||||
.unwrap();
|
||||
assert_eq!(schema["required"], json!([name]));
|
||||
assert_eq!(schema["properties"][name]["required"], fields);
|
||||
assert_eq!(schema["properties"][name]["additionalProperties"], false);
|
||||
for field in fields.as_array().unwrap() {
|
||||
assert!(record[name].get(field.as_str().unwrap()).is_some());
|
||||
}
|
||||
}
|
||||
server.stop().await;
|
||||
}
|
||||
@@ -6,10 +6,10 @@ fn authored() -> SourceBundle {
|
||||
let bundle = SourceBundle {
|
||||
entry: "harbor.arcadia".into(),
|
||||
files: BTreeMap::from([
|
||||
("harbor.arcadia".into(), "// Harbor engineering rationale 航海\r\narcadia 4; project harbor \"Harbor rescue\" {\r\n include \"team/first.arcadia\";\r\n include \"team/second.arcadia\";\r\n include \"contracts.arcadia\";\r\n include \"work.arcadia\";\r\n}\r\n".into()),
|
||||
("harbor.arcadia".into(), "// Harbor engineering rationale 航海\r\narcadia 3; project harbor \"Harbor rescue\" {\r\n include \"team/first.arcadia\";\r\n include \"team/second.arcadia\";\r\n include \"contracts.arcadia\";\r\n include \"work.arcadia\";\r\n}\r\n".into()),
|
||||
("team/first.arcadia".into(), "// First payload rationale\r\nsa_exchange_item first \"First payload\" { description \"Reviewed first\"; }\r\n".into()),
|
||||
("team/second.arcadia".into(), "// Second payload rationale\r\nsa_exchange_item second \"Second payload\" {}\r\n".into()),
|
||||
("contracts.arcadia".into(), "// Contract ownership stays here\r\nsa_interface contract \"Contract\" {}\r\ninterface_item_allocation first_allocation \"First allocation\" { source @contract; target @first // first reference rationale\r\n; order 0; }\r\ninterface_item_allocation second_allocation \"Second allocation\" { source @contract; target @second // second reference rationale\r\n; order 1; }\r\n".into()),
|
||||
("contracts.arcadia".into(), "// Contract ownership stays here\r\nsa_interface contract \"Contract\" { items [\r\n @first // first reference rationale\r\n ,\r\n @second // second reference rationale\r\n ]; }\r\n".into()),
|
||||
("work.arcadia".into(), "// Operational rationale\r\noa_activity assess \"Assess assistance\" {}\r\n".into()),
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
//! The public state-view representation is shared by the language, REST and UI.
|
||||
use oa_core::{Command, Id, Kind, Layer, Project, ProjectionKind, language, validate_candidate};
|
||||
use oa_server::{
|
||||
app,
|
||||
store::{Store, StoredProject},
|
||||
};
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::{path::Path, sync::Arc};
|
||||
use tokio::{sync::oneshot, task::JoinHandle};
|
||||
|
||||
struct Server {
|
||||
base: String,
|
||||
client: Client,
|
||||
stop: oneshot::Sender<()>,
|
||||
task: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
async fn start(path: &Path) -> Self {
|
||||
let path = path.to_owned();
|
||||
let store = tokio::task::spawn_blocking(move || Store::open(path))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let base = format!("http://{}/api/v1", listener.local_addr().unwrap());
|
||||
let (stop, done) = oneshot::channel();
|
||||
let task = tokio::spawn(async move {
|
||||
axum::serve(listener, app(Arc::new(store), None))
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = done.await;
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
});
|
||||
Self {
|
||||
base,
|
||||
client: Client::new(),
|
||||
stop,
|
||||
task,
|
||||
}
|
||||
}
|
||||
|
||||
async fn get(&self, id: Id) -> StoredProject {
|
||||
self.client
|
||||
.get(format!("{}/projects/{id}", self.base))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn edit(&self, current: &StoredProject, projection: Value) -> reqwest::Response {
|
||||
let diagram = current.model.diagrams.values().next().unwrap();
|
||||
let mut record = json!(diagram);
|
||||
record["projection"] = projection;
|
||||
self.client
|
||||
.post(format!(
|
||||
"{}/projects/{}/transactions",
|
||||
self.base, current.resource_id
|
||||
))
|
||||
.header("if-match", current.etag())
|
||||
.json(&json!({"commands":[{"EditDiagram":[diagram.id, record]}],"dry_run":false}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn stop(self) {
|
||||
let _ = self.stop.send(());
|
||||
drop(self.client);
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), self.task)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn id(project: &Project, alias: &str) -> Id {
|
||||
project
|
||||
.identities()
|
||||
.into_iter()
|
||||
.find(|(_, a)| *a == alias)
|
||||
.unwrap()
|
||||
.0
|
||||
}
|
||||
|
||||
fn documented_model() -> Project {
|
||||
// Parse the actual complete example, so an invalid published constructor or
|
||||
// reference cannot drift away from the server's accepted representation.
|
||||
let contract = include_str!("../../../docs/language.rst");
|
||||
let example = contract
|
||||
.split("This complete source example places one state from an existing machine:")
|
||||
.nth(1)
|
||||
.unwrap()
|
||||
.split(".. code-block:: text")
|
||||
.nth(1)
|
||||
.unwrap();
|
||||
let source = example
|
||||
.lines()
|
||||
.skip_while(|line| line.trim().is_empty())
|
||||
.take_while(|line| line.starts_with(" ") || line.is_empty())
|
||||
.map(|line| line.strip_prefix(" ").unwrap_or(line))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let model = language::parse(&source).unwrap();
|
||||
validate_candidate(&model).unwrap();
|
||||
model
|
||||
}
|
||||
|
||||
fn assert_pair(current: &StoredProject) {
|
||||
use oa_core::source_project::{ProjectLimits, compile_bundle};
|
||||
let compiled = compile_bundle(¤t.source_project, ProjectLimits::default()).unwrap();
|
||||
assert_eq!(compiled.decoded.project, current.model);
|
||||
assert_eq!(compiled.bundle, current.source_project);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_view_source_json_transactions_and_restart_share_the_published_contract() {
|
||||
let mut model = documented_model();
|
||||
let machine = id(&model, "modes");
|
||||
let crew = id(&model, "crew");
|
||||
let other = model
|
||||
.add_element(Kind::StateMachine, Layer::Oa, "Other modes", Some(crew))
|
||||
.unwrap();
|
||||
let system = model
|
||||
.add_element(Kind::System, Layer::Sa, "System", None)
|
||||
.unwrap();
|
||||
let wrong_layer = model
|
||||
.add_element(Kind::StateMachine, Layer::Sa, "System modes", Some(system))
|
||||
.unwrap();
|
||||
let diagram = id(&model, "mode_view");
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = Server::start(directory.path()).await;
|
||||
let response = server
|
||||
.client
|
||||
.post(format!("{}/projects/import", server.base))
|
||||
.json(&model)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let imported: Value = response.json().await.unwrap();
|
||||
let resource = imported["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
let original = server.get(resource).await;
|
||||
assert_eq!(original.model, model);
|
||||
assert_pair(&original);
|
||||
|
||||
let source = server
|
||||
.client
|
||||
.get(format!("{}/projects/{resource}/source", server.base))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.text()
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(source.contains("machine @modes;"));
|
||||
assert_eq!(language::parse(&source).unwrap(), model);
|
||||
let response = server
|
||||
.client
|
||||
.post(format!("{}/projects/import", server.base))
|
||||
.header("content-type", "text/plain")
|
||||
.body(source)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
let imported: Value = response.json().await.unwrap();
|
||||
let source_resource = imported["resource_id"].as_str().unwrap().parse().unwrap();
|
||||
assert_eq!(server.get(source_resource).await.model, model);
|
||||
|
||||
for malformed in [
|
||||
json!("StateMachine"),
|
||||
json!({"StateMachine":{}}),
|
||||
json!({"StateMachine":{"machine":null}}),
|
||||
json!({"StateMachine":{"machine":"not-a-uuid"}}),
|
||||
json!({"StateMachine":{"machine":machine,"extra":1}}),
|
||||
json!({"StateMachine":{"machine":machine},"Overview":null}),
|
||||
] {
|
||||
assert_eq!(
|
||||
server.edit(&original, malformed.clone()).await.status(),
|
||||
StatusCode::BAD_REQUEST,
|
||||
"{malformed}"
|
||||
);
|
||||
}
|
||||
for (context, code) in [
|
||||
(Id::nil(), "state_diagram_context"),
|
||||
(crew, "state_diagram_context"),
|
||||
(wrong_layer, "state_diagram_context"),
|
||||
(other, "state_diagram_member"),
|
||||
] {
|
||||
let response = server
|
||||
.edit(&original, json!({"StateMachine":{"machine":context}}))
|
||||
.await;
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
let problem: Value = response.json().await.unwrap();
|
||||
assert_eq!(problem["code"], "command_rejected");
|
||||
// Commands report the shared validator's first failure as detail.
|
||||
// Candidate validation provides the structured diagnostic collection.
|
||||
let mut candidate = original.model.clone();
|
||||
candidate.diagrams.get_mut(&diagram).unwrap().projection =
|
||||
ProjectionKind::StateMachine { machine: context };
|
||||
let checked = server
|
||||
.client
|
||||
.post(format!("{}/projects/{resource}/validation", server.base))
|
||||
.json(&json!({"format":"json","model":candidate}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(checked.status(), StatusCode::OK);
|
||||
let checked: Value = checked.json().await.unwrap();
|
||||
assert_eq!(checked["valid"], false);
|
||||
assert!(
|
||||
checked["issues"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|issue| issue["code"] == code),
|
||||
"{checked}"
|
||||
);
|
||||
}
|
||||
let unchanged = server.get(resource).await;
|
||||
assert_eq!(json!(unchanged), json!(original));
|
||||
|
||||
// A context change and removal of incompatible placements is one accepted
|
||||
// diagram edit; no semantic record or implied replacement placement appears.
|
||||
let mut changed = model.diagrams[&diagram].clone();
|
||||
changed.projection = ProjectionKind::StateMachine { machine: other };
|
||||
changed.nodes.clear();
|
||||
let response = server
|
||||
.client
|
||||
.post(format!("{}/projects/{resource}/transactions", server.base))
|
||||
.header("if-match", original.etag())
|
||||
.json(&json!({"commands":[Command::EditDiagram(diagram, changed.clone())],"dry_run":false}))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let current = server.get(resource).await;
|
||||
assert_eq!(current.revision, original.revision + 1);
|
||||
assert_eq!(current.model.diagrams[&diagram], changed);
|
||||
assert_eq!(current.model.elements, model.elements);
|
||||
assert_pair(¤t);
|
||||
|
||||
// Source writes use the identical context reference and conditional commit.
|
||||
let source = language::format(¤t.model);
|
||||
let invalid = source.replace("machine @other_modes;", "machine @crew;");
|
||||
assert_ne!(invalid, source);
|
||||
let response = server
|
||||
.client
|
||||
.put(format!("{}/projects/{resource}/source", server.base))
|
||||
.header("if-match", current.etag())
|
||||
.header("content-type", "text/plain")
|
||||
.body(invalid)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
assert_eq!(json!(server.get(resource).await), json!(current));
|
||||
let response = server
|
||||
.client
|
||||
.put(format!("{}/projects/{resource}/source", server.base))
|
||||
.header("if-match", current.etag())
|
||||
.header("content-type", "text/plain")
|
||||
.body(source.replace("machine @other_modes;", "machine @modes;"))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let final_state = server.get(resource).await;
|
||||
assert_eq!(final_state.revision, current.revision + 1);
|
||||
assert_eq!(
|
||||
final_state.model.diagrams[&diagram]
|
||||
.projection
|
||||
.state_machine(),
|
||||
Some(machine)
|
||||
);
|
||||
assert!(final_state.model.diagrams[&diagram].nodes.is_empty());
|
||||
assert_pair(&final_state);
|
||||
server.stop().await;
|
||||
let server = Server::start(directory.path()).await;
|
||||
assert_eq!(json!(server.get(resource).await), json!(final_state));
|
||||
server.stop().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn served_state_projection_schema_preserves_closed_string_and_payload_variants() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let server = Server::start(directory.path()).await;
|
||||
let spec: Value = server
|
||||
.client
|
||||
.get(format!("{}/openapi.json", server.base))
|
||||
.send()
|
||||
.await
|
||||
.unwrap()
|
||||
.error_for_status()
|
||||
.unwrap()
|
||||
.json()
|
||||
.await
|
||||
.unwrap();
|
||||
let schemas = &spec["components"]["schemas"];
|
||||
assert_eq!(
|
||||
schemas["Diagram"]["properties"]["projection"]["$ref"],
|
||||
"#/components/schemas/ProjectionKind"
|
||||
);
|
||||
let variants = schemas["ProjectionKind"]["oneOf"].as_array().unwrap();
|
||||
assert_eq!(variants.len(), 2);
|
||||
let strings = variants[0]["enum"].as_array().unwrap();
|
||||
assert_eq!(strings.len(), 6);
|
||||
for value in strings {
|
||||
assert_eq!(
|
||||
json!(serde_json::from_value::<ProjectionKind>(value.clone()).unwrap()),
|
||||
*value
|
||||
);
|
||||
}
|
||||
assert!(!strings.contains(&json!("StateMachine")));
|
||||
assert_eq!(variants[1]["required"], json!(["StateMachine"]));
|
||||
assert_eq!(variants[1]["additionalProperties"], false);
|
||||
let payload = &variants[1]["properties"]["StateMachine"];
|
||||
assert_eq!(payload["required"], json!(["machine"]));
|
||||
assert_eq!(payload["additionalProperties"], false);
|
||||
assert_eq!(payload["properties"]["machine"]["type"], "string");
|
||||
assert_eq!(payload["properties"]["machine"]["format"], "uuid");
|
||||
for example in schemas["ProjectionKind"]["examples"].as_array().unwrap() {
|
||||
assert_eq!(
|
||||
json!(serde_json::from_value::<ProjectionKind>(example.clone()).unwrap()),
|
||||
*example
|
||||
);
|
||||
}
|
||||
server.stop().await;
|
||||
}
|
||||
+12
-90
@@ -775,8 +775,8 @@ envelope allows both bounded representations plus small framing overhead.
|
||||
Compressed or encoded request bodies and browser Origin requests are
|
||||
not supported.</p>
|
||||
<p>Model admission permits at most 5,000 elements plus relations, 20,000 total
|
||||
persistent records (including the project, views, placements, routes and manual
|
||||
port anchors), and 50,000 ordered step references. Source declaration counts and decoded JSON record
|
||||
persistent records (including the project, views and placements), and 50,000
|
||||
ordered step references. Source declaration counts and decoded JSON record
|
||||
counts are checked before structural validation. Legacy import counts the
|
||||
occurrences and placements migration will generate before validating or
|
||||
migrating that model. Exceeding these budgets returns <span class="docutils literal">422 model_limit</span>;
|
||||
@@ -784,8 +784,8 @@ candidate validation reports the same diagnostic in its 200 response.
|
||||
Parsing, validation and query processing use snapshot workers, so this work
|
||||
does not hold the catalog lock or serialize unrelated model preparation.</p>
|
||||
<p>Import accepts schema-1 JSON through the shared explicit migration adapter,
|
||||
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
|
||||
as well as schema-2 native JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 3 / arcadia 3. Native JSON
|
||||
replacement accepts supported older schemas through the same explicit migration.
|
||||
Only documented query parameters are accepted. In particular, appending
|
||||
<span class="docutils literal"><span class="pre">?revision=...</span></span> to model/source export is an error, not historical retrieval.</p>
|
||||
@@ -796,7 +796,7 @@ Only documented query parameters are accepted. In particular, appending
|
||||
<pre class="literal-block">{
|
||||
"entry": "rescue.arcadia",
|
||||
"files": {
|
||||
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"rescue.arcadia": "arcadia 3; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
|
||||
}
|
||||
}</pre>
|
||||
@@ -807,9 +807,8 @@ targets or another hosted resource. All submitted files must be reachable;
|
||||
missing files, cycles, duplicate keys, case collisions and escaping paths are
|
||||
rejected. The bundle permits at most 256 files, 32 include edges along a path,
|
||||
and 1,024 include edges in total. Model and source-byte budgets apply across
|
||||
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
|
||||
preserving their files/includes/comments. V1/V2 single-file migration remains
|
||||
available through interchange import.</p>
|
||||
the complete bundle, not separately per file. Bundle syntax is source3;
|
||||
single-file legacy migration remains available through interchange import.</p>
|
||||
<p>Compilation inserts UUIDs into headers that omit them. Import returns 201 with
|
||||
Location/ETag; GET source-project retrieves the stabilized accepted bundle.
|
||||
PUT returns <span class="docutils literal">revision</span>, <span class="docutils literal">changed</span>, located <span class="docutils literal">issues</span> and that accepted
|
||||
@@ -911,30 +910,6 @@ nor reserves IDs. A committed response omits the proposed model and source
|
||||
project. Commit must check the revision again. A no-op is successful with
|
||||
<span class="docutils literal">changed: false</span> and the original ETag.</p>
|
||||
</section>
|
||||
<section id="diagram-port-positions">
|
||||
<h2>Diagram port positions</h2>
|
||||
<p><span class="docutils literal">NodePlacement.port_anchors</span> is an optional list of
|
||||
<span class="docutils literal"><span class="pre">{"port":"<port</span> <span class="pre">UUID>","side":"Top","offset":0.25}</span></span> records. Side is
|
||||
Left, Right, Top, or Bottom; offset is a finite fraction from 0 through 1,
|
||||
measured from top on vertical edges and from left on horizontal edges.
|
||||
The port must be a direct child of the placement's semantic element and occur
|
||||
at most once in its anchor list. Placement is presentation state; it changes
|
||||
neither semantic ownership nor port direction. Other diagrams are independent.</p>
|
||||
<p>The existing transaction endpoint accepts:</p>
|
||||
<pre class="literal-block">{"commands":[{"SetPortAnchor":{
|
||||
"diagram":"<diagram UUID>","node":"<placement UUID>",
|
||||
"anchor":{"port":"<port UUID>","side":"Top","offset":0.25}
|
||||
}}]}</pre>
|
||||
<p><span class="docutils literal">SetPortAnchor</span> upserts by port UUID. <span class="docutils literal">RemovePortAnchor</span> takes
|
||||
<span class="docutils literal">{diagram,node,port}</span> and restores automatic positioning. Repeating a reset
|
||||
is a no-op only while the referenced port still belongs to that placement's
|
||||
element; missing or stale owners are rejected. Remove old anchors before
|
||||
reparenting in the same atomic batch. Both commands support ordinary dry run,
|
||||
ETag checks and source-project writeback. Rejected batches preserve the model,
|
||||
authored files and revision. JSON/source imports may omit <span class="docutils literal">port_anchors</span>;
|
||||
it defaults to <span class="docutils literal">[]</span> and empty lists are omitted on export. The normative
|
||||
diagram semantics and source spelling are in <span class="docutils literal">language.rst</span> section 5.</p>
|
||||
</section>
|
||||
<section id="contextual-process-and-chain-topology">
|
||||
<h2>Contextual process and chain topology</h2>
|
||||
<p>Operational processes and system functional chains can combine work
|
||||
@@ -966,11 +941,8 @@ does not execute conditions or simulate a process.</p>
|
||||
store <span class="docutils literal">message_kind</span>, optional correlated-call <span class="docutils literal">reply_to</span> and contextual
|
||||
<span class="docutils literal">values</span>. Message kinds are Asynchronous, Call, Reply, Create, Delete, or
|
||||
<span class="docutils literal"><span class="pre">{"Timer":{"expression":"after</span> 5 <span class="pre">s"}}</span></span>. Each value record contains a
|
||||
carried DataField UUID in <span class="docutils literal">field</span>, enclosing field UUID list <span class="docutils literal">path</span> (default
|
||||
[]) and textual <span class="docutils literal">expression</span>. Identity is (path, field), allowing repeated uses
|
||||
of a Record. Operation Call/Reply direction checks use the direct parameter;
|
||||
Reference storage stops nested payload traversal. Expressions do not change the
|
||||
shared exchange item's type or evaluate server-side.</p>
|
||||
carried DataField UUID in <span class="docutils literal">field</span> and its textual <span class="docutils literal">expression</span>; expressions
|
||||
do not change the shared exchange item's type or evaluate server-side.</p>
|
||||
<p><span class="docutils literal">ScenarioUse</span> contains a referenced <span class="docutils literal">scenario</span> and one-to-one lifeline
|
||||
<span class="docutils literal">bindings</span> records with <span class="docutils literal">referenced</span> and <span class="docutils literal">local</span> UUIDs. The core validates
|
||||
viewpoint/layer compatibility, call/reply order and reversal, lifetime order,
|
||||
@@ -982,26 +954,6 @@ exports make those compatibility defaults explicit. This preserves original V2
|
||||
models while the release schema is being integrated; it does not silently
|
||||
reinterpret old mixed lifelines as a strict new viewpoint.</p>
|
||||
</section>
|
||||
<section id="data-and-interface-contracts">
|
||||
<h2>Data and interface contracts</h2>
|
||||
<p>Schema4 uses typed bounded DataTypeDefinition and DataValue constructors, field
|
||||
storage/defaults/directions, and single specialization. Exact JSON shapes are in
|
||||
<span class="docutils literal">openapi.json</span> and semantic rules and runnable source in <span class="docutils literal">language.rst</span> section 4.5.
|
||||
Interface membership is an identified InterfaceItemAllocation relation with
|
||||
independent order/transmission/acquisition. DataAssociation relates Record types
|
||||
with named, navigable, multiplicity-bearing ends; it does not add carried fields.</p>
|
||||
<p>Use ordinary relation queries, transactions and artifact links, for example
|
||||
<span class="docutils literal">GET <span class="pre">/api/v1/projects/{id}/relations?kind=InterfaceItemAllocation</span></span>. The service
|
||||
and desktop use the same inheritance/path/default validation. Invalid bounds,
|
||||
defaults, directions, cycles or contract-breaking deletions reject atomically,
|
||||
preserving the accepted source/model, revision and stored bytes. Correct a related
|
||||
contract in the same transaction when removing a used interface allocation.</p>
|
||||
<p>Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
|
||||
add semantic defaults, generated interface allocations and unresolved message
|
||||
values. Old source3 bundles retain files/includes/comments during upgrade; all
|
||||
current exports use schema4/source4. Reading an old envelope does not write it or
|
||||
advance its ETag. Canonical exports omit transient migration reports.</p>
|
||||
</section>
|
||||
<section id="validation">
|
||||
<h2>Validation</h2>
|
||||
<p>GET returns current-model diagnostics. POST accepts either:</p>
|
||||
@@ -1009,11 +961,10 @@ advance its ETag. Canonical exports omit transient migration reports.</p>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"arcadia", "source":"complete source document"}</pre>
|
||||
<p>or:</p>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}</pre>
|
||||
<pre class="literal-block">{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}</pre>
|
||||
<p>A well-formed validation request returns 200 with <span class="docutils literal">valid</span> and <span class="docutils literal">issues</span> even
|
||||
if its candidate is invalid, including a model record that fails typed decoding.
|
||||
Malformed request JSON or an invalid format/payload envelope is a 400.
|
||||
Source diagnostics include one-based line and column according to the
|
||||
if its candidate is invalid. A malformed request envelope or JSON model record
|
||||
is a 400. Source diagnostics include one-based line and column according to the
|
||||
shared language parser. Candidate validation does not replace current state.
|
||||
Source-project validation locations refer to the submitted bytes before UUID
|
||||
insertion. Successful source-project PUT diagnostics refer to the returned
|
||||
@@ -1043,35 +994,6 @@ introduce a completion transition. Invalid typed references reject the whole
|
||||
candidate and preserve revision/ETag. All writes retain the same conditional
|
||||
commit and disk persistence contract as other semantic records.</p>
|
||||
</section>
|
||||
<section id="state-diagram-representation">
|
||||
<h2>State diagram representation</h2>
|
||||
<p><span class="docutils literal">Diagram.projection</span> keeps the existing string values <span class="docutils literal">Overview</span>,
|
||||
<span class="docutils literal">Decomposition</span>, <span class="docutils literal">Allocation</span>, <span class="docutils literal">Traceability</span>, <span class="docutils literal">FunctionalDataflow</span> and
|
||||
<span class="docutils literal">Capabilities</span>. A dedicated state view instead uses the required payload:</p>
|
||||
<pre class="literal-block">{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}</pre>
|
||||
<p>The UUID MUST identify an existing StateMachine in the diagram's layer.
|
||||
Only that machine, its Regions and its state vertices may be placed, including
|
||||
nested members. Empty and partial views are valid. No machine, Region, vertex
|
||||
or placement is synthesized by accepting or reading a diagram. Source uses
|
||||
<span class="docutils literal">projection { StateMachine { machine @modes; }; };</span> with the same reference.</p>
|
||||
<p>Use the ordinary <span class="docutils literal">CreateDiagram</span> or <span class="docutils literal">EditDiagram</span> transaction commands,
|
||||
JSON model import/replacement, or source/source-project writes. A bare
|
||||
<span class="docutils literal">"StateMachine"</span>, missing or null <span class="docutils literal">machine</span>, malformed UUID, or additional
|
||||
payload field is malformed JSON model data (400). A missing, wrong-kind or
|
||||
cross-layer machine produces the semantic issue <span class="docutils literal">state_diagram_context</span>;
|
||||
an incompatible placement produces <span class="docutils literal">state_diagram_member</span>. These reject
|
||||
writes with 422. Transaction rejections use the <span class="docutils literal">command_rejected</span> problem
|
||||
code and include the first core failure in <span class="docutils literal">detail</span>; candidate validation
|
||||
provides the structured <span class="docutils literal">issues</span> collection.
|
||||
Source syntax errors use the source rejection response (422) with locations.
|
||||
The validation endpoint returns 200 with <span class="docutils literal">valid:false</span> for a well-formed
|
||||
invalid candidate, as described above.</p>
|
||||
<p>When changing a diagram's machine or selecting StateMachine for an existing
|
||||
diagram, remove or relocate incompatible placements in the same transaction.
|
||||
Other projections continue to preserve hidden placements. Rejected writes
|
||||
retain the accepted model, source bundle, revision and ETag. Reading a state
|
||||
view has no semantic or persistence side effects.</p>
|
||||
</section>
|
||||
<section id="cross-layer-transitions">
|
||||
<h2>Cross-layer transitions</h2>
|
||||
<p>Preview accepts the shared <span class="docutils literal">TransitionRequest</span> record, including direction
|
||||
|
||||
+12
-102
@@ -136,8 +136,8 @@ Compressed or encoded request bodies and browser Origin requests are
|
||||
not supported.
|
||||
|
||||
Model admission permits at most 5,000 elements plus relations, 20,000 total
|
||||
persistent records (including the project, views, placements, routes and manual
|
||||
port anchors), and 50,000 ordered step references. Source declaration counts and decoded JSON record
|
||||
persistent records (including the project, views and placements), and 50,000
|
||||
ordered step references. Source declaration counts and decoded JSON record
|
||||
counts are checked before structural validation. Legacy import counts the
|
||||
occurrences and placements migration will generate before validating or
|
||||
migrating that model. Exceeding these budgets returns ``422 model_limit``;
|
||||
@@ -146,8 +146,8 @@ Parsing, validation and query processing use snapshot workers, so this work
|
||||
does not hold the catalog lock or serialize unrelated model preparation.
|
||||
|
||||
Import accepts schema-1 JSON through the shared explicit migration adapter,
|
||||
as well as schema-2, schema-3 and current schema-4 JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 4 / arcadia 4. Native JSON
|
||||
as well as schema-2 native JSON. Source import uses the shared language parser
|
||||
and its supported migrations. Export uses schema 3 / arcadia 3. Native JSON
|
||||
replacement accepts supported older schemas through the same explicit migration.
|
||||
Only documented query parameters are accepted. In particular, appending
|
||||
``?revision=...`` to model/source export is an error, not historical retrieval.
|
||||
@@ -160,7 +160,7 @@ A source bundle contains ``entry`` and a map of ``files``. For example::
|
||||
{
|
||||
"entry": "rescue.arcadia",
|
||||
"files": {
|
||||
"rescue.arcadia": "arcadia 4; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"rescue.arcadia": "arcadia 3; project rescue \"Rescue\" { include \"oa/work.arcadia\"; }\n",
|
||||
"oa/work.arcadia": "// Engineering rationale\noa_activity assess \"Assess assistance\" {}\n"
|
||||
}
|
||||
}
|
||||
@@ -172,9 +172,8 @@ targets or another hosted resource. All submitted files must be reachable;
|
||||
missing files, cycles, duplicate keys, case collisions and escaping paths are
|
||||
rejected. The bundle permits at most 256 files, 32 include edges along a path,
|
||||
and 1,024 include edges in total. Model and source-byte budgets apply across
|
||||
the complete bundle, not separately per file. Current bundle syntax is source4; source3 bundles migrate explicitly while
|
||||
preserving their files/includes/comments. V1/V2 single-file migration remains
|
||||
available through interchange import.
|
||||
the complete bundle, not separately per file. Bundle syntax is source3;
|
||||
single-file legacy migration remains available through interchange import.
|
||||
|
||||
Compilation inserts UUIDs into headers that omit them. Import returns 201 with
|
||||
Location/ETag; GET source-project retrieves the stabilized accepted bundle.
|
||||
@@ -292,34 +291,6 @@ nor reserves IDs. A committed response omits the proposed model and source
|
||||
project. Commit must check the revision again. A no-op is successful with
|
||||
``changed: false`` and the original ETag.
|
||||
|
||||
Diagram port positions
|
||||
----------------------
|
||||
|
||||
``NodePlacement.port_anchors`` is an optional list of
|
||||
``{"port":"<port UUID>","side":"Top","offset":0.25}`` records. Side is
|
||||
Left, Right, Top, or Bottom; offset is a finite fraction from 0 through 1,
|
||||
measured from top on vertical edges and from left on horizontal edges.
|
||||
The port must be a direct child of the placement's semantic element and occur
|
||||
at most once in its anchor list. Placement is presentation state; it changes
|
||||
neither semantic ownership nor port direction. Other diagrams are independent.
|
||||
|
||||
The existing transaction endpoint accepts::
|
||||
|
||||
{"commands":[{"SetPortAnchor":{
|
||||
"diagram":"<diagram UUID>","node":"<placement UUID>",
|
||||
"anchor":{"port":"<port UUID>","side":"Top","offset":0.25}
|
||||
}}]}
|
||||
|
||||
``SetPortAnchor`` upserts by port UUID. ``RemovePortAnchor`` takes
|
||||
``{diagram,node,port}`` and restores automatic positioning. Repeating a reset
|
||||
is a no-op only while the referenced port still belongs to that placement's
|
||||
element; missing or stale owners are rejected. Remove old anchors before
|
||||
reparenting in the same atomic batch. Both commands support ordinary dry run,
|
||||
ETag checks and source-project writeback. Rejected batches preserve the model,
|
||||
authored files and revision. JSON/source imports may omit ``port_anchors``;
|
||||
it defaults to ``[]`` and empty lists are omitted on export. The normative
|
||||
diagram semantics and source spelling are in ``language.rst`` section 5.
|
||||
|
||||
Contextual process and chain topology
|
||||
-------------------------------------
|
||||
|
||||
@@ -356,11 +327,8 @@ Scenario bodies store an explicit ``viewpoint``. Occurrence Exchange bodies
|
||||
store ``message_kind``, optional correlated-call ``reply_to`` and contextual
|
||||
``values``. Message kinds are Asynchronous, Call, Reply, Create, Delete, or
|
||||
``{"Timer":{"expression":"after 5 s"}}``. Each value record contains a
|
||||
carried DataField UUID in ``field``, enclosing field UUID list ``path`` (default
|
||||
[]) and textual ``expression``. Identity is (path, field), allowing repeated uses
|
||||
of a Record. Operation Call/Reply direction checks use the direct parameter;
|
||||
Reference storage stops nested payload traversal. Expressions do not change the
|
||||
shared exchange item's type or evaluate server-side.
|
||||
carried DataField UUID in ``field`` and its textual ``expression``; expressions
|
||||
do not change the shared exchange item's type or evaluate server-side.
|
||||
|
||||
``ScenarioUse`` contains a referenced ``scenario`` and one-to-one lifeline
|
||||
``bindings`` records with ``referenced`` and ``local`` UUIDs. The core validates
|
||||
@@ -374,29 +342,6 @@ exports make those compatibility defaults explicit. This preserves original V2
|
||||
models while the release schema is being integrated; it does not silently
|
||||
reinterpret old mixed lifelines as a strict new viewpoint.
|
||||
|
||||
Data and interface contracts
|
||||
----------------------------
|
||||
|
||||
Schema4 uses typed bounded DataTypeDefinition and DataValue constructors, field
|
||||
storage/defaults/directions, and single specialization. Exact JSON shapes are in
|
||||
``openapi.json`` and semantic rules and runnable source in ``language.rst`` section 4.5.
|
||||
Interface membership is an identified InterfaceItemAllocation relation with
|
||||
independent order/transmission/acquisition. DataAssociation relates Record types
|
||||
with named, navigable, multiplicity-bearing ends; it does not add carried fields.
|
||||
|
||||
Use ordinary relation queries, transactions and artifact links, for example
|
||||
``GET /api/v1/projects/{id}/relations?kind=InterfaceItemAllocation``. The service
|
||||
and desktop use the same inheritance/path/default validation. Invalid bounds,
|
||||
defaults, directions, cycles or contract-breaking deletions reject atomically,
|
||||
preserving the accepted source/model, revision and stored bytes. Correct a related
|
||||
contract in the same transaction when removing a used interface allocation.
|
||||
|
||||
Explicit schema1/2/3 migration preserves prior identities and typed ports. Reports
|
||||
add semantic defaults, generated interface allocations and unresolved message
|
||||
values. Old source3 bundles retain files/includes/comments during upgrade; all
|
||||
current exports use schema4/source4. Reading an old envelope does not write it or
|
||||
advance its ETag. Canonical exports omit transient migration reports.
|
||||
|
||||
Validation
|
||||
----------
|
||||
|
||||
@@ -410,12 +355,11 @@ or::
|
||||
|
||||
or::
|
||||
|
||||
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 4; project rescue \"Rescue\" {}"}}}
|
||||
{"format":"source-project", "source_project":{"entry":"rescue.arcadia", "files":{"rescue.arcadia":"arcadia 3; project rescue \"Rescue\" {}"}}}
|
||||
|
||||
A well-formed validation request returns 200 with ``valid`` and ``issues`` even
|
||||
if its candidate is invalid, including a model record that fails typed decoding.
|
||||
Malformed request JSON or an invalid format/payload envelope is a 400.
|
||||
Source diagnostics include one-based line and column according to the
|
||||
if its candidate is invalid. A malformed request envelope or JSON model record
|
||||
is a 400. Source diagnostics include one-based line and column according to the
|
||||
shared language parser. Candidate validation does not replace current state.
|
||||
Source-project validation locations refer to the submitted bytes before UUID
|
||||
insertion. Successful source-project PUT diagnostics refer to the returned
|
||||
@@ -448,40 +392,6 @@ introduce a completion transition. Invalid typed references reject the whole
|
||||
candidate and preserve revision/ETag. All writes retain the same conditional
|
||||
commit and disk persistence contract as other semantic records.
|
||||
|
||||
State diagram representation
|
||||
----------------------------
|
||||
|
||||
``Diagram.projection`` keeps the existing string values ``Overview``,
|
||||
``Decomposition``, ``Allocation``, ``Traceability``, ``FunctionalDataflow`` and
|
||||
``Capabilities``. A dedicated state view instead uses the required payload::
|
||||
|
||||
{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}
|
||||
|
||||
The UUID MUST identify an existing StateMachine in the diagram's layer.
|
||||
Only that machine, its Regions and its state vertices may be placed, including
|
||||
nested members. Empty and partial views are valid. No machine, Region, vertex
|
||||
or placement is synthesized by accepting or reading a diagram. Source uses
|
||||
``projection { StateMachine { machine @modes; }; };`` with the same reference.
|
||||
|
||||
Use the ordinary ``CreateDiagram`` or ``EditDiagram`` transaction commands,
|
||||
JSON model import/replacement, or source/source-project writes. A bare
|
||||
``"StateMachine"``, missing or null ``machine``, malformed UUID, or additional
|
||||
payload field is malformed JSON model data (400). A missing, wrong-kind or
|
||||
cross-layer machine produces the semantic issue ``state_diagram_context``;
|
||||
an incompatible placement produces ``state_diagram_member``. These reject
|
||||
writes with 422. Transaction rejections use the ``command_rejected`` problem
|
||||
code and include the first core failure in ``detail``; candidate validation
|
||||
provides the structured ``issues`` collection.
|
||||
Source syntax errors use the source rejection response (422) with locations.
|
||||
The validation endpoint returns 200 with ``valid:false`` for a well-formed
|
||||
invalid candidate, as described above.
|
||||
|
||||
When changing a diagram's machine or selecting StateMachine for an existing
|
||||
diagram, remove or relocate incompatible placements in the same transaction.
|
||||
Other projections continue to preserve hidden placements. Rejected writes
|
||||
retain the accepted model, source bundle, revision and ETag. Reading a state
|
||||
view has no semantic or persistence side effects.
|
||||
|
||||
Cross-layer transitions
|
||||
-----------------------
|
||||
|
||||
|
||||
+53
-290
@@ -623,7 +623,7 @@ a { text-decoration: none; }
|
||||
<h1 class="title">Arcadia Studio OA / SA Language and Editing Contract</h1>
|
||||
<dl class="docinfo simple">
|
||||
<dt class="version">Version<span class="colon">:</span></dt>
|
||||
<dd class="version">4</dd>
|
||||
<dd class="version">3</dd>
|
||||
<dt class="status">Status<span class="colon">:</span></dt>
|
||||
<dd class="status">Normative current implementation contract; first-release integration</dd>
|
||||
<dt class="core-implementation">Core implementation<span class="colon">:</span></dt>
|
||||
@@ -637,13 +637,14 @@ a { text-decoration: none; }
|
||||
<h2>1. Scope and conformance</h2>
|
||||
<p>This specification defines the shared Operational Analysis (OA) and System
|
||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
|
||||
validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT,
|
||||
and MAY indicate requirements, prohibitions, and permitted choices.</p>
|
||||
<p>Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
|
||||
mechanisms, qualified message values, specialization, identified interface
|
||||
allocations and reference associations. It retains schema 3 typed ports and
|
||||
state views. Explicit older readers preserve existing meanings and identities.
|
||||
This contract does not by itself establish native acceptance or release completion.</p>
|
||||
<p>Schema 3 introduces typed function and component ports, required functional
|
||||
exchange endpoints, independently oriented boundary exchanges, and explicit
|
||||
PortAllocation relations. First-release scenario/state additions remain in
|
||||
scope; this schema change does not declare native acceptance or the wider
|
||||
release complete. The preserved schema-2 reader validates the original
|
||||
contract before migration.</p>
|
||||
<p>OA describes stakeholders, operational work and needs. SA describes the
|
||||
selected system boundary, external actors, functions, services and expected
|
||||
behavior. SA permits at most one System of Interest; its absence is an
|
||||
@@ -661,7 +662,7 @@ Actual verification evidence is separate from this contract.</p>
|
||||
</section>
|
||||
<section id="one-authoritative-model">
|
||||
<h2>2. One authoritative model</h2>
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (4), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<p>A Project stores <span class="docutils literal">schema_version</span> (3), <span class="docutils literal">id</span>, <span class="docutils literal">alias</span>, <span class="docutils literal">name</span>,
|
||||
<span class="docutils literal">description</span>, and maps named <span class="docutils literal">elements</span>, <span class="docutils literal">relations</span>, <span class="docutils literal">diagrams</span>,
|
||||
<span class="docutils literal">artifact_links</span>, and <span class="docutils literal">traces</span>. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.</p>
|
||||
@@ -721,20 +722,20 @@ lists all additional fields. <span class="docutils literal">ref</span> means <sp
|
||||
<td><p><span class="docutils literal">[]</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_exchange_item</span>, <span class="docutils literal">sa_exchange_item</span></p></td>
|
||||
<td><p><span class="docutils literal">kind</span>: Information, Material, Energy; <span class="docutils literal">mechanism</span>: ExchangeMechanism; <span class="docutils literal">supertype</span>: item ref?</p></td>
|
||||
<td><p>Information; Unspecified; none</p></td>
|
||||
<td><p><span class="docutils literal">kind</span>: Information, Material, Energy, Event</p></td>
|
||||
<td><p>Information</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_data_type</span>, <span class="docutils literal">sa_data_type</span></p></td>
|
||||
<td><p><span class="docutils literal">definition</span>: DataTypeDefinition; <span class="docutils literal">default</span>: DataValue?; <span class="docutils literal">unit</span>: string</p></td>
|
||||
<td><p>String with minimum_length 0 / maximum_length none; none; <span class="docutils literal">""</span></p></td>
|
||||
<td><p><span class="docutils literal">definition</span>: Boolean, Integer, Real, String, Record, or Enumeration constructor</p></td>
|
||||
<td><p>String</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_data_field</span>, <span class="docutils literal">sa_data_field</span></p></td>
|
||||
<td><p><span class="docutils literal">type_ref</span>; <span class="docutils literal">minimum</span>: u32; <span class="docutils literal">maximum</span>: optional u32; <span class="docutils literal">unit</span>: string; <span class="docutils literal">direction</span>: ParameterDirection; <span class="docutils literal">storage</span>: FieldStorage; <span class="docutils literal">default</span>: DataValue list?</p></td>
|
||||
<td><p>String; 1; 1; <span class="docutils literal">""</span>; Unspecified; Value; none</p></td>
|
||||
<td><p><span class="docutils literal">type_ref</span>; <span class="docutils literal">minimum</span>: u32; <span class="docutils literal">maximum</span>: optional u32; <span class="docutils literal">unit</span>: string</p></td>
|
||||
<td><p>String; 1; 1; <span class="docutils literal">""</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_interface</span>, <span class="docutils literal">sa_interface</span></p></td>
|
||||
<td><p><span class="docutils literal">supertype</span>: interface ref?</p></td>
|
||||
<td><p>none; membership is an identified interface_item_allocation relation</p></td>
|
||||
<td><p><span class="docutils literal">items</span>: exchange-item refs</p></td>
|
||||
<td><p><span class="docutils literal">[]</span></p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">oa_port</span>, <span class="docutils literal">sa_port</span></p></td>
|
||||
<td><p><span class="docutils literal">contract</span>: required PortContract; <span class="docutils literal">legacy</span>: optional LegacyPortContract</p></td>
|
||||
@@ -882,14 +883,6 @@ references. Their kind is the declaration keyword:</p>
|
||||
<td><p>Same-layer participant → leaf activity/function</p></td>
|
||||
<td><p>None</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">interface_item_allocation</span></p></td>
|
||||
<td><p>Same-layer Interface → ExchangeItem</p></td>
|
||||
<td><p><span class="docutils literal">order</span> u32 / 0; <span class="docutils literal">transmission</span> TransmissionProtocol / Unspecified; <span class="docutils literal">acquisition</span> AcquisitionProtocol / Unspecified</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">data_association</span></p></td>
|
||||
<td><p>Same-layer Record DataType → Record DataType</p></td>
|
||||
<td><p><span class="docutils literal">source_role</span>, <span class="docutils literal">target_role</span> / ""; <span class="docutils literal">source_minimum</span>, <span class="docutils literal">target_minimum</span> / 0; <span class="docutils literal">source_maximum</span>, <span class="docutils literal">target_maximum</span> / none; <span class="docutils literal">source_navigable</span>, <span class="docutils literal">target_navigable</span> / false</p></td>
|
||||
</tr>
|
||||
<tr><td><p><span class="docutils literal">interaction</span></p></td>
|
||||
<td><p>OA activity → OA activity</p></td>
|
||||
<td><p><span class="docutils literal">items</span> refs / []</p></td>
|
||||
@@ -970,8 +963,7 @@ references. Their kind is the declaration keyword:</p>
|
||||
</table>
|
||||
<p>Relation endpoints and carried ExchangeItems stay in the same layer. Parallel
|
||||
exchanges with distinct identities are permitted. Self-relations are invalid
|
||||
except for a state transition that returns to its source state or an explicit
|
||||
DataAssociation between two ends of the same Record type.</p>
|
||||
except for a state transition that returns to its source state.</p>
|
||||
<p>Direct Allocation has at most one performer per leaf. Role sharing is a
|
||||
different relationship: several OA participants can realize a Role. A direct
|
||||
allocation inconsistent with its role's realized performers produces an
|
||||
@@ -984,10 +976,9 @@ declares <span class="docutils literal">outgoing_items</span>; <span class="docu
|
||||
The lists contain distinct same-layer ExchangeItem references. A functional
|
||||
exchange MUST reference output/input ports owned by its respective functions,
|
||||
and each carried item MUST fit both declarations. Empty declarations accept
|
||||
no items. A carried item satisfies an identical declaration or a declaration
|
||||
of one of its explicit base items. A base item does not satisfy a more
|
||||
specialized declaration; names imply no compatibility. Port declarations
|
||||
remain independent of incident exchanges after creation.</p>
|
||||
no items. Item compatibility is identity equality; future specialization is
|
||||
not inferred from names. Port declarations remain independent of incident
|
||||
exchanges after creation.</p>
|
||||
<p>A <span class="docutils literal">Component</span> contract belongs to an SA System or SystemActor and stores
|
||||
<span class="docutils literal">kind</span> (Standard or Flow) and <span class="docutils literal">orientation</span> (Unset, In, Out, InOut).
|
||||
Standard requires Unset. Flow Unset is an incomplete draft, not InOut. Typed
|
||||
@@ -1020,13 +1011,12 @@ not create endpoints. Graphical/command FE creation plans ports atomically,
|
||||
using unique compatible typed-port reuse or deterministic new endpoints.
|
||||
Port deletion cascades to incident FEs; FE deletion retains independent ports.</p>
|
||||
<p>For example:</p>
|
||||
<pre class="literal-block">arcadia 4;
|
||||
<pre class="literal-block">arcadia 3;
|
||||
project ports_example "Typed function contract" {
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
sa_exchange_item payload "Payload" {}
|
||||
sa_interface old_service "Preserved service" {}
|
||||
interface_item_allocation service_payload "Service payload" { source @old_service; target @payload; }
|
||||
sa_interface old_service "Preserved service" { items [@payload]; }
|
||||
sa_port output "Output" {
|
||||
parent @send;
|
||||
contract { FunctionOutput { outgoing_items [@payload]; }; };
|
||||
@@ -1135,19 +1125,11 @@ local to its sender lifeline, has no global exchange or values, and has either
|
||||
no receiver or the same receiver as sender. Its nonempty expression describes
|
||||
the delay/deadline; an empty expression warns. Conditions and expressions are
|
||||
stored engineering annotations, not an executable simulation language.</p>
|
||||
<p>Each MessageValue is <span class="docutils literal">{ path [@parameter]; field @data_field; expression "value expression"; }</span>.
|
||||
Its identity is the pair (path, field), so two uses of one Record can have
|
||||
different values for the same terminal field. Path contains enclosing DataField
|
||||
references, excluding the terminal field; direct parameters use <span class="docutils literal">[]</span>. Every
|
||||
hop MUST follow Value storage through a named Record's effective fields. Reference
|
||||
storage stops traversal. Operation Call accepts In/InOut and Reply accepts
|
||||
Out/InOut/Return, interpreted from the provider's perspective. Nested field paths
|
||||
inherit their direct parameter's direction. Unspecified direction warns.
|
||||
At most one annotation for a pair is allowed. Omitted path defaults to <span class="docutils literal">[]</span>;
|
||||
an ambiguous preserved legacy nested annotation warns <span class="docutils literal">message_value_ambiguous_path</span>
|
||||
until an explicit path is selected. New graphical choices always identify a path.
|
||||
Values never overwrite types or items. Expression text is descriptive, preserved
|
||||
exactly and not evaluated; an empty expression warns.</p>
|
||||
<p>Each MessageValue is <span class="docutils literal">{ field @data_field; expression "value expression"; }</span>.
|
||||
The field MUST be a DataField carried by the message exchange, including fields
|
||||
inside named nested records. At most one annotation for a given field is
|
||||
allowed in one message. Values do not overwrite the referenced type or global
|
||||
exchange item. Expression text is preserved exactly; an empty expression warns.</p>
|
||||
<p>ScenarioUse references an existing same-layer scenario and maps its lifelines
|
||||
with <span class="docutils literal">bindings [ { referenced @callee_line; local @caller_line; } ];</span>.
|
||||
Each binding is one-to-one and references a direct lifeline of the appropriate
|
||||
@@ -1298,184 +1280,23 @@ DeepHistory remembers nested configuration. Fallback applies when no resumable
|
||||
history exists. History is per Region, including top-level Regions in this
|
||||
Studio profile; no running history state is persisted or synthesized.</p>
|
||||
</section>
|
||||
<section id="data-contracts-specialization-and-interface-allocation">
|
||||
<h3>4.5. Data contracts, specialization and interface allocation</h3>
|
||||
<p>ExchangeItem <span class="docutils literal">kind</span> describes nature: Information, Material or Energy.
|
||||
<span class="docutils literal">mechanism</span> is Unspecified, Flow, Event, Operation or SharedData. Unknown
|
||||
mechanisms remain explicit Unspecified drafts with a warning. Material/Energy
|
||||
with Event/Operation/SharedData warns for engineering review; it is not silently
|
||||
reclassified. Empty Event notifications and void Operations are valid. An empty
|
||||
carried/allocated Flow, SharedData or Unspecified item warns.</p>
|
||||
<p>An Operation's directly owned DataFields are parameters. <span class="docutils literal">direction</span> is
|
||||
Unspecified, In, Out, InOut or Return, from the provider's perspective. At most
|
||||
one effective parameter is Return. Unspecified parameters warn; non-operation
|
||||
fields MUST use Unspecified. Nested record fields inherit direction from their
|
||||
direct parameter when used in message paths, rather than storing context on a
|
||||
shared type. <span class="docutils literal">storage</span> is Value or Reference. Reference requires a named Record,
|
||||
has no explicit default, and does not convey ownership or deletion of the type.</p>
|
||||
<p>DataTypeDefinition has exactly the following constructors:</p>
|
||||
<table>
|
||||
<caption>Reusable definitions</caption>
|
||||
<thead>
|
||||
<tr><th class="head"><p>Constructor</p></th>
|
||||
<th class="head"><p>Required fields</p></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><p>Boolean</p></td>
|
||||
<td><p>None</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Integer</p></td>
|
||||
<td><p><span class="docutils literal">minimum</span>, <span class="docutils literal">maximum</span>: optional signed 64-bit integers</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Real</p></td>
|
||||
<td><p><span class="docutils literal">minimum</span>, <span class="docutils literal">maximum</span>: optional finite IEEE-754 binary64 values</p></td>
|
||||
</tr>
|
||||
<tr><td><p>String</p></td>
|
||||
<td><p><span class="docutils literal">minimum_length</span>: u32; <span class="docutils literal">maximum_length</span>: optional u32</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Enumeration</p></td>
|
||||
<td><p><span class="docutils literal">literals</span>: unique nonempty, case-sensitive strings</p></td>
|
||||
</tr>
|
||||
<tr><td><p>Record</p></td>
|
||||
<td><p><span class="docutils literal">supertype</span>: same-layer Record ref?</p></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p>Bounds are inclusive and ordered; an absent bound means unbounded within the
|
||||
represented numeric domain. Integer comparisons MUST NOT convert to binary64.
|
||||
NaN, infinities, overflow and reversed bounds reject. String length counts Unicode
|
||||
scalar values, not UTF-8 bytes or grapheme clusters. Enum defaults match exact
|
||||
literals. Empty enum definitions remain incomplete drafts with a warning.</p>
|
||||
<p>DataValue is a tagged scalar: <span class="docutils literal">{ Boolean true; }</span>, <span class="docutils literal">{ Integer 42; }</span>,
|
||||
<span class="docutils literal">{ Real 1.5; }</span>, <span class="docutils literal">{ String "value"; }</span> or <span class="docutils literal">{ Enumeration "Ready"; }</span>.
|
||||
There is no implicit numeric or string coercion. A type default is one compatible
|
||||
scalar within its bounds. Record type defaults and record/object instance literals
|
||||
are unsupported. A field default is absent (<span class="docutils literal">none</span>) or a list of compatible
|
||||
scalars with length inside its multiplicity. <span class="docutils literal">[]</span> explicitly supplies an empty
|
||||
collection and requires minimum 0, including Value Record fields; a nonempty
|
||||
Record literal rejects. Reference fields MUST use <span class="docutils literal">none</span>. A one-valued scalar
|
||||
field uses a one-value list. A named type default supplies an individual element's
|
||||
default, not a collection or an implicit instance of a mandatory field.</p>
|
||||
<p>Numeric types may carry unit text. Empty field units inherit the named type unit;
|
||||
an explicit different unit warns when that type has a unit. Units on nonnumeric
|
||||
fields/types warn and remain preserved. Unit strings never convert values or
|
||||
establish dimensional compatibility. Expressions are not executed.</p>
|
||||
<p>Record, ExchangeItem and Interface each permit one same-family, same-layer
|
||||
supertype. Specialization is acyclic and separate from containment. Effective
|
||||
fields are base fields followed by local fields, preserving original field UUIDs
|
||||
and owners. Local names MUST NOT shadow inherited names. Item specialization
|
||||
preserves base nature and any specified mechanism; an Unspecified base mechanism
|
||||
may be refined. Deleting a derived record never deletes its base. Mandatory
|
||||
Value-field cycles warn <span class="docutils literal">record_no_finite_value</span>; optional or Reference cycles
|
||||
are valid without that warning. Traversal is cycle guarded and bounded; a payload
|
||||
path budget failure is an explicit diagnostic, not silent truncation.</p>
|
||||
<p>InterfaceItemAllocation is an ordinary identified relation from Interface to
|
||||
ExchangeItem. It stores <span class="docutils literal">order</span> and independent transmission/acquisition
|
||||
protocols. Transmission is Unspecified, Synchronous, Asynchronous, Unicast,
|
||||
Multicast or Broadcast; acquisition is Unspecified, Read or Accept. No protocol
|
||||
combination is inferred from mechanism. Effective allocations are base-first,
|
||||
then local ordered by (order, alias, UUID). A local item already allocated locally
|
||||
or inherited is an error. Removing an allocation keeps its item and other
|
||||
allocations; if live boundary compatibility would break, the transaction MUST
|
||||
repair the affected contract explicitly or reject atomically. Artifacts can
|
||||
reference the allocation identity independently of the global item.</p>
|
||||
<p>An exchange carrying a specialized item satisfies a declaration of its base,
|
||||
never the reverse. Standard provided/required compatibility uses explicit
|
||||
interface ancestry. Identity/ancestry, not matching names, defines conformance.
|
||||
The current typed function/component port contracts remain unchanged.</p>
|
||||
<p>DataAssociation connects Record types without adding fields or transitively
|
||||
carrying payload. Each named end has role, multiplicity and navigability. Source
|
||||
multiplicity counts source instances per target instance and vice versa; it is
|
||||
not a count of model elements. An end's navigable flag permits navigation toward
|
||||
that end. Self-associations are valid; if both ends are navigable they require
|
||||
distinct nonempty roles. Empty roles and duplicate role contracts warn, whereas
|
||||
invalid ranges reject. Composition/shared aggregation, multiple inheritance,
|
||||
redefinition, instance literals and arbitrary-precision decimal arithmetic are
|
||||
outside this bounded contract.</p>
|
||||
<p>This complete data contract example is also exercised through the REST API:</p>
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
project engineering_data "Engineering data" {
|
||||
sa_data_type voltage "Voltage" {
|
||||
definition { Real { minimum 0.0; maximum 48.0; }; };
|
||||
default { Real 24.0; }; unit "V";
|
||||
}
|
||||
sa_data_type coordinate "Coordinate" {
|
||||
definition { Record { supertype none; }; };
|
||||
sa_data_field latitude "Latitude" { type_ref Real; }
|
||||
}
|
||||
sa_data_type located "Located asset" {
|
||||
definition { Record { supertype @coordinate; }; };
|
||||
sa_data_field supply "Supply" { type_ref { Named @voltage; }; }
|
||||
}
|
||||
sa_exchange_item navigate "Navigate" {
|
||||
mechanism Operation;
|
||||
sa_data_field origin "Origin" { type_ref { Named @coordinate; }; direction In; }
|
||||
sa_data_field destination "Destination" { type_ref { Named @coordinate; }; direction In; }
|
||||
sa_data_field accepted "Accepted" { type_ref Boolean; direction Return; }
|
||||
}
|
||||
sa_interface navigation "Navigation" {}
|
||||
sa_interface monitoring "Monitoring" {}
|
||||
interface_item_allocation nav_operation "Navigation operation" {
|
||||
source @navigation; target @navigate; transmission Synchronous; acquisition Accept;
|
||||
}
|
||||
interface_item_allocation monitor_operation "Monitoring operation" {
|
||||
source @monitoring; target @navigate; transmission Asynchronous; acquisition Read;
|
||||
}
|
||||
data_association location_link "Location relationship" {
|
||||
source @coordinate; target @located;
|
||||
source_role "location"; target_role "assets";
|
||||
source_maximum 1;
|
||||
}
|
||||
}</code></pre>
|
||||
</section>
|
||||
</section>
|
||||
<section id="diagrams-artifacts-and-cross-layer-traceability">
|
||||
<h2>5. Diagrams, artifacts, and cross-layer traceability</h2>
|
||||
<p><span class="docutils literal">diagram</span> has name, identity, alias, description, <span class="docutils literal">layer</span> (Oa by default),
|
||||
<span class="docutils literal">projection</span> (Overview by default), and <span class="docutils literal">routes</span> (empty list by default).
|
||||
The string projections are Overview, Decomposition, Allocation,
|
||||
FunctionalDataflow, Capabilities, and Traceability. StateMachine is a payload
|
||||
constructor with one required <span class="docutils literal">machine</span> reference. Traceability diagrams
|
||||
can show both layers; other diagrams show their chosen layer.</p>
|
||||
Other projections are Decomposition,
|
||||
Allocation, FunctionalDataflow, Capabilities, and Traceability. Traceability diagrams can show both layers;
|
||||
other diagrams show their chosen layer.</p>
|
||||
<p>Allocation is an architecture view: it shows participants and work, nests
|
||||
allocated work under its performer, and displays interactions, functional
|
||||
exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy
|
||||
and its exchanges. Capabilities shows participants, roles, missions and
|
||||
capabilities with their involvement, exploitation, include, extend and
|
||||
generalization links. Decomposition and Overview retain general containment;
|
||||
Overview also exposes all relationships. These string projections hide
|
||||
Overview also exposes all relationships. Changing the projection hides
|
||||
inapplicable placements without deleting them or changing semantic records.</p>
|
||||
<p>A StateMachine projection MUST reference an existing StateMachine in the
|
||||
diagram's layer. Its placements MUST refer to that machine, its Regions, or
|
||||
its state vertices, including nested Regions and vertices. Events, other
|
||||
machines and their members, and unrelated semantic elements are not valid
|
||||
placements in this view. Empty and partial state views are valid; neither
|
||||
creating nor reading a view synthesizes semantic elements or placements.
|
||||
Changing an existing diagram to StateMachine, or changing its machine, MUST
|
||||
remove or relocate incompatible placements in the same transaction. Context
|
||||
errors use <span class="docutils literal">state_diagram_context</span>; incompatible placements use
|
||||
<span class="docutils literal">state_diagram_member</span>. Neither case silently hides invalid members.</p>
|
||||
<p>This complete source example places one state from an existing machine:</p>
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main region" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
diagram mode_view "Operating modes" {
|
||||
layer Oa;
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
node ready_view { diagram @mode_view; element @ready; }
|
||||
}</code></pre>
|
||||
<p>The JSON projection is
|
||||
<span class="docutils literal"><span class="pre">{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}</span></span>,
|
||||
where the UUID is the referenced machine's identity. A bare <span class="docutils literal">"StateMachine"</span>,
|
||||
a missing or null <span class="docutils literal">machine</span>, an invalid UUID, or additional payload fields
|
||||
is malformed. Existing projections retain their string representation, for
|
||||
example <span class="docutils literal">"Overview"</span> in JSON and <span class="docutils literal">projection Overview;</span> in source.</p>
|
||||
<p>Ports are boundary anchors of visible owners, with their existing
|
||||
<p>Ports are calculated boundary anchors of visible owners, with their existing
|
||||
semantic identity. An explicitly placed port is not rendered as a separate
|
||||
large node. Functional exchanges use their declared source/target ports;
|
||||
boundary exchanges attach to their participant ports even if those ports have
|
||||
@@ -1484,45 +1305,13 @@ may terminate at a visible ancestor without creating a new port or exchange.</p>
|
||||
<p><span class="docutils literal">node</span> has an alias and identity but no display-name string. Required fields
|
||||
are <span class="docutils literal">diagram</span> and <span class="docutils literal">element</span> references. Optional fields are <span class="docutils literal">position</span>
|
||||
(record with x/y, default 0/0), <span class="docutils literal">size</span> (width/height, default 224/94), and
|
||||
<span class="docutils literal">collapsed</span> (false), and <span class="docutils literal">port_anchors</span> (empty list). Node identity is
|
||||
independent of its semantic element.
|
||||
<span class="docutils literal">collapsed</span> (false). Node identity is independent of its semantic element.
|
||||
Coordinates are absolute diagram coordinates. Placement does not alter parent,
|
||||
allocation, or realization. Hiding an element from a diagram must remain
|
||||
distinguishable from deleting it from the model.
|
||||
Changing a node's diagram preserves the placement identity, alias, coordinates,
|
||||
size, collapse state, manual port anchors, and attached artifacts. The destination
|
||||
must accept the element's layer, satisfy StateMachine membership when applicable,
|
||||
and must not already contain another placement of that element.</p>
|
||||
<p>Each <span class="docutils literal">port_anchors</span> entry is a PortAnchor record with required <span class="docutils literal">port</span>
|
||||
reference, <span class="docutils literal">side</span> (Left, Right, Top, or Bottom), and <span class="docutils literal">offset</span> (finite
|
||||
binary32 from 0 through 1). It has no separate identity or alias. The port MUST
|
||||
be an existing Port directly owned by the placement's <span class="docutils literal">element</span>. A port MAY
|
||||
have one manual anchor per placement; different diagrams MAY position it
|
||||
independently. List order is preserved but has no positional meaning.</p>
|
||||
<p><span class="docutils literal">offset</span> measures a fraction of the complete projected owner edge, from top
|
||||
on Left/Right or from left on Top/Bottom. Zero and one denote its corners.
|
||||
The side is presentation data and MUST NOT change the port's contract,
|
||||
orientation, ownership, allocations, or connected exchanges. Absent anchors use
|
||||
automatic positioning. Empty lists are omitted in canonical JSON/source;
|
||||
earlier schema-3 input without this field hydrates as <span class="docutils literal">[]</span>.</p>
|
||||
<pre class="code text literal-block"><code>node actor_view {
|
||||
diagram @architecture; element @vessel;
|
||||
port_anchors [{ port @communication; side Top; offset 0.25; }];
|
||||
}</code></pre>
|
||||
<p><span class="docutils literal">SetPortAnchor { diagram, node, anchor }</span> upserts by port identity and retains
|
||||
unrelated entries. <span class="docutils literal">RemovePortAnchor { diagram, node, port }</span> restores
|
||||
automatic positioning; it is a no-op only for an existing direct child port
|
||||
of that placement. Unknown fields, duplicate port references, non-port or
|
||||
foreign-owner references, and invalid offsets are structural errors. Each
|
||||
anchor counts as one record against <span class="docutils literal">ParseLimits.max_records</span>.</p>
|
||||
<p>These commands use the same atomic history, conditional REST transactions, and
|
||||
source writeback as other presentation edits. Source references resolve across
|
||||
files, and anchor comments follow port identity. Reidentifying a port updates
|
||||
anchor references; deleting it removes its anchors. Reparenting MUST repair
|
||||
old-owner anchors in the same transaction, for example by removing them before
|
||||
the Reparent command. Moving, resizing, arranging, or folding an owner retains
|
||||
its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
|
||||
<span class="docutils literal">port_anchors</span>; their explicit migration supplies an empty list.</p>
|
||||
size, collapse state, and attached artifacts. The destination must accept the
|
||||
element's layer and must not already contain another placement of that element.</p>
|
||||
<p>Each <span class="docutils literal">routes</span> entry is an EdgeRoute record with a required <span class="docutils literal">edge</span> reference
|
||||
to an existing Relation or Trace, <span class="docutils literal">waypoints</span> (default <span class="docutils literal">[]</span>), and <span class="docutils literal">label</span>
|
||||
(default <span class="docutils literal">none</span>). Route records have no separate UUID or alias. An edge occurs
|
||||
@@ -1666,7 +1455,7 @@ spellings can be used in alias positions because references begin with <span cla
|
||||
<span class="docutils literal"><span class="pre">element-kind</span></span> and <span class="docutils literal"><span class="pre">relation-kind</span></span> are exactly the corresponding declaration
|
||||
keywords listed above. Typed field
|
||||
sets and enum constructors are closed by sections 3–5.</p>
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "4" ";" project
|
||||
<pre class="code text literal-block"><code>document = "arcadia" "3" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
@@ -1703,16 +1492,16 @@ Ordinary string fields require quoted strings and cannot contain an unquoted
|
||||
<span class="docutils literal">@alias</span> reference. Enum fields require unquoted named values or their typed
|
||||
constructor records. Typed values MUST NOT nest more than 64 levels; malformed
|
||||
deeply nested drafts produce a diagnostic instead of exhausting the parser stack.</p>
|
||||
<section id="modular-source-projects">
|
||||
<h3>6.1. Modular source projects</h3>
|
||||
<section id="modular-source3-projects">
|
||||
<h3>6.1. Modular source3 projects</h3>
|
||||
<p>The pure <span class="docutils literal">source_project</span> core API compiles a virtual source bundle, containing
|
||||
<span class="docutils literal">entry</span> and a <span class="docutils literal">files</span> map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source4 project header above. A project entry
|
||||
root-level <span class="docutils literal">.arcadia</span> file with the source3 project header above. A project entry
|
||||
MAY also be <span class="docutils literal">include string ";"</span>. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur at document level,
|
||||
never inside an element body or typed field value. For example:</p>
|
||||
<pre class="literal-block">arcadia 4;
|
||||
<pre class="literal-block">arcadia 3;
|
||||
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "Operational model";
|
||||
include "oa/participants.arcadia";
|
||||
@@ -1770,12 +1559,9 @@ the source index. Columns remain one-based Unicode scalars within the real file;
|
||||
spans never refer to concatenated or synthetic source.</p>
|
||||
<p>Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through explicit migration. Modular projects accept source3 and source4;
|
||||
source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
ownership and comments through targeted token edits. Generated allocation
|
||||
declarations stay in their interface's existing file. Individual include fragments
|
||||
are not independently migrated. Missing UUIDs stabilize before migration so new
|
||||
allocation identities are deterministic on subsequent compilation.</p>
|
||||
continue through their existing migration readers and reports. Modular projects
|
||||
require source3; migration is performed before modular export, not independently
|
||||
on included fragments. Modular source does not change the semantic JSON schema.</p>
|
||||
</section>
|
||||
<section id="nested-element-ownership">
|
||||
<h3>6.2. Nested element ownership</h3>
|
||||
@@ -1829,8 +1615,7 @@ preserve authored comments and whitespace on Apply while inserting missing
|
||||
identities. Explicit canonical export remains a separate operation. Project
|
||||
update/export distinctions are described below.
|
||||
Camera position, selection, source cursor, and undo history are not stored
|
||||
model fields. Layout, collapse state, and manual port anchors inside
|
||||
NodePlacement are model data.</p>
|
||||
model fields. Layout and collapse state inside NodePlacement are model data.</p>
|
||||
<p>The pure <span class="docutils literal">format_bundle</span> exporter uses the same typed declaration writer as
|
||||
<span class="docutils literal">format</span>. It creates a small entry file, layer/category modules, separate
|
||||
activity/function roots and behavior contexts, and separate diagrams with their
|
||||
@@ -1979,31 +1764,9 @@ explicitly before Apply succeeds.</p>
|
||||
<h2>9. Persistence and explicit migration</h2>
|
||||
<p>Loading detects JSON from its first non-whitespace <span class="docutils literal">{</span>; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an <span class="docutils literal">.arcadia</span> suffix uses
|
||||
the accepted V4 source bundle (<span class="docutils literal">arcadia 4</span>); other suffixes use V4 JSON. Both forms preserve the same
|
||||
the accepted V3 source bundle (<span class="docutils literal">arcadia 3</span>); other suffixes use V3 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.</p>
|
||||
<p>The schema-3 reader freezes the changed data payloads and old constructor catalog;
|
||||
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
|
||||
typed PortContract records remain unchanged. Information/Material/Energy retain
|
||||
their nature with Unspecified mechanism; old Event becomes Information with Event
|
||||
mechanism. Old bounds/defaults/supertypes are absent, String length is 0..unbounded,
|
||||
and fields retain type/multiplicity/unit with Unspecified direction, Value storage
|
||||
and no explicit default. No physical mechanism or direction is guessed.</p>
|
||||
<p>Each old Interface.items entry generates one InterfaceItemAllocation with its
|
||||
original zero-based order and Unspecified protocols. UUID v5 uses the project UUID
|
||||
namespace and name <span class="docutils literal"><span class="pre">v2-interface-item:<interface-uuid>:<item-uuid></span></span> consistently
|
||||
for schema-2 and schema-3 inputs. Aliases are deterministic and collision-free;
|
||||
collision with any existing identity is an error rather than an overwrite. Existing
|
||||
interface/item identities remain intact. Old nested message values acquire a path
|
||||
only when exactly one valid path exists. Ambiguous paths, including recursive
|
||||
reuse, retain the old annotation with a located warning rather than guessing.</p>
|
||||
<p>Migration reports additionally contain <span class="docutils literal">semantic_defaults</span> (object, field,
|
||||
description), <span class="docutils literal">generated_interface_allocations</span> (id, source, target, alias,
|
||||
order) and <span class="docutils literal">unresolved_message_values</span> (occurrence, field, reason). These new
|
||||
report arrays default empty when reading older responses. Version 1 normalizes
|
||||
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
|
||||
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 4; reading/migrating alone never saves or changes revisions.</p>
|
||||
<p>Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
entry is installed last. A transaction journal records originals and intended
|
||||
@@ -2027,7 +1790,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
<span class="docutils literal"><span class="pre">crates/oa-core/tests/fixtures</span></span> MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults through the explicit schema-3 and schema-4 migrations.</p>
|
||||
the added defaults through the explicit schema-3 migration.</p>
|
||||
<p>The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
direction/interface record. Existing FE endpoint IDs remain unchanged. For
|
||||
@@ -2044,13 +1807,13 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 4. Model/source exports contain only the canonical model. Migration
|
||||
schema 3. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.</p>
|
||||
<p>Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V4 graph are
|
||||
construction. Both the old V2 contract and the resulting V3 graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses these same V2-to-3-to-4 stages.</p>
|
||||
model and then uses this same V2-to-3 stage.</p>
|
||||
<p>Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
not truncate a previous file. External concurrent-writer conflict resolution
|
||||
@@ -2066,20 +1829,20 @@ step becomes a distinct occurrence element owned by its process. Generated
|
||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||
as namespace and deterministic role/index names. If such an identity collides,
|
||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||
input therefore yields the same V4 result, including repeated occurrences.
|
||||
input therefore yields the same V3 result, including repeated occurrences.
|
||||
No System or SA content is invented during an OA-only migration.</p>
|
||||
<p>V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||
canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||
A project alias and aliases for new records are generated without colliding
|
||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||
V4 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
V3 projects. Future versions remain errors until a separate migration exists.</p>
|
||||
</section>
|
||||
<section id="complete-authoring-example">
|
||||
<h2>10. Complete authoring example</h2>
|
||||
<p>This document intentionally omits IDs for new authoring. Apply and Save write
|
||||
the assigned UUIDs explicitly.</p>
|
||||
<pre class="code text literal-block"><code>arcadia 4;
|
||||
<pre class="code text literal-block"><code>arcadia 3;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
|
||||
+52
-307
@@ -1,7 +1,7 @@
|
||||
Arcadia Studio OA / SA Language and Editing Contract
|
||||
====================================================
|
||||
|
||||
:Version: 4
|
||||
:Version: 3
|
||||
:Status: Normative current implementation contract; first-release integration
|
||||
:Core implementation: ``crates/oa-core``
|
||||
:Text suffix: ``.arcadia``
|
||||
@@ -11,14 +11,15 @@ Arcadia Studio OA / SA Language and Editing Contract
|
||||
|
||||
This specification defines the shared Operational Analysis (OA) and System
|
||||
Analysis (SA) model, its textual language, graphical editing obligations,
|
||||
validation, persistence, and explicit migration from versions 1, 2 and 3. MUST, MUST NOT,
|
||||
validation, persistence, and explicit migration from versions 1 and 2. MUST, MUST NOT,
|
||||
and MAY indicate requirements, prohibitions, and permitted choices.
|
||||
|
||||
Schema 4 adds bounded reusable data types, typed scalar defaults, exchange
|
||||
mechanisms, qualified message values, specialization, identified interface
|
||||
allocations and reference associations. It retains schema 3 typed ports and
|
||||
state views. Explicit older readers preserve existing meanings and identities.
|
||||
This contract does not by itself establish native acceptance or release completion.
|
||||
Schema 3 introduces typed function and component ports, required functional
|
||||
exchange endpoints, independently oriented boundary exchanges, and explicit
|
||||
PortAllocation relations. First-release scenario/state additions remain in
|
||||
scope; this schema change does not declare native acceptance or the wider
|
||||
release complete. The preserved schema-2 reader validates the original
|
||||
contract before migration.
|
||||
|
||||
OA describes stakeholders, operational work and needs. SA describes the
|
||||
selected system boundary, external actors, functions, services and expected
|
||||
@@ -39,7 +40,7 @@ Actual verification evidence is separate from this contract.
|
||||
2. One authoritative model
|
||||
--------------------------
|
||||
|
||||
A Project stores ``schema_version`` (4), ``id``, ``alias``, ``name``,
|
||||
A Project stores ``schema_version`` (3), ``id``, ``alias``, ``name``,
|
||||
``description``, and maps named ``elements``, ``relations``, ``diagrams``,
|
||||
``artifact_links``, and ``traces``. Each map is keyed by the UUID contained in
|
||||
its record. Duplicate map keys and mismatching keys/identities are errors.
|
||||
@@ -95,17 +96,17 @@ lists all additional fields. ``ref`` means ``@alias``; ``ref?`` also accepts
|
||||
- ``steps``: occurrence refs
|
||||
- ``[]``
|
||||
* - ``oa_exchange_item``, ``sa_exchange_item``
|
||||
- ``kind``: Information, Material, Energy; ``mechanism``: ExchangeMechanism; ``supertype``: item ref?
|
||||
- Information; Unspecified; none
|
||||
- ``kind``: Information, Material, Energy, Event
|
||||
- Information
|
||||
* - ``oa_data_type``, ``sa_data_type``
|
||||
- ``definition``: DataTypeDefinition; ``default``: DataValue?; ``unit``: string
|
||||
- String with minimum_length 0 / maximum_length none; none; ``""``
|
||||
- ``definition``: Boolean, Integer, Real, String, Record, or Enumeration constructor
|
||||
- String
|
||||
* - ``oa_data_field``, ``sa_data_field``
|
||||
- ``type_ref``; ``minimum``: u32; ``maximum``: optional u32; ``unit``: string; ``direction``: ParameterDirection; ``storage``: FieldStorage; ``default``: DataValue list?
|
||||
- String; 1; 1; ``""``; Unspecified; Value; none
|
||||
- ``type_ref``; ``minimum``: u32; ``maximum``: optional u32; ``unit``: string
|
||||
- String; 1; 1; ``""``
|
||||
* - ``oa_interface``, ``sa_interface``
|
||||
- ``supertype``: interface ref?
|
||||
- none; membership is an identified interface_item_allocation relation
|
||||
- ``items``: exchange-item refs
|
||||
- ``[]``
|
||||
* - ``oa_port``, ``sa_port``
|
||||
- ``contract``: required PortContract; ``legacy``: optional LegacyPortContract
|
||||
- No implicit contract; ``legacy`` defaults none. OA admits Legacy only.
|
||||
@@ -229,12 +230,6 @@ references. Their kind is the declaration keyword:
|
||||
* - ``allocation``
|
||||
- Same-layer participant → leaf activity/function
|
||||
- None
|
||||
* - ``interface_item_allocation``
|
||||
- Same-layer Interface → ExchangeItem
|
||||
- ``order`` u32 / 0; ``transmission`` TransmissionProtocol / Unspecified; ``acquisition`` AcquisitionProtocol / Unspecified
|
||||
* - ``data_association``
|
||||
- Same-layer Record DataType → Record DataType
|
||||
- ``source_role``, ``target_role`` / ""; ``source_minimum``, ``target_minimum`` / 0; ``source_maximum``, ``target_maximum`` / none; ``source_navigable``, ``target_navigable`` / false
|
||||
* - ``interaction``
|
||||
- OA activity → OA activity
|
||||
- ``items`` refs / []
|
||||
@@ -295,8 +290,7 @@ references. Their kind is the declaration keyword:
|
||||
|
||||
Relation endpoints and carried ExchangeItems stay in the same layer. Parallel
|
||||
exchanges with distinct identities are permitted. Self-relations are invalid
|
||||
except for a state transition that returns to its source state or an explicit
|
||||
DataAssociation between two ends of the same Record type.
|
||||
except for a state transition that returns to its source state.
|
||||
|
||||
Direct Allocation has at most one performer per leaf. Role sharing is a
|
||||
different relationship: several OA participants can realize a Role. A direct
|
||||
@@ -311,10 +305,9 @@ declares ``outgoing_items``; ``FunctionInput`` declares ``incoming_items``.
|
||||
The lists contain distinct same-layer ExchangeItem references. A functional
|
||||
exchange MUST reference output/input ports owned by its respective functions,
|
||||
and each carried item MUST fit both declarations. Empty declarations accept
|
||||
no items. A carried item satisfies an identical declaration or a declaration
|
||||
of one of its explicit base items. A base item does not satisfy a more
|
||||
specialized declaration; names imply no compatibility. Port declarations
|
||||
remain independent of incident exchanges after creation.
|
||||
no items. Item compatibility is identity equality; future specialization is
|
||||
not inferred from names. Port declarations remain independent of incident
|
||||
exchanges after creation.
|
||||
|
||||
A ``Component`` contract belongs to an SA System or SystemActor and stores
|
||||
``kind`` (Standard or Flow) and ``orientation`` (Unset, In, Out, InOut).
|
||||
@@ -354,13 +347,12 @@ Port deletion cascades to incident FEs; FE deletion retains independent ports.
|
||||
|
||||
For example::
|
||||
|
||||
arcadia 4;
|
||||
arcadia 3;
|
||||
project ports_example "Typed function contract" {
|
||||
sa_function send "Send" {}
|
||||
sa_function receive "Receive" {}
|
||||
sa_exchange_item payload "Payload" {}
|
||||
sa_interface old_service "Preserved service" {}
|
||||
interface_item_allocation service_payload "Service payload" { source @old_service; target @payload; }
|
||||
sa_interface old_service "Preserved service" { items [@payload]; }
|
||||
sa_port output "Output" {
|
||||
parent @send;
|
||||
contract { FunctionOutput { outgoing_items [@payload]; }; };
|
||||
@@ -482,19 +474,11 @@ no receiver or the same receiver as sender. Its nonempty expression describes
|
||||
the delay/deadline; an empty expression warns. Conditions and expressions are
|
||||
stored engineering annotations, not an executable simulation language.
|
||||
|
||||
Each MessageValue is ``{ path [@parameter]; field @data_field; expression "value expression"; }``.
|
||||
Its identity is the pair (path, field), so two uses of one Record can have
|
||||
different values for the same terminal field. Path contains enclosing DataField
|
||||
references, excluding the terminal field; direct parameters use ``[]``. Every
|
||||
hop MUST follow Value storage through a named Record's effective fields. Reference
|
||||
storage stops traversal. Operation Call accepts In/InOut and Reply accepts
|
||||
Out/InOut/Return, interpreted from the provider's perspective. Nested field paths
|
||||
inherit their direct parameter's direction. Unspecified direction warns.
|
||||
At most one annotation for a pair is allowed. Omitted path defaults to ``[]``;
|
||||
an ambiguous preserved legacy nested annotation warns ``message_value_ambiguous_path``
|
||||
until an explicit path is selected. New graphical choices always identify a path.
|
||||
Values never overwrite types or items. Expression text is descriptive, preserved
|
||||
exactly and not evaluated; an empty expression warns.
|
||||
Each MessageValue is ``{ field @data_field; expression "value expression"; }``.
|
||||
The field MUST be a DataField carried by the message exchange, including fields
|
||||
inside named nested records. At most one annotation for a given field is
|
||||
allowed in one message. Values do not overwrite the referenced type or global
|
||||
exchange item. Expression text is preserved exactly; an empty expression warns.
|
||||
|
||||
ScenarioUse references an existing same-layer scenario and maps its lifelines
|
||||
with ``bindings [ { referenced @callee_line; local @caller_line; } ];``.
|
||||
@@ -636,150 +620,14 @@ DeepHistory remembers nested configuration. Fallback applies when no resumable
|
||||
history exists. History is per Region, including top-level Regions in this
|
||||
Studio profile; no running history state is persisted or synthesized.
|
||||
|
||||
4.5. Data contracts, specialization and interface allocation
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
ExchangeItem ``kind`` describes nature: Information, Material or Energy.
|
||||
``mechanism`` is Unspecified, Flow, Event, Operation or SharedData. Unknown
|
||||
mechanisms remain explicit Unspecified drafts with a warning. Material/Energy
|
||||
with Event/Operation/SharedData warns for engineering review; it is not silently
|
||||
reclassified. Empty Event notifications and void Operations are valid. An empty
|
||||
carried/allocated Flow, SharedData or Unspecified item warns.
|
||||
|
||||
An Operation's directly owned DataFields are parameters. ``direction`` is
|
||||
Unspecified, In, Out, InOut or Return, from the provider's perspective. At most
|
||||
one effective parameter is Return. Unspecified parameters warn; non-operation
|
||||
fields MUST use Unspecified. Nested record fields inherit direction from their
|
||||
direct parameter when used in message paths, rather than storing context on a
|
||||
shared type. ``storage`` is Value or Reference. Reference requires a named Record,
|
||||
has no explicit default, and does not convey ownership or deletion of the type.
|
||||
|
||||
DataTypeDefinition has exactly the following constructors:
|
||||
|
||||
.. list-table:: Reusable definitions
|
||||
:header-rows: 1
|
||||
|
||||
* - Constructor
|
||||
- Required fields
|
||||
* - Boolean
|
||||
- None
|
||||
* - Integer
|
||||
- ``minimum``, ``maximum``: optional signed 64-bit integers
|
||||
* - Real
|
||||
- ``minimum``, ``maximum``: optional finite IEEE-754 binary64 values
|
||||
* - String
|
||||
- ``minimum_length``: u32; ``maximum_length``: optional u32
|
||||
* - Enumeration
|
||||
- ``literals``: unique nonempty, case-sensitive strings
|
||||
* - Record
|
||||
- ``supertype``: same-layer Record ref?
|
||||
|
||||
Bounds are inclusive and ordered; an absent bound means unbounded within the
|
||||
represented numeric domain. Integer comparisons MUST NOT convert to binary64.
|
||||
NaN, infinities, overflow and reversed bounds reject. String length counts Unicode
|
||||
scalar values, not UTF-8 bytes or grapheme clusters. Enum defaults match exact
|
||||
literals. Empty enum definitions remain incomplete drafts with a warning.
|
||||
|
||||
DataValue is a tagged scalar: ``{ Boolean true; }``, ``{ Integer 42; }``,
|
||||
``{ Real 1.5; }``, ``{ String "value"; }`` or ``{ Enumeration "Ready"; }``.
|
||||
There is no implicit numeric or string coercion. A type default is one compatible
|
||||
scalar within its bounds. Record type defaults and record/object instance literals
|
||||
are unsupported. A field default is absent (``none``) or a list of compatible
|
||||
scalars with length inside its multiplicity. ``[]`` explicitly supplies an empty
|
||||
collection and requires minimum 0, including Value Record fields; a nonempty
|
||||
Record literal rejects. Reference fields MUST use ``none``. A one-valued scalar
|
||||
field uses a one-value list. A named type default supplies an individual element's
|
||||
default, not a collection or an implicit instance of a mandatory field.
|
||||
|
||||
Numeric types may carry unit text. Empty field units inherit the named type unit;
|
||||
an explicit different unit warns when that type has a unit. Units on nonnumeric
|
||||
fields/types warn and remain preserved. Unit strings never convert values or
|
||||
establish dimensional compatibility. Expressions are not executed.
|
||||
|
||||
Record, ExchangeItem and Interface each permit one same-family, same-layer
|
||||
supertype. Specialization is acyclic and separate from containment. Effective
|
||||
fields are base fields followed by local fields, preserving original field UUIDs
|
||||
and owners. Local names MUST NOT shadow inherited names. Item specialization
|
||||
preserves base nature and any specified mechanism; an Unspecified base mechanism
|
||||
may be refined. Deleting a derived record never deletes its base. Mandatory
|
||||
Value-field cycles warn ``record_no_finite_value``; optional or Reference cycles
|
||||
are valid without that warning. Traversal is cycle guarded and bounded; a payload
|
||||
path budget failure is an explicit diagnostic, not silent truncation.
|
||||
|
||||
InterfaceItemAllocation is an ordinary identified relation from Interface to
|
||||
ExchangeItem. It stores ``order`` and independent transmission/acquisition
|
||||
protocols. Transmission is Unspecified, Synchronous, Asynchronous, Unicast,
|
||||
Multicast or Broadcast; acquisition is Unspecified, Read or Accept. No protocol
|
||||
combination is inferred from mechanism. Effective allocations are base-first,
|
||||
then local ordered by (order, alias, UUID). A local item already allocated locally
|
||||
or inherited is an error. Removing an allocation keeps its item and other
|
||||
allocations; if live boundary compatibility would break, the transaction MUST
|
||||
repair the affected contract explicitly or reject atomically. Artifacts can
|
||||
reference the allocation identity independently of the global item.
|
||||
|
||||
An exchange carrying a specialized item satisfies a declaration of its base,
|
||||
never the reverse. Standard provided/required compatibility uses explicit
|
||||
interface ancestry. Identity/ancestry, not matching names, defines conformance.
|
||||
The current typed function/component port contracts remain unchanged.
|
||||
|
||||
DataAssociation connects Record types without adding fields or transitively
|
||||
carrying payload. Each named end has role, multiplicity and navigability. Source
|
||||
multiplicity counts source instances per target instance and vice versa; it is
|
||||
not a count of model elements. An end's navigable flag permits navigation toward
|
||||
that end. Self-associations are valid; if both ends are navigable they require
|
||||
distinct nonempty roles. Empty roles and duplicate role contracts warn, whereas
|
||||
invalid ranges reject. Composition/shared aggregation, multiple inheritance,
|
||||
redefinition, instance literals and arbitrary-precision decimal arithmetic are
|
||||
outside this bounded contract.
|
||||
|
||||
This complete data contract example is also exercised through the REST API:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 4;
|
||||
project engineering_data "Engineering data" {
|
||||
sa_data_type voltage "Voltage" {
|
||||
definition { Real { minimum 0.0; maximum 48.0; }; };
|
||||
default { Real 24.0; }; unit "V";
|
||||
}
|
||||
sa_data_type coordinate "Coordinate" {
|
||||
definition { Record { supertype none; }; };
|
||||
sa_data_field latitude "Latitude" { type_ref Real; }
|
||||
}
|
||||
sa_data_type located "Located asset" {
|
||||
definition { Record { supertype @coordinate; }; };
|
||||
sa_data_field supply "Supply" { type_ref { Named @voltage; }; }
|
||||
}
|
||||
sa_exchange_item navigate "Navigate" {
|
||||
mechanism Operation;
|
||||
sa_data_field origin "Origin" { type_ref { Named @coordinate; }; direction In; }
|
||||
sa_data_field destination "Destination" { type_ref { Named @coordinate; }; direction In; }
|
||||
sa_data_field accepted "Accepted" { type_ref Boolean; direction Return; }
|
||||
}
|
||||
sa_interface navigation "Navigation" {}
|
||||
sa_interface monitoring "Monitoring" {}
|
||||
interface_item_allocation nav_operation "Navigation operation" {
|
||||
source @navigation; target @navigate; transmission Synchronous; acquisition Accept;
|
||||
}
|
||||
interface_item_allocation monitor_operation "Monitoring operation" {
|
||||
source @monitoring; target @navigate; transmission Asynchronous; acquisition Read;
|
||||
}
|
||||
data_association location_link "Location relationship" {
|
||||
source @coordinate; target @located;
|
||||
source_role "location"; target_role "assets";
|
||||
source_maximum 1;
|
||||
}
|
||||
}
|
||||
|
||||
5. Diagrams, artifacts, and cross-layer traceability
|
||||
----------------------------------------------------
|
||||
|
||||
``diagram`` has name, identity, alias, description, ``layer`` (Oa by default),
|
||||
``projection`` (Overview by default), and ``routes`` (empty list by default).
|
||||
The string projections are Overview, Decomposition, Allocation,
|
||||
FunctionalDataflow, Capabilities, and Traceability. StateMachine is a payload
|
||||
constructor with one required ``machine`` reference. Traceability diagrams
|
||||
can show both layers; other diagrams show their chosen layer.
|
||||
Other projections are Decomposition,
|
||||
Allocation, FunctionalDataflow, Capabilities, and Traceability. Traceability diagrams can show both layers;
|
||||
other diagrams show their chosen layer.
|
||||
|
||||
Allocation is an architecture view: it shows participants and work, nests
|
||||
allocated work under its performer, and displays interactions, functional
|
||||
@@ -787,45 +635,10 @@ exchanges and boundary exchanges. FunctionalDataflow shows the work hierarchy
|
||||
and its exchanges. Capabilities shows participants, roles, missions and
|
||||
capabilities with their involvement, exploitation, include, extend and
|
||||
generalization links. Decomposition and Overview retain general containment;
|
||||
Overview also exposes all relationships. These string projections hide
|
||||
Overview also exposes all relationships. Changing the projection hides
|
||||
inapplicable placements without deleting them or changing semantic records.
|
||||
|
||||
A StateMachine projection MUST reference an existing StateMachine in the
|
||||
diagram's layer. Its placements MUST refer to that machine, its Regions, or
|
||||
its state vertices, including nested Regions and vertices. Events, other
|
||||
machines and their members, and unrelated semantic elements are not valid
|
||||
placements in this view. Empty and partial state views are valid; neither
|
||||
creating nor reading a view synthesizes semantic elements or placements.
|
||||
Changing an existing diagram to StateMachine, or changing its machine, MUST
|
||||
remove or relocate incompatible placements in the same transaction. Context
|
||||
errors use ``state_diagram_context``; incompatible placements use
|
||||
``state_diagram_member``. Neither case silently hides invalid members.
|
||||
|
||||
This complete source example places one state from an existing machine:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 4;
|
||||
project operating "Operating modes" {
|
||||
oa_entity crew "Crew" {}
|
||||
oa_state_machine modes "Modes" { parent @crew; }
|
||||
oa_region main "Main region" { parent @modes; }
|
||||
oa_state ready "Ready" { parent @main; }
|
||||
diagram mode_view "Operating modes" {
|
||||
layer Oa;
|
||||
projection { StateMachine { machine @modes; }; };
|
||||
}
|
||||
node ready_view { diagram @mode_view; element @ready; }
|
||||
}
|
||||
|
||||
The JSON projection is
|
||||
``{"StateMachine":{"machine":"11111111-1111-4111-8111-111111111111"}}``,
|
||||
where the UUID is the referenced machine's identity. A bare ``"StateMachine"``,
|
||||
a missing or null ``machine``, an invalid UUID, or additional payload fields
|
||||
is malformed. Existing projections retain their string representation, for
|
||||
example ``"Overview"`` in JSON and ``projection Overview;`` in source.
|
||||
|
||||
Ports are boundary anchors of visible owners, with their existing
|
||||
Ports are calculated boundary anchors of visible owners, with their existing
|
||||
semantic identity. An explicitly placed port is not rendered as a separate
|
||||
large node. Functional exchanges use their declared source/target ports;
|
||||
boundary exchanges attach to their participant ports even if those ports have
|
||||
@@ -835,52 +648,13 @@ may terminate at a visible ancestor without creating a new port or exchange.
|
||||
``node`` has an alias and identity but no display-name string. Required fields
|
||||
are ``diagram`` and ``element`` references. Optional fields are ``position``
|
||||
(record with x/y, default 0/0), ``size`` (width/height, default 224/94), and
|
||||
``collapsed`` (false), and ``port_anchors`` (empty list). Node identity is
|
||||
independent of its semantic element.
|
||||
``collapsed`` (false). Node identity is independent of its semantic element.
|
||||
Coordinates are absolute diagram coordinates. Placement does not alter parent,
|
||||
allocation, or realization. Hiding an element from a diagram must remain
|
||||
distinguishable from deleting it from the model.
|
||||
Changing a node's diagram preserves the placement identity, alias, coordinates,
|
||||
size, collapse state, manual port anchors, and attached artifacts. The destination
|
||||
must accept the element's layer, satisfy StateMachine membership when applicable,
|
||||
and must not already contain another placement of that element.
|
||||
|
||||
Each ``port_anchors`` entry is a PortAnchor record with required ``port``
|
||||
reference, ``side`` (Left, Right, Top, or Bottom), and ``offset`` (finite
|
||||
binary32 from 0 through 1). It has no separate identity or alias. The port MUST
|
||||
be an existing Port directly owned by the placement's ``element``. A port MAY
|
||||
have one manual anchor per placement; different diagrams MAY position it
|
||||
independently. List order is preserved but has no positional meaning.
|
||||
|
||||
``offset`` measures a fraction of the complete projected owner edge, from top
|
||||
on Left/Right or from left on Top/Bottom. Zero and one denote its corners.
|
||||
The side is presentation data and MUST NOT change the port's contract,
|
||||
orientation, ownership, allocations, or connected exchanges. Absent anchors use
|
||||
automatic positioning. Empty lists are omitted in canonical JSON/source;
|
||||
earlier schema-3 input without this field hydrates as ``[]``.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
node actor_view {
|
||||
diagram @architecture; element @vessel;
|
||||
port_anchors [{ port @communication; side Top; offset 0.25; }];
|
||||
}
|
||||
|
||||
``SetPortAnchor { diagram, node, anchor }`` upserts by port identity and retains
|
||||
unrelated entries. ``RemovePortAnchor { diagram, node, port }`` restores
|
||||
automatic positioning; it is a no-op only for an existing direct child port
|
||||
of that placement. Unknown fields, duplicate port references, non-port or
|
||||
foreign-owner references, and invalid offsets are structural errors. Each
|
||||
anchor counts as one record against ``ParseLimits.max_records``.
|
||||
|
||||
These commands use the same atomic history, conditional REST transactions, and
|
||||
source writeback as other presentation edits. Source references resolve across
|
||||
files, and anchor comments follow port identity. Reidentifying a port updates
|
||||
anchor references; deleting it removes its anchors. Reparenting MUST repair
|
||||
old-owner anchors in the same transaction, for example by removing them before
|
||||
the Reparent command. Moving, resizing, arranging, or folding an owner retains
|
||||
its authored sides and fractions. Frozen schema-1/schema-2 readers do not accept
|
||||
``port_anchors``; their explicit migration supplies an empty list.
|
||||
size, collapse state, and attached artifacts. The destination must accept the
|
||||
element's layer and must not already contain another placement of that element.
|
||||
|
||||
Each ``routes`` entry is an EdgeRoute record with a required ``edge`` reference
|
||||
to an existing Relation or Trace, ``waypoints`` (default ``[]``), and ``label``
|
||||
@@ -1026,7 +800,7 @@ sets and enum constructors are closed by sections 3–5.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
document = "arcadia" "4" ";" project
|
||||
document = "arcadia" "3" ";" project
|
||||
project = "project" alias string identity? "{" project-entry* "}"
|
||||
project-entry = "description" string ";" | declaration
|
||||
declaration = element
|
||||
@@ -1068,18 +842,18 @@ Ordinary string fields require quoted strings and cannot contain an unquoted
|
||||
constructor records. Typed values MUST NOT nest more than 64 levels; malformed
|
||||
deeply nested drafts produce a diagnostic instead of exhausting the parser stack.
|
||||
|
||||
6.1. Modular source projects
|
||||
6.1. Modular source3 projects
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
The pure ``source_project`` core API compiles a virtual source bundle, containing
|
||||
``entry`` and a ``files`` map from logical paths to UTF-8 strings. Its entry is a
|
||||
root-level ``.arcadia`` file with the source4 project header above. A project entry
|
||||
root-level ``.arcadia`` file with the source3 project header above. A project entry
|
||||
MAY also be ``include string ";"``. An included fragment consists only of ordinary
|
||||
declarations and include statements; it MUST NOT contain another version header,
|
||||
project wrapper, or project description. Includes MUST occur at document level,
|
||||
never inside an element body or typed field value. For example::
|
||||
|
||||
arcadia 4;
|
||||
arcadia 3;
|
||||
project rescue "Rescue" id "00000000-0000-4000-8000-000000000001" {
|
||||
description "Operational model";
|
||||
include "oa/participants.arcadia";
|
||||
@@ -1145,12 +919,9 @@ spans never refer to concatenated or synthetic source.
|
||||
|
||||
Single-string parse/lint APIs remain filesystem-free and reject include
|
||||
statements with an explicit source-bundle requirement. V1/V2 single-file imports
|
||||
continue through explicit migration. Modular projects accept source3 and source4;
|
||||
source3 is upgraded as one project, preserving file paths, includes, nested
|
||||
ownership and comments through targeted token edits. Generated allocation
|
||||
declarations stay in their interface's existing file. Individual include fragments
|
||||
are not independently migrated. Missing UUIDs stabilize before migration so new
|
||||
allocation identities are deterministic on subsequent compilation.
|
||||
continue through their existing migration readers and reports. Modular projects
|
||||
require source3; migration is performed before modular export, not independently
|
||||
on included fragments. Modular source does not change the semantic JSON schema.
|
||||
|
||||
6.2. Nested element ownership
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
@@ -1215,8 +986,7 @@ preserve authored comments and whitespace on Apply while inserting missing
|
||||
identities. Explicit canonical export remains a separate operation. Project
|
||||
update/export distinctions are described below.
|
||||
Camera position, selection, source cursor, and undo history are not stored
|
||||
model fields. Layout, collapse state, and manual port anchors inside
|
||||
NodePlacement are model data.
|
||||
model fields. Layout and collapse state inside NodePlacement are model data.
|
||||
|
||||
The pure ``format_bundle`` exporter uses the same typed declaration writer as
|
||||
``format``. It creates a small entry file, layer/category modules, separate
|
||||
@@ -1382,35 +1152,10 @@ explicitly before Apply succeeds.
|
||||
|
||||
Loading detects JSON from its first non-whitespace ``{``; other input is parsed
|
||||
as Arcadia source regardless of filename. Saving an ``.arcadia`` suffix uses
|
||||
the accepted V4 source bundle (``arcadia 4``); other suffixes use V4 JSON. Both forms preserve the same
|
||||
the accepted V3 source bundle (``arcadia 3``); other suffixes use V3 JSON. Both forms preserve the same
|
||||
complete model. JSON rejects unknown fields throughout typed payloads,
|
||||
duplicate maps, unsupported versions, and structural errors.
|
||||
|
||||
The schema-3 reader freezes the changed data payloads and old constructor catalog;
|
||||
schema-4 fields/constructors MUST NOT be silently accepted as schema 3. Existing
|
||||
typed PortContract records remain unchanged. Information/Material/Energy retain
|
||||
their nature with Unspecified mechanism; old Event becomes Information with Event
|
||||
mechanism. Old bounds/defaults/supertypes are absent, String length is 0..unbounded,
|
||||
and fields retain type/multiplicity/unit with Unspecified direction, Value storage
|
||||
and no explicit default. No physical mechanism or direction is guessed.
|
||||
|
||||
Each old Interface.items entry generates one InterfaceItemAllocation with its
|
||||
original zero-based order and Unspecified protocols. UUID v5 uses the project UUID
|
||||
namespace and name ``v2-interface-item:<interface-uuid>:<item-uuid>`` consistently
|
||||
for schema-2 and schema-3 inputs. Aliases are deterministic and collision-free;
|
||||
collision with any existing identity is an error rather than an overwrite. Existing
|
||||
interface/item identities remain intact. Old nested message values acquire a path
|
||||
only when exactly one valid path exists. Ambiguous paths, including recursive
|
||||
reuse, retain the old annotation with a located warning rather than guessing.
|
||||
|
||||
Migration reports additionally contain ``semantic_defaults`` (object, field,
|
||||
description), ``generated_interface_allocations`` (id, source, target, alias,
|
||||
order) and ``unresolved_message_values`` (occurrence, field, reason). These new
|
||||
report arrays default empty when reading older responses. Version 1 normalizes
|
||||
through V2, then the typed-port V3 adapter and this shared V4 data upgrade. Original
|
||||
V2 and real V3 fixtures remain unchanged compatibility inputs. Current exports
|
||||
always use version 4; reading/migrating alone never saves or changes revisions.
|
||||
|
||||
Native source Save All checks the original contents of observed dependencies
|
||||
before staging writes. Existing target files are replaced individually and the
|
||||
entry is installed last. A transaction journal records originals and intended
|
||||
@@ -1435,7 +1180,7 @@ defaults without changing any prior identity, name, reference, order, geometry,
|
||||
artifact or trace field. The original V2 golden source/JSON in
|
||||
``crates/oa-core/tests/fixtures`` MUST NOT be regenerated from the newer demo;
|
||||
they verify that every old persistent field survives. Canonical output writes
|
||||
the added defaults through the explicit schema-3 and schema-4 migrations.
|
||||
the added defaults through the explicit schema-3 migration.
|
||||
|
||||
The V2 adapter preserves every original identity, alias, prose, parent,
|
||||
placement, trace and artifact. Generic ports become Legacy with their original
|
||||
@@ -1454,14 +1199,14 @@ IDs, required resolutions, generated endpoint IDs/aliases/owners, and boundary
|
||||
orientation defaults. REST import, replacement, candidate validation and
|
||||
project snapshots surface the same report. Opening an old server envelope
|
||||
preserves its resource ID/revision and bytes; the next explicit edit writes
|
||||
schema 4. Model/source exports contain only the canonical model. Migration
|
||||
schema 3. Model/source exports contain only the canonical model. Migration
|
||||
reports are transient response metadata, not persisted model facts.
|
||||
|
||||
Admission checks account for generated objects and their references before
|
||||
construction. Both the old V2 contract and the resulting V4 graph are
|
||||
construction. Both the old V2 contract and the resulting V3 graph are
|
||||
validated. Migration itself does not save or increase a stored revision.
|
||||
Canonical second saves are stable. V1 normalization produces the frozen V2
|
||||
model and then uses these same V2-to-3-to-4 stages.
|
||||
model and then uses this same V2-to-3 stage.
|
||||
|
||||
Save validates a snapshot, writes a sibling temporary file, flushes it, and
|
||||
atomically replaces the destination. Failed serialization or validation must
|
||||
@@ -1480,7 +1225,7 @@ step becomes a distinct occurrence element owned by its process. Generated
|
||||
diagram, node, and occurrence identities use UUID v5 with the old Project ID
|
||||
as namespace and deterministic role/index names. If such an identity collides,
|
||||
a deterministic suffix is tried. Reimporting the same fully identified V1
|
||||
input therefore yields the same V4 result, including repeated occurrences.
|
||||
input therefore yields the same V3 result, including repeated occurrences.
|
||||
No System or SA content is invented during an OA-only migration.
|
||||
|
||||
V1 source aliases are retained when valid in V2. JSON imports generate the old
|
||||
@@ -1488,7 +1233,7 @@ canonical aliases deterministically. Any alias newly reserved in V2 is changed
|
||||
by a deterministic suffix; aliases were not persistent V1 semantic data.
|
||||
A project alias and aliases for new records are generated without colliding
|
||||
with retained aliases. The existing bundled V1 text and JSON migrate to equal
|
||||
V4 projects. Future versions remain errors until a separate migration exists.
|
||||
V3 projects. Future versions remain errors until a separate migration exists.
|
||||
|
||||
10. Complete authoring example
|
||||
------------------------------
|
||||
@@ -1498,7 +1243,7 @@ the assigned UUIDs explicitly.
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
arcadia 4;
|
||||
arcadia 3;
|
||||
project harbor "Harbor assistance" {
|
||||
oa_entity control "Harbor control" {}
|
||||
oa_activity assess "Assess assistance" {}
|
||||
|
||||
+60
-758
File diff suppressed because it is too large
Load Diff
+19
-25
@@ -1,20 +1,20 @@
|
||||
# Current scope and tradeoffs
|
||||
# V2 scope and tradeoffs
|
||||
|
||||
Arcadia Studio implements a systems-engineering workbench spanning Operational Analysis and System Analysis. The normative [language and editing contract](language.rst) defines the supported Studio profile. It does not claim full Arcadia, Eclipse Capella or SysML conformance. The [first-release plan](../harness/PLAN.md) remains incomplete; [current state](../harness/STATE.md) records current implementation and verification. The [V2 acceptance](acceptance.md) and [validation](validation.md) records describe the earlier baseline, not acceptance of every subsequent change.
|
||||
V2 implements a general systems-engineering workbench spanning Operational Analysis and System Analysis. The normative [language and editing contract](language.rst) defines the supported Studio profile. It does not claim full Arcadia, Eclipse Capella or SysML conformance. [Acceptance](acceptance.md), [validation](validation.md) and [current state](../harness/STATE.md) record what has actually been exercised; implementation and verification are distinct.
|
||||
|
||||
## Supported modeling profile
|
||||
|
||||
| Area | Implemented semantics and authoring |
|
||||
|---|---|
|
||||
| Operational Analysis | Entities, actors, roles, activities, missions, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement |
|
||||
| Operational Analysis | Entities, actors, roles, activities, capabilities and processes; entity/work decomposition; leaf allocation; role realization/assignment; interactions and capability/process involvement |
|
||||
| System Analysis | One System of Interest, external Actors, functions, missions, capabilities and functional chains; functional decomposition independent of the system boundary; allocation, functional/boundary exchanges and mission-capability links |
|
||||
| Data and interfaces | Information/material/energy nature and Flow/Event/Operation/SharedData mechanisms, bounded primitive/enum/record types, typed defaults, field multiplicity/units/direction/storage, specialization, identified interface allocations and reference associations, separate function input/output and Standard/Flow component ports, interface provision/requirement, port and exchange allocations |
|
||||
| Behavior | Process/chain work and exchange contributions, control topology and sequence reuse; scenario lifelines, correlated calls/replies, timers, action/state occurrences, ALT/PAR/LOOP/OPTIONAL fragments, scenario reuse and pre/postconditions |
|
||||
| States and conditions | Participant-owned state machines, nested regions, states/modes and typed pseudostates, signal/time/change events, trigger/guard/effect transitions, linked entry/do/exit work, availability and constraints |
|
||||
| Data and interfaces | Information/material/energy/event exchange items, primitive/enum/record data types, typed fields with multiplicity/units, ports, interface contents, provided/required contracts and exchange allocations |
|
||||
| Behavior | Stable ordered exchange occurrences, repeated semantic exchanges, scenario lifelines and exchange/action/state occurrences, explicit ALT/PAR/LOOP/OPTIONAL fragments and branches, pre/postconditions |
|
||||
| States and conditions | Participant-owned state machines, regions, state/mode/initial/final nodes, trigger/guard/effect transitions, descriptive and linked entry/do/exit work, availability and constraints |
|
||||
| Perspectives and evidence | Bidirectional counterpart creation/linking, reviewed name/description updates, many-to-many typed/manual traces, arbitrary artifact URI/revision/fragment references |
|
||||
| Views and editing | Independent diagrams, decomposition/allocation nesting, collapse, pan/zoom/Fit/Arrange, editable connection routes and labels, four-side port positioning, direct interaction/functional/boundary exchange tools, contextual menus, SVG export, model explorer and typed properties |
|
||||
| Text and storage | `arcadia 4` source projects with relative includes and nested declarations, stable aliases/UUIDs, a source-file tree, per-file drafts and diagnostics, comment-preserving graphical updates, exact model/source Undo/Redo, legacy migration, atomic saves and interrupted source-save recovery |
|
||||
| Automation | Rust REST service for project/source-bundle/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
|
||||
| Views and editing | Multiple independent diagrams, decomposition/allocation nesting, collapse, dragging, pan/zoom/Fit/Arrange, SVG export, model explorer, typed property controls, exact strings, UUID/alias editing, history and source navigation |
|
||||
| Text and storage | Canonical `arcadia 2` language, persistent aliases/UUIDs, typed constructors, highlighting/live lint, recoverable invalid drafts, text/JSON equality, deterministic V1 migration and atomic saves |
|
||||
| Automation | Rust REST service for project/source/query/validation/transaction/transition/artifact workflows, ETag preconditions, bounded requests and atomic current-revision storage |
|
||||
|
||||
Graphical and textual parity applies to the persistent fields of this profile. Diagram placements are model data; camera, selection, source cursor and undo history are session state. Canvas gestures need not match textual operations one for one: typed inspectors and explicit commands expose precise semantics. Full source replacement must repair dependencies in its candidate, while graphical delete/reidentify commands maintain references automatically.
|
||||
|
||||
@@ -24,11 +24,9 @@ Graphical and textual parity applies to the persistent fields of this profile. D
|
||||
- Work decomposition and performer allocation are independent. Only leaves have direct allocations. A parent drawn within one performer can be a computed leaf summary, not an additional allocation. Role sharing does not create several direct performers for one function.
|
||||
- Nesting expresses semantic containment or the selected allocation projection. Placement movement does not change ownership or allocation. Hide and delete have different effects.
|
||||
- Counterpart creation copies mapped typed content and required references; existing-target updates compare only names/descriptions against a reviewed baseline. They do not synchronize arbitrary structure, reverse-engineer operational needs or automatically overwrite the other perspective. Scope follows ownership/reference dependencies, not every incident relationship.
|
||||
- Processes/chains use contributions and explicit sequence links; imported ordered exchange paths retain their implied order until topology conversion. Rich fragments belong to scenarios. Guards, expressions and state behavior text are descriptive; there is no execution engine. Mixed states and modes are permitted by this Studio profile.
|
||||
- Preserved OA boundary exchanges use Legacy ports. Capability grouping is containment; include, extend and generalization use separate typed relationships in OA and SA. Data/item references remain within their layer and can have explicit transitioned counterparts.
|
||||
- Applying a source project preserves authored comments and formatting while assigning missing identities. Graphical updates preserve unaffected source bytes; canonical export is a separate operation. JSON does not preserve comments or source-file organization. See the [source contract](language.rst) for comment ownership and update rules.
|
||||
- Port position and connection routing belong to each diagram and do not change semantic ownership or endpoints. Folding may temporarily promote an edge to a visible container; expansion restores its authored route. [Routing verification](../harness/release-routing-verification.md) records accepted behavior and measured limits.
|
||||
- Artifact references retain author-provided URI/revision/fragment values without fetching or executing resources.
|
||||
- Processes/chains use ordered exchange occurrences. Rich fragments belong to scenarios. Guards, expressions and state behavior text are descriptive; there is no execution engine. Mixed states and modes are permitted by this Studio profile.
|
||||
- OA boundary exchanges are a simplified operational-communication representation. Capability grouping is containment, not include/extend/generalization. Data/item references remain within their layer and can have explicit transitioned counterparts.
|
||||
- Stable aliases are retained; comments and formatting normalize on Apply. Artifact references retain author-provided URI/revision/fragment values without fetching or executing resources.
|
||||
|
||||
## Capella-inspired improvement targets
|
||||
|
||||
@@ -36,24 +34,20 @@ These are concrete design responses, not claims that every Capella user experien
|
||||
|
||||
| Concern | Studio response | Remaining tradeoff |
|
||||
|---|---|---|
|
||||
| Dense workbench and difficult onboarding | Task workspaces, OA/SA context, contextual menus, direct architecture connections, searchable guidance and relation/transition builders | Direct behavior gestures and Home/recent-project workflows remain release work |
|
||||
| Noisy architecture pictures | Nested ownership/allocation views, independent placements, collapse, automatic routing, editable bends/labels and port positions | Dense diagrams may need manual adjustment; routing has a bounded solve budget |
|
||||
| Hard-to-review model files | Modular typed source, stable aliases/UUIDs, preserved comments/formatting and source-aware history | Semantic merges, completion and an LSP are absent |
|
||||
| Dense workbench and difficult onboarding | Task workspaces, OA/SA context, focused properties, guided example and explicit relation/transition builders | Broader usability studies, assistive technology and localization remain |
|
||||
| Noisy architecture pictures | Nested ownership/allocation views, independent placements and collapse | Manual edge routing and very large diagram optimization remain |
|
||||
| Hard-to-review model files | Readable typed source, stable aliases/UUIDs, canonical ordering and one specification | Comment preservation, semantic merges and an LSP are absent |
|
||||
| Hidden mutation effects | Validated atomic commands, dependency cleanup, identity maintenance, previewed counterpart changes and undo | No distributed editing or automatic structural reconciliation |
|
||||
| Platform/plugin overhead | Native Rust/Iced desktop and independent headless service | WGPU has substantial dependencies and needs a working graphics adapter |
|
||||
| UI-coupled automation | Renderer-independent core and documented REST commands/queries | Desktop-hosted model editing is absent; transfer uses import/export |
|
||||
|
||||
## Required first-release work
|
||||
|
||||
Direct scenario/process canvas authoring, semantic-content drift and reviewed counterpart reconciliation, native acceptance of expanded data contracts, persistent coverage/filter views, Home/recent-project and unsaved-work recovery, hosted desktop editing, guidance and final acceptance remain in the [release plan](../harness/PLAN.md). These are unfinished requirements, not exclusions from the release. Direct state editing has scoped Windows/Linux acceptance recorded in [state evidence](../harness/release-state-editing.md). Interrupted source-save recovery restores a save transaction; it does not checkpoint unsaved models or drafts.
|
||||
|
||||
## Remaining boundaries
|
||||
|
||||
- Logical and Physical Architecture, deployment semantics, executable behavior, complete SysML or Capella metamodel support, and native `.capella`/`.aird` import/export.
|
||||
- Complete Capella communication/scenario variants beyond the specified Studio profile.
|
||||
- Semantic merge, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
|
||||
- Collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits; the current desktop transfers hosted data through explicit import/export until the planned client is implemented.
|
||||
- Production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
|
||||
- Advanced capability include/extend/generalization and complete Capella communication/scenario variants beyond the specified Studio profile.
|
||||
- Autosave/crash recovery, recent-project management, semantic diff/merge, comment-preserving source edits, completion and language-server tooling. Local external-file checks do not provide a cross-process transaction lock.
|
||||
- Direct desktop connection to hosted models, collaborative editing, server undo/history, retry receipts, multiple service replicas and network-synchronized data directories. The service stores current models under explicit capacity limits.
|
||||
- Editable edge routing, production-scale performance guarantees, full screen-reader/keyboard diagram support, high-contrast/localized experiences and signed installers.
|
||||
- Runtime plugin loading or an extension ABI. External tools can use the REST API; additional in-process infrastructure needs a concrete use case.
|
||||
|
||||
Windows and Linux builds are supported. Documented Docker/systemd deployment recipes are available for the service, but a recipe is not evidence of a running remote deployment. Platform, package and GUI verification must be reported from the acceptance record rather than inferred from source or CI configuration.
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
# Arcadia Studio server
|
||||
|
||||
`arcadia-server` is a headless Rust REST service for the same schema-4 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each model and its authored source project together in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
|
||||
`arcadia-server` is a headless Rust REST service for the same schema-3 model, commands, source language, validation, artifacts and OA/SA traces used by the desktop. It stores each model and its authored source project together in an atomic JSON envelope. The service provides a deployment artifact and configuration examples; this repository does not assert that a remote instance has been deployed.
|
||||
|
||||
The complete HTTP contract is [api.rst](api.rst); machine-readable schemas are in [openapi.json](openapi.json), also served at `/api/v1/openapi.json`. The original [service research](research-v2-service.md) discusses broader alternatives; retained history, SQLite, idempotency receipts and stored transition plans from that research are **not** the implemented storage contract.
|
||||
|
||||
@@ -44,7 +44,7 @@ The data directory contains `.arcadia.lock` and one `<resource-id>.json` file pe
|
||||
|
||||
Startup validates every committed envelope, its filename/UUID, revision, size and complete model. Any corrupt or unsupported envelope stops startup; the service does not silently skip bad data or invent an empty replacement. Sibling `.arcadia-write-*` files from an interrupted pre-commit write are not committed models and are ignored on startup.
|
||||
|
||||
Earlier model-only envelopes are accepted through the existing model migrations and receive deterministic source files in memory. Opening, reading or an exact no-op neither rewrites those envelopes nor advances their revision. The next changed edit persists the coupled representation. Already-coupled schema3 envelopes explicitly migrate both representations in memory; the independently migrated graphs must agree. Current-format source must contain stable IDs. Inconsistent pairs are rejected at startup. Migration reports remain visible until a current-format edit/save; opening alone never rewrites the envelope. Capacity accounting includes source generated in memory for old envelopes, so an over-capacity old store fails explicitly without rewriting or pruning it.
|
||||
Earlier model-only envelopes are accepted through the existing model migrations and receive deterministic source files in memory. Opening, reading or an exact no-op neither rewrites those envelopes nor advances their revision. The next changed edit persists the coupled representation. Already-coupled envelopes must contain stable source IDs and matching model/source; inconsistent pairs are rejected at startup. Capacity accounting includes source generated in memory for old envelopes, so an over-capacity old store fails explicitly without rewriting or pruning it.
|
||||
|
||||
Parsing, validation and blocking disk operations run on blocking workers. A shared store mutex captures snapshots and serializes final revision checks and writes. Parsing, semantic validation, source writeback and query processing use the captured snapshot outside that lock; an expensive candidate does not block the catalog or another model's preparation. Commit rechecks the exact ETag, so a candidate prepared against a superseded snapshot returns `412 revision_conflict`. A successful changed update increments revision once. Both model and source bundle must be unchanged for `changed: false`; comment-only edits advance the same revision. Each write serializes the whole envelope to a sibling temporary file, syncs it, and atomically replaces the destination. Unix also syncs the directory. The in-memory snapshot changes only after the replacement succeeds. A directory-sync error after replacement is reported as an uncertain write outcome while the in-memory head still matches the installed file.
|
||||
|
||||
|
||||
@@ -35,37 +35,3 @@
|
||||
- Recovery state is independent of source diagnostics. Interrupted Save All journals remain discoverable after lint, typing, missing entry files or first-save failure. Recovery validates current bytes against known transaction states; it does not promise atomic multi-file visibility or cross-process filesystem locking. Source/property draft guards prevent one representation from silently replacing pending edits in the other.
|
||||
- Production source3 nesting expresses semantic element ownership only. Extend the existing parser and source index with real lexical owners/body spans, rather than adopting the experiment's parallel parser. Flat canonical interchange remains valid. Structural writeback composes declaration pieces after ordinary token edits and uses original occurrence provenance through temporary UUID collisions. This preserves independent parent/child moves, authored files, comments and exact history while allowing new children to be emitted inside their owners. Allocation and ordered occurrence membership remain explicit.
|
||||
- A hosted revision owns the accepted model and stabilized source bundle together. Store one atomic envelope, use the same core writer for commands and previews, and advance the ETag for comment-only edits. Old model-only envelopes acquire deterministic source in memory without a read-time write or revision change. A detached source sidecar would create inconsistent revisions and unsafe restart behavior. Accepted model equality alone is insufficient for a no-op when command provenance deletes/recreates a declaration; this was reproduced and corrected in desktop and REST adapters.
|
||||
- Contextual actions use a controlled Iced overlay and captured semantic target plus document/revision/view context. Menus reuse existing handlers, and opening a menu does not select/apply another draft. Canvas focus scopes routing keys; Actions is an accessible alternative to right-click. Source files use a folder tree with exact paths and aggregate draft/diagnostic markers.
|
||||
- Port anchors are per-placement presentation records with four sides and normalized offsets. Automatic sides use port direction and visible peer geometry; explicit anchors do not change owner minimum dimensions. Canvas, routing, Fit and SVG share projected anchors and side normals. Graphical reparent batches old-owner anchor cleanup with semantic reparent; raw source and API remain strict and require explicit repair.
|
||||
- Direct connections use explicit Select/move and exchange palette tools. Compatible visible endpoints are highlighted; click-click or drag creates one ordinary validated command. Function-card gestures use the existing automatic port planner. Tool invocation, blank-canvas restart, Escape and model/view changes invalidate old gesture contexts. No project clone or whole-model compile occurs per highlighted candidate; final commit still validates the complete model/source change.
|
||||
- Port identity feedback is a passive stacked Canvas: Iced Frame separation alone
|
||||
does not create GPU layering. The independent native overprint reproduction
|
||||
established the need; input stays with the existing interactive child.
|
||||
- Each checkout uses a distinct Cargo target directory. A shared-target Linux run
|
||||
reused a different worktree's test executable; only explicitly rebuilt main
|
||||
results count as evidence.
|
||||
|
||||
## Persisted state workspace and input coordinates (2026-09-06)
|
||||
|
||||
State-machine views use the core projection/routing and ordinary Document commands,
|
||||
with a retained behavior owner/view separate from Architecture. Palette gestures
|
||||
create elements plus placements atomically; typed forms remain reachable for Local,
|
||||
Internal and timed transitions. Context menus capture the state diagram so fold,
|
||||
hide and placement properties cannot modify an unrelated Architecture view.
|
||||
|
||||
Gesture displacement is measured in ordered window coordinates, not coordinates
|
||||
relative to a canvas whose origin may move when selection changes the toolbar.
|
||||
Viewport notifications fit only on actual size changes. Native testing also showed
|
||||
Iced WGPU places parent meshes after clipped child meshes; backgrounds must share
|
||||
their content clip to avoid hiding vector geometry while leaving text visible.
|
||||
|
||||
State camera/tool lifetime follows effective machine and diagram identity, even
|
||||
when selection changes through Catalog or Layer outside Behavior. Reset only on
|
||||
actual context change; preserve same-view return. Independent review reproduced
|
||||
the leak and native Windows/Linux tests verified the correction in316dc55.
|
||||
|
||||
- Carried-value property browsing follows one exact record scope at a time, with
|
||||
bounded pages and a separate read-only browser location. Flattened representative
|
||||
paths were proven ambiguous in native recursive/reused data. Browsing does not
|
||||
change the selected field or expression; choosing a field remains an explicit
|
||||
draft edit through the ordinary validated command. No persistent browser schema.
|
||||
|
||||
+8
-23
@@ -4,7 +4,7 @@
|
||||
|
||||
Deliver the first release of Arcadia Studio for system engineers: complete Operational Analysis and System Analysis/System Architecture workflows, equivalent graphical and textual models, clear artifact traceability and a deployable/programmatically accessible Rust service. Logical/Physical Architecture and later Arcadia layers are future releases, as the new objective explicitly states.
|
||||
|
||||
The authoritative objective is attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md. It retains first-release scope and source/include, drag-selection and direct behavior editing requirements, and strengthens domain coherence, alternative evaluation and target-user task evidence. TASK_LEDGER.md separates given/verified facts, assumptions and open candidates; STATE.md records progress. The accepted0.2.0 prototype is the baseline, not proof of release completeness. Its source is preserved at D:/CodexBuilds/Capella2-release1-01a06e2e/v2-source-baseline.zip and existing archives remain in dist until replacement passes acceptance.
|
||||
The authoritative objective is attachment07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md. It retains first-release scope and source/include, drag-selection and direct behavior editing requirements, and strengthens domain coherence, alternative evaluation and target-user task evidence. TASK_LEDGER.md separates given/verified facts, assumptions and open candidates; STATE.md records progress. The accepted0.2.0 prototype is the baseline, not proof of release completeness. Its source is preserved at D:/CodexBuilds/Capella2-release1-01a06e2e/v2-source-baseline.zip and existing archives remain in dist until replacement passes acceptance.
|
||||
|
||||
## Existing architecture and retained decisions
|
||||
|
||||
@@ -14,32 +14,17 @@ The current forms are a closed typed-record adapter, not a second metamodel. Exp
|
||||
|
||||
## Milestones and measurable acceptance
|
||||
|
||||
Nested source ownership and persistent REST bundles are implemented and verified: Windows minimum-size source-to-graphics, nested reparent/source/Undo/Redo/Save/reopen, recovery, rebuilt Linux source/history/Save/reopen, independent review and both platform suites passed. Preserve the existing grammar/updater and separate allocation/order semantics.
|
||||
Nested source ownership and persistent REST bundles are implemented and under final native acceptance. Per-file repair/recovery passed at 1100×700; production parsing, exact structural writeback and coupled service revisions passed independent review and Windows/Linux suites. Root's Windows nested reparent/source/Undo/Redo/Save/reopen passed; minimum-size source-to-graphics review and rebuilt Linux trial remain. Preserve the existing grammar/updater and separate allocation/order semantics. Next, use the updated direct-state core/desktop maps to freeze one small persisted-canvas contract before implementation.
|
||||
|
||||
Explorer/port discovery, custom routing, direct palette connections, four-side port
|
||||
anchors, contextual menus and source folders have scoped Windows/Linux acceptance.
|
||||
See release-explorer-usability.md, release-routing-verification.md,
|
||||
release-routing-linux.md and release-port-drawing.md for exact evidence and limits.
|
||||
The user-directed actor/system port and model-navigation milestone is accepted: visible Add port → Connect boundary exchange, grouped roots, focused containment, breadcrumbs, contextual search and paging, clear full-depth outline, and separate allocations. Native Windows authoring/save/reopen, 251-root paging, 12-level navigation, 1440×900/1100×700 inspection, rebuilt Linux save/reopen and single-line font correction passed. Independent requirements, correctness, native UI and simplicity findings are addressed; release-explorer-usability.md records exact evidence and remaining performance boundaries. Resume source recovery acceptance and the release milestones below; the full scope remains intact.
|
||||
|
||||
Direct state-machine editing is accepted through316dc55: typed persisted views,
|
||||
explicit-owner creation, movement, direct transitions/reconnection, typed detail
|
||||
forms, folding, placement and correct camera/tool lifetime. Windows default/minimum
|
||||
and Linux native acceptance plus independent reviews are recorded in
|
||||
release-state-editing.md. Direct scenario/process gestures remain required.
|
||||
Editable connection routing has passed code/native acceptance and independent follow-up at desktop checkpoint `23535b4`, with the Linux-discovered viewport correction in `366ce44` also verified on the actual rebuilt Linux app. Current WSL automated checks and routing runtime acceptance are recorded in release-routing-linux.md. Continue production source authoring under release-source-integration.md and direct behavior editing. Typed port integration and its native authoring/reopen checks are complete at `fa096ce`; `release-diagram-port-correction.md` records the domain correction. Existing R5 state authoring is preserved and still needs direct gestures and full native acceptance.
|
||||
|
||||
R6 data contracts are active in the isolated data integration checkout. Freeze the
|
||||
full payload/dictionary shape as schema/source4, keep existing typed ports, migrate
|
||||
older formats explicitly while preserving lexical ownership/comments, then verify
|
||||
GUI/text/REST parity and real engineering workflows. See release-data-contract.md
|
||||
and the implementation plan in that checkout. R3 semantic drift/reconciliation
|
||||
follows the R6 freeze; its current prose-only behavior is insufficient.
|
||||
The user's routing correction is implemented as editable connection bend points with automatic obstacle-aware starting routes. Persistent commands, source/API parity, shared geometry and desktop gestures passed automated and native verification. `65fa9ab` resolves six default SA label-overlap pairs; follow-up typed reopen, short gestures and SVG export at 1100×700 passed. `release-routing-verification.md` records exact evidence, completed independent native follow-up and the 119 ms dense full-solve limitation. Route identity belongs to the diagram representation and does not alter semantic endpoints or ownership. Direct state gestures remain required and should reuse geometry/routing primitives only where semantics match.
|
||||
|
||||
Continue source authoring improvements only through the production parser/updater.
|
||||
The23-test experiment is evidence, not architecture: independent probes exposed
|
||||
comment misattachment and unsupported edits. Additional shorthand must preserve
|
||||
complete edit/delete behavior and exact source history, and justify itself in real
|
||||
human authoring tasks. Shared state placements are now validated; scenario/chain
|
||||
interaction must preserve its own occurrence and ordering semantics.
|
||||
Drag selection, typed port/schema 3 and routing corrections are complete. Make behavior diagrams directly editable using shared persisted geometry and ordinary commands, and replace the single huge source editing experience with a modular project/include workflow. These are release requirements, not future limitations. The source bundle core and nested source experiments provide evidence and reusable foundations, not production editor acceptance. Source projects must preserve identity, precise per-file diagnostics, Git-friendly ownership of declarations, cycle/missing/duplicate include failures, safe local include resolution and GUI/API parity. Behavior acceptance requires actual canvas movement and contextual creation/editing, not only selection plus external menus. The proposed first state-machine slice is in `release-state-gesture-design.md`; verify its interfaces before changing the shared schema.
|
||||
|
||||
Source direction: extend the production parser/span index and shared updater for selected nesting and shorthand constructs, retaining the existing virtual-bundle foundation. The 23-test experiment is evidence, not production code to copy: independent probes found item-comment misattachment, shorthand description rejection and unsupported deletion. Verify explicit comment ownership, all edit/delete paths, exact source Undo, invalid drafts and per-file diagnostics before adopting syntax. Direct behavior editing first tests a state-machine task (create in region, drag, draw/reconnect transition, edit guard, undo/reopen) before propagating the interaction design to scenarios/chains. Compare successful task completion, semantic clarity, mode switches and recoverability, not control counts.
|
||||
|
||||
1. **Release coverage audit and interfaces.** Compare actual core/UI/API with official Capella OA/SA semantics and the new objective. Freeze a coverage table and concrete variant/reference/transition contracts before editing shared interfaces. Distinguish unsupported later layers from missing OA/SA workflows; V2 exclusions are reconsidered. Native audit exercises onboarding/help/authoring at supported sizes. Independent audits have separate file ownership.
|
||||
2. **Complete engineering semantics.** Fill verified gaps in capability relationships and mission involvement; operational process/functional-chain function/control topology; scenario/state/data constructs needed by OA/SA engineers. Add typed semantic-content drift and reviewed existing-counterpart reconciliation, including reference mapping and explicit dependency/ownership decisions. Every addition has a usable graphical authoring workflow, equivalent source/API representation, meaningful reject/undo/delete/reidentify/transition tests and a worked example. Exact additions and invariants are recorded in the coverage/interface artifact from milestone1, avoiding speculative LA/PA concepts.
|
||||
|
||||
+42
-158
@@ -1,183 +1,67 @@
|
||||
# First release — current engineering state
|
||||
|
||||
## Objective and phase
|
||||
## Objective and current phase
|
||||
|
||||
Direct scenario work is now in isolated `codex/scenario-direct`, checkout
|
||||
`D:/CodexBuilds/Capella2-scenario-direct`, base ab661f2, dedicated target
|
||||
`D:/CodexBuilds/Capella2-scenario-target`. Scope and acceptance are recorded in
|
||||
release-scenario-editing.md. Existing scene/order semantics and typed drafts are
|
||||
retained; direct drawing, explicit insertion, same-parent reorder and correlated
|
||||
activation rendering are implemented in d1d8f0b, with independent-review fixes
|
||||
in 7a66607 for stale scroll slots, actual Iced scroll coordinates, operand-specific
|
||||
activation segments and repeated picker cost, plus e7e37d4 for passive scroll
|
||||
observations during async save. DATA b35dede is integrated in dfd2e41 without
|
||||
conflicts. Combined Windows desktop tests pass 286/286, strict all-target desktop
|
||||
Clippy and optimized build pass; a copied artifact/manifest is frozen under
|
||||
`D:/CodexBuilds/Capella2-release1-01a06e2e/ui-scenario-data-windows-dfd2e41`. Exact artifact
|
||||
hash, before/after failures and timings are in release-scenario-editing.md.
|
||||
Focused independent recheck reports no remaining medium/high finding. Native
|
||||
Windows/Linux trials remain pending and root-owned; this branch claims no native
|
||||
acceptance and does not close process/chain work.
|
||||
The first-release goal remains active and incomplete: a Rust/Iced OA and SA/System Architecture tool for system engineers with equivalent graphical, textual and REST semantics, hierarchical diagrams and typed ports, modular source projects, artifact traceability, direct behavior editing, offline help, and Windows/Linux release evidence. The authoritative objective is attachment `07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md`, fully read on 2026-09-05. PLAN.md, TASK_LEDGER.md and release-semantics-audit.md retain the broader scope.
|
||||
|
||||
The first-release goal remains active and incomplete. Deliver usable Rust/Iced
|
||||
Operational Analysis and System Analysis/System Architecture, equivalent graphical,
|
||||
textual and REST semantics, artifact traceability, directly editable behavior,
|
||||
offline guidance, and Windows/Linux release evidence. Later Arcadia layers remain
|
||||
future releases. The full authoritative objective was reread on 2026-09-06:
|
||||
attachment10134df9-bea2-4c02-a820-67b53d6b86f7/goal-objective.md.
|
||||
PLAN.md retains the entire release scope; no subsystem acceptance completes it.
|
||||
The editable connection-routing milestone is accepted at `23535b4` with viewport follow-up `366ce44`. Per-diagram authoring, obstacle-aware geometry, typed/nested workshop movement, save/reopen, SVG and minimum-size gestures are verified. Commit `65fa9ab` resolved all six reproduced default SA label-overlap pairs. Fresh correctness and native UI review found no unresolved material routing finding; root also verified the actual Linux application and the viewport follow-up. The broader first release remains incomplete; production text authoring is active. See release-routing-verification.md and release-routing-linux.md for exact evidence and limitations. Root alone operates native applications.
|
||||
|
||||
Direct state-machine editing is accepted through production316dc55. The main
|
||||
checkout was fast-forwarded to acceptance a8f451e. R6 data/interface contracts
|
||||
through b35dede are now integrated into the isolated direct-scenario checkout.
|
||||
Root retains final main integration and acceptance; the combined direct scenario
|
||||
has not yet completed native trials.
|
||||
Meaningful implementation and runtime progress continues; no external blocker exists.
|
||||
## Completed foundations
|
||||
|
||||
## Accepted foundations
|
||||
Nested source/REST integration checkpoint: production parser and iterative structural writer are implemented; REST persists the accepted bundle and model in one ETag envelope. Final workspace runs passed 422 Windows and 425 Linux tests; strict workspace Clippy, formatting and Windows/Linux builds passed. Actual server import, comment-only revision, cross-file reparent, stale ETag and exact restart/conditional GET passed. Owner-opening-line comment reassignment and equal-model delete/recreate provenance defects were reproduced, fixed and reverified. Independent parser/writer/model-source boundary reviews found no remaining material findings; fresh spawn was unavailable because of the thread limit, so workers reviewed one another's artifacts. Strict language/API reST rendering passed after correcting an API heading underline. Root's Windows nested reparent/arrange/source/Undo/Redo/Save/reopen passed with 22 objects,13 links,0 checks. Independent minimum-size/source-created port QA is active; actual rebuilt Linux nesting trial remains. Evidence: release-source-integration.md.
|
||||
|
||||
- Core typed OA/SA semantics, validated atomic commands and ordinary history are
|
||||
shared by three Rust crates. Typed ports, explicit unresolved migration and
|
||||
original Capella observations: release-port-{contract,core,migration}.md and
|
||||
release-capella-workflow-benchmark.md.
|
||||
- Explorer/port discovery d364d3c: Add port on participants, grouped roots, focused
|
||||
children, breadcrumbs, contextual paging/search and full-depth outline. Actual
|
||||
Windows creation,251-root/12-level trials,1100x700 and Linux reopen/font fixes:
|
||||
release-explorer-usability.md.
|
||||
- Routing23535b4, viewport366ce44, labels65fa9ab: authored bends/labels, group
|
||||
movement, fold restoration, Undo/Redo, save/reopen and SVG, including minimum-size
|
||||
native trials. Dense full solve measured119ms; arbitrary crossings remain possible.
|
||||
See release-routing-verification.md and release-routing-linux.md.
|
||||
- Source4e8575b: nested lexical ownership/includes, provenance-aware token writes,
|
||||
exact accepted source/model history, retained drafts, Save All/recovery and coupled
|
||||
hosted source/model revisions. Windows/Linux native and actual REST restart,
|
||||
stale ETag, comment-only edit and cross-file reparent passed. Full422 Windows /
|
||||
425 Linux tests at that checkpoint. See release-source-integration.md.
|
||||
- Direct drawing fef2e0d plus tooltip ad7feb5: OA Interaction, SA Functional and
|
||||
Boundary Exchange by palette click-click or drag; compatible highlights, automatic
|
||||
function ports, four-side draggable anchors, contextual menus and source folders.
|
||||
Independent pointer/tooltip findings fixed and native retested. Windows193 desktop
|
||||
and Linux189+4 focused tests followed earlier full485/488 suites. Scoped native
|
||||
Windows/Linux acceptance: release-port-drawing.md and release-context-menus.md.
|
||||
Current continuation: previous goal turn made concrete progress at `d364d3c` (native-verified explorer/port usability). Root reread the complete authoritative objective. Native source recovery acceptance passed: comment-preserving reopen, graphical rename/writeback/Undo, inactive-file syntax repair, mixed interrupted-save rollback, first-save recovery with retained draft/retry, and included-file repair/Save/reopen at 1100×700. The prominent recovery banner closed normally with an empty runtime log. All implementation ownership is released to root. `explorer_usability_review` alone owns native input for its bounded QA; `source_document_review` and `source_integration_map` are mapping direct-state core/desktop contracts read-only. No blocker; release scope is unchanged.
|
||||
|
||||
## Newly accepted state workspace
|
||||
- The user-directed explorer/port usability milestone is accepted. Add port is visible on participants, and selected component ports expose Connect boundary exchange. The browser uses grouped roots, focused direct children, breadcrumbs, paged contextual search and Locate; the outline keeps real depth, guides and single-line labels with full-name tooltips. Root created two Standard ports and a Boundary Exchange, exercised Undo/Redo, Save/reopen, duplicate-name search, 251-root paging and 12-level navigation. Independent native review and root's final Windows build passed at 1100×700; actual rebuilt Linux save/reopen and font-fix inspection passed. Full Windows workspace tests passed 382, followed by all 138 desktop tests; Linux full workspace passed 386. Strict checks and final Windows/Linux builds passed. Slow picker, stale reparent focus, deep-row cost, collapsed header sizing, scroll reset and font wrapping findings are fixed. Exact scope, hashes and evidence: release-explorer-usability.md.
|
||||
- Source integration checkpoint `3c87576` passed full366 Windows tests; its draft-history cap follow-up passed122 desktop tests and strict workspace Clippy. Root ran native build SHA256 `FCA6140E835B17AE4FAB003B0DAC38D7D820DD36C7559F536BA3CC607E30FE51`: opened two included buffers, typed separate rationale comments, switched with drafts retained, Apply all, model Undo/Redo, then Save All to `ui-source-project/native-source-trial.arcadia`. The entry is1496 bytes with20 other included files; both authored comments are present on disk and the UI reports all changes saved,41objects/25links/0checks. Runtime log is empty; the window closed normally (exec session99563 exited0). Native source reopen/failure/minimum-size acceptance is still required.
|
||||
- Fresh source integration review at 3c87576 reproduced property-draft loss and diagnostic-only recovery state. Both are fixed in the current app: guards retain both drafts, and durable pending_recovery survives typing/lint, missing entries and initial Save failure. Four real-journal recovery tests and source/property collision tests pass in the full workspace run. The bounded reviews are complete; all agents released file/native ownership to root. Frozen source probe evidence: D:/CodexBuilds/Capella2-source-app-independent/REVIEW.md. The explorer Linux trial additionally verified failed Save to a missing directory retained the model and successful retry saved all files; interrupted-journal UI recovery still needs native acceptance.
|
||||
|
||||
Production316dc55 includes typed machine views, shared persisted geometry, atomic
|
||||
region/symbol/state creation, group movement, direct transitions and reconnection,
|
||||
Internal rows, Local edges, typed detail forms, folding, placement and navigation.
|
||||
Architecture and Behavior view selections are separate. Drafts survive rejected
|
||||
source/model commits; passive camera events never apply a form.
|
||||
- Branch `codex/release1-engineering`; baseline `93ba83d`, hierarchy `c7101b9`, drag selection `2ef16dc`, typed core/schema 3 merge `757ab88`, alias and batch lifecycle corrections `e41b8de`.
|
||||
- Typed desktop integration `fa096ce` provides contextual function/component ports, FE endpoint and exchange-item choices, generation, resolution, split and synchronization with semantic previews. Legacy migration, Standard/Flow distinctions and independent orientation are retained. Independent findings and native-discovered no-op synchronization and SA-only initial-perspective defects were fixed.
|
||||
- Native typed-port trials created real function endpoints and exchanges, supplementary output/items, and a Standard component implementation with two port allocations and an exchange allocation. Undo/redo and save passed with zero model checks. Reviewed reopen entered SA correctly; unchanged synchronization disabled Apply. The long-reference review modal was inspected at 1440×900 and 1100×700. Evidence and fixtures are under `D:/CodexBuilds/Capella2-release1-01a06e2e/ui-typed-ports` and `ui-typed-ports-reviewed`.
|
||||
- Source bundle foundation `06b3384` and token-preserving updater `dbc8971` passed 160 core tests, including 30 source-project tests, and strict core Clippy at that checkpoint. This is a pure core foundation; production editor, filesystem Save All/conflict handling and REST bundles remain to be integrated.
|
||||
- Isolated source authoring comparison `c5788fc` and nested writeback experiment `2dd17aa` passed 23 experiment tests and strict Clippy. An independent executable probe at `D:/CodexBuilds/Capella2-source-writeback-independent/probe.log` reproduced positional list-comment misattachment after item reorder, rejection of a valid description edit on port shorthand, and unsupported occurrence deletion. The same comment-reorder probe retained attachment in the production updater. Complete model equality alone does not protect comment meaning. Extend the production parser/span index and shared updater for selected nesting/shorthand constructs; do not copy the experiment into a parallel production parser/writer. Freeze comment ownership, exact source Undo and full edit/delete support before adoption. `experiments/source-authoring/WRITEBACK.md` retains the bounded evidence; production syntax is not yet adopted.
|
||||
- R1 process topology, R2 mission/capability, R4 scenarios and R5 events/states are implemented at the semantic/form level. Full native authoring acceptance and direct behavior canvas gestures remain required; release-state-gesture-design.md records the proposed first vertical slice.
|
||||
|
||||
Independent/native findings fixed: covered GPU shapes, pseudo-label/edge collision,
|
||||
selection-relayout drag displacement, missing typed form access, folded-content
|
||||
access, wrong-view placement Apply, and camera/tool leakage on machine navigation.
|
||||
An actual owner/view change resets gesture state and fits; returning to the same
|
||||
view preserves camera/tool. Exact evidence and limitations: release-state-editing.md.
|
||||
## Routing implementation and verification
|
||||
|
||||
Verification: full551 Windows /554 Linux workspace tests and strict workspace
|
||||
Clippy at e0b2210. Final316dc55:241 desktop tests, strict all-target desktop Clippy,
|
||||
and desktop builds on each platform; five new navigation tests included. Two guide
|
||||
tests passed after help changes. Source/reST/API state contracts and actual HTTP
|
||||
rejection/revision/source/restart tests passed earlier in the integration.
|
||||
- `4e5a88d`: per-diagram `EdgeRoute` pins and label placement, atomic set/remove commands, validation, delete/reidentify cleanup, compatible empty defaults, group movement and layout translation. Routes refer to existing relation/trace IDs and do not duplicate semantic identity.
|
||||
- `a7c6f96`: equivalent source, source-project update, JSON and REST behavior, located validation, OpenAPI and reST contracts.
|
||||
- `68b1eff`: shared deterministic orthogonal routing and geometry for typed ports, nested obstacles, labels, bounds and collapse suspension. Desktop draw, hit testing, gesture previews, Fit and SVG use this geometry.
|
||||
- `30c7db1`: label-only edits preserve the settled polyline and warnings; single-edge previews retain settled per-edge routing budgets. `65fa9ab` adds stable bounded automatic label placement, reducing the workshop's six reproduced label-overlap pairs to zero. All 18 geometry tests, strict core Clippy and fresh independent correctness review passed.
|
||||
- `23535b4`: accepted desktop route authoring, shared geometry integration, route/label controls, guarded atomic gestures and release-cursor correction. Final independent native review captured clear minimum-size controls with the authored bend/label and the readable lower SA label band. Its full SA view was partly occluded; root's earlier full SA captures provide the complete-view evidence.
|
||||
- Locked workspace tests passed with 196 core, 103 desktop and 22 server tests before the last geometry regressions were added. Final follow-up passed 104 desktop tests, strict full-workspace Clippy and desktop/server builds (`routing-final-*` logs). Independent review also passed four focused desktop tests for the final release-cursor correction, which commits fast drags despite coalesced motion events. Earlier follow-up passed the 1500-edge reproducer and six external preview/commit fixtures. Exact scopes are in release-routing-verification.md; a final full-workspace test rerun after all later edits is not claimed.
|
||||
- Independent stress measurements on 100 nodes/150 edges with eight pins per edge: maximum single-edge preview 16.4 ms debug and 2.4 ms release; settled full solve 863 ms debug and 119 ms release. The optimized settled result slightly exceeds the initial 100 ms target; this remains a measured performance limitation, not a claim that the target passed.
|
||||
- Routing workshop `4b8545d` adds deterministic, validated `.arcadia` and JSON examples plus a reproducible Rust generator. OA and SA architecture views contain three participants and nested work groups; SA includes real functional/component ports, Standard and Flow exchanges, and an internal System exchange for group-movement testing. Generation, source/JSON equality and zero semantic/routing issues were verified.
|
||||
|
||||
Root and independent Windows native trials covered1440x900 and1100x700 creation,
|
||||
nested movement, first-drag transition, endpoint/menu reconnect, invalid target,
|
||||
Escape, timed Internal, Local/EntryPoint, placement Apply, Undo/Redo and save/reopen.
|
||||
Linux native covered1440x900 creation, first transition/move, Undo/Redo, fold and
|
||||
saved collapsed reopen. Final316dc55 native on both platforms proved same-view
|
||||
camera/tool preservation and different-machine Fit/Select reset. Logs are empty.
|
||||
## Native checkpoint and evidence boundaries
|
||||
|
||||
## Active R6 implementation and next actions
|
||||
The reviewed executable at `ui-routing-reviewed/arcadia-studio.exe` has SHA256 `6DAD490D75F2A67A79A0804F33192EF62A3DCBEA92F5EEDA8EA47788D12273B8`. Root performed default-size OA edge selection without canvas movement, segment drag without a retracing spur, separate label drag with unchanged polyline, displaced-label selection, existing-bend drag, Undo/Redo, Remove bend, Reset routing, and undo restoration. Folding an endpoint container used an automatic promoted edge and disabled route editing; expansion restored the authored pin and label. Model checks remained zero.
|
||||
|
||||
Checkout D:/CodexBuilds/Capella2-data-integration; branch
|
||||
codex/data-contract-integration, base5ce3eef+plan2bf4c54+navigation9118218.
|
||||
Dedicated target D:/CodexBuilds/Capella2-data-target. Contract freeze is schema/source4
|
||||
with full data payload and dictionary slices together; existing typed ports remain.
|
||||
See release-data-implementation.md and release-data-contract.md in that checkout.
|
||||
Native Save As and SVG export produced `ui-routing-reviewed/routing-authoring-ui.arcadia` (33,548 bytes) and `.svg` (2,564 bytes). The source contains the authored waypoint and label offset. Root then launched `ui-routing-final/arcadia-studio.exe`, SHA256 `5558CC203E9DAFED3D0BCA1C40B5E4BDE426F00F8BC37DCD9B33A7338ADD5615`, containing `30c7db1`, duplicate fold-notice cleanup and export-dialog copy changes. Native Open reopened the saved source with “All changes saved”, 41 objects, 25 links and zero checks; the bend remained visible and the detached label selected the same edge. Root rendered the actual exported SVG to PNG with Sharp and visually verified the authored detour, label, bounds and nesting.
|
||||
|
||||
R6 core/source/desktop are frozen at ab661f2. Full Windows607 tests and Linux610
|
||||
tests pass (one explicit benchmark ignored on each platform); strict workspace
|
||||
all-target Clippy passes on both. Optimized desktop/server Linux build also passed.
|
||||
Windows desktop261 tests include the bounded scenario value editor. Exact logs,
|
||||
binaries and runtime evidence: release-data-verification.md.
|
||||
The workshop loaded with 12 cards/seven links in OA and 12 cards/12 links in SA; the full model reported 75 objects, 61 links and zero checks. Root dragged the internal Incident decision line to create a pin. Moving one function left that pin fixed; moving the whole System by snapped delta (-20, +40) translated the pin by the same amount. A single Undo/Redo restored both placement and pin state. The typed trial was saved to `ui-routing-final/typed-routing-authoring-ui.arcadia` (66,598 bytes); folding and expanding the System restored the route and the UI reported all changes saved.
|
||||
|
||||
All4 R6 actual HTTP workflows pass, covering data protocols/artifact identity,
|
||||
rejected defaults, qualified Call/Reply paths, validation locations, source edits,
|
||||
reidentification, source3 migration and restart persistence. The published example's
|
||||
actual server responses passed served/local OpenAPI validation; obsolete shapes
|
||||
reject. Strict reST rendering and81 OpenAPI schemas passed.
|
||||
On `ui-routing-labels/arcadia-studio.exe`, SHA256 `4D4C8163EA2A81F2D9A241921571BE3118AE57ECD2987E7088878CBCA9F50536`, native reopen of that typed trial retained the SA custom pin and separated labels. Dragging an automatically displaced label was continuous and Undo worked. This closes the label finding together with the geometry tests and independent review.
|
||||
|
||||
Independent core review found association transition closure pulling unrelated
|
||||
types and missing empty-enum warning; both corrected and focused tests passed.
|
||||
Repeated per-ancestor full scans were simplified to one scan per helper call.
|
||||
Explicit optimized inheritance timings250/500/1000: Windows21.070/89.088/391.590ms,
|
||||
Linux22.381/111.662/408.129ms. These single-run empty-chain fixtures measure core
|
||||
validation, not general UI latency. A1000-deep chain remains a pathological cost.
|
||||
The accepted executable at `ui-routing-accepted/arcadia-studio.exe` has SHA256 `E9B662788CC3979F8E883299CAF9B16917498D6F0DEAB62E02D9A6CE5C18AFB2`. At 1100×700 (captured frame 1102×732), a short 29-pixel bend drag and approximately 18-pixel label drag succeeded on the first attempt. The canvas and controls remained stable; SVG dialog copy fitted. Native export produced `minimum-routing-ui.svg`; its PNG rendering was visually inspected for correct route, label and nesting. This later successful trial supersedes the earlier width-only resize attempt. The accepted minimum-size window retains an unsaved demo with those two gestures; preserve it before switching models. The saved typed workshop remains the durable SA fixture.
|
||||
|
||||
Independent desktop review fixes now pass tests: a focused dictionary with24-row
|
||||
pages replaces unbounded inherited rows; recursive paths support explicit drill-down
|
||||
and imported finite paths; same-phase styles/same-exchange reselection preserve
|
||||
valid drafts. Read-only follow-up found no new path/style defect. Paging resets
|
||||
only on actual layer/document changes; its regression failed before and passes now.
|
||||
The bounded scenario editor replaces whole-recursion enumeration and per-field
|
||||
inputs with24-row browsing and a single focused value draft. The reply call picker
|
||||
no longer filters alternative calls using values belonging to the old exchange.
|
||||
Automated and independent checks pass; native scenario checks are next.
|
||||
Unexpected concurrent arrangement/view changes were separately preserved to `ui-routing-final/workshop-arrangement-preserved.arcadia` (66,434 bytes). A prior icon-only capture did not establish an application failure; subsequent full native captures succeeded. Obtain fresh inventory before further input instead of relying on historical window IDs.
|
||||
|
||||
Root exclusively operates native R6. The Windows13-object data model has saved
|
||||
native bounds/default repair, a typed physical flow/field, allocation protocol
|
||||
edit, allocation removal/Undo, and inherited-owner navigation. Association endpoint
|
||||
labels/selectability were exercised. The1100x700 data layout has been inspected.
|
||||
A separate53-object recursive-operation fixture was prepared with core APIs and
|
||||
round-tripped for upcoming native scenario trials; fixture construction is not
|
||||
claimed as native authoring. See the verification note for remaining gates.
|
||||
Original Capella was operated in a disposable workspace. Native creation demonstrated real FE/function ports and explicit component-port allocation. `Format > Line Style > Oblique` followed by a segment drag created an actual bend without changing its Audio Signal/FOP1/FIP1 endpoints. A rectilinear-corner attempt did not move. See release-capella-workflow-benchmark.md; the reference sample was not saved.
|
||||
|
||||
Parallel isolated work: drawing_correctness implements direct scenario gestures in
|
||||
Capella2-scenario-direct. data_core owns typed semantic review core and
|
||||
state_architecture_review owns source5/migration in Capella2-semantic-review.
|
||||
The typed facet C design won the bounded experiment/criticism round; its production
|
||||
plan and decision are recorded in that checkout. No overlapping writers or targets.
|
||||
Earlier Studio windows disappeared during concurrent work. Fresh inventory confirmed absence and no matching application-crash event was observed; the earlier unsaved routing trial was not recovered. It is superseded by the explicitly saved reviewed fixture, not retroactively claimed as recovered.
|
||||
|
||||
1. State evidence is committed and main fast-forwarded to a8f451e; DATA received
|
||||
the same evidence in c5276b1. Finish native data-authoring acceptance and bounded
|
||||
scenario-value authoring, then full integrated Windows/Linux verification.
|
||||
2. Finish R6 core/source/desktop, integrate actual REST/schema/reST, independent
|
||||
semantic/correctness/UX review, Windows/Linux checks and real data authoring.
|
||||
3. Implement R3 typed semantic drift/reconciliation after R6 freezes. Current
|
||||
baselines/reconciliation only cover prose. Use the experimentally selected typed facet review design C; the semantic
|
||||
integration checkout records the full scope and source5 migration plan.
|
||||
4. Complete direct scenario/process gestures, persistent coverage/filters,
|
||||
Home/recent/recovery, hosted desktop ETag workflows and offline guidance.
|
||||
5. Fresh final requirements/domain/correctness/native UX/architecture review; full
|
||||
Windows/Linux/MSRV checks, real runtime, reproducible docs and release packaging.
|
||||
## Exact next actions
|
||||
|
||||
## Environment and native ownership
|
||||
1. Explorer/port acceptance is complete; preserve release-explorer-usability.md and the saved native/large-model fixtures. Finish native source comment-preserving reopen/error/recovery acceptance under release-source-integration.md.
|
||||
Source integration is committed at 3c87576; recovery/property guard follow-ups are implemented and tested with the explorer milestone. Identity provenance and conflict-aware Save All foundations are committed at 86bf36b/271f062. Root verified multi-file editing, Apply, Undo/Redo and Save natively; comment-preserving reopen and interrupted recovery still require runtime evidence. Preserve these tests/evidence rather than restarting integration. Then extend production nested syntax and REST bundles; do not promote the experiment unchanged.
|
||||
2. Linux routing acceptance is complete. Independent verification passed all 324 workspace tests, strict Clippy, formatting and optimized desktop/server builds at `23535b4`. Root's actual Linux/WSLg bend, label, Undo/Redo, Save and reopen passed. That run exposed a dialog-induced viewport recenter, fixed in `366ce44`: all 105 Windows desktop tests and strict desktop Clippy passed, Windows/Linux rebuilds passed, and root's rebuilt Linux native trial preserved the viewport throughout Save. `release-routing-linux.md` records exact scopes/hashes; final saved fixture is `ui-routing-viewport/linux-routing.arcadia` (33,546 bytes). The Linux test window was closed normally after saving. No full 325-test Linux invocation is claimed.
|
||||
3. Implement direct state/scenario/process gestures, R3 semantic drift and reviewed reconciliation, R6 data contracts, persistent coverage/filters, Home/recent/recovery, hosted ETag workflows, navigable offline guidance and normative reST/API completion. These are required release work, not non-blocking limitations of a finished release.
|
||||
4. Run fresh requirements, correctness, native UI/UX and architecture reviews for the complete release; address material findings. Verify current Windows/Linux/MSRV builds, runtime workflows, documentation, packages and checksums before completion.
|
||||
|
||||
The user explicitly reauthorized native testing after Escape. Root's f5467e5 R6
|
||||
window3476204 and exec93768 closed normally after verified source migration and
|
||||
55-object recursive scenario reopen; no runtime output. The property-browser
|
||||
replacement build b35dede has passed the root's Windows native retest. The
|
||||
combined direct-scenario binary is frozen for its separate pending native trial.
|
||||
Original Capella is present. Older protected user7210312/6228978 were absent;
|
||||
do not reset/save/close them if they return. Re-inventory before switching apps.
|
||||
## Environment and reproducibility
|
||||
|
||||
Evidence: D:/CodexBuilds/Capella2-release1-01a06e2e. State source checkout/target:
|
||||
D:/CodexBuilds/Capella2-state-integration and -target. Linux source/target:
|
||||
D:/CodexBuilds/Capella2-state-linux-review and -target. Use distinct Cargo targets
|
||||
per checkout; shared-cache results were previously caught and excluded.
|
||||
Windows Rust 1.98.1; `CARGO_TARGET_DIR=D:/CodexBuilds/Capella2-01a06e2e`. Release logs and native fixtures are under `D:/CodexBuilds/Capella2-release1-01a06e2e`. Use `cargo test --locked --workspace`, `cargo clippy --locked --workspace --all-targets -- -D warnings`, and `cargo build --locked -p oa-desktop -p oa-server`. Generate the routing example with `cargo run --locked -p oa-core --example routing_workshop`; historical migration goldens stay frozen.
|
||||
|
||||
Windows: cargo test --locked --workspace; cargo clippy --locked --workspace
|
||||
--all-targets -- -D warnings; cargo build --locked -p oa-desktop -p oa-server.
|
||||
WSL Ubuntu24.04 uses target/linux-cargo.toml and Mesa software Vulkan for native
|
||||
WSLg. No remote push or usage reset authorized. No broad recursive target deletion.
|
||||
|
||||
Latest R6 checkpoint: b35dede Windows264 desktop tests, strict Clippy, optimized
|
||||
build and root-owned native retest pass for the one-record property browser. Paging and
|
||||
location changes retain accepted model and typed expressions. f5467e5 Windows
|
||||
and Linux263 desktop tests, strict Clippy and optimized release passed. Native
|
||||
Windows source3 migration preserved comments/nesting through explicit Save All
|
||||
and clean source4 reopen. Native recursive55-object scenario values and paired
|
||||
Call/Reply history were verified; flat property paths prompted the latest fix.
|
||||
The independent bounded-browser review reports no remaining medium/high finding.
|
||||
Linux native R6 workflows remain a root-owned acceptance gate. See
|
||||
release-data-verification.md. Main remains a8f451e; R6 is not yet accepted.
|
||||
Prior WSL Ubuntu 24.04 evidence is in verification-v2-linux.md; current routing automated/build evidence is in release-routing-linux.md. The Linux routing application was launched with the documented Mesa Vulkan software adapter and operated through Computer Use. Full first-release Linux runtime/package acceptance is still required after remaining implementation. There is no current disk blocker. Do not retry the earlier rejected recursive target deletion. No usage reset or remote push is authorized.
|
||||
|
||||
+40
-59
@@ -1,75 +1,56 @@
|
||||
# Task Ledger — first release
|
||||
|
||||
Authoritative objective: attachment10134df9-bea2-4c02-a820-67b53d6b86f7/
|
||||
goal-objective.md, read fully2026-09-06. STATE.md records execution; PLAN.md retains
|
||||
scope; DECISIONS.md and linked contracts preserve rationale.
|
||||
Authoritative objective: attachment `07aee762-7d27-4cbe-81c3-2e82c7170711/goal-objective.md`, read fully on 2026-09-05. STATE.md records execution evidence; PLAN.md is the living strategy; DECISIONS.md and linked release contracts retain detailed rationale.
|
||||
|
||||
## Goal and success criteria
|
||||
|
||||
System engineers develop and iteratively reconcile OA/SA models through equivalent
|
||||
graphical, textual and REST workflows. Rust/Iced Windows/Linux, strict reST,
|
||||
traceability, navigable offline guidance and reproducible deployment. Acceptance
|
||||
requires native workflows, semantic evaluators and fresh independent review.
|
||||
System engineers can develop a coherent OA→SA system model, including iterative changes and evidence, through equivalent graphical, textual and REST workflows. Rust/Iced Windows and Linux release, strict reST semantics, navigable offline concepts/tutorials, and reproducible build/run/evaluation instructions. Later Arcadia layers are future releases. Completion requires actual native workflows, automated invariants, and fresh cross-disciplinary acceptance, not compilation or a feature count.
|
||||
|
||||
## Given facts
|
||||
|
||||
- User prioritizes Capella domain fidelity, hierarchy, typed ports and intuitive
|
||||
transitions in both directions, without its clutter and navigation friction.
|
||||
- Direct exchange drawing must avoid large endpoint dropdowns; ports must move
|
||||
between sides; contextual menus should replace persistent action clutter.
|
||||
- Modular source must be practical for humans/AI and Git. Text/graph/API parity is
|
||||
required. Behavior canvases must support editing and preserve drag selection.
|
||||
- Git/worktrees are authorized. Remote publication is not requested.
|
||||
- User prioritizes engineering usability and Capella domain fidelity, hierarchical containment/allocation displays, real function/component ports and intuitive transitions in both directions.
|
||||
- Text-only model development must be practical; modular files/includes are the proposed answer to the current huge flat file.
|
||||
- Drag selection must remain stable; behavior/scenario canvases must support direct editing consistent with architecture.
|
||||
- Git and worktrees are authorized. Remote publication is not requested.
|
||||
- Connection routes need explicit bend points/customization or sufficiently effective automatic routing. User observed straight connections prevent readable diagrams. Treat saved per-diagram routes and practical obstacle avoidance as a primary requirement, not visual decoration.
|
||||
|
||||
## Verified facts
|
||||
|
||||
- Original Capella SAB creates FE function input/output endpoints; component ports
|
||||
are separately Standard/Flow with explicit allocation. Native observations are in
|
||||
release-capella-workflow-benchmark.md; adopted contracts preserve that distinction.
|
||||
- Explorer/port discovery, editable routing, nested modular source and coupled REST
|
||||
bundles have scoped Windows/Linux acceptance. Evidence links are in STATE.md.
|
||||
- Direct palette connections, four-side port anchors, context menus and source
|
||||
folders passed real Windows/Linux operation. Passive stacked Canvas fixes native
|
||||
tooltip overprint; separate Frames did not. See release-port-drawing.md.
|
||||
- Persisted state editing now passes Windows/Linux native and independent review:
|
||||
creation, movement, direct transitions, typed forms, reconnection, fold/reopen.
|
||||
See release-state-editing.md. Scenario/process canvases still need direct editing.
|
||||
- Ordinary Document commands atomically synchronize source/model; failed writes
|
||||
retain drafts. Source comments require occurrence/provenance-aware edits, not
|
||||
whole-model equality or flat reformatting. Independent counterexamples were fixed.
|
||||
- R3 existing-target reconciliation remains name/description only. R6 data shape
|
||||
expansion is active implementation, not accepted functionality.
|
||||
- R6 ab661f2 full suites passed Windows607/Linux610; f5467e5 desktop263 and
|
||||
strict Clippy/release passed on both platforms. Native Windows data editing,
|
||||
typed defaults, allocations, recursive qualified Call/Reply values, history and
|
||||
saved reopening passed. Source3 migration now preserves comments and nesting.
|
||||
Native found flat property browsing still ambiguous; one-record browsing with
|
||||
bounded pages now passes264 desktop tests/Clippy, pending rebuilt native checks.
|
||||
Linux native R6 acceptance remains outstanding.
|
||||
- Native modular source editing/reopen/repair/recovery passed on Windows at 1100×700. Production nested ownership, structural writeback and coupled REST source bundles now pass an initial 416-test workspace run. Actual service import/comment-only revision/cross-file move/stale ETag passed. Comment fidelity needs independent checks: review exposed owner-opening-line comment reassignment; root reproduced it and all 12 updater tests pass after the fix. Equal-model delete/recreate provenance was also reproduced and fixed in desktop and REST. Native nested workflow and final integrated review/checks remain.
|
||||
|
||||
## Assumptions and open questions
|
||||
- Explorer/port usability accepted at `d364d3c`: actual Windows Actor → Standard port → Boundary Exchange, save/reopen and Undo/Redo; contextual duplicate-name search, 251-root paging, 12-level ancestry and 1100×700 inspection. Linux port save/reopen and font-dependent outline wrapping correction were verified. Exact evidence: release-explorer-usability.md. This resolves the latest discovery/navigation complaint without claiming broader release completeness.
|
||||
|
||||
- Nested aggregate files reduce cognitive load; any additional port/exchange/step
|
||||
shorthand must justify grammar complexity through real authoring tasks.
|
||||
- State placements proved viable without a second geometry system. Scenario/chain
|
||||
gestures still need their own occurrence/order semantics and task verification.
|
||||
- Routing offers a usable initial path plus manual pins; arbitrary dense diagrams
|
||||
are not guaranteed crossing-free. Measured dense full solve119ms remains a limit.
|
||||
- R3 selected design C uses finite self-tagged typed facets, separate membership
|
||||
and order acknowledgements, and transient constructor groups. Source5 historical
|
||||
references are implemented in the isolated semantic checkout. Independent review
|
||||
found narrow-acknowledgement/canonical-agreement/rollup defects; the worker has
|
||||
added regressions and fixes. Full catalog, GUI/API/spec and acceptance remain.
|
||||
- Native Capella SAB authoring creates function input/output endpoints with an FE. Component ports separately carry Standard/Flow kind, orientation, interface roles and explicit allocated function ports. Details and successfully performed versus merely observed operations are in release-capella-workflow-benchmark.md.
|
||||
- Studio uses one typed Project and validated atomic commands across Rust core/desktop/server. Hierarchy projection distinguishes actual allocation from inferred parent summaries; IDs and placement are separate.
|
||||
- Drag selection bug fixed and committed `2ef16dc`; 71 desktop tests and real moved-card selection/property editing passed before typed-port integration.
|
||||
- Typed ports/schema 3 migration merged at `757ab88`. Existing port identities survive as Legacy and missing FE endpoints are generated deterministically; migration and port tests provide scoped evidence in release-port-core.md/release-port-migration.md.
|
||||
- Independent core review reproduced alias reservation and same-transaction allocation replacement defects; fixes have focused regression evidence. Typed desktop integration `fa096ce` passed full checks and native creation/generation, undo/redo, save/reopen and no-op synchronization trials. Long-reference previews were inspected at 1440×900 and 1100×700; detailed evidence is in STATE.md.
|
||||
- Per-diagram pins and labels, source/API parity and shared orthogonal routing are implemented in `4e5a88d`, `a7c6f96`, `68b1eff` and `30c7db1`. `65fa9ab` resolves all six reproduced default SA label-overlap pairs. Native OA/SA editing, movement/undo, fold restoration, save/reopen, SVG inspection and short gestures at 1100×700 passed. Routing code/native acceptance and independent native follow-up are complete at `23535b4`; exact scope is recorded in release-routing-verification.md.
|
||||
- The source experiment passed 23 tests, but an independent probe reproduced positional item-comment misattachment, shorthand description rejection and unsupported deletion. Whole-model equality does not establish source-comment fidelity. The production updater retained comment attachment on the same reorder probe; extend its parser/updater instead of copying the experimental parallel implementation.
|
||||
- Current behavior diagrams derive geometry and only handle selection; direct behavior editing remains a product gap. The source editor retains modular accepted source; nested element syntax is implemented and under native acceptance. Port/exchange/steps shorthand remains an independent future implementation slice required by the current authoring strategy.
|
||||
- Independent source review exposed declaration/comment reassignment during UUID changes and swaps, Linux FIFO hangs during disk admission, file-mode changes on save, and overlong detached-file receipt lifetimes. Command provenance, pre-open regular-file admission, permission retention and saved-detach receipt retirement resolve the reproduced cases. Exact probes and test scopes are in STATE.md.
|
||||
|
||||
## Decisions and current strategy
|
||||
## Assumptions to test
|
||||
|
||||
Preserve one typed core and validated command boundary. Retain current typed ports;
|
||||
R6 uses schema/source4 with explicit1/2/3 migration and no intermediate public4
|
||||
shape. Freeze payload, defaults, inheritance, associations and identified interface
|
||||
allocations together. Preserve lexical includes/comments during migration.
|
||||
- Aggregate-oriented source files will reduce cognitive load enough for routine engineering edits; file splitting alone may leave declaration syntax too flat.
|
||||
- Behavior layout can reuse NodePlacement plus typed diagram context without duplicating semantic state; reconnection/reordering must preserve existing occurrence/relation identities.
|
||||
- Editable per-diagram bends and the automatic initial route passed the recorded workshop trials. This does not guarantee an ideal arrangement or zero crossings for arbitrary models; the measured dense release full solve is 119 ms, above the initial 100 ms target.
|
||||
- Source comments and ownership can survive graphical edits by replacing only changed declaration spans. Generated IDs must stabilize before subsequent text edits.
|
||||
|
||||
Finish and independently verify R6, then semantic R3 reconciliation and direct
|
||||
scenario/process gestures. Complete lifecycle/recovery, hosted editing, guidance
|
||||
and fresh cross-disciplinary Windows/Linux acceptance. No subsystem checkpoint
|
||||
completes the first release. Current work is making measured implementation,
|
||||
review and runtime progress; no external blocker exists.
|
||||
## Open questions
|
||||
|
||||
- Which port/exchange/steps shorthand constructs add enough clarity to justify extending the accepted nested ownership syntax? Preserve comment attachment, exact source Undo and complete edit/delete support against the independent probe cases.
|
||||
- What remaining OA/SA gaps materially block target workflows? R3 semantic reconciliation, R6 data contracts, directly editable behavior, lifecycle/recovery and hosted editing remain in the release plan.
|
||||
|
||||
## Candidate designs and current decisions
|
||||
|
||||
- Source candidates compared explicit source3 aggregate files/includes with nested owner/context syntax lowering to the same model. The chosen implementation direction extends the production parser/span index and shared updater for selected constructs, preserving the pure bundle foundation. Do not copy the experiment's separate parser/writer. Freeze grammar only after comment ownership, edit/delete coverage, invalid drafts and exact source Undo pass their acceptance tasks.
|
||||
- Behavior alternatives: independent UI-only coordinates, new behavior-layout schema, or existing diagram placements with explicit context. Prefer the last candidate provisionally: it preserves text/API parity and command history without a second geometry system. Validate on direct state-machine creation/move/reconnect before extending all behavior views.
|
||||
- Ports: adopted typed separation based on official metamodel plus direct native evidence; rejected generic InOut/interface port conflation and inferred allocation facts. See release-port-contract.md. Revisit only with contradictory domain evidence or concrete usability failures.
|
||||
- Routing: a unique per-diagram route list references existing relation/trace IDs; pinned points and relative label placement are persisted, while orthogonal segments are derived by a shared pure router. This avoids duplicate semantic edge identities and preserves text/API/SVG parity. Folded endpoint promotion suspends custom geometry without deleting it. See release-routing-contract.md.
|
||||
|
||||
## Current strategy and evaluators
|
||||
|
||||
Accept native per-file editing, safe project saves and exact source Undo, then extend production source parsing/updating using the independent probe findings as regressions and integrate REST bundles. Linux routing is accepted with actual WSLg Save/reopen; all11 current Linux disk integration tests also pass. Full release Linux acceptance remains open. Prototype persisted direct behavior gestures next. Continue R3 reconciliation, R6 data contracts, lifecycle/recovery, hosted editing, offline guidance and fresh Windows/Linux release acceptance; no subsystem checkpoint constitutes release completion.
|
||||
|
||||
Meaningful progress now includes verified drag and typed-port authoring, accepted editable routing with native save/reopen, group movement and minimum-size evidence, and tested source-bundle/nested-writeback foundations with independent limitations exposed. Production source and direct behavior usability remain open engineering tasks; feature counts alone do not establish completion.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user